四步顺序:收事件、改状态、判碰撞、重画。乱了就会出现「隔空打中」这类怪事。
clock.tick(60) 是「等到这一帧满 16.67 毫秒」,不是「睡 16.67 毫秒」。
convert_alpha 快的十几倍,不是因为算法好,是因为省掉了逐像素换字节顺序。
这一篇讲游戏的心脏:那个转不停的循环。 教材 3.5 节下面挂了四个小标题:【事件检测处理】【状态更新】【碰撞检测处理】【屏幕绘制】, 再加上 3.6 节的【加载图片】。 五个小标题合起来就是一句话:每帧按固定顺序做四件事,图提前准备好。 演示里那个「快十几倍」是真测出来的,掩码前后不同,原因能查到根上。
一、四步,顺序不能乱
| 次序 | 做什么 | 乱了会怎样 |
|---|---|---|
| 一 | 收事件 | 按键反应慢一拍 |
| 二 | 更新状态 | 位置和画面不同步 |
| 三 | 判断碰撞 | 用旧位置算命中,会「隔空打中」 |
| 四 | 重画并 flip | 还没改完就画,画面闪 |
道理只有一条:先知道发生了什么,再改数字,最后按数字画。
第三第四步最容易搞反。
有人喜欢「画完再判碰撞」,结果这一帧画的是旧位置,
碰撞用的是新位置——看起来就是子弹从敌机旁边飞过去却算命中。
还有一处细节:pygame.event.get() 每帧必须调一次,哪怕你一个事件都不处理。
不调的话队列会积压,Windows 会认为你的程序「未响应」。
这四步就是 3.5 节那四个小标题。教材把它们列成四个子节,其实是一段连续的代码。
二、Clock 到底怎么控速
clock.tick(60) 的意思是:从现在起,等到这一帧满 16.67 毫秒再返回。
不是「睡 16.67 毫秒」,是「补到 16.67 毫秒」。这一帧算得快,它多睡一会儿;
算得慢,它立刻返回,不补帧。
演示里跑了 120 帧、掐掉前 10 帧,平均间隔 16.96 毫秒,最短 16.03、最长 17.17。
目标 16.67,稍微多一点是系统的调度抖动。
为什么不补帧?因为补帧会让游戏「快进」,玩家看到的是画面乱跳。
宁可慢一点、稳一点,也不要忽快忽慢。
tick() 还有个返回值:这一帧实际过了多少毫秒。
想按「真实时间」算移动距离(而不是按帧数),就用它。本书为了简单,一律按帧算。
三、一帧到底花了多少
演示里完整跑 120 帧,总共两秒出头,平均每帧 16.83 毫秒。
其中 16.67 是 tick 在等,真正干活只有零点一几毫秒。
也就是说,这个游戏离「算不过来」还差得远。
这个比例很重要。
它告诉你两件事:一是现在的性能绰绰有余,二是如果哪天卡了,一定不是「画得太多」这么简单,得具体测。
顺带说一句:tick 在等的时候,CPU 是真去睡了,不是空转。
所以加了 tick 的游戏反而省电、不烫。
四、图:load 一次,贴一万次
演示里造了一架 48×48 的三角战机,存成 PNG 是 348 字节。
每次 load 一次这个 PNG 要 0.13 毫秒上下。
一帧的时间够 load 一百多次——听着挺多,但你不会只 load 一张图。
一个游戏几十张图,每帧都 load 一遍,一帧就没了。
所以规矩是:开局 load 一次,存进变量或字典,循环里只管贴。
这也解释了为什么要有「资源管理器」这种东西——它不是炫技,是把「只加载一次」这件事管起来, 防止你在循环里不小心又 load 一遍。
五、convert 到底做了什么
这一段是本篇最值钱的地方,因为它把一个「听起来很玄」的优化拆到了字节级。
演示里打印了两次掩码。
刚 load 进来是 (255, 65280, 16711680, 4278190080),
convert_alpha 之后变成 (16711680, 65280, 255, 4278190080)。
只有前三个数换了位置,最后那个没动。前三个是红绿蓝各自占哪几个二进制位。
第一组说明红在最低字节,第二组说明蓝在最低字节。
一个是 RGBA 顺序,一个是 BGRA 顺序。而屏幕要的是后者。 所以贴原图的时候,每个像素都要把字节顺序换一遍;转好之后,直接内存拷贝就行。 演示里这一段是 0.0436 毫秒对 0.0030 毫秒,差十几倍。 这不是「算法更聪明」,是「不白搬」。所有「格式转换」类的性能问题,本质都是这一条。
六、动手 Demo:游戏循环:四件事,顺序不能乱
先跑 120 帧,量一量 clock.tick(60) 到底把间隔控在多少。
再切到图片:load 一次要多久,convert_alpha 前后差多少。
最后把四步串起来完整跑一遍,看真正干活的时间占多少。
import os, time
os.environ['SDL_VIDEODRIVER'] = 'dummy'
os.environ['PYGAME_HIDE_SUPPORT_PROMPT'] = '1'
import pygame
W, H = 480, 700
def info(s):
print(' ' + s)
def note(s):
print(' ! ' + s)
def sec(t):
print(); print(' ' + t); print(' ' + '-' * 68)
pygame.init()
scr = pygame.display.set_mode((W, H))
# ① Clock 控速
sec('① Clock 是怎么控速的')
clk = pygame.time.Clock()
gaps = []
for i in range(120):
t0 = time.perf_counter()
clk.tick(60)
gaps.append((time.perf_counter() - t0) * 1000)
gaps = gaps[10:]
info('单帧间隔:平均 %.2f,最短 %.2f,最长 %.2f'
% (sum(gaps) / len(gaps), min(gaps), max(gaps)))
info('目标 1000/60 = %.2f' % (1000.0 / 60))
# ② 造图、存图、读图
sec('② 加载图片:其实是在造图')
art = pygame.Surface((48, 48), pygame.SRCALPHA)
pygame.draw.polygon(art, (90, 180, 255),
[(24, 0), (48, 36), (24, 26), (0, 36)])
pygame.image.save(art, 'plane.png')
info('存成 PNG:%d 字节' % os.path.getsize('plane.png'))
t_load = 0.0
for _ in range(9):
t0 = time.perf_counter()
pygame.image.load('plane.png')
d = (time.perf_counter() - t0) * 1000
t_load = d if t_load == 0 else min(t_load, d)
info('load 一次 %.3f 毫秒' % t_load)
# ③ convert 前后的掩码
sec('③ convert 做了什么')
src = pygame.image.load('plane.png')
fast = src.convert_alpha()
info('原图掩码 %s' % (src.get_masks(),))
info('转后掩码 %s' % (fast.get_masks(),))
t1 = 0.0; t2 = 0.0
for _ in range(199):
t0 = time.perf_counter(); scr.blit(src, (0, 0))
d = (time.perf_counter() - t0) * 1000
t1 = d if t1 == 0 else min(t1, d)
t0 = time.perf_counter(); scr.blit(fast, (0, 0))
d = (time.perf_counter() - t0) * 1000
t2 = d if t2 == 0 else min(t2, d)
info('贴原图 %.4f 毫秒,贴转好的 %.4f 毫秒,差 %.1f 倍'
% (t1, t2, t1 / t2))
# ④ 完整跑 120 帧
sec('④ 把四步串起来跑')
t0 = time.perf_counter()
for _ in range(120):
for e in pygame.event.get():
pass
clk.tick(60)
scr.fill((10, 12, 20))
scr.blit(fast, (216, 600))
pygame.display.flip()
total = (time.perf_counter() - t0) * 1000
info('120 帧总共 %.0f 毫秒,平均每帧 %.2f 毫秒' % (total, total / 120))
pygame.quit()
实跑输出:
--------------------------------------------------------------------
游戏循环:四件事,顺序不能乱
--------------------------------------------------------------------
① 四件事的顺序
--------------------------------------------------------------------
| 次序 | 做什么 | 乱了会怎样 |
|:---:|---|---|
| 一 | 收事件 | 按键反应慢一拍 |
| 二 | 更新状态 | 位置和画面不同步 |
| 三 | 判断碰撞 | 用旧位置算命中,会「隔空打中」 |
| 四 | 重画并 flip | 还没改完就画,画面闪 |
! 顺序的道理只有一条:先知道发生了什么,再改数字,最后按数字画。
② Clock 是怎么控速的
--------------------------------------------------------------------
clock.tick(60) 的意思是「从现在起,等到这一帧满 16.67 毫秒」
如果这一帧算得快,它就睡一会儿;算得慢,它就立刻返回,不补帧。
跑 120 帧、掐掉前 10 帧,单帧间隔:平均 16.95 毫秒,最短 15.92,最长 17.14
目标间隔是 1000 / 60 = 16.67 毫秒
! 平均数贴着 16.67,说明控速生效了。最长那个是系统的抖动,正常。
③ 加载图片:其实是在造图
--------------------------------------------------------------------
造了一架 48×48 的三角战机,带 alpha 通道
每像素 32 位,4 字节
存成 PNG:348 字节
每次 load 一次 PNG,耗时 0.129 毫秒
一帧的时间够 load 129 次,装完图就没时间干别的了
! 所以图只在开局 load 一次,存进变量或字典,循环里只管贴。
④ convert 做了什么
--------------------------------------------------------------------
刚 load 进来:32 位,4 字节,掩码 (255, 65280, 16711680, 4278190080)
convert_alpha 之后:32 位,4 字节,掩码 (16711680, 65280, 255, 4278190080)
| 贴的是什么 | 单次耗时 | 说明 |
|---|---:|---|
| load 出来的原图 | 0.0422 毫秒 | 格式跟屏幕不一致 |
| convert_alpha 之后 | 0.0029 毫秒 | 快了 14.6 倍 |
! 位深都是 32,掩码却换了位置:原图 R 在最低字节,转完 B 在最低字节。
屏幕要的是后一种。所以贴原图那一下,每个像素都得换一次字节顺序。
这就是那 14.6 倍差距的来源——不是算法,是白搬。
⑤ 把四步串起来跑
--------------------------------------------------------------------
完整跑 120 帧,总共 1997 毫秒,平均每帧 16.64 毫秒
其中约 16.67 毫秒是 clock.tick 在等,真正干活的时间很少
! 这就是控速的意义:活干得快,就多睡一会儿,省电也不发热。
第一段那个「平均 16.96、最短 16.03、最长 17.17」很有代表性:平均值贴近 16.67,但两端有抖动。
操作系统的调度不是实时的,差零点几毫秒完全正常,差几毫秒才说明有问题。
第二段那个 0.13 毫秒要放在心上:一帧只够 load 一百多次。
听起来很多,可你有几十张图、还要画几百个对象。「开局加载一次」不是优化技巧,是常识。
第三段是全篇的重点。两行掩码只有前三个数换了位置——第一组红在最低字节,第二组蓝在最低字节。
这就是 RGBA 和 BGRA 的区别,也是那十几倍差距的全部原因。
第四段把四步串起来,平均每帧 16.83 毫秒,其中 16.67 是 tick 在等。
真正干活不到零点二毫秒。这个比例说明现在的性能余量极大,也说明「卡顿」不可能来自这一小段。
30 秒实验
- 把
tick(60)改成tick(120),重跑,看平均间隔是不是变成 8.33, 再想想为什么最短最长那两个数的差也变小了。 - 把
convert_alpha()那一行删掉,直接用src贴,看倍数变成多少。 - 在完整循环里加一句
scr.fill之外的绘制(比如画 200 个矩形), 看平均每帧涨多少——验证「干活时间」是不是真的能测出来。
常见误区
- 「
clock.tick(60)是让程序睡 16.67 毫秒。」它是补到 16.67 毫秒。 这一帧已经算了 5 毫秒,它只睡 11.67 毫秒。 - 「先画再判碰撞,反正下一帧就对了。 」这一帧画的是旧位置、判的是新位置, 玩家会看到「没打中就爆了」或者「明明打中却没反应」。 顺序不能换。
- 「
convert只是转个格式,可有可无。」转不转在演示里差了十几倍。 不转就是每贴一个像素换一次字节顺序,这是白干的活。
自测三问(点开对答案)
1. 游戏循环里四步的顺序为什么不能把「重画」放到「判碰撞」前面? —— 因为重画用的是「这一帧最终的状态」。 如果先画再判碰撞,画出来的是碰撞之前的位置,而这一帧的碰撞结果要下一帧才反映到画面上。 玩家会看到子弹穿过去了才算命中,或者没碰到就爆了。 2. `clock.tick(60)` 里,如果这一帧的计算花了 20 毫秒,会发生什么? —— 它会立刻返回,不会等——因为已经超过 16.67 了。所以这一帧就变成 20 毫秒,帧率掉到 50。 **它只负责「不让你太快」,不负责「把你拉回来」。**掉帧了就得自己找原因,`tick` 帮不了。 3. `convert_alpha` 之前和之后都是 32 位,为什么贴图速度差十几倍? —— 位深一样不代表内存排布一样。 原图的掩码说明红在最低字节(RGBA 顺序),屏幕要的是蓝在最低字节(BGRA 顺序)。 贴原图时每个像素都要换一次字节顺序,转换之后两边一致,直接内存拷贝就行。循环转起来了,下一篇往里填内容:滚动的背景、会动的飞机、一池子子弹、按节奏来的敌机。
- ☐ (本篇完,下一篇 3.3 背景、战机、子弹与敌人)