Elytra
Blog数学

傅里叶变换:游戏开发中的频域思维

从 Waveform 与 Spectrum 出发,理解傅里叶变换为什么把时域问题转换到频域,以及 FFT、采样、窗口、STFT、卷积和频域海洋在游戏开发中的意义。

傅里叶变换(Fourier Transform)经常以一种令人望而生畏的方式出现:积分、复数、指数函数,以及一连串 FT、DFT、FFT、STFT 的缩写。

但对游戏程序员来说,更值得首先理解的并不是如何手算傅里叶变换,而是一个更基础的问题:

为什么我们要把一个信号从原来的表示方式,转换到“频率”这个表示空间?

这背后其实是一种非常通用的工程思想:

当一个问题在原来的坐标系里很复杂时,换一种表示,可能会让它的结构突然变得简单。

声音是理解这一点最直观的入口。

一段音频既可以表示为随时间变化的波形,也可以表示为不同频率成分的组合:

Waveform⟷Spectrum\boxed{ \text{Waveform} \quad\longleftrightarrow\quad \text{Spectrum} }

它们不是两份不同的数据所描述的两种声音,而是同一个信号的两种表示。

傅里叶变换,就是连接这两种表示最重要的数学工具之一。

Loading diagram…

1. 为什么需要频域?

1.1 计算机看到的声音是什么?

数字音频最直接的形式,其实只是一串数字:

0.02, 0.11, 0.37, 0.68, 0.42, -0.15, -0.61, ...

每一个数值代表某个离散时刻的采样值。

如果把它画出来,就是我们熟悉的 Waveform(波形):

Amplitude
    ↑
    │       /\          /\
    │   /\ /  \   /\   /  \
────┼──/──V────\_/──\_/──────→ Time
    │

横轴是时间:

tt

纵轴是振幅:

x(t)x(t)

因此时域中的信号回答的是:

某个时刻,信号的值是多少?

这里的纵轴并不是“音高”。

音高来自振动的周期性:波形重复得越快,对应的频率通常越高。

一个纯正弦信号可以写成:

x(t)=Asin⁡(2πft+ϕ)x(t)=A\sin(2\pi ft+\phi)

其中:

符号含义
AA振幅(Amplitude)
ff频率(Frequency)
ϕ\phi相位(Phase)
tt时间

例如,一个 440 Hz440\text{ Hz} 的纯正弦波意味着它每秒完成 440 个周期。

问题在于,现实中的声音几乎从来都不像单一正弦波那么简单。

它通常更像:

x(t)=A1sin⁡(2πf1t+ϕ1)+A2sin⁡(2πf2t+ϕ2)+⋯x(t) = A_1\sin(2\pi f_1t+\phi_1) + A_2\sin(2\pi f_2t+\phi_2) + \cdots

大量频率同时叠加之后,时域波形会迅速变得复杂。


1.2 时域能告诉我们什么,又不能告诉我们什么?

假设现在有这样一个信号:

x(t)=sin⁡(2π⋅100t)+0.5sin⁡(2π⋅1000t)x(t) = \sin(2\pi\cdot100t) + 0.5\sin(2\pi\cdot1000t)

它是两个简单正弦波的叠加:

  • 一个 100 Hz100\text{ Hz};
  • 一个 1000 Hz1000\text{ Hz},振幅只有前者的一半。

如果直接观察 Waveform,我们看到的是两者叠加之后的一条复杂曲线。

但如果问题变成:

这段声音里到底包含哪些频率?

时域就不再是最自然的表示。

我们更希望得到类似:

Magnitude
    ↑
    │   █
    │   █
    │   █                 █
    │   █                 █
────┼───█─────────────────█────→ Frequency
       100 Hz           1000 Hz

这就是 Spectrum(频谱)。

它不再主要回答:

每个时刻发生了什么?

而是在回答:

这个信号由哪些频率组成,每个频率的成分有多强?

这便是从 Time Domain(时域) 转向 Frequency Domain(频域) 的动机。


1.3 从 Waveform 到 Spectrum:换一种方式观察同一个信号

Spectrum 并不是 Waveform 的平均值。

它们之间也不是:

Waveform→统计一下→Spectrum\text{Waveform} \rightarrow \text{统计一下} \rightarrow \text{Spectrum}

更准确的理解是:

同一个信号,在不同基底下的表示\boxed{ \text{同一个信号,在不同基底下的表示} }

可以类比一个三维向量。

同一个向量 v\mathbf v 可以在一组坐标轴下写成:

v=xex+yey+zez\mathbf v = x\mathbf e_x+y\mathbf e_y+z\mathbf e_z

如果换一组基底,它又可能写成:

v=ab1+bb2+cb3\mathbf v = a\mathbf b_1+b\mathbf b_2+c\mathbf b_3

向量没有改变。

改变的是描述它的坐标系。

傅里叶变换所做的事情与此非常相似:

不再问信号在每个时间位置上是多少,而是问它在每一个“频率基函数”方向上有多少分量。

Spectrum 不是 Waveform 的平均。

Waveform 与 Spectrum 是同一个信号的两种表示。 前者以时间为坐标,后者以频率成分为坐标。


2. 傅里叶变换到底在做什么?

2.1 复杂信号可以由简单周期波组成

傅里叶分析最重要的思想之一,是把复杂函数表示为大量周期成分的组合。NumPy 的 FFT 文档也将 Fourier Analysis 概括为:用周期成分的和表示函数,并能够从这些成分恢复原函数。1

最熟悉的形式是正弦和余弦:

x(t)=∑kAksin⁡(2πfkt+ϕk)x(t) = \sum_k A_k\sin(2\pi f_kt+\phi_k)

如果使用复数,表达会更加简洁。

根据 Euler 公式:

eiθ=cos⁡θ+isin⁡θe^{i\theta} = \cos\theta+i\sin\theta

一个频率成分可以写成:

ei2πfte^{i2\pi ft}

于是傅里叶分析实际上是在问:

对于每一个频率 ff,信号 x(t)x(t) 中到底含有多少 ei2πfte^{i2\pi ft}?

这就是频率分解的核心。


2.2 频率、振幅与相位

如果只看常见的频谱可视化,很容易产生一个误解:

傅里叶变换的输出就是“每个频率有多强”。

实际上,完整的傅里叶变换结果通常是复数。

可以写成:

X(f)=∣X(f)∣eiϕ(f)X(f) = |X(f)|e^{i\phi(f)}

其中:

∣X(f)∣|X(f)|

代表 Magnitude;

而:

ϕ(f)\phi(f)

代表 Phase。

因此,一个频率成分并不只有“有多少”,还有:

这个周期波相对于其他周期波处在什么相位。

NumPy 的定义中:

  • abs(A) 对应 amplitude spectrum;
  • abs(A) ** 2 对应 power spectrum;
  • angle(A) 对应 phase spectrum。1

游戏里的频谱柱状图通常只关心 Magnitude,因此 Phase 很容易从直觉中消失。

但如果要把频谱重新还原成原始信号,相位是不能随意丢掉的。


2.3 从“分解信号”理解傅里叶变换

连续傅里叶变换的一种常见定义是:

X(f)=∫−∞+∞x(t)e−i2πft dtX(f) = \int_{-\infty}^{+\infty} x(t)e^{-i2\pi ft}\,dt

乍看起来,这只是一个复杂积分。

但如果把它理解成:

拿信号 x(t) 与频率 f 的波进行匹配\boxed{ \text{拿信号 }x(t) \text{ 与频率 }f \text{ 的波进行匹配} }

直觉就会清楚很多。

当 x(t)x(t) 中确实包含某个频率时,它与这个频率的复指数会产生较强的对应结果。

如果不存在这个频率,相互抵消之后的结果就会较小。

于是:

x(t)→X(f)x(t) \rightarrow X(f)

可以理解成:

从“每个时间点有多少”,转换为“每个频率方向上有多少”。


2.4 傅里叶变换也可以理解为换基

对于游戏程序员来说,“换基”可能比“频率分解”更加统一。

在线性代数里,一个向量可以投影到不同基底:

v→(c1,c2,c3,…)\mathbf v \rightarrow (c_1,c_2,c_3,\ldots)

傅里叶分析只是把基底换成了一组不同频率的周期函数。

因此可以粗略理解为:

Time Basis→Frequency Basis\text{Time Basis} \rightarrow \text{Frequency Basis}

这也是为什么傅里叶思想并不限于声音。

声音的自变量是时间,所以我们讨论:

Temporal Frequency\text{Temporal Frequency}

图像的自变量是空间位置,所以可以讨论:

Spatial Frequency\text{Spatial Frequency}

海面同样是空间中的高度场,也能够用不同波长、方向和波数的波来表示。


2.5 逆傅里叶变换:还能变回去吗?

如果频域真的是同一个信号的另一种完整表示,那么理论上当然应该能变回来。

连续情况下存在逆傅里叶变换:

x(t)=∫−∞+∞X(f)ei2πft dfx(t) = \int_{-\infty}^{+\infty} X(f)e^{i2\pi ft}\,df

于是整个过程是:

x(t)↔FourierX(f)\boxed{ x(t) \xleftrightarrow{\text{Fourier}} X(f) }

这点非常重要。

傅里叶变换并不是单纯的“分析工具”。

它实际上建立了两种表示之间的双向转换。

后面我们会看到,FFT Ocean 正是先在频域构造海浪,再通过逆变换生成空间中的海面。


3. 从 Fourier Transform 到 FFT

3.1 Fourier Series、FT、DFT 是什么关系?

工程里经常混在一起出现的几个名词,其实处在不同层次。

概念典型对象核心意义
Fourier Series周期函数用离散的谐波序列表示周期函数
Fourier Transform连续信号从连续原始域转换到连续频域
DTFT离散时间、无限序列离散信号对应的连续周期频谱
DFT有限个离散采样点得到有限个离散频率分量
FFT算法高效计算 DFT

实际游戏程序里,输入通常是:

float samples[N]

而不是数学意义上无限连续的 x(t)x(t)。

所以程序真正计算的通常是 DFT(Discrete Fourier Transform)。


3.2 为什么计算机实际使用 DFT?

假设我们有 NN 个采样:

x[0],x[1],…,x[N−1]x[0],x[1],\ldots,x[N-1]

一种常见的 DFT 定义是:

X[k]=∑n=0N−1x[n]e−i2πkn/NX[k] = \sum_{n=0}^{N-1} x[n] e^{-i2\pi kn/N}

其中:

k=0,1,…,N−1k=0,1,\ldots,N-1

这里的每一个 kk 就对应一个离散频率位置。

因此:

N 个时域采样
        ↓
       DFT
        ↓
N 个复数频域系数

对于实值音频信号,还存在一个很重要的性质:

X[−f]=X[f]∗X[-f]=X[f]^*

即负频率与正频率具有共轭对称性。

因此对真实音频,很多 FFT API 只需要保存从 00 到 Nyquist Frequency 的一半频谱;例如 NumPy 的 rfft 对 NN 个实值输入只返回非负频率部分。1


3.3 FFT 不是另一种变换

这是名字上最容易造成误解的地方。

FFT(Fast Fourier Transform)不是一种与 DFT 并列的新变换。

它是一类:

快速计算 DFT 的算法。

因此:

FFT≠新的频域定义\boxed{ \text{FFT} \neq \text{新的频域定义} }

而是:

FFT=更聪明地计算 DFT\boxed{ \text{FFT} = \text{更聪明地计算 DFT} }

1965 年,James Cooley 与 John Tukey 发表的经典论文 An Algorithm for the Machine Calculation of Complex Fourier Series 系统描述了通过分解问题,把计算量从与 N2N^2 成比例降到与 Nlog⁡NN\log N 成比例的方法。2


