〖共创稿事节〗HarmonyOS 7 新特性实战(63):四轮返工一条糙边——地图浮雕边界从格子楼梯到多边形真裁剪



# 〖共创稿事节〗HarmonyOS 7 新特性实战(63):四轮返工一条糙边——地图浮雕边界从格子楼梯到多边形真裁剪
上一篇写了整部《山海经》怎么逐字上屏,这一篇回到应用的另一头——探索页的 3D 地图。点击地块,它沿自己的边界浮起,露出泥土岩层的切面,这套交互在系列第 62 篇之前就已立住(真机 9/9 钉点全命中)。但用户指着屏幕说了一句话:
> 地块边缘的锯齿有些太明显了
这句话引出了整整四轮返工。回头看,每一轮都各有一个关键判断:第一轮立住「能浮起」,第二轮把切面材质做实,第三轮把病因量化后发现「细化顶面救不了轮廓」,第四轮才挖出真正的病根——糙边根本不是多边形自己的边界,而是被算法延伸出去的格子边。今天(2026-11-09)门禁复跑依然全绿,终局判据是几何直读而不是目测。

一、先把病因量化:10.7px 的台阶从哪来
用户说「锯齿明显」,第一反应可能是抗锯齿问题。但先量化,别猜。真机(Pura 70 Pro+,1260×2844,地图带高 957px)实测:
| 量 | 值 |
|---|---|
| 地形网格 | 106 × 128 = 13,568 格 |
| 一格的世界尺寸 | 0.1887 × 0.1875 世界单位 |
| 一格投影到屏幕 | 9.3 ~ 12.7 px,地图中心 10.7 px |
| 跨区界的格子 | 1,237 / 13,568(9.1%) |
地图打开时选中区域就是抬起态——loadScene() 里直接调 animateElevation(selectedRegionId),抬起的那块露出一圈切面。而切面的几何生成方式是:沿格子边出一张张竖直四边形。斜向的区域边界被这些四边形近似,必然走成楼梯——一格一台阶 ≈ 10.7px。锯齿不是渲染问题,是几何分辨率问题,抗锯齿救不了。
这也顺便解释了为什么只有 9.1% 的格子惹麻烦:大部分格子两侧属于同一区域,切面被邻区顶面盖住。用 GLB 直接判「每片墙的顶边是否被邻块顶面盖住」,93% 被盖住,外露的只有 120/1,830 片——正是抬起那一块的轮廓。用户看到的锯齿,就长在这 120 片外露墙上。
二、k=3 细分:台阶变密 ≠ 台阶消失
第三轮给了三个方案(A 子格细分 k=3 / B 按真边界裁剪 / C 低成本缓解),当时选了 A。思路朴素:只跨 9.1% 的格子,局部细化很便宜;全局翻倍是 4× 格子、约 3.6 MB,超 3 MB 预算。
顶面按 1/3 细格重切(BOUNDARY_SUBDIVISION = 3,细网格 318 × 384),子格高度取所属粗格四角高度的双线性插值。这里有一条白捡的性质:双线性插值沿粗格边退化成线性,细格顶点与粗格顶点在共享边上严格共线,T 型接点天然重合——细化在结构上不可能裂缝,不需要缝合。
但第一版实测就翻车:只细化了顶面,切面仍沿粗格边出。结果是错的,而且比原来更糟——
- 每片墙恰好 1.000 格长,抬起来还是一圈 10.7px 粗台阶;
- 顶面已按 1/3 裁过、墙没跟上,边界穿过粗格内部的位置直接缺一段墙,抬起来是一圈带缺口的毛边。
改成逐细格扫描后,数字确实变好了:
| 墙片数 | 顶边均值 | ≤0.4 格占比 | |
|---|---|---|---|
| OLD | 1,830 | 1.000 格 | 0.0% |
| NEW(k=3) | 3,003 | 0.612 格 | 51.4% |
门禁顺势钉了两条:最细墙步 ≤ 1/SUB 格,以及 ≥25% 的墙片短于 0.4 格(后者专门钉死「只细化顶面」这种回归)。全量 39/39 PASS。
可真机看过之后,用户判「还是太糙」。复盘的判断是:k=3 把台阶从 1 格(10.7px)降到 1/3 格(3.6px),几何直读确实变好,但每一片墙仍然走在格子边上,顶边长度只能是 1/3 格的整数倍——它把「一级大台阶」换成了「三级小台阶」,仍然是楼梯。更要紧的是另一条观察:顶面覆盖当时已达 99.99%,糙的其实是抬起区域的切面外轮廓,不是顶面。顶面细化救错了对象。
三、真病根:无主格的 BFS 补值把边界「延伸」到了多边形之外
第四轮先回答了一个此前没问过的问题:区域多边形和格子是什么关系?
九张区域多边形的并集只覆盖地图带的 83.3%(多边形之间还互相重叠,并集 83.3% 小于各自之和 84.2%),剩下 16.7% 是纸边和云边——不属于任何区域的「无主格」。旧版生成器对这片无主格的处理是 BFS 补值:把每个无主格就近划给最近的区域。
// 旧版逻辑(已被门禁反向钉禁):无主格按 BFS 就近补值给最近区域
// latticeOwnerFill 的后果:
// 无主格在多边形之外,却被算作某个区域的领地;
// 于是该区域在纸边/云边处的轮廓沿着格子边一路延伸出去,
// 一抬起就是一圈 ~10px 的格子楼梯——而且出现在离多边形本身很远的地方。
这一刀补出来的轮廓才是用户两次否掉的那条糙边:它长在离多边形很远的地方,比多边形自己的边界还糙。也就是说,即使多边形边界的裁剪做到亚像素,纸边那圈格子楼梯照样在。病因是两个,第二个才是主因:
| 病因 | 表现 | 性质 |
|---|---|---|
| 切面沿格子边出 | 斜向边界走成楼梯 | 第三轮已知,k=3 只能缓解 |
| 无主格被 BFS 补值 | 区域轮廓沿格子边延伸到纸边,一圈 ~10px 楼梯 | 主因,k=3 完全治不了 |
四、治法三刀:真裁剪、无主格独立、抬升降高
第四轮把当时未选的 B、C 一并做了,外加一个 BFS 的反面。
第一刀:切面按区域多边形真裁剪(方案 B)。 每个被边界穿过的格子,用区域多边形把它裁开:格子被切成若干片,每片的归属由片内探测点做点在多边形内测试(ownerIndexAtExact)决定。于是区域的轮廓就是那条多边形本身(亚像素,多边形 880 个顶点、中位边长 0.2637 世界单位),不再是格子边。切面不再是「每条格子边出一片」,而是把每片的有向边登记进 edgeUse,只出现一次的边才是外轮廓,才出切面:
// 顶面出完,逐边登记。绕向朝上时,边 p→q 的外法线是 (-dz, dx)。
for (let index = 0; index < face.length; index += 1) {
const pointA = face[index];
const pointB = face[(index + 1) % face.length];
const entry = edgeUse.get(edgeWeldKey(pointA, pointB));
if (entry !== undefined) { entry.count += 1; continue; }
edgeUse.set(key, { count: 1, a: pointA, b: pointB, outward: [-dz / length, 0, dx / length] });
}
// 轮廓边 = 只被一个面用到的边。两侧都是本块地的边被用到两次,不出墙。
for (const entry of edgeUse.values()) {
if (entry.count !== 1) continue;
if (!acceptWall(entry.a, entry.b)) continue;
emitWall(entry.a, entry.b, entry.outward);
}
「顶面按多边形裁开」和「切面沿裁出来的真实轮廓出」从此是同一套几何,不可能对不上。无裂缝的依据沿用第三轮立下的那条:顶面高度是格子四角 latticeHeight 的双线性插值,沿格子边退化成线性,落在格子内部的直线段被精确表示——无裂缝、无 T 型接点。
裁剪本身有三个坑,一个比一个隐蔽,靠 RELIEF_DEBUG 计数加逐格 trace 才挖完:
- 多边形共享顶点 ⇒ 半截折线。 九张多边形之间有共用顶点,逐格切出的折线一端落在格子内部、另一端不在格子面上,单独一条切不动。修法是先把能首尾相接的折线接成整条(跨区域接也可以,归属是每个面重新判的)。失败 76 → 63。
- 线段相交判据漏掉 t = 0 / 1。 折线顶点恰好落在面上或面顶点上时,交点参数正好是 0 或 1,被线段相交判据排除。修法是加
vertexBreak显式断点。失败 63 → 15。 - 固定步长采样漏掉极短裁剪。 按固定步长在边界上打点收集格子,漏过一个只有 0.005 世界单位的角部裁剪,整格没被切开,留一颗 10px 的小凸块。修法是改成 DDA 逐格步进,一格不落:
// 二维 DDA:把线段经过的每一格都登记下来。旧版固定步长(0.15 格)
// 会漏掉只擦到格角的极短段——实测漏过 0.005 世界单位(0.027 格)的擦角。
const rasterizeEdgeCells = (a, b) => {
const cells = [];
let column = Math.floor((a[0] - PLANE_MIN_X) / CELL_SIZE_U);
let row = Math.floor((a[1] - PLANE_MIN_Z) / CELL_SIZE_V);
// ... deltaColumn / deltaRow / maxColumn / maxRow 的 DDA 初始化 ...
while (guard-- > 0) {
cells.push([column, row]);
if (column === endColumn && row === endRow) return cells;
if (maxColumn < maxRow) { column += stepColumn; maxColumn += deltaColumn; }
else { row += stepRow; maxRow += deltaRow; }
}
cells.push([endColumn, endRow]); // 浮点退化兜底:多登一格只是多切一刀空刀
};
修完三个坑还有最后一个主 bug:splitFaceByChain 把切出来的两半接错了链的方向,切面上多出一条 v0→v2 的对角线——那是一条不在多边形上的边,最差距离 0.0922 世界单位,屏幕上就是边界外的凸起。修法一行:
// 折线必须接在它自己那一端:forward 若从 a 走到 b,就得接"b→a"的回程;
// 接反了两块都会变成锯齿面,切面上多出一条不在多边形上的斜线。
const forwardFromA = slotA === low;
第二刀:无主格独立、永不抬升。 那 16.7% 的纸边格子仍然要有顶面(否则浮雕四周露一圈 0.2 世界单位的缺口),但不能再挂在任何区域节点上。它们单独出成一块 myth_map_relief_field 网格,挂在根节点下、不在区域 ID 表里,抬升动画永远遍历不到它。它的切面只出在地图带外缘——多边形那一侧已由各区域出了同一片墙,出两次会 z-fighting:
// 无主格:有顶面、无切面(除带外缘)、永不抬升。
{
name: 'myth_map_relief_field',
wallMaterialIndex: 1,
acceptPiece: (piece) => piece.owner < 0, // 无主格
acceptWall: onBandRim // 切面只在地图带外缘出
}
第三刀:抬升高度 1.25 → 0.8(方案 C)。 边界改成真裁剪之后,抬升不再需要把粗糙的格子边界「顶到眼前」来转移注意力;抬低一点反而更像一块浮雕被轻轻揭起。按相机参数逐锚点算屏幕位移:
MYTH_MAP_RAISED_Y | 屏幕抬升(vp) |
|---|---|
| 1.25(旧) | 12.7 ~ 15.9 |
| 1.0 | 10.2 ~ 12.8 |
| 0.8(定稿) | 8.1 ~ 10.2 |
| 0.7 | 7.1 ~ 8.9 |
门禁据此把抬升带钉在 7 ~ 11vp:低于 7vp 读成「没反应」,高于 11vp 读成「一块板被顶起来」。
五、结果怎么验:几何直读 + 解耦的真机 A/B
从 GLB 直读每片切面四边形的顶边,量它到「最近的区域多边形边或地图带外缘」的距离:
| k=3(第三轮) | 真边界(本轮) | |
|---|---|---|
| GLB 体积 | 1,731,140 B | 1,750,848 B(预算 3 MB,用了 56%) |
| 三角数 | 37,704 | 38,366 |
| 切面四边形 | 3,003 | 3,068(2,588 在多边形上 + 480 在外缘上) |
| 距离 中位 / p90 / 最大 | 沿格子边 | 0.0000 / 0.0000 / 0.0255 世界单位 |
| ≥0.05 世界单位偏离 | ~100% | 0 |
| 顶边恰好 1.000 格占比 | — | 1.7% |
| 顶面覆盖 | 99.99% | 99.99%(一次)/ 0.01%(重叠) |
两张表里最有说服力的是「恰好 1.000 格」这一行:格量化几何下这个数会是 ~100%,实测只有 1.7%(外缘那 480 片天然是整格,占了大部分)——这是边界真的走在多边形上、而不是走在格子边上最直接的反证。剩下的最大值 0.0255(≈1px)是多边形自身 1e-4 世界单位级的几何细节,不是量化误差;为它把焊接量再调小一档,收益 1px、代价是顶点数上涨,不值。门禁阈值因此定 0.04。
门禁把整条判据收进一个「Boundary fidelity」块:把 880 条多边形边建出来,遍历九个区域加纸边网格的所有切面四边形,断言采样数 ≥ 1500、最大距离 ≤ 0.04、恰好一整格比例 ≤ 0.3。同时反向钉死不许回归:BFS 补值、k=3 那一整套符号不得再出现,三个关键实现点必须存在。这里还复发过一个老坑——「纸边网格不得被运行侧引用」的判据,正则必须写成带引号的字符串字面量形态;不带引号会被 .ets 文件里的注释命中,误报一票。
几何直读之外,真机 A/B 补了「变了」和「变对了」的区分。同一取景、同一冷启动路径抓两次,两帧的区域徽标上沿 y 坐标严格一致,可逐像素相减;差异像素确实大涨(全屏 2.77%),但差异只说明变了。于是两处改动分别单独验证:
- 几何:切面 3× 放大直接看——BEFORE 能数出台阶还带一排小凸齿,AFTER 台阶完全消失,切面是一条连续折线。
- 高度:抬升是纯竖直平移,取昆仑抬起块内部四块窗口在前后两帧上做互相关,四块一致给 +19 px,与预期值 17.9 px(
(1.25−0.8) × 957 / 24)差 1px,来自透视下屏幕比例随深度微变;昆仑外的静止区同一量法 dy = 0,证明取景严格一致、19px 不是整页漂移。
这里有一个值得记住的教训:第一版互相关窗口把固定不动的徽标框了进去,相关性被徽标主导,四块全给出 dy = 0 的假结论。做位移相关必须避开屏幕固定的覆盖层。



