技术博客

3D 空间音频:距离衰减与多普勒,AudioStreamPlayer3D 的衰减模型

第 126 / 149 篇 · 2025-05-29 · godot · 音频系统3D 音频源码分析

把 3D 音效做"对"不难——引擎都算好了;难的是调参数时知道自己在动哪个数学量。AudioStreamPlayer3D 的 inspector 里一排 attenuation model、unit size、doppler factor,背后是三套独立的公式:距离衰减、声像平移、多普勒频移。这篇拆开讲,源码在 scene/audio/audio_stream_player_3d.cpp。

总流程:3D 是在 mix 之前算的

先要建立一个正确的心智模型:AudioServer 的混音器(八-1)对声道布局一无所知,它收到的永远是已经混好声像的立体声 AudioFrame 流。3D 处理发生在 playback 把数据交给混音器之前——AudioStreamPlayer3D 每帧拿到听者(当前 Viewport 的 camera)和自己的世界坐标,算出两样东西:

  • 总增益 $g \in [0, 1]$,由距离衰减公式决定;
  • 左右声道权重 $(w_L, w_R)$,$w_L + w_R = 1$,由方位角决定。

然后 playback 产出的每帧 $f = (l, r)$ 被映射成 $(l \cdot g \cdot w_L,\ r \cdot g \cdot w_R)$。这就是为什么 3D 音源要求输入基本是单声道——立体声源的两路会被同一组权重缩放,空间信息在源内部和引擎算的声像打架,结果是不可预测的相位抵消。要播立体声环境音(比如雨声环绕),用 AudioStreamPlayer(非 3D)挂在 bus 上是对的。

距离衰减:三种模型,一个通用形式

三种 attenuation model 都是"距离 $d$ 映射到增益 $g$"的函数,只是曲线形状不同。设 unit_size 为 $u$(声音大小标度,默认 1 米)、max_distance 为 $d_{\max}$。

Linear(线性):$g = \max\!\big(0,\ 1 - \frac{d - u}{d_{\max} - u}\big)$。从 unit_size 处开始线性跌到 max_distance 处归零。直觉好但物理上不像声音——真实声强随距离平方衰减,线性模型在近距离变化太陡。

Inverse(反比,默认):$g = \frac{u}{u + d}$。恒定参考距离 $u$ 处 $g=1$,之后按双曲线衰减。没有硬截止——声音无限远也能听到一点点,所以工程上要么配 max_distance 做截断,要么接受远处永远有底噪。

Logarithmic(对数):$g = \frac{\log(1 + u/d_{\max})}{\log(1 + u/d)}$ 的归一化形式。近处衰减平缓、远处快速跌落,最接近人耳对声压级的感知(分贝本来就是 log 标度),做 FPS 脚步声我基本固定用这个。

实际工程经验: footsteps、环境底噪用 Logarithmic(远处自然消失);爆炸、瞬发特效用 Linear + max_distance(明确 cutoff,别拖尾巴);需要跨半张图都听得见的提示音(空投落点)用 Inverse 把 unit_size 调大。

声像:正弦平移不是线性平移

方位角 $\theta$(源相对听者正前方的偏角)映射到左右权重,引擎用的是正弦平移(sine panning):

$$ w_L = \cos\!\big(\tfrac{\pi}{4}(1 + \sin\theta)\big)^2,\qquad w_R = 1 - w_L $$

为什么不是 $w_L = (1-\sin\theta)/2$ 的线性分配?因为功率守恒。线性平移在正中央($\theta=0$)给两耳各 0.5 增益,总功率 $0.5^2 + 0.5^2 = 0.5$——同一声音从中央移到耳边,功率先跌 3dB 再涨回来,移动中的音源会有明显的"中间凹陷"。正弦平移满足 $w_L^2 + w_R^2 = 1$ 的恒等条件,功率全程不变,只有能量在左右间转移。代价是正中央时每耳增益是 $\cos(\pi/4)^2 = 0.5$,即中央声像本身比满声像低 6dB 功率——但这是正确的中央定位代价,不是 bug。

4.x 还没做 HRTF(头相关传输函数)双声道化,只有一阶声像 + 可选的 panning_strength。要更真实的头部遮挡/耳廓滤波,得等后续版本或自己挂效果器近似。

AudioStreamPlayer3D 每帧的空间计算:先算增益与声像,再进混音器 输入:源位置 + 听者位姿 距离 d、方位角 θ 相对速度 v(多普勒) 三个独立计算 增益 g = f(d),三种衰减模型 权重 wL, wR = g(θ) 正弦平移 pitch 偏移 = doppler(v) 帧映射 (l·g·wL, r·g·wR) 交给 bus 混音 模型选择速查 Linear:g = 1 − (d−u)/(dmax−u),近处陡、有硬截止 —— 瞬发特效 Inverse:g = u/(u+d),双曲线无截止 —— 远距提示音,配 unit_size 调半径 Logarithmic:近缓远陡,最接近声压感知 —— 脚步、环境底噪 声像恒等式 wL² + wR² = 1:功率守恒,中央定位低 6dB 是正确代价

多普勒:调的是播放速度,不是数据

多普勒效应的公式人人都知道:$f' = f \cdot \frac{c}{c \mp v}$。引擎的实现把这个频率比换算成 pitch scale 直接作用在 playback 的采样率上——音源接近时 playback 以 1.05× 速度读采样,远离时 0.95×。实现便宜(复用已有的重采样路径),听感正确。

但注意两个边界。第一,多普勒改的是整个流的播放速度,对循环环境音(循环篝火声)会造成音高漂移循环感——doppler_factor 对这种源就该置零。第二,4.x 的多普勒用的是每帧一次的离散相对速度 $\Delta d / \Delta t$,高速掠过的音源在帧率边界处会有 pitch 跳变,帧率越低越明显。竞速游戏里如果听感出戏,先查 audio mix rate 和物理 tick 的匹配,再考虑自己用 set_pitch_scale 手动平滑。

性能侧记

3D 空间计算每帧每源一次(在 _process 里算增益/声像,音频线程只做乘法),几百个 3D 源的开销可以忽略。真正的成本在同时激活的 playback 数——每个 3D 源都是一条独立的流式解码 + 效果链,上规模后是内存和解码线程的瓶颈(八-2 的缓冲预算乘上源数)。所以大场景音效管理的第一原则不是"算 3D 多少钱",而是"同时响的源别超过几十个",超出的用距离 cutoff 静音。

本文基于 Godot 4.2–4.4,源码 scene/audio/audio_stream_player_3d.cpp、servers/audio_server.cpp;HRTF 支持在 4.x 主线中未实现,声像为一阶 sine panner。