3.4 FFT 为什么能比直接计算 DFT 快?

直接按照 DFT 定义计算:

X[k]=∑n=0N−1⋯X[k] = \sum_{n=0}^{N-1}\cdots

有 NN 个 kk。

而每个 kk 又需要遍历 NN 个输入。

于是大约需要:

O(N2)O(N^2)

次运算。

FFT 利用了复指数中的周期性与对称性,把大的 DFT 拆成许多更小的 DFT,并复用中间结果。

经典 radix-2 思路会不断拆分:

Loading diagram…

最终复杂度降低到:

O(Nlog⁡N)O(N\log N)

这使频域方法真正成为实时计算中的实用工具。

FFT 并不要求数学上的 DFT 长度必须是 2 的幂。

某些 FFT 算法和具体 API 对 2n2^n 长度特别友好,甚至直接要求如此,但现代 FFT 库也可以处理任意长度。FFTW 例如明确支持 arbitrary length,并为所有长度采用 O(nlog⁡n)O(n\log n) 级算法。3

因此“FFT 必须是 2 的幂”是具体实现限制,不是 DFT 本身的数学要求。


4. 现实中的 FFT 为什么没有那么完美?

理论中的例子经常是:

sin⁡(2πft)\sin(2\pi ft)

然后傅里叶变换得到一个干净的频率尖峰。

但真正把音频数据丢进 FFT 后,经常会发现:

  • 一个频率扩散到了附近很多 Bin;
  • 同一个频率换个 FFT Size,结果似乎变了;
  • 峰值并不刚好落在目标 Hz 上;
  • 改一个 Window,频谱形状也发生变化。

问题并不在 FFT。

问题在于:

计算机处理的是有限长度、离散采样的信号。


4.1 Sample Rate:离散采样意味着什么?

假设连续信号为:

x(t)x(t)

采样间隔为:

TsT_s

数字信号实际上记录的是:

x[n]=x(nTs)x[n]=x(nT_s)

采样率则是:

fs=1Tsf_s=\frac{1}{T_s}

如果:

fs=48000 Hzf_s=48000\text{ Hz}

就意味着每秒获得 48000 个样本。

这也是数字音频从连续世界进入计算机的第一步。


4.2 Nyquist Frequency 与 Aliasing

离散采样并不能无条件保存任意高的频率。

对于采样率 fsf_s,Nyquist Frequency 为:

fNyquist=fs2f_{\text{Nyquist}} = \frac{f_s}{2}

例如:

fs=48000 Hzf_s=48000\text{ Hz}

则:

fNyquist=24000 Hzf_{\text{Nyquist}}=24000\text{ Hz}

SciPy 的 FFT 文档同样把:

fNy=fs2f_{\text{Ny}}=\frac{f_s}{2}

定义为 Nyquist Frequency。4

超过可表示范围的频率如果未经带限处理就进入采样系统,会折叠成较低频率,这就是 Aliasing(混叠)。

这与实时图形学中的空间混叠其实是同一类数学问题:

原始信号包含的变化速度,超过了离散采样网格能够表达的上限。


4.3 FFT Bin 是什么?

FFT 不会告诉你“所有连续频率”的大小。

它只计算一组离散频率。

如果采样率为 fsf_s,FFT 长度为 NN,那么相邻频率采样之间的间隔为:

Δf=fsN\Delta f = \frac{f_s}{N}

第 kk 个 Bin 对应:

fk=kfsNf_k = k\frac{f_s}{N}

假设:

fs=48000f_s=48000

并且:

N=1024N=1024

那么:

Δf=480001024≈46.875 Hz\Delta f = \frac{48000}{1024} \approx46.875\text{ Hz}

FFT 并不是在问:

440.000 Hz 有多少?
441.000 Hz 有多少?
442.000 Hz 有多少?

而是在固定的离散网格上进行测量。

这也解释了为什么真实频率往往不会刚好落在某个 Bin 中心。


4.4 FFT Size 为什么影响频率分辨率?

在采样率固定时:

Δf=fsN\Delta f=\frac{f_s}{N}

因此 NN 越大,Bin 间距越小。

但这里有一个非常重要的工程细节:

Bin 更密,不一定意味着获得了更多真实频率信息。

如果 NN 增加是因为真正观察了更长的信号,那么:

T=NfsT = \frac{N}{f_s}

也随之增加。

更长的时间窗口允许我们更可靠地区分彼此接近的频率。

但如果原本只有 1024 个样本,只是在后面补 3072 个零:

真实数据
████████

Zero Padding
████████000000000000000000

FFT 输出确实会拥有更多采样点。

但原始观测时间没有增加。

因此它主要是在:

更密地采样原有频谱形状,而不是创造新的频率信息。

SciPy 的频谱分析文档也把 zero-padding 描述为对频谱的过采样,而真正的泄漏与主瓣宽度仍然由有限观测窗口及窗口函数决定。5


4.5 为什么会出现 Spectral Leakage?

想象一个无限持续的纯正弦波:

x(t)=sin⁡(2πft)x(t)=\sin(2\pi ft)

它的理想频谱可以非常集中。

但计算机不可能观察:

−∞<t<+∞-\infty<t<+\infty

我们实际上只截取:

0<t<T0<t<T

这等价于把原始信号乘上一个矩形窗口:

xobserved(t)=x(t)w(t)x_{\text{observed}}(t) = x(t)w(t)

而“时域相乘”在频域中对应卷积。

矩形窗口自身拥有 sinc 形状的频谱,因此原本集中的频率会向周围扩散。

这就是:

Spectral Leakage(频谱泄漏)。

SciPy 的信号处理教程明确指出,有限时间区间造成的截断会产生旁瓣和频谱泄漏;信号越短,这种影响通常越明显。5


4.6 Window Function 到底解决了什么?

既然突然截断信号会造成强烈的不连续,我们可以在 FFT 前逐渐压低窗口两端:

Rectangular

████████████████████

Hann-like

    ▂▄▆██████▆▄▂