收个尾
四轮返工,真正值钱的不是任何一刀的具体实现,而是几条顺序不能颠倒的判断:
- 先量化,别猜。 「锯齿明显」的第一反应是抗锯齿,量化之后才知道是一格 10.7px 的几何分辨率问题——抗锯齿救不了几何。
- 细化不等于重建。 k=3 让每一片墙变短,但墙仍走在格子边上;只要归属还是按格判定,边界就永远被格子绑架。归判定从「按格」改成「按多边形」,才是结构性的那一刀。
- 差异像素证明「变了」,不证明「变对」。 真机 A/B 之后还要把每一处改动解耦出独立证据:几何看放大图,高度靠互相关。
- 回归要反向钉。 好的判据不只是「新实现必须做对」,还包括「旧病根必须绝迹」——BFS 补值和 k=3 的符号在生成器里出现即红。
四轮下来,糙边从「一级 10.7px 的格子楼梯」走到「最大偏差 1px 的多边形真轮廓」,GLB 只从 1.73 MB 涨到 1.75 MB。代价控制得住,是因为三刀各自只动该动的那一层:裁剪动归属,纸边动挂载,观感动高度。用户第二次说「还是不行」的时候,答案不在他指的那条边上,而在离它很远的一圈纸边格子里——这大概是整个四轮里最像调试的一课。
附:本篇证据清单
- 四轮验收记录:
acceptance/2026-10-10-relief-map/(真机 9/9 钉点、切面材质标定前置)、acceptance/2026-10-12-relief-cutface-and-dwell/(岩层切面与 900ms 停留)、acceptance/2026-10-12-relief-boundary-refinement/(k=3 细分与量化)、acceptance/2026-10-12-relief-true-boundary/(真边界裁剪、A/B 差分与互相关)。 - 生成器:
app-harmony/scripts/generate-myth-map-relief.mjs(splitFaceByChain/rasterizeEdgeCells/ownerIndexAtExact/edgeUse轮廓边判定 /myth_map_relief_field);切面贴图烘焙app-harmony/scripts/generate-myth-map-wall-texture.py。 - 门禁:
app-harmony/scripts/verify-myth-map-prototype.mjs(Boundary fidelity:3068 切面 / worst 0.0255 / median 0.0000 / 整格 21.6%;反向钉 BFS 与 k=3;抬升带 7~11vp),2026-11-09 复跑全量 39/39 PASS。 - GLB 规格:glTF 2.0 Specification(本篇浮雕网格的容器格式,材质与采样器约定均按此实现)。
- 关联稿件:3D 浮雕地图的交互立住与切面岩层材质见系列第 62 篇之前的地图轮次;内容侧的逐字对账见系列第 62 篇。
- 已知残留:切面最大偏差 0.0255 世界单位(≈1px)为多边形自身细节,不再压缩;
splitFailures余 12 处均为多边形边压在地图带外缘的定位边界情况,仅在调试开关下打印。
更多推荐
所有评论(0)