简介:Unity AudioVisualizer是一款面向Unity开发者和交互创作者的音频可视化插件,能实时分析音频频率、振幅,转化为波形、频谱、粒子、网格变形等视觉反馈,适用于游戏、音乐应用及沉浸式展示等场景。压缩包为rar格式,约163.62MB,内含4378个文件;以bin序列化资源(2929个)为主体,配合390个info、255个meta、74个材质、54个DLL、37个C#脚本,并附带Prefab预制体、Unity场景、png贴图、wav/mp3音频样本、shader与FBX模型,目录结构完整,可直接导入工程查看效果,便于按模块检索和使用。已有297人学习下载。demo提供了可运行场景、预设可视化样式和示例代码,清晰展示实时音频处理流程及简洁API的调用方式;开发者可调整材质、粒子等参数或创建自定义模板,也能延续其在性能优化上的优势,覆盖PC、移动端和VR环境,快速产出个性化的音频可视化方案。
1. Unity AudioVisualizer:为什么同样一个频谱插件,有人用得爽、有人一换歌就翻车
音频可视化插件 demo 在 Unity 里一搜一大把,但能称得上“强大好用”的并不多。A 同学从某跨平台系统 demo 里拖了一个 AudioVisualizer 资源包下来,第一眼确实炫:条子跟着鼓点跳、颜色还能渐变。可换成他自己混的一首曲子,条形图直接不跟节奏了,打到 Android 真机上干脆纹丝不动。这类翻车几乎都不是 shader 写得不够花,而是频谱取数、频率映射和真机适配这条链路出了问题。Unity AudioVisualizer 的核心说白了就一句话:从 AudioSource 拿到频域数据,再把每个频率段的能量映射成形状、颜色或位移。它适合做音乐 App 的播放界面、节奏玩法的打击反馈、大屏演出的背景动效。这篇文章不聊花哨效果,只把取数、映射、平滑和平台适配讲透,让你拿到的 demo 能改、能调、能落地。
2. 频谱数据的原理与选型:GetSpectrumData 够用,但别踩窗口和映射的坑
2.1 从 AudioSource 到频域:Unity 的 FFT 封装到底做了什么
先明确一个基本事实:AudioSource 播放的是时域波形,而 GetSpectrumData 在内部做了一次 FFT,输出的是复数的幅度谱。FFT 的输入长度必须是 2 的幂次方,Unity 提供了 64、128、256、512、1024、2048、4096 这几档可选。很多人以为 4096 一定比 1024 好,其实不是:fftSize 越大,频率分辨率越细,但每一帧的计算量也上去了,Visualizer 这种每帧都要取一次数据的场景,1024 和 2048 是最常用的区间,64 和 128 得到的频域数据实在太粗糙,只适合做整体响度指示,不适合驱动条形图。
以 44100Hz 采样率、fftSize 为 1024 为例,FFT 输出的频率 bin 总数是 fftSize 的一半,也就是 512 个。每个 bin 覆盖的宽度等于采样率除以 fftSize,算出来大约 43Hz。这意味着你拿到的频谱里,第 0 个 bin 代表 0~43Hz,第 1 个 bin 代表 43~86Hz,以此类推。这种线性排列和人耳对频率的感知严重不符:人耳对低频的分辨能力远高于高频,但线性 bin 在低频段又粗又少,直接拿数组前几十个元素去驱动条形图,你会得到一坨挤在底部的“低频团”,高频段则稀稀拉拉没有细节。
再看窗口函数。GetSpectrumData 的第三个参数提供了 FFTWindow 的枚举,常见的有 Rect、Hanning、Hamming、Blackman 和 BlackmanHarris。Rect 等于不做加窗,频谱泄漏最严重,一个正弦波的能量会洇到旁边一堆 bin 里,导致条形图的“尾巴”特别脏。Hanning 和 BlackmanHarris 对泄漏的抑制好,但代价是主瓣变宽、绝对幅值被压低。做可视化我一般会固定用 BlackmanHarris,视觉上最干净,代价是幅值会比 Hanning 低一点,后面用 gain 补偿即可。这里有个常见误区:以为 GetSpectrumData 返回的是 0~1 的归一化音量。它返回的是与输入时域信号幅度相关的绝对值,会受 AudioSource 音量、音频素材本身响度、窗口函数共同影响。所以拿到数据之后必须自己做归一化和动态范围压缩,而不是直接把数组当“0~1 标准数据”用。
channel 参数也很容易埋雷。第二个参数 0 代表第一个声道,1 代表第二个声道。对立体声素材来说,很多官方示例都写 0,这在大部分机器上没问题。但有些安卓机型在特定音频输出模式下,声道索引行为和编辑器不一致,传 1 可能越界返回全 0。稳妥做法是在初始化时根据 clip.channels 判断:单声道素材强行传 1 反而会拿不到数据。另外 GetSpectrumData 的输入是 AudioSource 的输出链路,如果你加了 AudioMixer 做后期处理,从 GetSpectrumData 拿到的其实是经过 mixer 之前的源数据,不是混音后的最终结果。做可视化通常在源数据上取就够了,不必为此去接 AudioMixer 的快照。
2.2 为什么“换首歌就废”:对数频率映射与帧间平滑才是关键
绝大多数“换首歌就废”的 demo,问题不在取数而在映射。人耳对频率的感知近似对数,20Hz 到 20kHz 跨越了十来个八度,如果你把 FFT 结果均匀切成 N 段,低频段几个 bin 挤一个条,高频段几百个 bin 挤一个条,视觉上就是低频一坨、高频无细节。正确做法是把条形索引映射到对数频率轴:第 i 根条对应的目标频率按几何级数在 minFreq 和 maxFreq 之间分布。
常见做法是先算好频率映射表。下面这段代码演示了如何把一根条形图索引换算到 FFT bin:
// barCount 为条形总数,freqPerBin 为每个 bin 覆盖的频宽(采样率的一半除以 fftSize) float t = (float)i / (barCount - 1); float freq = minFreq * Mathf.Pow(maxFreq / minFreq, t); int bin = Mathf.FloorToInt(freq / freqPerBin); bin = Mathf.Clamp(bin, 0, fftSize / 2 - 1);minFreq 我建议直接给 20,低于人耳下限的频段几乎没有能量,给了也白给。maxFreq 给采样率的一半,也就是 Nyquist 频率,超过这个值 FFT 结果里全是 0。采样率不要写死,运行时用 AudioSettings.outputSampleRate 取值,因为不同平台差异极大:编辑器可能是 48000,某台安卓手机可能是 44100,另一台可能是 48000,写死必然在某台设备上偏频。freqPerBin 的计算方法是采样率除以 fftSize,注意不是采样率除以二再除以 fftSize,FFT 结果的 bin 宽就是 采样率 / fftSize 这个值。
第二个让 demo 显得“神经质”的原因是帧间平滑缺失。原始频谱 bin 的数值每帧波动很大,直接拿它驱动 Cube 的 scale,条形图会快得跟白噪声一样,眼睛根本来不及捕捉节奏。常见做法是给每个条维护一个平滑值,用指数平滑让条子的上升和回落都有惯性:
smoothedValue = Mathf.Lerp(smoothedValue, 目标值, 1 - Mathf.Exp(-smoothSpeed * Time.deltaTime));smoothSpeed 单位是 1/秒,值越大越跟手但越抖,值越小越稳当但越迟钝。不同频段对平滑的敏感度不一样:低频段可以给大一点保持打击感,高频段给小一点避免闪烁。这个参数后面调试的时候会反复动,建议直接暴露成 public 字段,不要写死在脚本里。
还要说明一下为什么很多实现选择自己写 FFT 而不是依赖 GetSpectrumData。GetSpectrumData 有个隐藏限制:它只能在 AudioSource 处于播放状态时输出有效数据,且拿不到未播放剪辑的频域内容。如果你要做音频预览,或者要处理一个还没挂到 AudioSource 上的 AudioClip,就得用 AudioClip.GetData 读时域 PCM,再用自己实现的 FFT 转换到频域。代价是每帧多一次数组分配和几百次复数运算,移动端上要谨慎。实时播放场景里能用 GetSpectrumData 就用它,简单、免费、性能好。
3. 搭一个可复现的 AudioVisualizer demo:从新建场景到条形频谱动起来
3.1 最小场景与取数脚本:先让频谱数据“看得见”
这一节的目标是搭一个最小可跑工程:新建场景、挂 AudioSource、写一个取频谱的脚本、用 Console 或临时 UI 验证数据在动,然后才是做条。不要一上来就摆一堆 Cube,数据没通之前做任何视觉都是白费。
场景准备三步走:第一步新建一个空场景;第二步创建空物体,挂 AudioSource 组件,把测试音频拖进 AudioClip,勾选 Loop;第三步创建一个脚本 AudioSpectrumReader.cs,挂到同一个物体上,把 AudioSource 拖进 public 引用。如果只是验证链路,Volume 调到 1,Spatial Blend 保持 0,保持 2D 模式,避免 3D 衰减在 Camera 距离上影响音量。
取数脚本我习惯这样写:
using UnityEngine; public class AudioSpectrumReader : MonoBehaviour { public AudioSource audioSource; [Range(64, 4096)] public int fftSize = 1024; public FFTWindow window = FFTWindow.BlackmanHarris; private float[] spectrum; private bool isPaused; void Update() { // 防止切场景或重挂脚本时数组长度不一致 if (spectrum == null || spectrum.Length != fftSize) spectrum = new float[fftSize]; if (audioSource == null || !audioSource.isPlaying || isPaused) return; audioSource.GetSpectrumData(spectrum, 0, window); } public float GetBandValue(int binIndex) { if (spectrum == null || binIndex < 0 || binIndex >= spectrum.Length) return 0f; return spectrum[binIndex]; } }逻辑说明:Update 里先检查数组长度再取值,防止运行中改了 fftSize 导致数组越界;audioSource 没在播放时直接返回,避免拿全 0 数据;isPaused 字段留给上层控制,暂停时冻结输出。GetBandValue 是给外界读取的接口,不直接暴露数组,防止外部代码无意中修改数据。参数说明:fftSize 选了 [Range(64, 4096)],实际运行中建议 1024 起步;window 用 BlackmanHarris,频谱泄漏小,条形图视觉干净。这个方法每帧拉一次频谱,对移动端性能影响可以忽略,但不要在 FixedUpdate 里调用,物理帧率不稳定会导致数据更新不均匀。
写完后先别做条,直接在 Inspector 里把脚本拖到物体上,运行场景,在 Update 里加一行 Debug.Log(spectrum[2]) 看数值是否随音乐跳变。如果一直不动,优先查 AudioSource 是否没播、Volume 是否为 0。数值在 0.0001 到 0.1 量级都是正常的,不用惊讶,这正是后面需要 gain 和自动增益的原因。
3.2 频谱条:对数映射、平滑与 scaleMultiplier 一次到位
数据链路通了之后,开始生成条形图。常见的做法有两条路:用 UI Image 做,方便做圆角、渐变和 Canvas 布局;用 Cube 做,方便在 3D 场景里摆放、旋转、加特效。这里用 Cube 实现,因为 3D Transform 的 scale 驱动最直观,也方便你换成 Sprite 或 UI。
核心生成和更新逻辑在一个脚本里完成。注意 Start 时要根据当前采样率计算 freqPerBin,不要用常量;条形物体的生成用 GameObject.CreatePrimitive 只是为了跑通逻辑,正式项目建议换成对象池或提前实例化的数组。
using UnityEngine; public class SpectrumBars : MonoBehaviour { public AudioSpectrumReader reader; public int barCount = 32; public float minFreq = 20f; public float maxFreq = 20000f; public float smoothSpeed = 8f; public float scaleMultiplier = 20f; public float gain = 1f; private GameObject[] bars; private float[] smoothedValues; private float[] freqMap; // 每根条对应的目标频率 private float[] binMap; // 每根条对应的 FFT bin 索引 private float freqPerBin; void Start() { float sampleRate = AudioSettings.outputSampleRate; freqPerBin = sampleRate / reader.fftSize; bars = new GameObject[barCount]; smoothedValues = new float[barCount]; freqMap = new float[barCount]; binMap = new float[barCount]; // 预先算好对数频率映射,避免每帧重复计算对数 for (int i = 0; i < barCount; i++) { float t = (float)i / (barCount - 1); float freq = minFreq * Mathf.Pow(maxFreq / minFreq, t); freqMap[i] = freq; binMap[i] = Mathf.FloorToInt(freq / freqPerBin); GameObject cube = GameObject.CreatePrimitive(PrimitiveType.Cube); cube.transform.SetParent(transform, false); cube.name = "Bar_" + i; bars[i] = cube; } } void Update() { for (int i = 0; i < barCount; i++) { int bin = Mathf.Clamp((int)binMap[i], 0, reader.fftSize / 2 - 1); float raw = reader.GetBandValue(bin) * gain; // 指数平滑:smoothSpeed 越大越跟手,越小越稳 float lerpFactor = 1f - Mathf.Exp(-smoothSpeed * Time.deltaTime); smoothedValues[i] = Mathf.Lerp(smoothedValues[i], raw, lerpFactor); Vector3 scale = bars[i].transform.localScale; scale.y = Mathf.Max(0.05f, smoothedValues[i] * scaleMultiplier); bars[i].transform.localScale = scale; bars[i].transform.localPosition = new Vector3(i * 1.2f, scale.y * 0.5f, 0f); } } }逻辑说明:Start 里先算好 freqMap 和 binMap,Update 里直接查表,省掉每帧的对数和除法运算,这在 barCount 较大时能明显降低开销。每根条用原始 spectrum 值乘 gain,再做指数平滑,最后用 scaleMultiplier 放大成可见的物体高度。localPosition 的 y 设为 scale.y 的一半,是为了让条子“从地面往上长”,而不是以中心点向两边伸展。
参数说明:barCount 建议在 24 到 48 之间定义,太少看不出频率分布,太多视觉噪点多且每帧计算量大。minFreq 与 maxFreq 的几何映射保证低频段条子密度高、高频段密度低——但如果你觉得高频条太少,可以把 maxFreq 从 20000 降到 12000,牺牲一点极高频细节换取视觉密度。smoothSpeed 默认给 8,这是我试过比较多场景的中等值;做 EDM 鼓点反馈可以提到 12,做氛围循环视频可以降到 4。scaleMultiplier 的取值直接取决于音频素材的响度,没有万能值,先用 20 跑一遍,如果条子基本趴着不动把它翻倍,如果动不动顶到天花板就减半。
运行起来如果条子高度波形正常、节奏跟得上,你的 AudioVisualizer demo 就已经完成了 70%。剩下的 30% 是让它在不同音乐、不同平台上都不翻车,见下一章。
4. 排查与避坑:频谱不动、真机空白、条子乱跳的五个典型问题
4.1 问题一:频谱数据全零,换一个 clip 却又正常了
现象:条形图全部趴在底部不动,Debug.Log 打出来的 spectrum 值全是 0。换一个音频文件后恢复正常,但那个“问题音频”在系统播放器里明明有声。
原因:这个坑多出在音频素材本身的编码或导入设置上。如果 clip 的 Load Type 被设成了 Streaming,而播放代码在 clip 尚未加载完成时就调用 GetSpectrumData,取到的就是全 0。另外,如果 AudioSource 未勾选 Play On Awake 且没有代码调用 Play(),isPlaying 为 false,取数脚本会直接 return。
解决:检查 AudioClip 的 Import Settings,Load Type 改成 Decompress On Load 或 Compressed In Memory,不要在 Update 里手动 Play 又立即取数。还拿不到数据时,手动调 audioSource.Play() 之后再等一帧再取值。我的排查习惯是先打 audioSource.isPlaying 确认播放状态,再打 spectrum 数组的 max 值,两步就能定位是输入问题还是脚本问题,不用瞎猜。
4.2 问题二:Editor 里正常,Android 真机上高频特别猛或低频几乎没了
现象:同一段音乐,在编辑器的 Game 视图里条形图分布正常,打包到 Android 真机后,高频条子异常突出,低频部分几乎趴平,整体视觉分布完全变形。
原因:这是采样率不一致导致的频率映射偏移。编辑器里 AudioSettings.outputSampleRate 可能是 48000,你按 48000 算好了 freqPerBin;但某台真机以 44100 输出,实际每个 bin 覆盖的频宽比你的映射表小,你在根索引上读到的值根本不是期望频段的能量。还有一种情况:手机开启了超采样或系统级音效处理,导致 AudioSource 实际输出采样率与你查询到的值不同。
解决:不要缓存一次采样率就用到死。在 OnAudioConfigurationChanged 回调里重新读 AudioSettings.outputSampleRate,并重建 binMap。音频设置变化这个事件在真机上偶尔会被触发,特别是切换蓝牙耳机或外接声卡时。代码层面对策如下:
private void OnAudioConfigurationChanged(bool deviceWasChanged) { if (deviceWasChanged) { float newSampleRate = AudioSettings.outputSampleRate; freqPerBin = newSampleRate / reader.fftSize; RebuildBinMap(); } }另外在手机上不要用采样率 22050 以下的低频增强——很多安卓机型对 100Hz 以下做了滤波,低频能量本身就被砍了一截,你在条子上看到低频弱不一定是映射问题,也可能是设备特性。这种情况没有代码解法,只能接受或者把分频段增益调高,算是一种玄学但有效的补偿。
4.3 问题三:条形图跳动幅度过大,像白噪声一样闪瞎眼
现象:条子上下抖动频率极高,视觉上无法跟住节奏,截图看是残影,视频里像接触不良的灯带。
原因:三个叠加因素。第一,原始 FFT bin 值帧间变化剧烈,尤其是高频段;第二,smoothSpeed 设得过大,等于没平滑;第三,GetSpectrumData 在 FixedUpdate 里每物理帧调用,而物理帧率在手机上有波动,导致数据更新间隔不均匀,看起来像加速抖动。
解决:GetSpectrumData 放 Update,不放 FixedUpdate。平滑系数分频段差异化配置:低频 10,中频 6,高频 3。再加一道硬限幅,单帧变化幅度不超过前值的 30%。如果你用的是上一章的 SpectrumBars 脚本,核心改动在平滑这一行:把 lerpFactor 从统一值改成按频段插值,低频用更快的响应时间。我一般还在后面串一个简单的一阶低通,相当于双重平滑,极端情况下能救回明显带噪的素材。血泪经验:先确认是不是素材本身高频就炸,很多劣质 MP3 高频段全是量化噪声,这种不是代码能救的,直接换素材。
4.4 问题四:暂停音频后条子“僵住”或者瞬间归零再弹起
现象:游戏切后台或主动 Pause 音频时,条形图瞬间掉到零;Resume 之后条子从零往上冲,节奏感全断。有些实现里用 Time.timeScale = 0 做暂停,条子原地定住,看起来是好的,但频率数据其实已经不更新了。
原因:AudioSource 暂停后没有新的 PCM 数据流入。如果暂停时取数脚本没有感知到暂停状态,继续调用 GetSpectrumData 会拿到 0;如果用 Time.timeScale 暂停,Update 里的 Time.deltaTime 变 0,平滑系数变成 0,smoothedValues 相当于被冻结。两者表现不同但本质都是停止更新。
解决:给 reader 增加暂停状态,暂停时不读频谱但保留最后一帧数据;Resume 后的第一帧用当前值与冻结值做一次加权平均,避免从零爬升的突兀感。代码模板:
public void SetPaused(bool paused) { isPaused = paused; if (paused) frozenValues = (float[])smoothedValues.Clone(); else // 恢复时从冻结值起步,不从零开始 for (int i = 0; i < barCount; i++) smoothedValues[i] = frozenValues[i] * 0.5f + smoothedValues[i] * 0.5f; }这里要注意 frozenValues 必须在暂停的最后一帧拷贝,不要在 Update 之后再赋值,否则可能拿到半更新的脏数据。
4.5 问题五:WebGL 平台频谱一直为零或者数值诡异跳变
现象:同一套代码,编辑器正常,打包成 WebGL 之后频谱数据要么全 0,要么数值乱蹦和音乐完全对不上。
原因:WebGL 的音频输出走的是浏览器音频栈,Unity 在 WebGL 上对 GetSpectrumData 的支持不完整,拿到的数据不可靠。这个问题在国内被反复提及,很多“可视化插件 web 版失效”的反馈都是这个原因。
解决:公开的可行方案是绕开 GetSpectrumData,改用 AudioClip.GetData 读原始时域数据,在脚本里自己跑一个 FFT。这会增加 GC 压力和 CPU 消耗,所以只建议在需要兼容 WebGL 的工程里使用。我给过某图像处理 Demo 项目做过类似改造,实测 1024 点 FFT 每帧跑一次在普通笔记本上无压力,但在低端手机上自写 FFT 还是会有可感知的卡顿。另一个取巧方案是不要取实时频谱,而是把音频播放位置与预分析的频谱数据对齐做“伪实时”,适合演出类应用,不适合对精确性要求高的场合。
5. 从 demo 到能用的进阶技巧:峰值保持、频段聚合与自动增益
本节三个技巧可以让 AudioVisualizer 从“能跑”变成“耐看”,并且在不同曲风、不同响度素材之间保持一致表现。第一是峰值保持。人眼对节奏的感知依赖于持续几十毫秒的视觉痕迹,纯平滑值回落太快,高频条会显得闪烁。常见做法是给每根条维护一个峰值数组:当前值大于峰值时,峰值立即跟踪;当前值小于峰值时,峰值以固定速率衰减。这样条子顶端形成“拖尾”效果,视觉上更有节奏感。实现时注意 decay 速率不要线性,用指数衰减更自然。
第二是频段聚合。与其 32 根条各自为战,不如把频谱分成低频(20~250Hz)、中频(250~4000Hz)、高频(4000~20000Hz)三个区间,各区间内求对数平均能量,再驱动场景级动画。这样做的价值在于:单个 bin 的数值容易波动,聚合后的频段能量非常稳定,适合驱动光照强度、摄像机震动、粒子系统发射速率这类全局效果。我的习惯是“条形图走细节,频段聚合走氛围”,两层叠加出来的画面比单纯一排条子有层次得多。
第三是自动增益,这是换歌不翻车的压舱石。不同音乐的母带响度差异可能达到 20dB 以上,固定 scaleMultiplier 必然导致响的歌条子顶爆、轻的歌条子趴着。做法是维护一个随时间缓慢更新的增益系数:每帧计算当前频谱最大值,如果它大于历史峰值就快速上升,如果小于历史峰值则缓慢回落。这个系数作为分母对原始值做归一化。Rate 的选择有讲究:上升要快,避免开头 0.5 秒内条子顶爆;回落要慢,避免安静的乐段突然放大噪声。公式写成:
autoGain = Mathf.Lerp(autoGain, currentMax, 1f - Mathf.Exp(-rate * Time.deltaTime));我一般还会对 currentMax 做一次中值滤波,防止个别异常帧把增益拉崩。这三个技巧加起来代码量不超过 60 行,但带来的稳定性提升是立竿见影的。我现在接到音频可视化相关的需求,第一条建议就是先把自动增益和峰值保持写进去,没有这两样,再炫的着色器也撑不过第二首歌。希望帮到你。
本文还有配套的精品资源,点击获取