ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

〖共创稿事节〗HarmonyOS 7 新特性实战(35):让 3D 浮雕沿真实地貌边界抬升

〖共创稿事节〗HarmonyOS 7 新特性实战(35):让 3D 浮雕沿真实地貌边界抬升

浮雕地图最容易露馅的时刻,是用户点下一块地、它从整幅画里抬起来的那一瞬间。

静止时,连续顶面会遮住大部分接缝;地块抬起后,侧壁和外轮廓全部暴露。如果轮廓仍沿规则网格行走,山脊、海岸和云气边缘就会变成一段段水平或竖直的楼梯。提高抗锯齿没有用,因为锯齿已经写进几何形状本身。

《山海万灵》的第一版浮雕解决了整图高度场、光照和区域点击。本轮继续解决更难的一层:让九个区域按 880 个边界顶点真裁剪,抬起的切面跟着地貌曲线走;多边形之外的纸边独立留在原地;切面使用自己的岩层材质;点选后留出足够时间让用户看清反馈。

一、先确认锯齿来自哪里

地图网格为 106×128,共 13,568 个格子。真机上一个格子投影约 9.3~12.7px,中心区域约 10.7px。旧切面沿格子四边生成,因此斜向边界必然以约 10px 为一级台阶。

观察项数值判断
基础网格106×128足以表达起伏,不足以表达精细区界
单格屏幕投影9.3~12.7px几何楼梯肉眼明显
跨区域格子1,237 / 13,568只需处理约 9.1% 的边界格
区域边界9 个多边形、880 个顶点已有可复用的真实地貌分界

第一轮尝试把边界格细分为 3×3 子格。顶面和切面都同步细化后,台阶从约 10.7px 降到约 3.6px,缺口也消失了。但它仍然只能沿 1/3 格的水平或竖直边行走,本质是把一级大台阶换成三级小台阶。

结论随之明确:需要改变切面的几何边,而不是继续增加规则网格密度。

二、让边界折线真正切开网格

每个区域已有一条连续多边形。生成模型时,先找出多边形边穿过的格子,再用折线把格子面切成若干子面。子面内部取一个探测点,执行点在多边形内判断,得到它的精确归属。

单条线段裁进格子可使用 Liang–Barsky 参数裁剪。端点落在 0 或 1 时直接复用原始点,避免相邻格因为浮点计算生成两个几乎相同的顶点。

const clipSegmentToRect = (a, b, rect) => { const dx = b[0] - a[0]; const dz = b[1] - a[1]; let t0 = 0; let t1 = 1; for (const [p, q] of [ [-dx, a[0] - rect.x0], [dx, rect.x1 - a[0]], [-dz, a[1] - rect.z0], [dz, rect.z1 - a[1]] ]) { if (p === 0) { if (q < 0) return null; continue; } const ratio = q / p; if (p < 0) t0 = Math.max(t0, ratio); else t1 = Math.min(t1, ratio); if (t0 > t1) return null; } return { lo: t0 <= 0 ? a : [a[0] + dx * t0, a[1] + dz * t0], hi: t1 >= 1 ? b : [a[0] + dx * t1, a[1] + dz * t1] }; };

多边形在一个格子里可能拐弯,因此不能拿每一小段直线分别切面:折线中间点位于面内部,单独线段的两端并不都在面边界上。正确做法是把完整折线一次插入,沿原面边界分出两条路径,再分别接回折线的正序或逆序。

const splitFaceByChain = (face, chain) => { const a = chain[0]; const b = chain[chain.length - 1]; const slots = insertEndpointsIntoFace(face, a, b); if (slots.a < 0 || slots.b < 0 || slots.a === slots.b) { return null; } const low = Math.min(slots.a, slots.b); const high = Math.max(slots.a, slots.b); const forward = slots.points.slice(low, high + 1); const backward = [ ...slots.points.slice(high), ...slots.points.slice(0, low + 1) ]; const interior = chain.slice(1, -1); const forwardFromA = slots.a === low; const left = cleanup([ ...forward, ...(forwardFromA ? interior.slice().reverse() : interior) ]); const right = cleanup([ ...backward, ...(forwardFromA ? interior : interior.slice().reverse()) ]); return [normalizeWinding(left), normalizeWinding(right)]; };

这里最隐蔽的错误是折线方向接反。两块子面仍能生成,模型也能加载,但面内会多出一条不属于区域多边形的对角线,抬升后表现为边界外突然长出一个凸块。修复时必须确保两半各自连接折线的正确一端,并用几何连续性验证结果。

切开的子面不再保证是凸多边形,因此顶面三角化不能继续使用扇形展开,需要采用耳切,并统一顶面绕向。

三、短边不能靠固定步长采样发现

最初按固定间隔在 880 条边界上取样,以此收集受影响格子。大多数地方正常,但一个长度只有 0.005 世界单位的角部裁剪被跨过去了,整格没有进入切割流程,屏幕上留下一个约 10px 的小凸块。

最终改为 DDA 逐格步进:沿边界线每跨过一条竖向或横向网格线,就把进入的格子登记下来。无论线段多短,只要进入某个格子,就不会漏掉。

