参考 05:实现模式库(可直接抄的代码与参数)
参考 05:实现模式库(可直接抄的代码与参数)
面向音频程序与 TA。所有片段为示意实现,重点在模式与参数量级,接入前按项目引擎适配。 参数量级是经验值,可直接作为起点再调。
1. 变体调度:避免"机关枪效应"
// 轮转 + 禁止连续重复 + 可选加权
struct VariantSet {
TArray<int32> Weights; // 每个变体的权重(稀有变体权重低)
int32 Last = -1;
uint32 Seed = 0x12345678;
int32 Pick() {
if (Weights.Num() == 1) return 0;
int32 Total = 0;
for (int32 i = 0; i < Weights.Num(); ++i) Total += (i == Last) ? Weights[i] / 4 : Weights[i];
int32 R = Rand(Seed) % Total;
for (int32 i = 0; i < Weights.Num(); ++i) {
int32 W = (i == Last) ? Weights[i] / 4 : Weights[i];
if (R < W) { Last = i; return i; }
R -= W;
}
return 0;
}
};
要点:纯随机在 3–4 个变体时仍会出现"连播同一个"(概率 1/N 不低)。降低上次播放项的权重比纯随机更自然。
随机化参数(每次播放)
| 参数 | 范围 | 说明 |
|---|---|---|
| 音量 | ±1.5 dB(±12%) | 超过 ±3dB 会听出"这次特别响",破坏一致性 |
| 音高 | ±50–150 cents | 换算:ratio = pow(2, cents / 1200);脚步 50–80,武器 100–150 |
| 起始偏移 | 0–40 ms | 连发时错开,避免完全同相叠加 |
| 低通截止 | ±10% | 微妙的自然感 |
| 立体声位置 | ±5° | 仅 3D 音,微小偏移更自然 |
音高变化会同时改变时长。若必须与动画严格同步(脚步),把音高范围压到 ±30 cents 以内。
2. Voice Manager:优先级 + 距离 + 抢占
struct Voice { float Priority; float Distance; bool bPlaying; float Gain; };
Voice* RequestVoice(float BasePriority, float Distance) {
// 距离衰减后的有效优先级
const float Effective = BasePriority / (1.f + Distance * 0.1f);
for (auto& V : Voices) if (!V.bPlaying) { V.Priority = Effective; V.bPlaying = true; return &V; }
Voice* Weakest = nullptr;
for (auto& V : Voices)
if (!Weakest || V.Priority < Weakest->Priority) Weakest = &V;
if (Effective > Weakest->Priority) {
Weakest->StartFadeOut(0.02f); // 20ms 淡出,避免抢占时爆音(关键)
return Weakest;
}
return nullptr; // 放弃本次播放
}
三个易错点:
- 抢占必须淡出,直接停会 click
- 优先级要按距离衰减,否则远处的低优先级音会挤掉近处的重要音
- 距离超过 Max Distance 直接不分配,而不是分配了再静音(省 CPU)
优先级基准表
| 类别 | Base Priority | Voice Limit |
|---|---|---|
| VO 语音 | 100 | 4 |
| UI | 90 | 4 |
| 玩家武器 | 85 | 16 |
| 敌人威胁音 | 80 | 12 |
| 载具 | 75 | 4 |
| 角色动作 | 70 | 12 |
| 冲击 / 碰撞 | 65 | 12 |
| 环境细节 | 30 | 8 |
| 装饰音 | 20 | 6 |
3. 距离衰减与遮挡(不依赖中间件时)
struct AttenuationResult { float Gain; float LpfHz; float ReverbSend; };
AttenuationResult EvalAttenuation(float Dist, float MaxDist, bool bOccluded) {
AttenuationResult R;
const float T = FMath::Clamp(Dist / MaxDist, 0.f, 1.f);
// 音量:接近对数,避免线性导致的"远处仍很响"
R.Gain = (T >= 1.f) ? 0.f : FMath::Pow(1.f - T, 1.8f);
// 空气吸收:远处高频损失
R.LpfHz = FMath::Lerp(12000.f, 800.f, FMath::Pow(T, 0.6f));
// 混响占比随距离上升
R.ReverbSend = FMath::Lerp(0.15f, 1.0f, T);
if (bOccluded) { // 完全隔断
R.Gain *= 0.12f; // -18 dB
R.LpfHz = FMath::Min(R.LpfHz, 450.f);
}
return R;
}
遮挡检测(每帧对每个声源全做 raycast 太贵):
- 只对重要声源(敌人、载具)做;装饰音不做
- 频率 8–12 Hz,且用插值平滑(RTPC 平滑 200ms),否则滤波跳变会"咔"
- 缓存命中结果,声源与听者都没大位移时复用
4. 基础 DSP 片段
// 一阶低通(每声道一个实例,状态需保留)
struct OnePoleLP {
float Z = 0.f;
float Process(float X, float CutoffHz, float SampleRate) {
const float A = FMath::Exp(-2.f * PI * CutoffHz / SampleRate);
Z = X * (1.f - A) + Z * A;
return Z;
}
};
// 一阶高通(去掉无用低频,几乎所有音效都该挂)
struct OnePoleHP {
float Z = 0.f, PrevX = 0.f;
float Process(float X, float CutoffHz, float SampleRate) {
const float A = FMath::Exp(-2.f * PI * CutoffHz / SampleRate);
Z = A * (Z + X - PrevX); PrevX = X;
return Z;
}
};
// 平滑增益(用于 ducking / RTPC 插值,避免阶变爆音)
struct SmoothGain {
float Current = 1.f;
void SetTarget(float Target, float TimeSec, float SampleRate) {
Coef = FMath::Exp(-1.f / FMath::Max(TimeSec * SampleRate, 1.f));
Goal = Target;
}
float Tick() { Current = Goal + (Current - Goal) * Coef; return Current; }
private:
float Coef = 0.f, Goal = 1.f;
};
// ADSR(线性段足够游戏用,指数段更自然但更贵)
struct ADSR {
float A = 0.005f, D = 0.08f, S = 0.6f, R = 0.25f;
float T = 0.f, ReleaseFrom = 0.f, ReleaseT = 0.f; bool bReleased = false;
float Tick(float Dt) {
if (!bReleased) {
T += Dt;
if (T < A) return T / A;
if (T < A + D) return 1.f - (T - A) / D * (1.f - S);
return S;
}
ReleaseT += Dt;
return FMath::Max(0.f, ReleaseFrom * (1.f - ReleaseT / R));
}
void Release(float Cur) { bReleased = true; ReleaseFrom = Cur; }
};
防 denormal(重要):滤波状态长期接近 0 时会进入非正规浮点数,CPU 暴跌。
在每帧末尾加 Z += 1e-20f; Z -= 1e-20f; 或启用平台的 FTZ/DAZ 标志。
5. 程序化音效示例
// 激光/能量:扫频 + 快速衰减(参数化,可复用为不同武器)
void SynthLaser(float* Out, int N, float SR, float F0, float F1, float DurSec) {
float Phase = 0.f;
for (int i = 0; i < N; ++i) {
const float T = (float)i / N;
const float F = FMath::Lerp(F0, F1, T); // 音高扫描
Phase += F / SR; if (Phase >= 1.f) Phase -= 1.f;
float Env = 1.f - T; Env *= Env; // 指数感衰减
Out[i] = (2.f * Phase - 1.f) * Env * 0.5f; // 锯齿
}
}
// 爆炸:低通噪声 + 次低频轰鸣(经典两层)
void SynthExplosion(float* Out, int N, float SR) {
OnePoleLP Lp; float Rumble = 0.f;
for (int i = 0; i < N; ++i) {
const float T = (float)i / N;
const float Noise = FMath::FRand() * 2.f - 1.f;
const float Cut = FMath::Lerp(4000.f, 300.f, T); // 截止随时间下降
const float Env = FMath::Exp(-T * 5.f);
Rumble = FMath::Sin(2.f * PI * 38.f * T) * (1.f - T);
Out[i] = (Lp.Process(Noise, Cut, SR) * 0.7f + Rumble * 0.3f) * Env;
}
}
纯程序化爆炸通常"不够狠"。实战做法:程序化只负责 Sub 与尾部的可变部分, 主体仍用实录采样 —— 混合方案既可控又有冲击力。
6. 伪程序化:多段 loop 交叉淡化(引擎/风/水)
// 5 段 RPM loop,等功率交叉淡化避免中间"变轻"
float CrossfadeGain(float X) { return FMath::Sin(0.5f * PI * X); } // 等功率
void UpdateEngine(float Rpm01) {
const float Pos = Rpm01 * (NumLayers - 1);
const int Lo = FMath::FloorToInt(Pos);
const int Hi = FMath::Min(Lo + 1, NumLayers - 1);
const float Frac = Pos - Lo;
SetLayerGain(Lo, CrossfadeGain(1.f - Frac));
SetLayerGain(Hi, CrossfadeGain(Frac));
// 播放速率微调跟随段内 RPM(±30% 内不失真)
const float Rate = 1.f + (Frac - 0.5f) * 0.6f;
SetLayerRate(Lo, Rate); SetLayerRate(Hi, Rate);
}
关键:交叉淡化用 等功率(sin/cos),线性交叉淡化在中点会有 -3dB 的凹陷,听感上"中间掉音量"。
7. 动态音乐:节拍对齐切换
// 只能在小节/拍边界切换,否则"卡不准"
void RequestMusicTransition(EMusicState NewState) {
PendingState = NewState; // 排队,不立即切
bPending = true;
}
// 由中间件音乐回调触发(Wwise: Music Sync Callback / FMOD: Beat Callback)
void OnMusicBeat(int Bar, int Beat) {
if (!bPending) return;
if (Beat == 1) { // 只在小节头切换
ApplyMusicState(PendingState);
bPending = false;
}
}
要点:
- 所有段落 同 BPM、同调性,否则切不动
- 若需要紧急切换(死亡、Boss 登场),用 Stinger 盖过过渡,而不是硬切
- 音乐回调还能反向驱动玩法(节奏游戏、UI 脉动)
8. 引擎接入片段
Wwise(Unreal C++)
#include "AkAudioDevice.h"
// 播放(带位置)
FAkAudioDevice::Get()->PostEvent(TEXT("Play_Weapon_Rifle_Fire"), Actor);
// 设置 RTPC
FAkAudioDevice::Get()->SetRTPCValue(nullptr, TEXT("RTPC_Vehicle_Speed"), SpeedKmh, 0, nullptr);
// 设置 Switch
FAkAudioDevice::Get()->SetSwitch(TEXT("Switch_Surface"), TEXT("Dirt"), Actor);
// 设置 State
FAkAudioDevice::Get()->SetState(TEXT("State_Combat"), TEXT("InCombat"));
// Bank 加载/卸载(必须成对)
FAkAudioDevice::Get()->LoadBank(TEXT("Bank_Level1"));
FAkAudioDevice::Get()->UnloadBank(TEXT("Bank_Level1"));
FMOD(Unity C#)
// 一次性事件
FMODUnity.RuntimeManager.PlayOneShot("event:/SFX/Weapon/Rifle_Fire", transform.position);
// 带参数的持续音(如引擎)
var inst = FMODUnity.RuntimeManager.CreateInstance("event:/Vehicles/Engine");
inst.set3DAttributes(FMODUnity.RuntimeUtils.To3DAttributes(transform));
inst.setParameterByName("RPM", rpm01);
inst.start();
inst.release(); // 注意:start 后 release 表示"播完自动释放"
// 全局参数
FMODUnity.RuntimeManager.StudioSystem.setParameterByName("Combat_Intensity", v);
Unity 自定义 DSP(OnAudioFilterRead)
void OnAudioFilterRead(float[] data, int channels) {
for (int i = 0; i < data.Length; i += channels) {
for (int c = 0; c < channels; c++) {
float x = data[i + c];
z = x * (1 - a) + z * a; // 一阶低通,a 由 cutoff 计算
data[i + c] = z * gain;
}
}
}
该回调在音频线程,禁止在其中分配内存、加锁、访问 Unity 主线程对象。
Unreal MetaSounds(蓝图侧)
MetaSound 内暴露参数接口 → 蓝图调用 SetFloatParameter("Speed", v)
→ 播放用 Audio Component + Play/Stop
分工建议:MetaSounds 负责程序化音源,中间件负责混音/音乐/3D,两者不要重叠做同一件事。
9. 通用实现检查表
- 循环音有明确的 Stop 路径(含物体销毁、场景切换、暂停)
- 每个 Post Event 有节流或状态判断
- 抢占声部有 20ms 淡出
- 所有音效挂了 HPF(40–80Hz)
- 3D 音设置了 Max Distance
- RTPC 有插值平滑(禁止直接跳变)
- 滤波器有 denormal 防护
- 音频线程回调中无内存分配、无锁、无主线程 API
- 参数变化有上下限 clamp(速度、强度、距离)
- 采样率无关:所有时间常数用秒换算,不写死采样点数