〖共创稿事节〗HarmonyOS 7 新特性实战(44):地图坐标如何变成声音方向——3D 地块与空间声源联动
# 〖共创稿事节〗HarmonyOS 7 新特性实战(44):地图坐标如何变成声音方向——3D 地块与空间声源联动
山海的地图是一块立体浮雕:昆仑在左上,东海在右边,南山在正下方。异兽讲解来自哪个方向,本该和这块地的位置对得上——点昆仑,声音偏左;点东海,声音偏右。可现在的讲解声从屏幕正中出来,九个方向听起来一模一样。
上一棒把 OHAudioSuite 的空间渲染管线立了起来,证明"坐标进、方位出"。这一棒补的是坐标从哪来:地图上每块地都有一个三维世界锚点,声场要的是另一套坐标,中间隔着一层换算。做完之后,页头的第二行直接写出"昆仑 · 左前 -30° · 4.8 米",三块地各渲染一份校准音,左右声道的能量差就是方位落进声音里的证据。
官方 API 参考:OHAudioSuite(C API)(核对日期 2026-10-29)
一、两套坐标,差的不只是数字
地图的坐标来自浮雕模型。九块地各有一个世界锚点,由 GLB 和地形同源生成:x 横向、y 是地形高程、z 是纵深。整幅地图按世界单位算,横向铺开 20,纵深 24。昆仑的锚点是 (-6.20, 3.68, -10.55),东海 (7.00, 1.40, 0.43),南山 (-0.20, 0.81, 5.06)。
空间渲染节点要的是另一套:以听者为原点,三个轴各在 [-5.0, 5.0] 米之间,单位是米。直接把世界坐标塞进去会遇到两个问题——纵深方向的数值超出范围,引擎按参数上限截断,所有北边的地块会被压到同一个平面上;而且数值范围对不上,声源距离失真,方位再准也没意义。
一个容易走偏的做法是拿地图页的相机位置当听者原点。相机在 41 个世界单位的高处俯视,把它当原点,九块地几乎都在脚下同一片区域,方位角算出来挤在一起。听者的位置应该是"站在地图中央的人",不是"看地图的相机"。所以换算的原点定在地图中心,朝向定成正北——画板上方那一侧。
二、先定三件事:原点、朝向、比例
换算之前把约定写死,后面所有读数都从这三条来:
| 约定 | 取值 | 理由 |
|---|---|---|
| 原点 | 地图中心,听者站在这里 | 相机在高处俯视,拿它当原点会把九块地压成一个点 |
| 朝向 | 世界 -z 方向(画板北侧),方位角 0° | 与地图的"上北下南"一致,用户看图就能对上 |
| 比例 | 世界单位 × 0.5 | 横向 20 个单位铺成 10 米,正好是声场宽度;纵深 24 个单位则会被球面收口 |
比例只用一个数,三个轴同乘。如果每个轴各给一个系数,地图形状会被拉歪——横竖方向的距离感对不上,方位角也跟着偏。同乘之后,声场里的相对位置和地图上的相对位置是同一个形状,只是整体缩了一半。
纵深方向比声场长,这是必然的:地图 20×24 的长宽比,缩放后是 10×12 米,而声场是一个半径 5 米的球。超出的部分不能截断,截断等于把两块不同的地按到同一个点上。做法是沿原方向收回到球面——只改距离,不改方位。
三、换算函数只有二十行
整个换算集中在一个静态方法里,三步:等比缩放、按 5 米球面收口、算方位角与水平距离。
// 世界坐标 → 声场坐标:先等比缩放,再按 5 米球面收口,最后算方位与水平距离 static mapWorld(worldX: number, worldY: number, worldZ: number): ShanhaiSoundFieldPoint { let x: number = worldX * MAP_WORLD_TO_FIELD_SCALE; let y: number = worldY * MAP_WORLD_TO_FIELD_SCALE; let z: number = worldZ * MAP_WORLD_TO_FIELD_SCALE; const length: number = Math.sqrt(x * x + y * y + z * z); let clamped: boolean = false; let clampFactor: number = 1; if (length > MAP_SOUND_FIELD_RADIUS && length > 0) { clampFactor = MAP_SOUND_FIELD_RADIUS / length; x *= clampFactor; y *= clampFactor; z *= clampFactor; clamped = true; } // 正北方向是 -z:声源在正前方时 atan2(0, 正数) = 0°,右手边时 x 为正、角度为正 const azimuthDeg: number = Math.atan2(x, -z) * 180 / Math.PI; const distanceM: number = Math.sqrt(x * x + z * z); return { x: x, y: y, z: z, azimuthDeg: azimuthDeg, distanceM: distanceM, clamped: clamped, clampFactor: clampFactor }; }两个细节值得单独说。
收口在缩放之后。长度判断用的是缩放后的坐标,收口系数按5 / 长度三轴同乘。顺序反了,判断的就不是声场里的距离,而是世界单位下的距离,收口会提前或滞后。昆仑就是被收口的那块地:缩放后是 (-3.10, 1.84, -5.28),长度 6.391,收口系数 0.782,三轴同乘得到 (-2.43, 1.44, -4.13)——距离回到了 5 米,方向一点没动。
水平距离不含高度。页头写"4.8 米"是给用户看"这块地离我多远",人耳对高低的感知远弱于左右,把 y 算进去只会让读数变大却解释不了听感。所以距离用sqrt(x² + z²),高度单独留在 y 上,交给引擎处理。
四、方位角与八扇区:把数字读成人话
方位角用atan2(x, -z)求,单位是度。取-z而不是z,是因为正北在世界坐标里是 -z;换成 +z,整张地图会前后镜像,东海跑到左边去。约定正北 0°、顺时针为正之后,-90° 是正左,+90° 是正右,±180° 是正后。
页头不能直接写 "-30.4°",用户得在脑子里换算。把 360° 按 45° 切成八份,取整后查表:
| 方位角区间 | 方位词 | 举例 |
|---|---|---|
| -22.5° ~ 22.5° | 正前 | 0° |
| 22.5° ~ 67.5° | 右前 | 45° |
| 67.5° ~ 112.5° | 正右 | 94° |
| 112.5° ~ 157.5° | 右后 | 135° |
| 157.5° ~ 180° / -180° ~ -157.5° | 正后 | -178° |
| -157.5° ~ -112.5° | 左后 | -135° |
| -112.5° ~ -67.5° | 正左 | -90° |
| -67.5° ~ -22.5° | 左前 | -30° |
// 归一化到 [0,360) 后按 45° 取整;359° 会回绕到正前,不会落进越界的下标 static directionLabel(azimuthDeg: number): string { let normalized: number = azimuthDeg % 360; if (normalized < 0) { normalized += 360; } const index: number = Math.round(normalized / 45) % 8; return DIRECTION_LABELS[index]; } // 页头第二行的读数:角度取整、距离一位小数,控制在窄屏一行内 static describe(source: ShanhaiSoundSource): string { const label: string = ShanhaiMapSoundField.directionLabel(source.azimuthDeg); const degrees: number = Math.round(source.azimuthDeg); const angle: string = (degrees === 0 ? 0 : degrees).toString(); return source.regionName + ' · ' + label + ' ' + angle + '° · ' + source.distanceM.toFixed(1) + ' 米'; }归一化那两行是必需的。atan2的返回值落在 (-180, 180],直接除以 45 再取整,负角度会得到负数下标,DIRECTION_LABELS[-3]在 ArkTS 里返回 undefined,页头就会显示 "undefined"。先加 360 转成正值,再对 8 取模,359° 才能正确回绕到正前。
五、三块地,一张对照表
真机上(HBN-AL80,API 26)把三块地的换算结果各渲染一份 440Hz 校准音,左右声道喂入完全相同,输出端的左右声道 RMS 由 native 侧当场算完:
| 地块 | 世界锚点 | 声场坐标 | 方位角 | 水平距离 | 球面收口 | 左 / 右 RMS |
|---|---|---|---|---|---|---|
| 昆仑 | (-6.20, 3.68, -10.55) | (-2.43, 1.44, -4.13) | -30.4°(左前) | 4.79 m | 是(×0.782) | 6859.6 / 4746.0 |
| 东海 | (7.00, 1.40, 0.43) | (3.50, 0.70, 0.22) | 93.5°(正右) | 3.51 m | 否 | 5887.8 / 9055.2 |
| 南山 | (-0.20, 0.81, 5.06) | (-0.10, 0.40, 2.53) | -177.7°(正后) | 2.53 m | 否 | 6890.3 / 7036.8 |
三组数据的读法不一样,正好各说明一件事:
- 昆仑在左前方,左声道比右声道响约 45%,方位和"偏左"对得上;它是唯一被球面收口的地块,收口之后仍然偏左,说明收口没有把方位拧掉。
- 东海在右侧偏后一点(93.5°,刚过正右),右声道比左声道响约 54%,左右关系随方位翻转。
- 南山在正后方,左右只差 2%——正后方的声源本来就该两边差不多,这个"差得少"反而是对的。要是它也出现明显偏向,说明换算把前后写成了左右。
引擎侧的回显和应用层的日志逐条对得上:source position: x=-2.43 y=1.44 z=-4.13、x=3.50 y=0.70 z=0.22、x=-0.10 y=0.40 z=2.53。应用层算出什么,引擎就收到什么,中间没有第二份换算。
六、页面接线:读数、日志与降级
页头的第二行和地图说明卡的第三行显示同一句话,两处都从同一个换算结果取,避免"页头写昆仑、卡片写东海"。页头那一侧靠@Watch盯住选中的区域:
@Prop @Watch('onSelectedRegionChanged') selectedRegionId: string = ''; @State supportsSpaceAudio: boolean = true; aboutToAppear(): void { // 能力探测只做一次(结果缓存在换算类里),别在每次选中时都建拆一次引擎 this.supportsSpaceAudio = ShanhaiMapSoundField.supportsSpaceAudio(); } private onSelectedRegionChanged(): void { if (this.selectedRegionId.length === 0) { return; } if (!this.supportsSpaceAudio) { hilog.info(0x2026, 'ShanhaiMapSoundField', 'space render unsupported, direction skipped: %{public}s', this.selectedRegionId); return; } const source: ShanhaiSoundSource | undefined = ShanhaiMapSoundField.resolve(this.selectedRegionId, this.regionName(this.selectedRegionId)); if (source === undefined) { return; } const world: string = 'world=(' + source.worldX.toFixed(2) + ', ' + source.worldY.toFixed(2) + ', ' + source.worldZ.toFixed(2) + ')'; const field: string = 'field=(' + source.fieldX.toFixed(2) + ', ' + source.fieldY.toFixed(2) + ', ' + source.fieldZ.toFixed(2) + ')'; const angle: string = 'azimuth=' + source.azimuthDeg.toFixed(1) + ' distance=' + source.distanceM.toFixed(2) + ' clamped=' + (source.clamped ? 'yes' : 'no'); hilog.info(0x2026, 'ShanhaiMapSoundField', source.regionName + ' ' + world + ' ' + field + ' ' + angle); }日志里同时打世界坐标、声场坐标、方位角、距离和收口标记。排查时最有用的是clamped:它一出现,说明这块地被球面收过,读数里的距离是收口后的 5 米内距离,不是地图上的真实平面距离,对表时不能拿它跟世界坐标直接比。
降级是硬要求。设备没有空间渲染节点时,页头不显示方位,改说"当前设备不支持空间声源",说明卡同样换成降级说明。理由很直接:方位读数是给用户看的,设备放不出方向,写一个算得再准的角度也只是误导。能力探测结果缓存一次就够,探测本身要建引擎、查节点类型、再拆引擎,挂在点击回调里会拖慢选区的反馈。
真机上还遇到一个观感问题:点地块后说明卡先刷新,页头晚一帧。截图抢在中间,会出现"卡片是东海、页头还是昆仑"的画面。这不是状态没同步——两处读的是同一个换算结果,只是 ArkUI 的重绘分了两拍。要确认读数,等一帧再看,或者退出地图重进,两处一定一致。
七、自检与契约:把换算钉住
换算的自检和上一棒的管线自检共用同一个渲染入口,只是位置列表从写死的左中右三点换成了三块地的真实锚点:
// 三块地各自的声场坐标先落日志,再各渲染一份校准音 static runDirectionSelfTest(context: common.Context): void { if (!SHANHAI_AUDIO_MAP_DIRECTION_SELFTEST || ShanhaiMapSoundField.selfTestRan) { return; } ShanhaiMapSoundField.selfTestRan = true; if (!ShanhaiMapSoundField.supportsSpaceAudio()) { return; } const positions: ShanhaiAudioPosition[] = []; for (const item of SELF_TEST_REGIONS) { const source: ShanhaiSoundSource | undefined = ShanhaiMapSoundField.resolve(item.id, item.name); if (source === undefined) { continue; } positions.push({ label: item.fileLabel, x: source.fieldX, y: source.fieldY, z: source.fieldZ }); } ShanhaiSpaceAudio.runPositions(context, positions, 'map-direction-selftest'); }开关默认关闭,编译期常量,打开后应用启动 8 秒自动跑一遍。WAV 落在应用沙箱,左右声道 RMS 由 native 侧算完打日志——真机沙箱不支持把文件拉回本地,把分析留在设备端是最省事也最可靠的做法。
换算的契约用一份静态检查钉住,26 条断言覆盖:声场半径是 5、缩放系数是 0.5、缩放发生在收口之前、三轴同乘同一个系数、方位角用atan2(x, -z)、水平距离不含 y、45° 分档、八档词表、区域表直读而非另写一份、页面挂了@Watch、能力探测只做一次、不支持时有降级文案、说明卡与页头同源、自检开关默认 false、自检覆盖三块地、复用同一个渲染入口。改坏任何一条,检查直接红。
反向验证也做过:把自检开关改回 true,检查立刻报"自检开关默认 false"并退出非零。契约检查的价值就在这里——它拦的不是语法错误,而是"功能还在、默认态被悄悄打开"这类发布事故。
八、还没接上的那一段
三块地的方位已经能从坐标算出来,也能从声音里测出来,但讲解声本身还没有走这条路。现在的链路是:点地块 → 算出方位 → 页头显示读数;校准音证明方位可以落进声音,但真正的 TTS 讲解仍然是从屏幕正中出来的立体声。
接上它需要三步:
| 待接项 | 现状 | 接入点 |
|---|---|---|
| TTS 输出接空间节点 | 讲解走普通播放,无方位 | 把 TTS 的 PCM 喂进空间渲染输入节点 |
| 方位随选中实时更新 | 每次渲染重建整条管线 | 管线常驻,只改空间节点的位置参数 |
| 无能力设备的降级播放 | 已降级文案,未降级音频 | 回落到普通立体声,保持音量与语速不变 |
另外两块地没覆盖到的边界也记一下:模拟器上还没有验证空间渲染节点的支持度,手机单栏下地图点完会收起,读数只在收起前的停留窗口可见;分栏右栏常驻地图的那条路径没有设备可测;主观听感也没有做,对照表只证明左右声道的能量差,不声称"闭上眼睛能听出昆仑在左前方"。
坐标这一层打通之后,剩下的都是接线:音频从哪来、什么时候改位置、放不出来时怎么退。方向感这件事,先有可测的方位,再谈好不好听。