const rasterizeEdgeCells = (a, b) => { const cells = []; let column = worldColumn(a[0]); let row = worldRow(a[1]); const endColumn = worldColumn(b[0]); const endRow = worldRow(b[1]); const stepX = Math.sign(b[0] - a[0]); const stepZ = Math.sign(b[1] - a[1]); let nextX = firstGridCrossingX(a, column, stepX); let nextZ = firstGridCrossingZ(a, row, stepZ); while (true) { cells.push(row * GRID_COLUMNS + column); if (column === endColumn && row === endRow) break; if (nextX.t <= nextZ.t) { column += stepX; nextX = advanceGridCrossingX(nextX, stepX); } else { row += stepZ; nextZ = advanceGridCrossingZ(nextZ, stepZ); } } return cells; };

多个手绘多边形还会共享顶点或局部相交。一条折线的端点可能落进已经切出的另一个面里,此时需要按当前面集的边界再次切段。除了常规线段交点,还必须把“折线顶点恰好落在面边或面顶点”的情况作为断点;这些交点参数正好是 0 或 1,普通相交判断通常会忽略它们。

四、顶面和切面必须来自同一套边

面切开后,每个子面把自己的有向边登记进edgeUse。同一网格内部的共享边会出现两次;只出现一次的边才是外轮廓,只有这些边需要生成切面。

const edgeUse = new Map(); function registerFace(face) { emitTriangulatedTop(face); for (let index = 0; index < face.length; index += 1) { const a = face[index]; const b = face[(index + 1) % face.length]; const key = undirectedWeldKey(a, b); const current = edgeUse.get(key); if (current !== undefined) { current.count += 1; } else { edgeUse.set(key, { count: 1, a, b, outward: outwardNormal(a, b) }); } } } for (const edge of edgeUse.values()) { if (edge.count === 1 && acceptWall(edge.a, edge.b)) { emitWall(edge.a, edge.b, edge.outward); } }

这个结构保证顶面边与切面顶边逐点重合。高度通过所在粗格四个格点做双线性插值;沿格子边时双线性会退化为线性,所以边界点既能保持原高度场,又不会引入 T 型接点或裂缝。

五、纸边必须成为永不抬升的独立节点

九个区域多边形的并集覆盖地图带约 83.3%,剩下 16.7% 是纸边和云边。旧实现用广度优先搜索把无主格归给最近区域。静止时它补全了顶面,区域抬升时却把纸边也带起来,形成远离真实多边形的规则楼梯。

新模型把无主面放入独立节点。它直接挂在场景根节点下,不属于九个区域节点,因此选择动画永远不会移动它。它只在整张地图带外缘生成切面,与区域相邻的一侧由区域切面负责,避免两层墙重合闪烁。

function addRegionMesh(region, regionIndex, wallMaterialIndex) { return buildReliefMesh({ name: `${region.id}_relief`, wallMaterialIndex, wallRegion: regionIndex, acceptPiece: (piece) => piece.owner === regionIndex, acceptWall: () => true }); } function addFieldMesh(wallMaterialIndex) { return buildReliefMesh({ name: 'myth_map_relief_field', wallMaterialIndex, wallRegion: -1, acceptPiece: (piece) => piece.owner < 0, acceptWall: onMapBandRim }); }

这一步比继续细化网格更关键:边界能否自然,不只取决于区域多边形本身,还取决于“不属于任何区域”的部分是否被错误地拖进动画。

六、切面要像岩层,不能纵向拉伸顶面贴图

旧切面沿用顶面贴图,两条竖边的上下端使用同一个 UV。结果是一行彩绘被纵向拉成浅色带,看起来像模型破面。

新切面使用独立的 256×256 岩层贴图。贴图由可平铺的噪声和近水平层理生成;UV 的横轴取沿墙方向的绝对世界坐标,纵轴取世界高度。相邻墙段因此天然连续,不会每段都从 0 开始。

const WALL_TEXTURE_SPAN_U = 4; const WALL_TEXTURE_SPAN_V = 3; const wallUv = (point, outward) => [ (outward[0] !== 0 ? point[2] : point[0]) / WALL_TEXTURE_SPAN_U, point[1] / WALL_TEXTURE_SPAN_V ]; materials.push({ name: 'myth_map_relief_wall', pbrMetallicRoughness: { baseColorTexture: { index: 1 }, metallicFactor: 0, roughnessFactor: 0.95 }, emissiveTexture: { index: 1 }, emissiveFactor: [0.75, 0.75, 0.75] });

四个朝向受光不同,最亮朝向的亮度倍率为 2.19,贴图最亮通道为 0.369,乘积约 0.807,仍低于过曝区间。受光面呈土棕色,背光面保持暗棕色,层理能够被看见,又不会抢走顶面彩绘。

七、让地图从同源云海中浮起来

3D 地图只覆盖页面中部,透明区域原先会露出另一幅带圆章和路线的通用背景,两种透视叠在一起,边界处形成硬缝。