也就是:

xw[n]=x[n]w[n]x_w[n] = x[n]w[n]

常见窗口包括:

Window大致特点常见考虑
Rectangular主瓣窄、旁瓣高频率刚好对齐时简单直接
Hann比较均衡通用频谱分析
Hamming与 Hann 类似常见 DSP 窗口
Blackman / Blackman-Harris更强旁瓣抑制更关注泄漏控制
Flat Top更强调峰值幅度精度牺牲较多频率分辨能力

窗口并不能“消灭傅里叶误差”。

它是在两件互相冲突的事情之间做权衡:

主瓣宽度↔旁瓣大小\boxed{ \text{主瓣宽度} \quad\leftrightarrow\quad \text{旁瓣大小} }

也就是:

更容易区分两个接近频率,还是更容易发现强频率旁边的弱频率?

SciPy 的窗口分析同样指出,抑制旁瓣通常需要付出主瓣变宽的代价。5


5. 如何真正读懂声音的 Spectrum?

5.1 基频与泛音

如果播放一个合成的纯 440 Hz440\text{ Hz} 正弦波,频谱主要会在对应频率附近出现能量。

但真实乐器不是纯正弦发生器。

一个周期性声音通常包含:

f0f_0

以及:

2f0,  3f0,  4f0,…2f_0,\;3f_0,\;4f_0,\ldots

这样的谐波成分。

其中 f0f_0 通常称为 Fundamental Frequency(基频)。

后面的整数倍频率则属于 Harmonics。

因此即使两个声音具有相似基频,它们的频谱形状仍然可能完全不同。


5.2 为什么相同音高可以拥有不同音色?

假设两个声音都有:

f0=440 Hzf_0=440\text{ Hz}

但第一个的频率成分可能更接近:

440      ███████
880      ██
1320     █
1760     ▏

另一个可能是:

440      █████
880      ██████
1320     ████
1760     ██

它们拥有相同或接近的基频,却拥有不同的:

  • Harmonic Distribution;
  • Attack;
  • Decay;
  • Noise;
  • Phase;
  • 非周期成分。

因此“音色”不能简单等同于“泛音列表”,但频谱结构确实是其中极其重要的一部分。


5.3 Magnitude Spectrum 与 Power Spectrum

完整 FFT 输出为复数:

X[k]X[k]

最常见的频谱可视化使用:

∣X[k]∣|X[k]|

也就是 Magnitude Spectrum。

如果关心功率,则常使用:

∣X[k]∣2|X[k]|^2

也就是 Power Spectrum。

因此常见的“频谱柱状图”只是完整 Fourier 表示的一部分。

它把复数:

X[k]=a+biX[k] = a+bi

压缩成:

∣X[k]∣=a2+b2|X[k]| = \sqrt{a^2+b^2}

于是相位信息不再显示。


5.4 Phase 为什么不能完全忽略?

如果只做:

  • 音乐可视化;
  • Bass 能量检测;
  • 粗略音色分析;

Magnitude 往往已经足够。

但如果要重建信号,就不能认为:

Magnitude 就是整个 Spectrum。

傅里叶系数本质上包含:

Magnitude+Phase\text{Magnitude}+\text{Phase}

不同频率之间如何在时间上对齐,会直接改变最终生成的波形。

这也是为什么:

∣X(f)∣|X(f)|

相同,并不保证两个时域信号完全相同。


5.5 “低频、中频、高频”究竟意味着什么?

FFT 得到的是许多离散 Bin。

而 Bass、Mid、Treble 通常是人为定义的频带。

例如一个游戏可以定义三个区间:

Blow,Bmid,BhighB_\text{low} ,\quad B_\text{mid} ,\quad B_\text{high}

然后把对应 Bin 的能量累积起来:

EB=∑k∈B∣X[k]∣2E_B = \sum_{k\in B} |X[k]|^2

这样就能得到:

Low    █████████
Mid    █████
High   ██

需要注意的是:

Bass / Mid / Treble 并不是 FFT 天生提供的三个值。

FFT 给的是频率 Bin。

如何把它们聚合成游戏需要的频带,是上层设计。

实际音乐可视化中,也经常使用对数或接近 octave 的频带划分,而不是把整个 Hz 范围机械地平均切成几块。


6. Spectrum 的问题:它没有告诉你“什么时候”

6.1 对整段音乐做一次 FFT 会发生什么?

假设一段 30 秒音乐中:

0s ───── 10s ───── 20s ───── 30s
鼓         人声        高频打击

如果把整个 30 秒一次性 FFT:

x(t)→X(f)x(t) \rightarrow X(f)

我们能够知道:

这 30 秒总体存在什么频率。

但会失去一个极其重要的问题:

这些频率是什么时候出现的?

Spectrum 只有频率轴。

它没有时间轴。


6.2 STFT:把音频切成一个个短窗口

解决方法非常直接:

不要一次分析整段声音,而是不断分析一小段。

Audio
──────────────────────────────────

Window 1
████████

    Window 2
    ████████

        Window 3
        ████████

每一个窗口分别进行 FFT:

Loading diagram…

这就是 STFT(Short-Time Fourier Transform)。

SciPy 将 STFT 描述为:使用滑动窗口遍历输入,并顺序计算 FFT,从而量化频谱如何随时间变化。6

于是原本的一维频谱:

X(f)X(f)

变成:

X(t,f)X(t,f)

6.3 从一串 Spectrum 到 Spectrogram

如果把每一个时间窗口得到的 Spectrum 横向排列:

              Time →
Frequency
    ↑
高  │ ░░▒▒██▒░░
    │ ░▒████▒░░
    │ ░██▒▒██▒░
低  │ ███▒░███▒
    └────────────

就得到 Spectrogram(时频谱图)。

它通常编码:

  • 横轴:Time;
  • 纵轴:Frequency;
  • 颜色或亮度:Magnitude / Power。

因此:

Waveform→Spectrum→Spectrogram\boxed{ \text{Waveform} \rightarrow \text{Spectrum} \rightarrow \text{Spectrogram} }

分别对应:

x(t),X(f),X(t,f)x(t) ,\quad X(f) ,\quad X(t,f)

6.4 时间分辨率与频率分辨率为什么互相牵制?

假设窗口长度为 NN,采样率为 fsf_s。

窗口持续时间:

Δt=Nfs\Delta t = \frac{N}{f_s}

频率 Bin 间隔:

Δf=fsN\Delta f = \frac{f_s}{N}

于是:

窗口变长:

N↑⇒Δf↓N\uparrow \Rightarrow \Delta f\downarrow

频率区分能力更好,但一个窗口覆盖更长的时间。

窗口变短:

能够更准确定位瞬间变化,但频率分辨能力下降。

这就是 STFT 设计中最基本的权衡之一。

你不能无限提高:

  • 时间分辨率;
  • 频率分辨率;

而完全不付代价。

窗口长度、Window Function 与 Hop Size,本质上共同决定你“以什么尺度观察信号”。


7. 游戏音频中的频域分析

7.1 Audio Reactive Visuals

游戏里最直观的 FFT 应用,就是让视觉元素响应音乐。

例如夜店场景可以设计:

Loading diagram…

每帧或每个音频分析周期,取得最近一段音频:

x[n]x[n]

进行窗口处理:

xw[n]=x[n]w[n]x_w[n]=x[n]w[n]

然后 FFT:

xw[n]→X[k]x_w[n] \rightarrow X[k]

再从指定频带累积能量。

例如:

Ebass=∑k∈Bbass∣X[k]∣2E_\text{bass} = \sum_{k\in B_\text{bass}} |X[k]|^2

最后把:

EbassE_\text{bass}

映射到:

  • Light Intensity;
  • Emission;
  • Particle Spawn Rate;
  • Camera Effect;
  • Material Parameter;
  • Gameplay Trigger。

这就是一套最基本的 Audio Reactive Pipeline。


7.2 如何得到稳定的 Bass、Mid、Treble?

直接把某个 FFT Bin 映射到灯光通常效果并不好。

现实中至少还需要考虑三件事。

首先,一个声音的能量经常分布在多个 Bin,而不是刚好集中在一个位置。

因此更合理的是做频带累积:

EB=∑k=k0k1∣X[k]∣2E_B = \sum_{k=k_0}^{k_1}|X[k]|^2

其次,音频强度范围很大。

常常需要进行:

log⁡(E+ϵ)\log(E+\epsilon)

或其他动态范围压缩。

最后,频谱本身变化很快。

直接驱动视觉效果容易产生抖动,所以可以增加时间平滑:

st=αst−1+(1−α)ets_t = \alpha s_{t-1} + (1-\alpha)e_t

其中:

  • ete_t:当前频带能量;
  • sts_t:平滑后的值;
  • α\alpha:平滑系数。

于是一个可用的音乐响应系统通常不是:

FFT → Light

而更接近:

FFT
→ Band Aggregation
→ Normalization
→ Log / Compression
→ Temporal Smoothing
→ Visual Mapping

7.3 Unity 中的 Spectrum Data

以 Unity 6.0 的 API 文档为例,AudioSource.GetSpectrumData 可以直接取得正在播放的 AudioSource 的频谱数据,并允许指定 FFTWindow。Unity 文档指出,该 API 使用 AudioSettings.outputSampleRate,并将频带分布在 00 到采样率一半的范围内。7

一个最小示意:

using UnityEngine;

public class AudioSpectrum : MonoBehaviour
{
    [SerializeField] private AudioSource audioSource;

    private readonly float[] spectrum = new float[1024];

    private void Update()
    {
        audioSource.GetSpectrumData(
            spectrum,
            0,
            FFTWindow.BlackmanHarris
        );

        float bass = BandEnergy(40f, 200f);

        // Example:
        // light.intensity = bass;
    }

    private float BandEnergy(float minHz, float maxHz)
    {
        float nyquist = AudioSettings.outputSampleRate * 0.5f;

        int minBin = Mathf.Clamp(
            Mathf.FloorToInt(minHz / nyquist * spectrum.Length),
            0,
            spectrum.Length - 1
        );

        int maxBin = Mathf.Clamp(
            Mathf.CeilToInt(maxHz / nyquist * spectrum.Length),
            minBin + 1,
            spectrum.Length
        );

        float energy = 0f;

        for (int i = minBin; i < maxBin; i++)
        {
            energy += spectrum[i] * spectrum[i];
        }

        return energy;
    }
}

这里的重点不是具体频段数值,而是数据流:

Audio→Spectrum→Frequency Band→Game Parameter\text{Audio} \rightarrow \text{Spectrum} \rightarrow \text{Frequency Band} \rightarrow \text{Game Parameter}

Unity 6.0 文档对 GetSpectrumData 还有几个值得注意的工程限制:

传入数组长度必须是 2 的幂,并限制在 64 到 8192;API 使用输出采样率而不是 AudioClip 自身采样率;文档还特别提醒,它访问的是短历史窗口,并不适合要求严格时序或低延迟的关键实时分析。7

因此真正的节奏游戏、DSP 或需要 sample-accurate timing 的系统,不应该简单把 Update() 中的频谱查询当成完整音频分析架构。


7.4 Beat、Onset 与 Pitch Detection

FFT 能提供频域信息,但很多游戏真正关心的是更高级的问题:

鼓点什么时候发生?

BPM 是多少?

现在唱的是哪个音?

这些问题并不等于:

做一次 FFT
→ 找最大值
→ 完成

例如 Onset Detection 可以观察相邻 STFT Frame 之间频谱能量的突然变化。

一种常见思路是计算类似 Spectral Flux 的量:

SF(t)=∑kmax⁡(0,∣Xt[k]∣−∣Xt−1[k]∣)SF(t) = \sum_k \max \left( 0, |X_t[k]|-|X_{t-1}[k]| \right)

