〖共创稿事节〗HarmonyOS 7 新特性实战(33):把《山海经》地图做成可交互的浮雕空间
《山海经》的世界是「山—海—大荒」嵌套的多层空间,摊成一张平面图会丢掉层次。应用「山海万灵」的探索页原本用一张 2D 概念板艺术图做主视觉,这一轮换成了 ArkGraphics 3D 的立体神话空间:整幅彩绘原画做成一片浅浮雕,九张高程控制图逐格抬升地形,九个神话区域仍是可点选的地块,被选中的那块沿自身边界整块浮起。
这片浮雕同时承担探索页的区域选择器。点开是一张满屏覆盖层,在 3D 地形上点一块区域,覆盖层云幕收起、回到信息页,信息页随即切到该区域的线索、神兽与展厅。下面记录落地时三处只有实测才能发现的问题。
一张原画配九张高程图
模型是离线烘焙好的,1.4 MB,10 个网格,30 798 个三角面。它的组织方式决定了后面几个坑长什么样:
- 顶面只用一张完整原画当贴图,不把原画切成九块,所以地块之间天然没有缝,也不会出现「被剪过」的边; - 形状由九张 16 位高程控制图给出,106 × 128 的格点高度场,区域内部起伏、区域边界处收敛到相邻两区高度的平均值; - 九块地加一层衬底,节点名与运行时按区域 id 查找完全对齐,相机节点固定叫 Camera。
场景加载和环境设置放在一起,环境相关的三行必须紧跟着 Scene.load:
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.mapCamera = this.findNodeByName(loaded.root, 'Camera') as Camera | null;
this.sceneReady = this.mapCamera !== null;
这几行的理由在第六节展开——只关背景不够,环境光照还在。
钉位投影用引擎自己的射线标定
这是最容易埋雷的一处。区域钉位必须落在立体地块的锚点正上方,而 3D 投影随相机 yfov、视口是否含状态栏而变化。源图 UV 线性映射加手调偏移那套写法在这里很脆弱:换个屏幕比例,钉位就整体漂移。
可行做法是让程序用引擎自己的射线反投影去标定:沿 NDC 轴取若干探针点做 raycast,把命中的世界坐标和屏幕坐标凑成样本,最小二乘解出投影参数。标定失败时保持 false,退回解析投影,页面照常可用。
private async raycastNdc(camera: Camera, x: number, y: number): Promise<number[] | null> {
try {
const viewPosition: Vec2 = { x: x, y: y };
const options: RaycastParameters = {};
const hits = await camera.raycast(viewPosition, options);
if (hits.length === 0) {
return null;
}
const position = hits[0].hitPosition;
return [position.x, position.y, position.z];
} catch (_) {
return null;
}
}
标定结果吸收进两个方向的比例因子之后,「横竖屏」「状态栏高度」这些歧义被一次性吃掉,钉位不会再漂。
选中浮起只改 Y
九块地在静止时全部贴在 y = 0 的原地,拼成一整片连续地形;被选中的那块抬起 MYTH_MAP_RAISED_Y,沿它自己的边界露出切面,读作「这一块从图上抬起来了」。抬升用 220 ms 的三次缓出,每一跳改完节点位置后显式调一次 renderFrame() 把这一帧推出去。
const tick = (): void => {
if (revision !== this.elevationRevision || this.scene === null) {
return;
}
const elapsed: number = Date.now() - startedAt;
const linear: number = Math.min(1, elapsed / 220);
const eased: number = 1 - Math.pow(1 - linear, 3);
MYTH_MAP_REGION_IDS.forEach((regionId: string) => {
const node: SceneNode | null = this.regionNode(regionId);
if (node !== null) {
const isSelected: boolean = regionId === selectedRegionId;
const targetY: number = isSelected ? MYTH_MAP_RAISED_Y * eased : 0;
node.position = { x: 0, y: targetY, z: 0 };
}
});
this.scene.renderFrame();
if (linear < 1) {
setTimeout(tick, 16);
}
};
tick();
position 里的 X/Z 必须恒为 0。浮雕网格的顶点已经是世界坐标,节点一旦带上 X/Z 偏移,整块地会被搬离它在地图上的位置,九块会一起塌到锚点连线上。
切面只在「邻格不属于本区」的格边上生成,并且复用顶面贴图与顶边 UV——切出来的侧面就是这张画纸的横截面,颜色与该处地面连续,弱光下不会被读成一块灰板。
点钉为什么会张冠李戴
功能写完,跑了一次 9 个区域各点一次的命中测试。结果是 1/9——点昆仑、西山、大荒、东海、幽都、南山、羽民国,最后几乎都选中了同一个区域,只有点它自己时才蒙对。
根因是 ArkUI 的点击事件冒泡。钉是 Button,它的 onClick 绑定「选钉自己」;但它外层还套了一个 Stack,Stack 的 onClick 也挂了地形点选。点钉时事件先触发钉的 onClick,再冒泡到 Stack 的 onClick,于是地形点选用异步 raycast 去选「钉所在屏幕点正下方的那块地形网格」——而钉是浮在地形之上的投影标签,它正下方的网格常常属于另一块区域。异步结果回头又调了一次选区,把钉选覆盖掉。
修法很直接:删掉外层 Stack 的地形点选。钉点只选钉,空白地形的点选仍由 Component3D 自己的 onClick 接管。
Component3D({ scene: this.scene, modelType: ModelType.TEXTURE })
.width('100%')
.height(this.mapFrameHeight())
.onAreaChange((_oldValue: Area, newValue: Area) => {
this.mapWidth = Number(newValue.width);
this.mapMeasuredHeight = Number(newValue.height);
})
.onClick((event: ClickEvent) => {
this.pickTerrain(event);
})
点选本身交给 pickTerrain:把点击坐标换算成 NDC,交给引擎 raycast,再从命中节点的父链上找回区域 id;引擎调用失败时退回按屏幕点解析所属地块。
private pickTerrain(event: ClickEvent): void {
if (this.mapCamera === null || this.mapWidth <= 0) {
return;
}
const viewPosition: Vec2 = {
x: event.x / this.mapWidth * 2 - 1,
y: 1 - event.y / this.mapMeasuredHeight * 2
};
const options: RaycastParameters = {};
this.mapCamera.raycast(viewPosition, options).then((hits) => {
const regionId: string | null = this.regionIdForNode(hits[0]?.node ?? null) ?? this.regionIdForMapPoint(event);
if (regionId !== null) {
this.selectRegion(regionId);
}
}).catch(() => {
const regionId: string | null = this.regionIdForMapPoint(event);
if (regionId !== null) {
this.selectRegion(regionId);
}
});
}
本 API 版本的 ClickEvent 没有 stopPropagation()(写了编译期就报错),所以这里用「删掉外层点击」而不是「在钉里阻止冒泡」。为了不让这个坑被加回来,代码里留了一条计数断言:this.pickTerrain(event) 必须恰好出现 1 次,且只在 Component3D 上;一旦有人把它重新挂回 Stack,计数变 2,构建期立刻红灯。
一条看起来最省事的路
这条值得单独写,因为它差点成了交付形态。
浮雕方案之前先做了一版伪 3D 模块图贴薄板:仓库里本来就有九张「伪 3D 地形模块」资源,看上去和九区一一对应,把每张当贴图烤进一块 3D 薄板、按九区锚点摆位就行——不用碰高度图,也不用碰地形管线。
真机跑出来的第一眼反馈是「所有地块都截断,而且全是倒立的」。两条硬伤都出在「看着差不多就直接用」的地方:
1. 截断。那张图的名字里带着区域 id,实际是一张画板上并排三座岛按等宽三栏切出来的中间那一栏,右边还带着隔壁岛的残片。贴上去必然读作「被切了一刀」。 2. 倒立。薄板的 UV 按「底边 = (0,0)」写,而 glTF 的 UV 原点在贴图左上角,(0,0) 是左上、(0,1) 是左下,于是每块板的贴图都上下颠倒。
最终的修法是换方案:形状本来就该由高度场给出,而「把一张画做成浅浮雕」才是这张彩绘原画该有的立体形态。伪 3D 的生成器留在仓库里作参考(UV 翻转那个 bug 已修好并被断言钉住,uvMap 必须是 [[0,1],[1,1],[1,0],[0,0]]),但它的产物已从包里删掉:运行时零引用,留在包里只是 8.7 MB 死重,要复现随时跑一遍生成器即可重出。
引擎的光照响应不是线性的
浮雕做出来之后,真机上看着像一张微微弯的卡纸,不像浮雕。按 glTF 规范推:平地亮度 = 自发光 + Σ 光强 × N·L,把自发光调到 0.68、两盏光 0.35 / 0.15,算出平地约等于原画亮度,参数看起来没问题。
真机上量出来,两版几乎一样,只差 2.4% 的像素——把主光从 0.35 提到 0.70,屏幕上根本看不出来。
于是做了一件事:在真机上标定引擎的传递函数。改常量、重生成、构建、装包、截图、量像素,一共跑了 5 档:
| 档 | 线性模型预测的平地倍率 | 实测倍率 |
|---|---|---|
| 自发光 1.00 / 无光(原画基准) | 1.000 | 1.000 |
| 自发光 0.68 / 光 0.35、0.15 | 0.999 | 0.673 |
| 自发光 0.60 / 光 0.70、0.10 | 1.012 | 0.617 |
| 自发光 0.00 / 光 2.00、0.40 | 1.239 | 0.176 |
| 自发光 0.75 / 光 8.00、1.50 | 5.655 | 2.576 |
线性模型完全对不上。拟合出来,引擎的实际关系是(L = Σ 光强 × N·L,取朝上平面):
有效倍率 A ≈ 自发光 + 0.1216 × L ^ 1.7
方向光带 1.7 次幂。这就是为什么「0.35 到 0.70 翻倍」在屏幕上约等于没变,而要给到个位数光强才推得动。用它反推「光占平地亮度的份额」——这才是浮雕明暗差的上限——才第一次算对:
- 旧参数(自发光 0.68 / 光 0.35、0.15):光份额 3%,这就是「像弯卡纸」的根因; - 定稿参数(自发光 0.72 / 光 6.0、1.1,太阳高度角 31°):光份额 61%,平地亮度 1.83 倍,过曝像素占比 1.26%,与原画基准的 1.23% 持平。
另外两个配套发现:
- 引擎的环境光照(IBL 间接漫反射)默认是开着的,即使把背景类型设成 BACKGROUND_NONE 也仍然把整幅画抬亮约 55%——沙漠实测从 (115,82,31) 被抬到 (190,165,111),深色崖壁被洗成中性灰。要把三个环境因子显式置零,颜色才回得来。 - 「浅浮雕」的接缝不能用真地形的参数:接缝淡出宽度是按 3.6 世界单位的真地形调的,压浅到 2.6 之后一格内仍差 0.6 到 0.9 世界单位,屏幕上读成格子级的硬折痕。加了限坡迭代(MAX_LATTICE_STEP = 0.32)之后,最差台阶从 0.894 降到 0.321,超过 0.4 的格边从 205 条降到 0 条。
真机验收
导航「探索」→ 在地图上选,浮雕地形完整渲染,九个区域钉全部就位。说明卡「立体神话分区 / 轻触地块使其浮起」与选中区提示「正在探索 · X」正确,而且这个提示跟着选中区走:选中昆仑时它贴在昆仑的位置,选中羽民国时贴到右下角。
在 API 26 真机(1260 × 2844)上整轮重跑 9/9 钉点命中,修前 1/9、修后 9/9:
| 所点区域 | 点击坐标 | 信息页选中区域 | 结果 |
|---|---|---|---|
| 昆仑 | (308, 1198) | 昆仑 | 命中 |
| 北冥 | (649, 1331) | 北冥 | 命中 |
| 青丘 | (980, 1309) | 青丘 | 命中 |
| 西山 | (190, 1612) | 西山 | 命中 |
| 大荒 | (629, 1697) | 大荒 | 命中 |
| 东海 | (1032, 1673) | 东海 | 命中 |
| 幽都 | (165, 1936) | 幽都 | 命中 |
| 南山 | (617, 1891) | 南山 | 命中 |
| 羽民国 | (1056, 2022) | 羽民国 | 命中 |