把云海放进 3D 引擎背景也不理想:3D 视口从页头下方开始,环境背景画不到状态栏和页头,反而会在视口上沿增加一道新接缝。最终方案是把同源云海铺成 ArkUI 页面底图,让页头、3D 透明视口和 2DContain留边看到同一层内容。

运行时关闭引擎环境背景与间接环境光,只保留 GLB 中已经标定的贴图、自发光和方向光:

const loaded: Scene = await Scene.load($rawfile('myth_map_relief_v1.glb')); loaded.environment.backgroundType = EnvironmentBackgroundType.BACKGROUND_NONE; loaded.environment.indirectDiffuseFactor = { x: 0, y: 0, z: 0, w: 0 }; loaded.environment.indirectSpecularFactor = { x: 0, y: 0, z: 0, w: 0 }; loaded.environment.environmentMapFactor = { x: 0, y: 0, z: 0, w: 0 }; this.scene = loaded; this.mapCamera = findCamera(loaded.root); if (this.mapCamera !== null) { this.mapCamera.enabled = true; this.mapCamera.clearColor = { r: 0, g: 0, b: 0, a: 0 }; }

HarmonyOS 的 ArkGraphics 3D 功能介绍明确支持异步加载 glTF、动态调整场景节点及自定义图片和材质;官方图片资源开发指导也给出了Scene.load()、相机与Component3D的组合方式。

八、动画需要留给用户看见

选中地块的抬升动画为 220ms。旧逻辑选中后立即关闭地图,云幕马上合拢,用户看到的是“点一下,云扑上来”,几乎看不到地块浮起。

现在先更新选区并执行抬升,停留 900ms 后再收起地图。停留期间若再次选择其他区域,则取消旧定时器、重新计时。手动关闭、重新打开和组件销毁也会清理定时器,避免过期回调修改已离开的页面。

const MAP_REGION_DWELL_MS: number = 900; private regionDwellTimer: number = -1; private cancelRegionDwell(): void { if (this.regionDwellTimer !== -1) { clearTimeout(this.regionDwellTimer); this.regionDwellTimer = -1; } } private pickRegionOnMap(regionId: string): void { this.cancelRegionDwell(); this.onSelectRegion(regionId); this.regionDwellTimer = setTimeout(() => { this.regionDwellTimer = -1; this.showWorldMap = false; }, MAP_REGION_DWELL_MS); }

抬升高度也从 1.25 世界单位降为 0.8。按九个区域锚点和当前相机计算,屏幕位移从 12.7~15.9vp 降到 8.1~10.2vp,更接近浅浮雕被轻轻揭起,而不是整块厚板被顶出画面。

const RAISED_Y: number = 0.8; private animateElevation(selectedRegionId: string): void { const revision = ++this.elevationRevision; const startedAt = Date.now(); const tick = (): void => { if (revision !== this.elevationRevision || this.scene === null) return; const linear = Math.min(1, (Date.now() - startedAt) / 220); const eased = 1 - Math.pow(1 - linear, 3); REGION_IDS.forEach((regionId: string) => { const node = this.regionNode(regionId); if (node !== null) { node.position = { x: 0, y: regionId === selectedRegionId ? RAISED_Y * eased : 0, z: 0 }; } }); this.scene.renderFrame(); if (linear < 1) setTimeout(tick, 16); }; tick(); }

九、几何正确性必须从 GLB 里读出来

视觉对比能说明效果变了,却无法证明三千多片切面都沿真实边界。验证脚本直接解析 GLB,读取每片切面四边形的顶边,计算它到最近区域多边形或地图带外缘的距离。

指标子格细分真边界裁剪
GLB 大小1,731,140 B1,750,848 B
三角面37,70438,366
切面四边形3,0033,068
边界距离中位数 / p90格子量化0.0000 / 0.0000
最大边界距离格子量化0.0255 世界单位
偏离达到 0.05 的切面近乎全部0
恰好一整格长的切面接近 100%21.6%(含地图外缘)

真机同取景对比中,昆仑块内部四个窗口测得一致的 19px 位移差,与高度从 1.25 降至 0.8 的理论值 17.9px接近;昆仑外静止区域位移为 0,排除了整页或相机漂移。

当前模型门禁通过:3,068 片切面的最大偏差为 0.0255,GLB 保持在 3MB 预算内;当前 HAP 构建成功。官方 SceneResource API还要求场景资源在结束使用后销毁,页面退出时相应调用scene.destroy()并清空引用。

十、从规则网格走到真实地貌的三个结论

第一,增加网格密度只能让台阶变小,不能让台阶消失。轮廓质量由边界边本身决定,最终必须让多边形参与几何裁剪。

第二,区域之外的无主部分也属于模型设计。纸边若被就近分配给区域,静止时没有问题,动画时就会带着错误轮廓一起抬起。

第三,3D 反馈由几何、材质和时间共同组成。连续边界负责形状,岩层贴图负责侧壁质感,云海负责空间衔接,900ms 停留负责让用户真正看见这次选择。缺少其中任何一层,地图都容易退回“技术上是 3D,观感仍像一张被切开的平面图”。

返回列表