〖共创稿事节〗HarmonyOS 7 新特性实战(44):地图坐标如何变成声音方向——3D 地块与空间声源联动
# 〖共创稿事节〗HarmonyOS 7 新特性实战44地图坐标如何变成声音方向——3D 地块与空间声源联动山海的地图是一块立体浮雕昆仑在左上东海在右边南山在正下方。异兽讲解来自哪个方向本该和这块地的位置对得上——点昆仑声音偏左点东海声音偏右。可现在的讲解声从屏幕正中出来九个方向听起来一模一样。上一棒把 OHAudioSuite 的空间渲染管线立了起来证明坐标进、方位出。这一棒补的是坐标从哪来地图上每块地都有一个三维世界锚点声场要的是另一套坐标中间隔着一层换算。做完之后页头的第二行直接写出昆仑 · 左前 -30° · 4.8 米三块地各渲染一份校准音左右声道的能量差就是方位落进声音里的证据。官方 API 参考OHAudioSuiteC 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-AL80API 26把三块地的换算结果各渲染一份 440Hz 校准音左右声道喂入完全相同输出端的左右声道 RMS 由 native 侧当场算完地块世界锚点声场坐标方位角水平距离球面收口左 / 右 RMS昆仑(-6.20, 3.68, -10.55)(-2.43, 1.44, -4.13)-30.4°左前4.79 m是×0.7826859.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 y1.44 z-4.13、x3.50 y0.70 z0.22、x-0.10 y0.40 z2.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 喂进空间渲染输入节点方位随选中实时更新每次渲染重建整条管线管线常驻只改空间节点的位置参数无能力设备的降级播放已降级文案未降级音频回落到普通立体声保持音量与语速不变另外两块地没覆盖到的边界也记一下模拟器上还没有验证空间渲染节点的支持度手机单栏下地图点完会收起读数只在收起前的停留窗口可见分栏右栏常驻地图的那条路径没有设备可测主观听感也没有做对照表只证明左右声道的能量差不声称闭上眼睛能听出昆仑在左前方。坐标这一层打通之后剩下的都是接线音频从哪来、什么时候改位置、放不出来时怎么退。方向感这件事先有可测的方位再谈好不好听。