浮雕地图最容易露馅的时刻是用户点下一块地、它从整幅画里抬起来的那一瞬间。静止时连续顶面会遮住大部分接缝地块抬起后侧壁和外轮廓全部暴露。如果轮廓仍沿规则网格行走山脊、海岸和云气边缘就会变成一段段水平或竖直的楼梯。提高抗锯齿没有用因为锯齿已经写进几何形状本身。《山海万灵》的第一版浮雕解决了整图高度场、光照和区域点击。本轮继续解决更难的一层让九个区域按 880 个边界顶点真裁剪抬起的切面跟着地貌曲线走多边形之外的纸边独立留在原地切面使用自己的岩层材质点选后留出足够时间让用户看清反馈。一、先确认锯齿来自哪里地图网格为 106×128共 13,568 个格子。真机上一个格子投影约 9.312.7px中心区域约 10.7px。旧切面沿格子四边生成因此斜向边界必然以约 10px 为一级台阶。观察项数值判断基础网格106×128足以表达起伏不足以表达精细区界单格屏幕投影9.312.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.715.9vp 降到 8.110.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.0255GLB 保持在 3MB 预算内当前 HAP 构建成功。官方 SceneResource API还要求场景资源在结束使用后销毁页面退出时相应调用scene.destroy()并清空引用。十、从规则网格走到真实地貌的三个结论第一增加网格密度只能让台阶变小不能让台阶消失。轮廓质量由边界边本身决定最终必须让多边形参与几何裁剪。第二区域之外的无主部分也属于模型设计。纸边若被就近分配给区域静止时没有问题动画时就会带着错误轮廓一起抬起。第三3D 反馈由几何、材质和时间共同组成。连续边界负责形状岩层贴图负责侧壁质感云海负责空间衔接900ms 停留负责让用户真正看见这次选择。缺少其中任何一层地图都容易退回“技术上是 3D观感仍像一张被切开的平面图”。
阅读完成 · 觉得有帮助?