这条链路只依赖「世界锚点 → 相机投影」,与模型里贴的是哪种图无关,所以换到浮雕形态后依然成立。与模拟器同选西山并排对比,两者是同一形态,差异只来自 20:9 更高屏的响应式缩放(地图带相对更宽更扁),没有真机专属回归。

| 检查项 | 预期结果 |
|---|---|
| 贴图数量 | 只允许一张完整原画,从结构上杜绝把原画切片 |
| 相邻区共享格点 | 同高,接缝不裂 |
| 引擎标定后的平地亮度 | 落在 1.4 到 2.4 倍 |
| 光份额 | 不低于 50% |
| 太阳高度角 | 不高于 40° |
| 生成器常量 | 与模型实际值一致,防止改了生成器却忘了重跑 |
这几条判据做过反向测试:把它们作用在被否掉的那一版(自发光 0.68 / 光 0.35、0.15)上会失败 5 条,其中就包括「光份额 3% 低于 50%」——这条断言恰好拦住了那个形态。
三条别信直觉
1. 别信「看起来对得上」的资源。名字里写着区域 id 的模块图,实际是「三岛画板的中间一栏」,真机上第一眼就是被切过的横条。 2. 别信规范推出来的光照。glTF 规范只规定参数怎么解释,不保证引擎按同一套算。方向光 1.7 次幂这件事只有在真机上标定才看得出来;照规范推参数,会得到一张「参数全对、观感全错」的平卡纸。 3. 别信「应该没问题」的验收。9/9 命中测试如果没跑,那个事件冒泡问题会一直藏在「看起来能点」的错觉里;光份额如果不算,那一版浮雕也会一直躺在「参数没问题」的自我确认里上线。
把主观观感(「像不像浮雕」)翻译成一个可计算、可校验的量(光份额、平地亮度、共享格点同高),是这一轮最值钱的部分。
更多推荐
所有评论(0)