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

图片集是把很多张小图拼成一张大图:打开一次比开六十次便宜。 切第 n 张图只要一个除法一个取余,不用读图,只要算数。 动画按秒算不按帧算,帧率改了动画速度才不会跟着变。

📝 本章和前面几章的关系

上一篇让东西「会动」,这一篇让东西「看起来在动」——frame animation,帧动画。 教材 6.6、6.7 节:【图片集】【动画】。 两节合起来是:图从哪来、怎么切、按什么节奏换。 切图这一段在沙箱里真跑了:真切一张 512×512 的图,逐格取像素比对。

一、为什么拼成一张

把 64 张 64×64 的小图拼成一张 512×512 的大图,这叫 sprite sheet。 好处有两个: 一是打开文件次数从 64 次变成 1 次,二是传给显卡的次数也变成 1 次。 老游戏机时代是显存太小被逼的,现在显存够了,但「少切换」依然省。 还有一个不那么明显的好处:拼成一张之后,换帧只是改一个坐标,不用换图片对象。 绘制的时候连着贴同一张大图的不同的块,比来回换图快得多。

代价是:改其中一帧,得重做整张大图。所以图片集适合「成套的动画」,不适合零散素材。

二、第 n 张在哪:一个除法一个取余

512×512 的图按 64×64 切,横竖各 8 格。第 n 张(从 0 数)在第 n % 8 列、第 n // 8 行。 左上角像素就是 (列 × 64, 行 × 64)。 演示算了 0、1、7、8、9、15、63 这几张,第 9 张在第 1 列第 1 行,左上角 (64, 64); 第 63 张在最后一格,(448, 448)。 这一步不需要读图,只需要算数。这意味着你可以在完全不加载图片的情况下, 先算好所有帧的位置——编辑器里那些动画预览就是这么做的。

三、真切一张看看

演示用 Pygame 真造了一张 512×512 的图,每格填一个不同的颜色,然后按算式切出第 0、1、9、63 张。 判断切对没有的方法很朴素: 切出来的图取一个像素,跟大图上同一个位置取一个像素,两个颜色一样就是对的。 结果是四张全对上:(0, 60, 200)、(4, 60, 197)、(36, 60, 173)、(252, 60, 11)。 两列一模一样,说明位置算对了。

还顺带确认了一件事:subsurface 只返回「同一块内存的视图」,不复制像素。 所以改子图会改到大图——省内存,但要小心。

四、按时间换帧

一个 8 帧的行走动画,每秒播 12 帧,一轮 0.667 秒。 换帧的公式是 第几帧 = int(已过秒数 × 12) % 8。 演示算了 0、84、167、250、500、667、1000、1500 毫秒,对应的帧是 0、1、2、3、6、0、4、2。 最后那个 % 8 是循环的关键。没有它,播到第 8 帧就越界了; 有了它,第 8 帧自动回到第 0 帧,一直贴着时间走。

看 667 毫秒那一行:播到第 0 帧。因为 0.667 × 12 = 8,8 % 8 = 0——刚好一轮,回到开头。

五、帧率和动画速度是两件事

游戏跑 60 帧每秒,动画每秒 12 帧,意味着每 5 个游戏帧换一张图。 很多人就在这儿写了个「每 5 帧换一张」。 能跑,但换台电脑就不对了——游戏帧率一变,动画就跟着变快变慢。 正确写法是按毫秒算:记下这一帧过了多少毫秒,累加起来,再用总毫秒数去算该显示第几帧。 这样游戏跑 30 帧还是 144 帧,动画都是一个速度。

演示列了 0、1、4、5、6、10、60 这几个游戏帧对应的动画帧:0.083 秒是第 1 帧,0.100 秒是第 1 帧, 1.000 秒是第 4 帧。同一套算式,跟帧率无关。 这跟上一章讲的「动作按秒算」是同一个道理。凡是跟时间有关的东西,都用秒,别用帧。

六、只有起始位置在变

切图的时候要给两个量:起始位置和尺寸。而尺寸永远不变——这就是切图能写成算式的根本原因。 如果每帧尺寸不一样(比如素材作者切得大小不一),就没法用算式算了,得额外存一张「每帧的框」表。 这也是很多图片集工具要求「所有帧一样大」的原因: 不是技术限制,是为了让调用方省事。用一点点空白换掉一张额外的表,划算。

