ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

〖共创稿事节〗HarmonyOS 7 新特性实战(44):地图坐标如何变成声音方向——3D 地块与空间声源联动

〖共创稿事节〗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 喂进空间渲染输入节点
方位随选中实时更新每次渲染重建整条管线管线常驻,只改空间节点的位置参数
无能力设备的降级播放已降级文案,未降级音频回落到普通立体声,保持音量与语速不变

另外两块地没覆盖到的边界也记一下:模拟器上还没有验证空间渲染节点的支持度,手机单栏下地图点完会收起,读数只在收起前的停留窗口可见;分栏右栏常驻地图的那条路径没有设备可测;主观听感也没有做,对照表只证明左右声道的能量差,不声称"闭上眼睛能听出昆仑在左前方"。

坐标这一层打通之后,剩下的都是接线:音频从哪来、什么时候改位置、放不出来时怎么退。方向感这件事,先有可测的方位,再谈好不好听。

返回列表