Skip to content

空间音频:远近与方位

《长风渡》有一场巡夜的重头戏:阿燕提着梆子绕台走,更声要跟着人走——人在左舷,声从左来;人走远了,声轻下去。到目前为止,所有声音都是“贴着耳朵响”的,没有远近,没有方位。把声音放进空间,要办两件事:给声源开空间档,给场上立一对耳朵

rust
// 发声体:阿燕与梆子——开了 spatial 的循环声源,挂在会动的实体上
    let layout = layouts.add(TextureAtlasLayout::from_grid(
        UVec2::new(32, 40),
        6,
        2,
        None,
        None,
    ));
    commands.spawn((
        Patrol { velocity: 120.0 },
        FrameClock(Timer::from_seconds(0.15, TimerMode::Repeating)),
        Sprite::from_atlas_image(
            asset_server.load("actors/ayan-sheet.png"),
            TextureAtlas { layout, index: 6 },
        ),
        Anchor::BOTTOM_CENTER,
        Transform::from_xyz(-LIMIT, -20.0, 2.0).with_scale(Vec3::splat(4.0)),
        AudioPlayer::new(asset_server.load("sfx/bangzi-loop.wav")),
        PlaybackSettings::LOOP.with_spatial(true),
    ));

    // 听者:一对耳朵,左红右青——空间音频只认这对耳朵的位置
    commands.spawn((
        SpatialListener::new(EAR_GAP),
        Transform::from_xyz(0.0, 0.0, 0.0),
        Visibility::default(),
        children![
            (
                Sprite::from_color(Color::srgb(0.86, 0.45, 0.40), Vec2::splat(36.0)),
                Transform::from_xyz(-EAR_GAP / 2.0, 0.0, 1.0),
            ),
            (
                Sprite::from_color(Color::srgb(0.42, 0.76, 0.70), Vec2::splat(36.0)),
                Transform::from_xyz(EAR_GAP / 2.0, 0.0, 1.0),
            ),
        ],
    ));

Listing 19-8(其一):发声体与听者——with_spatial(true) 挂上会动的实体,SpatialListener 是一对耳朵(examples/listing-19-08.rs)