七、动手 Demo:图片集:切图、算位置、按时间换帧

先算一张 512×512 的图切 64 张,每张在哪个位置。 再真切一张出来,逐格取像素验证位置对不对。 最后算「第 t 秒该显示第几帧」,看 12 帧每秒的循环。

import os
os.environ['SDL_VIDEODRIVER'] = 'dummy'
os.environ['PYGAME_HIDE_SUPPORT_PROMPT'] = '1'
import pygame

SHEET, FRAME = 512, 64
COLS = SHEET // FRAME          # 8
FPS_ANIM = 12

def info(s):
    print('  ' + s)

def sec(t):
    print(); print('  ' + t); print('  ' + '-' * 68)

# ① 算位置
sec('① 第 n 张在哪')
for n in (0, 1, 7, 8, 9, 63):
    c, r = n % COLS, n // COLS
    info('第 %2d 张 -> 第 %d 列第 %d 行,左上角 (%d, %d)'
         % (n, c, r, c * FRAME, r * FRAME))

# ② 真切一张
sec('② 真切一张出来')
pygame.init()
sheet = pygame.Surface((SHEET, SHEET))
for n in range(COLS * COLS):
    c, r = n % COLS, n // COLS
    sheet.fill((n * 4 % 256, 60, 200 - n * 3 % 200),
               pygame.Rect(c * FRAME, r * FRAME, FRAME, FRAME))
for n in (0, 1, 9, 63):
    c, r = n % COLS, n // COLS
    sub = sheet.subsurface(pygame.Rect(c * FRAME, r * FRAME, FRAME, FRAME))
    info('第 %2d 张:%dx%d,像素 %s  大图同点 %s'
         % (n, sub.get_width(), sub.get_height(),
            tuple(sub.get_at((5, 5)))[:3],
            tuple(sheet.get_at((c * FRAME + 5, r * FRAME + 5)))[:3]))
pygame.quit()

# ③ 按时间换帧
sec('③ 第 t 秒该显示第几帧')
for ms in (0, 84, 167, 250, 500, 667, 1000):
    info('%4d 毫秒 -> 第 %d 帧' % (ms, int(ms / 1000.0 * FPS_ANIM) % 8))

实跑输出:

--------------------------------------------------------------------
图片集与帧动画:切图、换帧、按时间走
--------------------------------------------------------------------

  ① 图片集是「很多张图拼成一张」
  --------------------------------------------------------------------
  一张 512×512 的大图,按 64×64 切,横竖各 8 格,一共 64 张图
  为什么拼成一张?打开一次文件、传一次显存,比开 64 次便宜得多。
  ! 这叫 sprite sheet。老游戏机时代是硬件限制逼出来的,现在还是这么干,因为省。

  ② 第 n 张图在大图的哪个位置
  --------------------------------------------------------------------
  第 n 张(从 0 数):列 = n % 8,行 = n // 8
  左上角像素 = (列 × 64, 行 × 64)

| 第几张 | 第几列第几行 | 左上角像素 |
|---:|---:|---|
| 0 | (0, 0) | (0, 0) |
| 1 | (1, 0) | (64, 0) |
| 7 | (7, 0) | (448, 0) |
| 8 | (0, 1) | (0, 64) |
| 9 | (1, 1) | (64, 64) |
| 15 | (7, 1) | (448, 64) |
| 63 | (7, 7) | (448, 448) |

  ! 就一个除法一个取余。切图不需要读图,只需要算数。

  ③ 真把图切下来看看
  --------------------------------------------------------------------

| 第几张 | 切出来多大 | 切出来的像素 | 大图上同一点的像素 |
|---:|---|---|---|
| 0 | 64×64 | (0, 60, 200) | (0, 60, 200) |
| 1 | 64×64 | (4, 60, 197) | (4, 60, 197) |
| 9 | 64×64 | (36, 60, 173) | (36, 60, 173) |
| 63 | 64×64 | (252, 60, 11) | (252, 60, 11) |

  ! 两列一样,说明切的位置没算错。subsurface 不复制像素,只是「看这一块」。

  ④ 帧动画:按时间换帧
  --------------------------------------------------------------------
  一个 8 帧的行走动画,每秒播 12 帧,一轮 0.667 秒

