《山海经》的世界是「山—海—大荒」嵌套的多层空间,摊成一张平面图会丢掉层次。应用「山海万灵」的探索页原本用一张 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.0001.000
自发光 0.68 / 光 0.35、0.150.9990.673
自发光 0.60 / 光 0.70、0.101.0120.617
自发光 0.00 / 光 2.00、0.401.2390.176
自发光 0.75 / 光 8.00、1.505.6552.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 命中测试如果没跑,那个事件冒泡问题会一直藏在「看起来能点」的错觉里;光份额如果不算,那一版浮雕也会一直躺在「参数没问题」的自我确认里上线。

把主观观感(「像不像浮雕」)翻译成一个可计算、可校验的量(光份额、平地亮度、共享格点同高),是这一轮最值钱的部分。

参考:ArkGraphics 3D 开发指南、ArkGraphics 3D API 参考。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