当大量频率能量突然增加时,往往意味着新的音符或打击开始。

Beat Detection 则通常还需要进一步分析 Onset 随时间形成的周期。

Pitch Detection 也不能简单认定:

最大的 FFT Peak 就是基频。

真实声音可能出现:

  • 高次谐波比基频更强;
  • 基频很弱;
  • 噪声;
  • 非整数 Bin;
  • Vibrato;
  • 多个声源。

因此 FFT 更像是:

许多音频分析算法的前端表示。

而不是所有问题的完整答案。


7.5 FFT、EQ 与滤波器是什么关系?

理解频域后,很容易产生另一个误区:

既然滤波就是改变不同频率,那所有滤波是不是都应该先 FFT?

不是。

对于一个实时低通滤波器:

y[n]=b0x[n]+b1x[n−1]−a1y[n−1]y[n] = b_0x[n] + b_1x[n-1] - a_1y[n-1]

一个简单 IIR / Biquad 每个采样只需要非常少的计算。

如果为了它:

Buffer
→ FFT
→ 修改频谱
→ IFFT

反而可能更加复杂,并引入 Buffering Latency。

可以粗略这样理解:

问题常见合适方法
简单 Low-pass / High-pass / EQIIR / Biquad
短 FIR直接 FIR
频谱分析FFT / STFT
很长的 FIR / Impulse ResponseFFT Convolution
离线频域编辑FFT
长窗口相关分析FFT 往往很有价值

傅里叶变换不是“频率问题的唯一算法”。

它提供的是一个新的表示空间。


8. 卷积:为什么频域还能让计算变简单?

频域最重要的价值之一,并不只是“看 Spectrum”。

而是:

有些在时域里计算非常昂贵的运算,到了频域之后会变得异常简单。

最经典的例子就是 Convolution(卷积)。


8.1 卷积在描述什么?

离散卷积可以写成:

y[n]=(x∗h)[n]=∑mx[m]h[n−m]y[n] = (x*h)[n] = \sum_m x[m]h[n-m]

直觉上,它是在做:

把一个 Kernel 在整个信号上滑动,并不断计算加权叠加。

这与图像卷积、FIR Filter 的思想完全一致。

如果:

x[n]x[n]

是输入,

h[n]h[n]

是系统对一个单位冲激的响应,

那么:

y[n]=x[n]∗h[n]y[n]=x[n]*h[n]

描述的就是输入经过这个线性时不变系统之后的结果。


8.2 Impulse Response 与空间声学

假设在一个大厅中播放一个极短的脉冲。

声音会:

  • 直接到达听者;
  • 从墙壁反射;
  • 从天花板反射;
  • 多次衰减;
  • 最后形成长长的混响尾部。

把这个响应记录下来,就得到:

Impulse Response,IR。

如果一个房间近似为线性时不变系统,那么任意声音经过这个空间之后可以表示为:

y=x∗hy=x*h

其中:

  • xx:原始声音;
  • hh:空间 IR;
  • yy:带有空间响应的声音。

这就是 Convolution Reverb 的基本思想。


8.3 为什么长卷积计算很贵?

假设输入长度大约为 NN,Impulse Response 长度为 MM。

直接计算卷积需要大量乘加:

O(NM)O(NM)

如果 IR 很长,例如包含数秒混响,直接逐采样计算会迅速变得昂贵。

这时傅里叶变换的一个重要定理开始发挥作用。


8.4 为什么时域卷积会变成频域乘法?

卷积定理告诉我们:

x∗h↔FX⋅H\boxed{ x*h \quad \xleftrightarrow{\mathcal F} \quad X\cdot H }

也就是说:

F{x∗h}=F{x}F{h}\mathcal F\{x*h\} = \mathcal F\{x\} \mathcal F\{h\}

于是原本的:

大量滑动
大量 Multiply-Accumulate

可以转换成:

Loading diagram…

在频域中,卷积变成了:

Y[k]=X[k]H[k]Y[k] = X[k]H[k]

也就是简单的逐点乘法。

NumPy 的 FFT 文档也明确把“大型卷积可以通过 FFT 加速”列为高维 Fourier 方法的重要用途之一。1


8.5 FFT Convolution 的工程价值

如果两个序列长度同阶,直接卷积大约是:

O(N2)O(N^2)

而使用 FFT:

FFT+point-wise multiply+IFFTFFT + \text{point-wise multiply} + IFFT

可以做到接近:

O(Nlog⁡N)O(N\log N)

这就是长 FIR 和 Convolution Reverb 中 FFT 极其重要的原因。

不过工程实现还必须处理一个问题:

DFT 天然对应的是 Circular Convolution。

如果想得到普通的 Linear Convolution,需要进行足够的 Zero Padding。

实时音频还不能等到整段声音结束以后再一次性 FFT,因此还会使用:

  • Overlap-Add;
  • Overlap-Save;
  • Partitioned Convolution;

把长信号分块处理。

于是这里可以第一次非常直观地看到:

频域并不只是让数据“更容易看懂”。

它甚至会改变一个问题的计算结构与复杂度。


9. 频率不只存在于声音中

9.1 Spatial Frequency:图片也有“频率”

声音的输入变量是:

tt

所以我们讨论每秒振动多少次。

图片的输入变量是:

x,yx,y

因此同样可以讨论:

图像在空间中变化得有多快?

例如一大片缓慢变化的天空:

████████████████████

属于较低的 Spatial Frequency。

而细密栅栏:

||||||||||||||||||||

则包含大量高 Spatial Frequency。

二维傅里叶变换可以写成:

F(u,v)=∑x∑yI(x,y)e−i2π(ux+vy)F(u,v) = \sum_x\sum_y I(x,y) e^{-i2\pi(ux+vy)}

于是图像也能够:

Spatial Domain⟷Frequency Domain\text{Spatial Domain} \longleftrightarrow \text{Frequency Domain}

9.2 模糊、细节与高低频