| 毫秒 | 秒 | 播第几帧 |
|---:|---:|---:|
| 0 | 0.000 | 0 |
| 84 | 0.084 | 1 |
| 167 | 0.167 | 2 |
| 250 | 0.250 | 3 |
| 500 | 0.500 | 6 |
| 667 | 0.667 | 0 |
| 1000 | 1.000 | 4 |
| 1500 | 1.500 | 2 |

  ! 最后那个取余 8 是循环的关键:播到第 8 帧自动回到第 0 帧。

  ⑤ 帧率和动画速度是两件事
  --------------------------------------------------------------------
  游戏跑 60 帧每秒,动画每秒 12 帧——意味着每 5 个游戏帧换一张图。

| 游戏第几帧 | 相当于几秒 | 动画第几帧 |
|---:|---:|---:|
| 0 | 0.000 | 0 |
| 1 | 0.017 | 0 |
| 4 | 0.067 | 0 |
| 5 | 0.083 | 1 |
| 6 | 0.100 | 1 |
| 10 | 0.167 | 2 |
| 60 | 1.000 | 4 |

  ! 把动画帧率写死成「每 5 帧换一张」,改游戏帧率动画就变快变慢。按毫秒算,怎么改都对。

  ⑥ 一张图上的偏移量
  --------------------------------------------------------------------
  切图的时候要给两个量:起始位置(那个左上角)和尺寸(64×64)。
  尺寸永远不变,变的只有起始位置——这就是切图能写成一行算式的根本原因。
  ! 如果每帧尺寸不一样,就没法用算式切了,得额外存一张「每帧的框」表。

第一段那个「一个除法一个取余」是这一篇的地基。 64 张图的位置全靠这两行算出来,没有一个数是手填的。 第二段是真跑的:subsurface 切出一块,get_at 取像素,跟大图同一点比。 两列一样,说明切的位置没错。这是验证切图最快的方法。 注意 subsurface 不复制像素。 改子图会改到大图——省内存,但也容易出「改了这张,那张也跟着变」的怪事。要独立就 copy() 一份。 第三段那个 % 8 是循环。 没有它,第 8 帧就越界了。667 毫秒正好一轮回到第 0 帧,是巧合也是设计——12 帧每秒,8 帧一轮, 一轮 0.667 秒。 这一篇的所有代码都能在沙箱里真跑,因为有 Pygame 撑着。 这就是「能真跑的一律真跑」的好处——算错一个数,输出的像素立刻对不上。

30 秒实验

  1. 把 FRAME 从 64 改成 128,重跑第一段,看第 9 张的位置变成多少。
  2. 把 FPS_ANIM 从 12 改成 24,重跑第三段,看 1000 毫秒变成第几帧。
  3. 把第二段那个 (5, 5) 改成 (0, 0),重跑,看两列是不是还是一样。

常见误区

  1. 「每张图单独存一个文件最清楚。」清楚,但打开和切换都费时间。 成套的动画用图片集,零散素材用单图。
  2. 「每 5 帧换一张,简单明了。」换台机器、改帧率,动画就变快变慢。按毫秒算才对。
  3. 「subsurface 是复制。」不是,它只是「看这一块」的视图。改它会改到原图。
自测三问(点开对答案) 1. 512×512 的图切 64×64,第 9 张在哪一格?左上角像素是多少? —— 第 1 列第 1 行,左上角 (64, 64)。9 % 8 = 1,9 // 8 = 1。 2. 为什么动画要按毫秒算,不按「每几帧换一张」? —— 按帧算的话,动画速度就跟游戏帧率绑死了——30 帧的机器上慢一半,144 帧的机器上快一倍。 按毫秒算,怎么改帧率动画都是一个速度。 3. 那个 `% 8` 是干什么的?去掉会怎样? —— 让帧号循环。8 帧的动画播到第 8 帧就该回第 0 帧,取余自动做到这件事。 去掉的话第 8 帧就超出图片集,取不到图。

画面会动了,下一篇加最后两样东西:声音和粒子——一个能听,一个好看。

相关笔记