🎮 第6章 Cocos2d Ⅱ
📄 一句话概括

声音三件事:格式选对、开局预加载、音效和音乐分两层音量。 粒子就是一个「有寿命、有速度、每帧受重力」的小点。 粒子不贵在计算,贵在绘制——画一百个小点比算一百个小点慢得多。

📝 本章和前面几章的关系

这一章的最后两样东西,一个管耳朵,一个管眼睛。 教材 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 秒实验

  1. 把正弦波的频率从 440 改成 880,重跑第一段,看时长变了没有——想想为什么。
  2. 把粒子的重力从 400 改成 800,重跑第三段,看它什么时候落到 y = 0 以下。
  3. 把喷发间隔从每 3 帧改成每 6 帧,重跑第四段,看峰值变成多少。

常见误区

  1. 「声音在事件里现读就行。」每次读磁盘,卡顿就是这么来的。开局读一次,存字典。
  2. 「音乐和音效共用一个音量。」玩家想关音乐就必须全关。两层音量,一层管 music 一层管 sound。
  3. 「粒子越多越好看,反正算得动。」算得动,画不动。瓶颈在绘制次数,不在计算量。
自测三问(点开对答案) 1. 1 秒 16 位双声道、44100 采样率的 wav 有多大? —— 44100 × 2 字节 × 2 声道 = 176400 字节,约 172 KB。**这就是「无损为什么贵」。** 2. 粒子的抛物线是怎么算出来的? —— 每帧给垂直速度加上「重力 × 时间步长」,再给位置加上「速度 × 时间步长」。 **跟现实里的物理一模一样,只是把连续的时间切成了离散的帧。** 3. 为什么说粒子「贵在绘制不在计算」? —— 一个粒子每帧只算几次乘加,一万个也才几万次运算。 但每个粒子单独一次绘制调用,驱动和显卡的调度开销远大于计算本身。所以要把粒子批量画。

到这儿 Cocos2d 的进阶功能都过了一遍。 下一篇进最后一个项目:超级马里奥——八种角色、一张地图、一条命。

相关笔记