一张图像中:

  • 平缓的大面积变化主要集中在低频;
  • 快速变化的边缘和细节对应更高频率。

因此从频域角度看:

Blur 可以理解成抑制高频:

Hhigh↓H_{\text{high}}\downarrow

而很多 Sharpening 方法,则可以理解成提高高频成分相对于低频的权重。

当然,这并不意味着游戏里的每次 Gaussian Blur 都应该 FFT。

对于很小的卷积 Kernel:

3 × 3
5 × 5

直接 Shader 卷积通常更加简单。

只有 Kernel 很大、卷积很长或者频域本身就是目标表示时,FFT 的优势才逐渐明显。


9.3 FFT Ocean:海浪为什么也可以放到频域?

海面是傅里叶思想在图形学里最漂亮的例子之一。

假设海面高度是:

h(x,t)h(\mathbf x,t)

其中:

x=(x,z)\mathbf x=(x,z)

一个复杂海面可以理解成大量不同:

  • Wave Vector;
  • 波长;
  • 方向;
  • 振幅;
  • 相位;

的波叠加。

可以写成类似:

h(x,t)=∑kh~(k,t)eik⋅xh(\mathbf x,t) = \sum_{\mathbf k} \tilde h(\mathbf k,t) e^{i\mathbf k\cdot\mathbf x}

这里:

k\mathbf k

是空间中的 Wave Vector。

其方向表示波传播方向,长度与空间波长有关。

Jerry Tessendorf 在经典 SIGGRAPH Course Notes Simulating Ocean Water 中系统描述了一类频域海面方法:先在 Fourier Space 中构造与时间演化海浪频谱,再通过 Fourier 变换生成空间中的海面高度。8

于是每一帧可以概念性地理解成:

Loading diagram…

这和音频里的:

X(f)→x(t)X(f) \rightarrow x(t)

本质上是相同的数学结构。


9.4 FFT Ocean 不等于通用流体模拟

这里也需要避免另一个常见误解:

使用 FFT 的海洋,并不是在一般意义上完整求解所有水体运动。

这类频谱海面非常适合描述:

  • 大尺度海洋表面;
  • 大量叠加波;
  • 风浪频谱;
  • 周期性大面积 Height Field。

但它并不天然解决:

  • 水撞击障碍物;
  • 杯子里的水;
  • 海浪沿复杂岸线破碎;
  • 任意拓扑液体;
  • 强烈局部流体交互。

因此它和 SPH、FLIP、Eulerian Fluid Simulation 等方案解决的不是完全相同的问题。

这再次说明:

选择频域不是因为 FFT 很高级,而是因为问题本身具有适合频域表达的结构。


10. 游戏程序员什么时候应该想到 FFT?

傅里叶变换真正值得掌握的,不是“以后遇到声音就用 FFT”。

更好的判断方式是问:

这个问题在频域中是否拥有比原始域更简单的结构?

可以建立这样一个粗略判断表:

问题是否应该优先想到频域?原因
看一段声音包含哪些频率是频域直接表达频率组成
音乐可视化是需要频带能量
Spectrogram是本质是 STFT
很长的卷积是卷积变乘法
FFT Ocean是海面天然适合波谱表示
简单低通滤波通常不是Biquad 更直接
3×3 图像模糊通常不是Spatial Kernel 更便宜
一次普通碰撞检测不是与频率结构无关
任意液体局部交互通常不是频谱方法不自然

可以进一步把问题抽象成三个判断。

第一,这个系统是否存在明显的周期、振动或尺度结构?

如果有,频率可能是自然坐标。

第二,原始域中的复杂运算是否在频域中会简化?

卷积定理就是最经典的情况。

第三,转换成本是否值得?

FFT 与 IFFT 本身并不是免费的。

如果时域里只需要几个乘加,那么转换来转换去反而是在制造复杂度。


10.1 FFT、IIR/FIR 与直接计算如何选择?

可以把它们理解成不同层次的工具,而不是竞争关系。

IIR 擅长用很少状态实现高效实时滤波。

FIR 可以通过有限冲激响应直接描述系统,并能获得一些 IIR 不容易得到的特性。

FFT 擅长:

  • 观察频率结构;
  • 批量处理很多频率;
  • 加速长卷积;
  • 处理频域天然存在的问题。

因此不存在:

FFT>FIR>IIRFFT > FIR > IIR

这样的简单排序。

选择取决于问题结构。


10.2 从傅里叶变换到“频域思维”

理解傅里叶变换之后,真正应该形成的思维方式不是:

“看到声音就 FFT。”

而是:

一个问题现在的表示方式,是不是让它显得比实际更复杂?

例如:

时域中的长卷积很复杂:

x∗hx*h

换到频域后变成:

XHXH

复杂海面在空间里似乎有无数顶点。

换到频域后,可以理解成许多波:

∑kh~(k)eik⋅x\sum_{\mathbf k} \tilde h(\mathbf k)e^{i\mathbf k\cdot\mathbf x}

一段看似混乱的声音波形:

_/\/\__/\/\/\/\___/\/\_/\/\_

换到频域以后:

100 Hz      ███████
440 Hz      ████
1 kHz       ██
4 kHz       █

结构突然出现了。


11. 总结:改变的不是信号,而是观察信号的方式

傅里叶变换最容易被误解成:

一个“用来分析声音频率”的算法。

它当然可以做到这一点。

但这只是表面应用。

更一般地说,傅里叶变换是在建立:

原始域⟷频域\boxed{ \text{原始域} \quad\longleftrightarrow\quad \text{频域} }

声音中:

Waveform⟷Spectrum\text{Waveform} \longleftrightarrow \text{Spectrum}

图像中:

Spatial Image⟷Spatial Frequency\text{Spatial Image} \longleftrightarrow \text{Spatial Frequency}

海洋模拟中:

Height Field⟷Wave Spectrum\text{Height Field} \longleftrightarrow \text{Wave Spectrum}