声源侧只多了一个 .with_spatial(true);梆子声照旧是 LOOP,挂在阿燕身上随她走。听者侧是新面孔 SpatialListener(空间听者组件):它不是一个点,而是一对耳朵——new(gap) 指定双耳间距,沿自身 X 轴左右排开(它声明了 #[require(Transform)],位置就是实体的位置)。我们给两只耳朵各配一块色板当可视化替身:左红右青。

第一坑:全程无声

按说这就齐活了。可要是就这么跑起来(不加任何别的配置),耳机里的梆子声小到近乎不存在。场记按公式把双耳增益算出来贴在台账上,全程是这样:

text
场记:阿燕在左舷  180 像素处,左耳 0.00、右耳 0.00(sink 暂停 false)。
场记:阿燕在右舷   58 像素处,左耳 0.00、右耳 0.00(sink 暂停 false)。

哪儿都是 0.00。要破案得去衰减模型的源码里看一眼。Bevy 的空间音频由底层音频库 rodio 实现,模型很直白:先把声源并成单声道,再分给左右声道,每只耳朵的增益各算各的

空间音频几何示意:俯视戏台上声源与左右双耳,分别标出到两耳的距离;右侧公式卡写明每耳增益等于声像系数乘 min(1/d²,1),衰减一单位内不衰减、声像由双耳距离差决定;右下角是衰减系数随距离的曲线,d=1 之前是平台

Figure 19-5:每只耳朵各算各的账——声像系数 ×min(1/d², 1)

  • 衰减按距离平方反比:min(1/d², 1)——一个单位以内不衰减,之后随 d² 跌落;
  • 声像看双耳距离差:近的那只耳朵系数大(范围 0.5 到 1),这就是“左边来的偏左响”;
  • 两项相乘,就是这只耳朵实际听到的份额。

坑就藏在那个 d 里:它用的是换算后的距离,而默认换算比例是 1:1——在我们的 2D 戏台上,1 像素就是 1 个单位。阿燕离耳朵动辄两三百像素,d = 300 时衰减是 1/90000,比 −90 dB 还低——不是没声音,是衰没了。

SpatialScale:自己定尺

修法是告诉引擎你的世界里多长算“1 个音频单位”。全局的尺配在 AudioPlugin 上(官方 2D 示例同款:100 像素折 1 单位):

rust
fn main() {
    App::new()
        .add_plugins(DefaultPlugins.set(AudioPlugin {
            // 全局换算尺:2D 里 1 像素 = 1 单位太大,按 100 像素 1 单位缩
            default_spatial_scale: SpatialScale::new_2d(AUDIO_SCALE),
            ..default()
        }).set(ImagePlugin::default_nearest()))
        .insert_resource(ClearColor(Color::srgb(0.10, 0.10, 0.14)))
        .add_systems(Startup, setup)
        .add_systems(
            Update,
            (
                patrol,
                animate,
                hud,
                night_report.run_if(on_timer(Duration::from_secs(2))),
            ),
        )
        .run();
}

Listing 19-8(其二):default_spatial_scale——100 像素折合 1 个音频单位

SpatialScale::new_2d 只缩 X、Y(Z 置零,2D 里它不该参与算距离);个别声源要用不同的尺,PlaybackSettings 上还有 .with_spatial_scale(...) 单独配。换算之后耳距 400 像素= 4 单位、巡台半径 420 像素 ≈ 4.2 单位,正好落在衰减曲线有戏的区间。

场记的台账系统按 rodio 的公式复算双耳增益——SpatialAudioSink 不提供增益读数接口,想看数就照源码自己算,这也顺便把公式钉进代码里:

rust
/// 按 rodio 源码的公式复算双耳增益:声像系数 × min(1/距离², 1)
fn ear_gains(emitter: Vec2, listener: &SpatialListener, origin: Vec2) -> (f32, f32) {
    let left = (origin + listener.left_ear_offset.truncate()) * AUDIO_SCALE;
    let right = (origin + listener.right_ear_offset.truncate()) * AUDIO_SCALE;
    let emitter = emitter * AUDIO_SCALE;
    let (dl, dr) = (left.distance(emitter), right.distance(emitter));
    let gap = left.distance(right);
    let pan = |near: f32, far: f32| (((near - far) / gap + 1.0) / 4.0 + 0.5).min(1.0);
    let dist = |d: f32| (1.0 / (d * d)).min(1.0);
    (pan(dl, dr) * dist(dl), pan(dr, dl) * dist(dr))
}

Listing 19-8(其三):把 rodio 的衰减公式复算出来——声像 × min(1/d², 1)

rust
/// 场记的更声台账:每两秒一笔——方位、距离、双耳各自的衰减
fn night_report(
    patrols: Query<(&Transform, &SpatialAudioSink), With<Patrol>>,
    listener: Single<(&Transform, &SpatialListener)>,
) {
    let Ok((walker, sink)) = patrols.single() else {
        return; // SpatialAudioSink 同样要等开播才上岗
    };
    let (listener_pos, ears) = *listener;
    let emitter = walker.translation.truncate();
    let (left, right) = ear_gains(emitter, ears, listener_pos.translation.truncate());
    let side = if emitter.x < -40.0 {
        "左舷"
    } else if emitter.x > 40.0 {
        "右舷"
    } else {
        "正中"
    };
    println!(
        "场记:阿燕在{side} {:>4.0} 像素处,左耳 {:.2}、右耳 {:.2}(sink 暂停 {})。",
        emitter.x.abs(),
        left,
        right,
        sink.is_paused(),
    );
}

Listing 19-8(其四):巡更台账——两秒一笔,方位、距离、双耳份额(注意查询的是 SpatialAudioSink)

console
cargo run -p ch19-audio --example listing-19-08
text
老雷:熄灯,巡夜。阿燕打更,你们坐台中听远近。
场记:阿燕在左舷  181 像素处,左耳 0.53、右耳 0.07(sink 暂停 false)。
场记:阿燕在右舷   59 像素处,左耳 0.12、右耳 0.33(sink 暂停 false)。
场记:阿燕在右舷  299 像素处,左耳 0.04、右耳 0.49(sink 暂停 false)。
场记:阿燕在左舷  418 像素处,左耳 0.10、右耳 0.03(sink 暂停 false)。

这回有远近也有方位了:阿燕贴近左耳时左 0.53、右 0.07,更声明明白白偏左;走到台口尽头,两耳都只剩零头。耳机里听到的与台账一致——梆子声跟着她从左滑到右,远了轻、近了重。

两幅截图:左幅阿燕正走到左侧红色耳朵方块上方,HUD 显示声源距台心 200 像素合 2.0 音频单位;右幅阿燕在右侧远端,HUD 显示 390 像素合 3.9 音频单位

Figure 19-6:巡夜——贴着左耳与远在右舷尽头,HUD 报换算后的距离

还有三处细则,都是源码里写明的脾气:

  • 开播之后插上的控制句柄是 SpatialAudioSink,不是 AudioSink——同一套 AudioSinkPlayback 面板(暂停、音量、变速都在),但它是另一个组件类型,查询要分开写。上一节“sink 要等开播才上岗”的时序它一并继承;
  • 位置追踪是全自动的。引擎在 PostUpdate(排在 Transform 传播之后,读的是第 12 章那本 GlobalTransform 总账)盯着声源与听者的位置变化,随时把新坐标递给声卡那侧——你只管挪 Transform。声卡侧以约 10 毫秒的粒度跟进,肉耳听不出迟滞;
  • 听者全场只该有一个。立了两个,引擎会发条警告然后只认第一个;声源那头若忘了给 Transform,也会得到一条警告并按原点处理。另外按文档的交底:Bevy 目前的空间音频就是“立体声声像 + 衰减”,不做 HRTF 这类高级头部建模——2D 游戏与多数 3D 场合够用,发烧需求得找第三方 crate。

文场有曲,武场有锣鼓,巡夜有了远近方位——零件齐了。下一节把它们装回同一台戏,给《长风渡》开演。