声音三件事:格式选对、开局预加载、音效和音乐分两层音量。 粒子就是一个「有寿命、有速度、每帧受重力」的小点。 粒子不贵在计算,贵在绘制——画一百个小点比算一百个小点慢得多。
这一章的最后两样东西,一个管耳朵,一个管眼睛。 教材 6.8 到 6.10 节:【声音】【Particle System】【小结】。 声音这一段在沙箱里只能验一半:对象能建、时长能算,但发不出响(没有声卡)。 本课如实标出这条边界。 粒子的轨迹、存活数、峰值,是纯 Python 真算的。
一、声音三件事
| 事 | 要点 |
|---|---|
| 格式 | wav 无损但大,ogg 压过但小 |
| 预加载 | 开局读一次,不要在每个事件里读 |
| 声道 | 同时能播几条;音效走声道池,音乐单独一路 |
短音效(按键、吃到、爆炸)用 wav,长音乐用 ogg。wav 无损但体积大,
ogq 有压缩但解码要花时间——短的用无损不心疼,长的用压缩才放得下。
预加载这件事很容易忘。
新手常写的代码是「吃到东西的时候 Sound('eat.wav')」,结果每次吃都读一次磁盘,卡顿就是这么来的。
开局读一次,存进字典。
音量要分两层:整体音量(管背景音乐)和单条音效(管音效)。
只做一层的话,玩家想关音乐就必须关掉所有声音。
二、真建一个声音对象
演示里用 dummy 音频驱动初始化了混音器,采样率 44100、16 位、单声道,
然后现场合成了一段 440 赫兹的正弦波。
44100 个采样点,get_length() 报的是 1.000 秒,
占内存 88200 字节——44100 × 2 字节,一秒 16 位单声道的大小,正好对得上。
边界要说明白:这一段是在 dummy 驱动下跑的。对象真建出来了、时长真算出来了,但沙箱没有声卡,
发不出响。所以「听起来怎么样」这个判断,本课不下。
这也算一种验证:你能不能验一个东西,取决于环境允许你验到哪一步。验到哪儿就写到哪儿, 别把没验过的写成结论。
三、声音占多大
16 位 wav 的大小 = 采样率 × 2 字节 × 声道数 × 秒数。演示算了一张表。 1 秒单声道 86.1 KB,双声道 172.3 KB;30 秒单声道 2.5 MB,双声道 5.0 MB。 30 秒的背景音乐就 5 MB,一首三分钟的歌就是 30 MB。这就是为什么长音乐必须压缩。
顺带一句:采样率降到 22050,文件大小减半,音质损失在游戏音效上基本听不出来。 手机游戏大量用 22050,不是偷懒,是算过账的。
四、粒子:一个小点的一生
一个粒子就四个数:位置、速度、寿命、重力。有的还会加颜色、透明度、大小。 每帧做的事:速度加上重力、位置加上速度、寿命减一帧、死了就删掉。就这四步。 演示算了一个粒子的轨迹:出生在原点,水平速度 120、垂直速度 -200(向上)、重力 400。
结果 y 先往负方向走(往上飞),到 -48 左右回头,1.2 秒时变成 +52。 整个轨迹是一条抛物线——跟扔石头一模一样。 重力就是每帧给速度加一点。这一句能解释游戏里九成的抛物线运动:跳跃、掉落、抛射、爆炸的碎片。
五、一群粒子
演示模拟了每秒喷 20 个、每个活 1.2 秒的情况,跑了 180 帧。 结果第 90 帧之后稳定在 24 个,峰值 25 个。「每秒数 × 寿命」= 24,跟实测对得上。 这个数很有用:它决定了你要为粒子预留多少性能。峰值 25 个和峰值 2500 个,是完全两个设计。
峰值比估算多一个,是因为喷发是每 3 帧一次、不是均匀铺开的。 这种小偏差不用去修,知道它从哪来就行。
六、代价在绘制不在计算
峰值 25 个粒子,每个每帧算 3 次乘加、判 1 次寿命,一帧 100 次运算。 这个量级微不足道。就算一万个粒子,计算量也才四万次,对现代 CPU 是小意思。 真正贵的是「每个粒子一次绘制调用」。 画一万个小点,如果每个都单独调一次绘制,驱动和显卡的调度开销会把你拖死。
所以粒子系统一般会做成「一次批量画」:把所有粒子的位置塞进一个数组,一次交上去。 算法便宜、绘制贵——这条在图形编程里几乎处处成立。
七、动手 Demo:声音对象与粒子轨迹
先真建一个声音对象,看它的时长和体积怎么算。 再把不同时长的 wav 大小列一张表,感受一下「为什么要压缩」。 最后算一个粒子的抛物线,和一群粒子的存活数量。
import array, math, os
os.environ['SDL_AUDIODRIVER'] = 'dummy'
os.environ['PYGAME_HIDE_SUPPORT_PROMPT'] = '1'
import pygame
def info(s):
print(' ' + s)
def sec(t):
print(); print(' ' + t); print(' ' + '-' * 68)
# ① 真建一个声音
sec('① 造一个 440 赫兹的正弦波')
pygame.mixer.init(frequency=44100, size=-16, channels=1, buffer=512)
rate = 44100
buf = array.array('h')
for i in range(rate):
buf.append(int(9000 * math.sin(2 * math.pi * 440 * i / rate)))
snd = pygame.mixer.Sound(buffer=buf.tobytes())
info('时长 %.3f 秒' % snd.get_length())
info('占内存 %d 字节' % (len(buf) * 2))
# ② 不同时长占多大
sec('② 16 位 wav 有多大')
for sec_ in (1, 5, 30):
for ch in (1, 2):
n = rate * 2 * ch * sec_
info('%2d 秒 %d 声道 -> %.1f MB' % (sec_, ch, n / 1024.0 / 1024.0))
pygame.mixer.quit()
# ③ 一个粒子的一生
sec('③ 粒子的抛物线')
g, dt = 400.0, 1 / 60.0
x, y, vx, vy = 0.0, 0.0, 120.0, -200.0
for i in range(73):
if i % 6 == 0:
info('第 %2d 帧 x = %5.1f y = %+6.1f' % (i, x, y))
vy += g * dt
x += vx * dt
y += vy * dt
# ④ 一群粒子
sec('④ 同时活着几个')
alive, peak = [], 0
for f in range(180):
t = f / 60.0
alive = [s for s in alive if t - s < 1.2]
if f % 3 == 0:
alive.append(t)
peak = max(peak, len(alive))
info('稳定时 %d 个,峰值 %d 个' % (len(alive), peak))
实跑输出:
--------------------------------------------------------------------
声音与粒子:能听的、能算的
--------------------------------------------------------------------
① 声音三件事
--------------------------------------------------------------------
| 事 | 要点 |
|---|---|
| 格式 | wav 无损但大,ogg 压过但小;短音效 wav,长音乐 ogg |
| 预加载 | 开局读一次,不要在每个事件里读 |
| 声道 | 同时能播几条;音效走声道池,背景音乐单独一路 |
! 音量分两层:整体音量(music)和单条音效(sound),调的时候别只调一层。
② 真建一个声音对象
--------------------------------------------------------------------
混音器初始化:采样率 44100,16 位,单声道
造一个 440 赫兹的正弦波,44100 个采样点
声音对象时长 1.000 秒
占内存约 88200 字节(44100 采样 × 2 字节)
! 这里用的是 dummy 音频驱动:对象真建出来了、时长真算得出,但发不出响——本沙箱没有声卡。
③ 声音占多大
--------------------------------------------------------------------
| 时长 | 声道 | 16 位 wav 大小 |
|---|---|---:|
| 1 秒 | 单声道 | 86.1 KB |
| 1 秒 | 双声道 | 172.3 KB |
| 5 秒 | 单声道 | 430.7 KB |
| 5 秒 | 双声道 | 861.3 KB |
| 30 秒 | 单声道 | 2.5 MB |
| 30 秒 | 双声道 | 5.0 MB |
! 30 秒双声道就快 5 MB。这就是为什么长音乐要用压缩格式,短音效才用 wav。
④ 粒子:一个小点的一生
--------------------------------------------------------------------
出生在原点,水平速度 120,垂直速度 -200(向上),重力 400,寿命 1.2 秒
| 秒 | x | y |
|---:|---:|---:|
| 0.0 | 0.0 | 0.0 |
| 0.1 | 12.0 | -17.7 |
| 0.2 | 24.0 | -31.3 |
| 0.3 | 36.0 | -41.0 |
| 0.4 | 48.0 | -46.7 |
| 0.5 | 60.0 | -48.3 |
| 0.6 | 72.0 | -46.0 |
| 0.7 | 84.0 | -39.7 |
| 0.8 | 96.0 | -29.3 |
| 0.9 | 108.0 | -15.0 |
| 1.0 | 120.0 | 3.3 |
| 1.1 | 132.0 | 25.7 |
| 1.2 | 144.0 | 52.0 |
! y 先往负方向走(往上飞),到 -48 左右回头,最后变成正数——重力就是每帧给速度加一点。
⑤ 一群粒子:谁还活着
--------------------------------------------------------------------
每秒喷 20 个,每个活 1.2 秒,稳定之后同时存在约 24 个
第 0 帧(0.00 秒)还活着 1 个
第 30 帧(0.50 秒)还活着 11 个
第 60 帧(1.00 秒)还活着 21 个
第 90 帧(1.50 秒)还活着 24 个
第 120 帧(2.00 秒)还活着 24 个
第 150 帧(2.50 秒)还活着 24 个
峰值 25 个
! 峰值比「每秒数 × 寿命」略高,因为喷发是每 3 帧一次,不是均匀铺开的。
⑥ 粒子的代价
--------------------------------------------------------------------
峰值 25 个粒子,每个每帧算 3 次乘加、判 1 次寿命,一帧就是 100 次运算。
这个量级小意思。真正贵的是「每个粒子一次绘制调用」——那才是帧率杀手。
! 所以粒子一般会做成「一次批量画」,或者用引擎的粒子系统。算法便宜,绘制贵。
第一段那段「造正弦波」的代码不长,但它说明一件重要的事:
声音在程序里就是一串数字。440 赫兹就是每秒 440 个周期,采样率 44100 就是每秒采 44100 个点。
%d 那个 array.array('h') 是 16 位有符号整数,一个采样点 2 字节。
所以 44100 个点正好 88200 字节,跟输出的数字对得上。
第二段那个表最该记住的是最后一行:30 秒双声道 5 MB。一首歌三分钟,
就是 30 MB——压缩格式不是可选项,是必需的。
第三段那个 y 从 0 到 -48 再回 +52,是标准的抛物线。
重力就是每帧给 vy 加 g × dt。这一条能吃一辈子。
第四段的 24 个跟「每秒 20 个 × 1.2 秒」算出来的一样,说明这个估算公式靠谱。以后设计粒子量,
先用这个算,再跑一遍验证。
30 秒实验
- 把正弦波的频率从 440 改成 880,重跑第一段,看时长变了没有——想想为什么。
- 把粒子的重力从 400 改成 800,重跑第三段,看它什么时候落到 y = 0 以下。
- 把喷发间隔从每 3 帧改成每 6 帧,重跑第四段,看峰值变成多少。
常见误区
- 「声音在事件里现读就行。」每次读磁盘,卡顿就是这么来的。开局读一次,存字典。
- 「音乐和音效共用一个音量。」玩家想关音乐就必须全关。两层音量,一层管 music 一层管 sound。
- 「粒子越多越好看,反正算得动。」算得动,画不动。瓶颈在绘制次数,不在计算量。
自测三问(点开对答案)
1. 1 秒 16 位双声道、44100 采样率的 wav 有多大? —— 44100 × 2 字节 × 2 声道 = 176400 字节,约 172 KB。**这就是「无损为什么贵」。** 2. 粒子的抛物线是怎么算出来的? —— 每帧给垂直速度加上「重力 × 时间步长」,再给位置加上「速度 × 时间步长」。 **跟现实里的物理一模一样,只是把连续的时间切成了离散的帧。** 3. 为什么说粒子「贵在绘制不在计算」? —— 一个粒子每帧只算几次乘加,一万个也才几万次运算。 但每个粒子单独一次绘制调用,驱动和显卡的调度开销远大于计算本身。所以要把粒子批量画。到这儿 Cocos2d 的进阶功能都过了一遍。 下一篇进最后一个项目:超级马里奥——八种角色、一张地图、一条命。
- ☐ (本篇完,下一篇 7.1 超级马里奥:规划与菜单场景)