而卷积则告诉我们,转换表示甚至可以改变计算本身:

x∗h⟷XH\boxed{ x*h \quad\longleftrightarrow\quad XH }

因此学习傅里叶变换时,最值得带走的不是某一个积分公式。

而是这样一种更普遍的工程直觉:

当一个问题在当前表示下很复杂时,不妨问:是否存在另一种基底、另一种坐标系或另一种表示,让问题本身变简单?

傅里叶变换只是这个思想最经典、最强大的例子之一。


注释

  1. NumPy Documentation,Discrete Fourier Transform (numpy.fft),NumPy 2.2 Manual。该文档给出了 DFT 定义、Spectrum / Frequency Domain 的术语、Magnitude / Power / Phase 的关系、实值输入的共轭对称性,以及 FFT 在高维分析与长卷积中的用途。 ↩ ↩2 ↩3 ↩4

  2. James W. Cooley, John W. Tukey,An Algorithm for the Machine Calculation of Complex Fourier Series,Mathematics of Computation,Vol. 19, No. 90, 1965, pp. 297–301,doi:10.1090/S0025-5718-1965-0178586-1。论文描述了把运算量从与 N2N^2 成比例降低到与 Nlog⁡NN\log N 成比例的快速 Fourier 算法。 ↩

  3. Matteo Frigo, Steven G. Johnson,FFTW 3.3.11 Documentation,Introduction。FFTW 官方文档说明其 DFT 实现支持 arbitrary length,并为所有长度使用 O(nlog⁡n)O(n\log n) 级算法,因此“FFT 必须使用 2 的幂”不是普遍数学限制。 ↩

  4. SciPy Documentation,Discrete Fourier Transforms (scipy.fft)。文档给出了采样频率、DFT 频率间隔以及 fNy=fs/2f_\text{Ny}=f_s/2 的定义。 ↩

  5. SciPy Documentation,Signal Processing (scipy.signal) — Spectral Analysis。教程解释了有限观测区间如何产生 Spectral Leakage、Windowing 如何通过降低旁瓣来缓解泄漏,以及主瓣宽度与泄漏之间的权衡;同时展示了 Zero Padding 对频谱采样密度的作用。 ↩ ↩2 ↩3

  6. SciPy Documentation,scipy.signal.ShortTimeFFT 与 Signal Processing Tutorial 的 STFT 章节。文档将 STFT 描述为沿信号滑动窗口并顺序进行 FFT,从而分析 Spectrum 随时间的变化。 ↩

  7. Unity Technologies,Unity 6.0 Scripting API,AudioSource.GetSpectrumData。文档说明该 API 返回正在播放 AudioSource 的 Spectrum Data,使用 AudioSettings.outputSampleRate,支持 FFT Window;传入数组长度要求为 2 的幂、范围 64–8192,并注明其短历史缓冲不适合要求严格时序或低延迟的关键实时分析。 ↩ ↩2

  8. Jerry Tessendorf,Simulating Ocean Water,SIGGRAPH Course Notes,1999–2004。该系列课程笔记系统介绍了以 Fourier-Space Ocean Spectrum 表示、演化并重建海面的方法,是计算机图形学中频域海洋模拟的经典资料。 ↩

CONTINUE EXPLORING

沿着这篇文章继续

在地图中查看

On this page

1. 为什么需要频域?1.1 计算机看到的声音是什么?1.2 时域能告诉我们什么,又不能告诉我们什么?1.3 从 Waveform 到 Spectrum:换一种方式观察同一个信号2. 傅里叶变换到底在做什么?2.1 复杂信号可以由简单周期波组成2.2 频率、振幅与相位2.3 从“分解信号”理解傅里叶变换2.4 傅里叶变换也可以理解为换基2.5 逆傅里叶变换:还能变回去吗?3. 从 Fourier Transform 到 FFT3.1 Fourier Series、FT、DFT 是什么关系?3.2 为什么计算机实际使用 DFT?3.3 FFT 不是另一种变换3.4 FFT 为什么能比直接计算 DFT 快?4. 现实中的 FFT 为什么没有那么完美?4.1 Sample Rate:离散采样意味着什么?4.2 Nyquist Frequency 与 Aliasing4.3 FFT Bin 是什么?4.4 FFT Size 为什么影响频率分辨率?4.5 为什么会出现 Spectral Leakage?4.6 Window Function 到底解决了什么?5. 如何真正读懂声音的 Spectrum?5.1 基频与泛音5.2 为什么相同音高可以拥有不同音色?5.3 Magnitude Spectrum 与 Power Spectrum5.4 Phase 为什么不能完全忽略?5.5 “低频、中频、高频”究竟意味着什么?6. Spectrum 的问题:它没有告诉你“什么时候”6.1 对整段音乐做一次 FFT 会发生什么?6.2 STFT:把音频切成一个个短窗口6.3 从一串 Spectrum 到 Spectrogram6.4 时间分辨率与频率分辨率为什么互相牵制?7. 游戏音频中的频域分析7.1 Audio Reactive Visuals7.2 如何得到稳定的 Bass、Mid、Treble?7.3 Unity 中的 Spectrum Data7.4 Beat、Onset 与 Pitch Detection7.5 FFT、EQ 与滤波器是什么关系?8. 卷积:为什么频域还能让计算变简单?8.1 卷积在描述什么?8.2 Impulse Response 与空间声学8.3 为什么长卷积计算很贵?8.4 为什么时域卷积会变成频域乘法?8.5 FFT Convolution 的工程价值9. 频率不只存在于声音中9.1 Spatial Frequency:图片也有“频率”9.2 模糊、细节与高低频9.3 FFT Ocean:海浪为什么也可以放到频域?9.4 FFT Ocean 不等于通用流体模拟10. 游戏程序员什么时候应该想到 FFT?10.1 FFT、IIR/FIR 与直接计算如何选择?10.2 从傅里叶变换到“频域思维”11. 总结:改变的不是信号,而是观察信号的方式注释