1. 先理清需求:碰撞检测到底在游戏里管哪几件事
1.1 一个"看着像没碰到、实际该碰到"的场景
做小游戏做到第三个月的时候,我遇到了一个特别让人抓狂的 bug:子弹明明从敌人身上飞过去,却没有结算伤害。屏幕上的两个精灵都已经叠在一起了,程序却像没看见一样继续运行。后来我发现问题出在我对碰撞检测的理解上——我把它当成一个"判断两个形状是否相交"的小功能,但实际上,碰撞检测在游戏里是一条完整的技术链路:形状建模、重叠判断、对象管理、性能优化、高速容错、以及检测之后的响应。
这篇文章主要讲 Python 游戏里最常见的那套碰撞检测方案,环境以 Pygame 为主,但思路放到其他框架也适用。适合的人群是:想从零开始接触游戏开发的 Python 选手,或者是正在用 Pygame 写小游戏但被穿模、误判、卡顿折磨的开发者。读完你至少能回答三个问题:两个角色到底怎么才算"碰到"了;几百个对象同时碰撞时怎么让游戏不卡;以及子弹高速移动时如何避免直接穿过目标。
1.2 检测和响应是两件事
很多初学者会把"碰撞检测"和"碰撞处理"混在一起,这在编码时很容易把自己绕晕。碰撞检测只回答一个问题:两个物体在几何上有没有重叠?至于重叠之后角色掉血、敌人消失、玩家被弹开,那是碰撞响应负责的事。两者可以分开写、分开调,这样出 bug 时定位范围会小很多。
还有一个理念上的认知要提前摆正:绝大多数游戏碰撞,都不是拿游戏素材的每一个像素去做比较的。因为图片形状可能非常不规则,如果每次都拿整张图的像素做精确比对,性能往往扛不住。更通用的做法是"包围盒"——用一个尽量贴合物体轮廓的矩形、圆形或者椭圆,先把物体包起来,然后判断这些规则形状是否相交。规则形状之间的相交计算,数学上成熟、代码量小、执行速度快,这也是这篇文章后面所有方案的基石。
2. 入门首选:用矩形盒子代替复杂形状
2.1 圆与圆:用距离平方判断,降到一次减法两次乘法
两个圆是否碰撞,几何定义最简单:两圆心之间的距离,小于等于半径之和,就说明已经接触。直接用数学库里的 sqrt 计算距离当然可以,但游戏里每帧可能有几百上千次同类判断,开根号是有开销的。更好的做法是比较距离的平方,因为两边都平方之后,不等式关系不变。
def circle_hit(x1, y1, r1, x2, y2, r2): dx = x1 - x2 dy = y1 - y2 radius_sum = r1 + r2 return dx * dx + dy * dy <= radius_sum * radius_sum这个函数里没有出现一次 math.sqrt,所有运算都是加减乘和一次比较,性能非常可观。要注意的是,如果你把角色中心点当成圆心,那半径的取值直接影响手感:半径定得太小,玩家会觉得明明贴上了却没触发;半径定得太大,又会产生"隔空打人"的体验。我一般开始时先取图片宽度和高度平均值的四分之一,再根据实际测试微调两三像素。
圆碰撞在什么场景用得多?最典型的就是各种圆形子弹、爆炸范围判定、以及角色本身长得比较圆润的情况。还有一个更隐蔽的用法:把角色视作圆形,和地面上的圆形障碍做碰撞时,角色的旋转对判断结果毫无影响,因为圆在各个方向上形状相同。这一点在塔防和俯视角射击游戏里非常省心。
2.2 矩形与矩形:重叠判定与 Pygame 的 Rect.colliderect
矩形与矩形的碰撞判断,是几乎所有 2D 游戏都会用到的基础算子。判断规则并不复杂:两个矩形在水平方向上有交叠,并且垂直方向上也有交叠,那它们就碰撞了。水平方向的交叠条件可以写成:A 的左边界小于 B 的右边界,且 A 的右边界大于 B 的左边界;垂直方向同理。
def rects_overlap(a, b): return a.left < b.right and a.right > b.left and a.top < b.bottom and a.bottom > b.top在 Pygame 里,这个判断被封装成了 Rect 对象的方法colliderect,自己写一遍主要是为了理解背后的几何逻辑。有一点需要特别当心:Pygame 的rect.right实际上是rect.x + rect.width,rect.bottom同理。也就是说,一个宽 10、x 坐标为 100 的矩形,它的right属性是 110,但最右边实际显示出来的像素列是 109,第 110 列已经在这个矩形之外了。
这导致一个常见误差:两个矩形如果只是"正好相邻",比如一个的right等于另一个的left,滤镜上不算相交。很多新手在这里会反复调试,以为碰撞判断写错了,其实是边界取值的问题。如果你需要边缘也触发,就要把比较符从严格小于改成小于等于。当然我建议先保持默认语义,想清楚你要的是"接触即判"还是"重叠才判",再决定是否调整边界。
2.3 圆与矩形:先粗筛再求最近点
圆形和矩形的碰撞,算是几种基础形状里最考验几何感觉的一类。直接拿圆的中心点去判断它是否在矩形内部是不行的,因为圆可能是在矩形的左上方角落位置擦过去,圆心不在矩形内部,但圆弧已经和矩形重叠了。
我的惯用做法分两步:第一,先做一个快速排除。把这个矩形向上下左右各自扩张 r 像素,形成一个新的矩形,如果圆的中心点不落在这个扩大后的矩形里,那这个圆和原矩形绝对没有交集,直接返回 False。这一步把所有距离很远的组合都筛掉了。第二,在圆心已经进入扩大矩形的前提下,找出矩形边界上离圆心最近的点,再判断这个距离和半径的关系。
def circle_rect_hit(cx, cy, r, rect): # 粗筛:圆心是否落在扩大 r 的矩形范围内 expanded = pygame.Rect(rect.x - r, rect.y - r, rect.width + 2 * r, rect.height + 2 * r) if not expanded.collidepoint(cx, cy): return False # 精判:求矩形上离圆心最近的点 closest_x = max(rect.left, min(cx, rect.right - 1)) closest_y = max(rect.top, min(cy, rect.bottom - 1)) dx = cx - closest_x dy = cy - closest_y return dx * dx + dy * dy <= r * r这里的closest_x和closest_y利用了 Python 的max和min函数做夹逼,这比用 if-elif 写一长串边界判断干净很多。最坏的情况总是出现在矩形四个角上,此时最近点就是角点本身,判断逻辑依然成立。这个函数我后来在好几个项目里复用,逻辑上没有出过问题。
3. Pygame Sprite 体系:把每个对象托管到 Group
3.1 Sprite 与 Group 解决了什么问题
纯用 Rect 做碰撞判断,在只有两三个对象的小 Demo 里确实够用,但这只是起点。一旦场景里出现玩家、敌人、子弹、道具、粒子,你很快就会意识到,游戏对象的管理才是更让人头痛的环节。每个对象都有自己的rect、image和状态,如果全部靠手写的列表遍历,代码会越来越乱。
Pygame 的sprite.Sprite和sprite.Group在这里价值很大。Sprite 类要求子类至少维护self.image和self.rect两个属性,一个负责渲染,一个负责位置和碰撞区域。Group 则统一管理多个 Sprite,可以做统一的update和draw,更重要的是它内置了碰撞查询接口。
class Bullet(pygame.sprite.Sprite): def __init__(self, x, y): super().__init__() self.image = pygame.Surface((6, 6)) self.image.fill((255, 215, 0)) self.rect = self.image.get_rect(center=(x, y)) self.mask = pygame.mask.from_surface(self.image)提前把mask在初始化时生成好,而不是每帧临时生成,这个习惯能帮你省掉很多隐藏的性能消耗。后面讲像素碰撞时我会再展开。
3.2 spritecollide 和 groupcollide:两个核心入口
在 Sprite 体系下,最常用的碰撞入口是pygame.sprite.spritecollide。它接收三个参数:被检测的单个精灵、目标 Group、以及一个布尔值,用于指定是否把碰到的精灵直接从 Group 里移除。如果设置了移除,碰撞后敌人会立刻从场景里消失,这在很多游戏里正是我们要的效果。
hits = pygame.sprite.spritecollide(bullet, enemies, False) if hits: bullet.kill() for enemy in hits: enemy.take_damage(1)这里有个细节值得注意:spritecollide的第三个参数即使传 True,移除的也只是目标 Group 里的精灵,不会移除传入的第一个精灵。想要一枪干掉敌人的同时子弹也消失,需要手动调用bullet.kill()。
如果两边都是 Group,比如"所有子弹"对"所有敌人",可以使用groupcollide。它返回的是一个字典,键是 Projectile 里的子弹精灵,值是和这颗子弹碰撞到的敌人精灵列表,这正好用来结算伤害:
hits = pygame.sprite.groupcollide(bullets, enemies, True, False) for bullet_sp, hit_enemies in hits.items(): for enemy in hit_enemies: enemy.take_damage(bullet_sp.damage)groupcollide的内部实际上会遍历两个 Group 中的每一个精灵组合,本质上没有省掉计算量,只是把嵌套循环的样板代码替你写好了。所以它适合对象数量在一两百个以内的游戏,如果数量更大,还是要回到后面讲的空间划分思路。
3.3 自定义 collided 回调
pygame.sprite.spritecollide还有一个可选参数collided,它是一个回调函数,用来决定"两个精灵是否算碰撞"。默认不传时,底层用的是collide_rect,也就是比较两个精灵的rect是否重叠。想改成圆形判断,就传入pygame.sprite.collide_circle;想改成像素级判断,就传入pygame.sprite.collide_mask。
hits = pygame.sprite.spritecollide(player, enemies, False, pygame.sprite.collide_circle)常用回调我整理成一个表,方便按场景选用:
| 回调函数 | 判定方式 | 适用场景 |
|---|---|---|
| collide_rect | AABB 矩形重叠 | 方块类物体、广相粗筛 |
| collide_rect_ratio | 按比例缩小矩形再判断 | 希望判定更宽松或更严格 |
| collide_circle | 以 rect 中心为圆心做圆判 | 子弹、圆形角色、道具 |
| collide_mask | 像素级重叠 | 形状复杂、需要精确判定的对象 |
这里有一个我反复踩过的坑:collide_circle默认会把精灵rect的宽和高取平均值的一半作为圆的半径,所以如果精灵的image里留有大片透明边距,判定结果会和视觉差很多。解决方式是手动设置self.radius属性,或者提前把图片裁切掉多余空白。
4. 像素级碰撞:角色不是方块时,把 Mask 请出来
4.1 Mask 的生成和 overlap 偏移
矩形、圆形碰撞再好用,面对不规则的图片也有明显局限。比如一个旋转过的小飞船,它的矩形包围盒可能把周边大量空白区域算进去,玩家在视觉上明明没有碰到船头,系统却已经判定命中了。这种场景需要的是像素级碰撞,Pygame 里的解决方案是mask。
Mask 本质上是一张位图,它把原始 Surface 根据透明度转化成 0 和 1 的像素矩阵。不透明的像素记为 1,透明像素记为 0。检测两个 Mask 是否重叠,只需要对两张位图做一次按位与运算。来看一个完整的用法:
mask1 = pygame.mask.from_surface(sprite1.image, threshold=127) mask2 = pygame.mask.from_surface(sprite2.image, threshold=127) offset = (sprite2.rect.x - sprite1.rect.x, sprite2.rect.y - sprite1.rect.y) overlap_point = mask1.overlap(mask2, offset) if overlap_point: print("像素级碰撞发生")offset这一段是新手最容易写错的地方。Mask 的overlap方法接收的不是某个绝对坐标,而是两个精灵左上角之间的相对偏移。向左偏移为负数,向右为正数,方向搞反了,整个碰撞区域就会错位到完全无关的地方。如果你发现自己明明贴着目标却不触发,十有八九是 offset 写成了相反方向。
threshold参数同样值得注意。它表示像素的 alpha 值达到多少以上才被算作有效像素。默认值是 127,如果素材边缘有大量半透明抗锯齿像素,你可能需要适当调高 threshold,让碰撞轮廓更贴合实心区域。否则玩家会觉得碰到的范围比看到的角色轮廓大了一圈。
4.2 什么时候用像素碰撞,什么时候忍住不用
像素碰撞并不是做得越精准越好,这个工具是有代价的。生成一张 Mask 需要扫描图片的每个像素,大尺寸图片的 Mask 在内存里也占用不小的空间。如果每个敌人每帧都重新from_surface一次,帧率会明显往下掉。
我的实践经验是三条:
第一,Mask 只做一次,生成之后把它缓存成精灵的属性,不要每帧重建。这个前面已经提过。第二,像素检测一般只用于关键对象,比如玩家本体和 Boss 子弹,小怪和普通道具用矩形或圆形就够了。第三,任何情况下先用矩形做一次粗筛,矩形不相交就直接跳过,只有矩形相交时才进入 Mask 重算。这套"粗筛加精判"的组合,就是用一点点额外代码换回大部分不必要的像素计算。
if player.rect.colliderect(important_enemy.rect): overlap = player.mask.overlap( important_enemy.mask, (important_enemy.rect.x - player.rect.x, important_enemy.rect.y - player.rect.y), ) if overlap: player.health -= 15. 别等卡顿才优化:用空间网格砍掉无效检查
5.1 朴素双层循环的问题规模
把对象全塞进一个列表,然后两层 for 循环检查每一对,这种写法在十几个对象时毫无压力,但规模一上来就露馅了。n 个对象的朴素检查次数是 n(n-1)/2,因为 A 撞 B 和 B 撞 A 是同一次碰撞,所以要去掉一半。也就是说 100 个对象要检查 4950 对,500 个对象要检查 124750 对,1000 个对象就接近 50 万对。
Python 的循环本来就偏慢,每对还要做colliderect或更复杂的判断,50 万对的规模一帧跑下来,帧率低到没法玩。优化思路并不复杂:不要在所有对象之间做判断,而只检查"有可能撞上"的那些对象。空间划分就是干这件事的。
5.2 网格划分的几个关键点
空间网格的思路是:把整个游戏区域划成若干个固定大小的格子,每个格子是一个桶,把落入该格子的对象登记进去。检测碰撞时,只检查同一个格子以及相邻格子里的对象。
class SpatialGrid: def __init__(self, cell_size=48): self.cell_size = cell_size self.cells = {} def clear(self): self.cells.clear() def insert(self, sprite): left = sprite.rect.left // self.cell_size top = sprite.rect.top // self.cell_size right = sprite.rect.right // self.cell_size bottom = sprite.rect.bottom // self.cell_size for cy in range(top, bottom + 1): for cx in range(left, right + 1): self.cells.setdefault((cx, cy), []).append(sprite) def query_nearby(self, sprite): left = sprite.rect.left // self.cell_size top = sprite.rect.top // self.cell_size right = sprite.rect.right // self.cell_size bottom = sprite.rect.bottom // self.cell_size result = [] for cy in range(top, bottom + 1): for cx in range(left, right + 1): result.extend(self.cells.get((cx, cy), [])) return result这部分代码有四个要点要记住。第一,每个对象可能横跨多个格子,比如一个很大的 Boss 占了四五个格子,它必须被插入到所有这些格子里,否则查询时容易漏。第二,每帧开始时要clear()重新建立网格,网格是帧级临时结构。第三,为了保证不漏判,碰撞检测时除了当前对象所在格,还要检查周围一圈相邻格,因为两个对象可能分别卡在相邻格子里,但实际边界已经重叠。第四,网格的 cell_size 要根据游戏对象的大小来定。对象越小,格子可以取得越小,排除效果越好;对象很大时格子太小会导致同一个大对象被反复插入十几个格子,内存和遍历开销都上去了。
5.3 实测感受和参数选择
我在一个弹幕小游戏里做过一次对比,场景里同时活跃的子弹和敌人加起来接近六百个。朴素的 O(n²) 双层循环在普通笔记本上肉眼可见地掉帧,用空间网格把候选范围缩小之后,画面立刻恢复流畅,体感差别非常明显。虽然没有做严谨的基准测试,但数量级上肯定差出十倍以上。
这里想强调一句:空间网格不是唯一方案,四叉树、八叉树在高密度场景里也常见,但网格胜在实现简单、不容易出错,用它应付绝大多数 2D 小游戏绰绰有余。不要一上来就整复杂的结构,先把能跑的方案跑起来。
6. 高速移动与隧道效应:子弹为什么"穿模"
6.1 一次穿模事故的过程复盘
回到文章开头那个问题:子弹为什么明明穿过了敌人,却没有触发碰撞?现在可以系统地解释了。游戏是一帧一帧运转的,每一帧程序先更新位置,再检测碰撞。如果子弹速度很快,上一帧它还在敌人左边,这一帧已经移到敌人右边,中间跨越的距离超过了敌人的宽度,那么这两帧里子弹的矩形都没有和敌人的矩形重叠,碰撞检测自然就漏掉了。
这不是数学判断写错了,而是采样间隔造成的物理失真,业界叫"隧道效应"。用生活的话说,你每十米拍一张照片,一辆高速驶过的汽车完全可能从两张照片的间隙里消失。子弹越快、目标越小、帧率越低,这个问题越明显。
6.2 子步进缓解隧道效应
最实用的缓解方案是"子步进":把一帧内的大位移拆成多段小位移,每移动一小段就做一次碰撞检测。这样子弹的轨迹被更密集地采样,穿模概率大大下降。
import math def update_bullet(bullet, walls): speed = 18 max_step = 4 steps = max(1, math.ceil(speed / max_step)) step_speed = speed / steps for _ in range(steps): bullet.rect.x += step_speed if pygame.sprite.spritecollide(bullet, walls, False): bullet.kill() returnmax_step的选择原则:它应该小于游戏里最小目标对象的宽度,这样每次采样时,子弹位移都不足以完整跳过某个目标。这个方案只增加了很少的代码,但效果立竿见影。代价是每帧碰撞检测次数乘以 step 数,所以子弹数量大时要和空间网格配合使用,否则子步进会把性能又拖下去。
如果你要做的是更严谨的连续碰撞检测,还需要用射线或者其他扫掠算法,把子弹的运动轨迹当成一条线段,和目标的包围盒求交。那是专门处理物理模拟的正确姿势,但对小游戏项目来说,子步进已经是性价比最高的第一步。
7. 碰撞之后:分离、反弹与落地判断
7.1 分轴移动与重叠回退
碰撞检测完成之后,紧接着要解决的是"接下来怎么办"。最基础的问题是:两个物体已经重叠了,要不要把其中一个推出去?如果不推,物体会越来越深地陷进墙壁里,画面表现非常怪。
我的做法是把移动拆成 X 和 Y 两个轴分别处理。先移动水平方向,做一遍碰撞检测,如果撞墙就回退 X 轴;再移动垂直方向,重复同样的逻辑。这样做的好处是,能根据当前帧的移动方向判断该把物体推到墙的哪一边,避免斜向移动时两个轴同时碰墙造成的混乱。
player.rect.x += player.vx for wall in walls: if player.rect.colliderect(wall.rect): if player.vx > 0: player.rect.right = wall.rect.left elif player.vx < 0: player.rect.left = wall.rect.right player.vx = 0 player.rect.y += player.vy for wall in walls: if player.rect.colliderect(wall.rect): if player.vy > 0: player.rect.bottom = wall.rect.top player.on_ground = True elif player.vy < 0: player.rect.top = wall.rect.bottom player.vy = 0这段代码是平台跳跃游戏的地基。X 轴和 Y 轴分开处理以后,"从侧面撞墙"和"落到头顶"这两种情况的判断依据就是当前帧的速度方向,逻辑非常清晰。on_ground这个标志位还会被跳跃系统复用,判断玩家能否起跳。
7.2 从检测到响应的一点个人经验
关于碰撞响应,我最后想分享几条比较零散但确实踩过坑的经验。
第一,碰撞检测后直接修改位置时,尽量用计算出来的精确边界值去赋值,而不是用"减去一个固定偏移"的方式。比如player.rect.right = wall.rect.left这种写法,在任何速度下都会得到正确位置;如果写成player.rect.x -= 2,超过一定速度就会留下可见的穿模缝隙。
第二,不要在同一帧里既做碰撞响应又立刻更新速度。很多新手把"碰到墙就反向"写在碰撞检测的循环里,结果物体会在一个物理帧内反复进出墙体,表现出来就是高频抖动。正确的做法是把新速度先算好存起来,到下一帧开始移动时再应用。
第三,如果你的游戏里有很多子弹,子弹命中后的处理要谨慎。一颗子弹打中多个目标是很常见的,尤其是穿透型子弹。写groupcollide时,返回的字典里同一个子弹可能对应多个敌人,这时要根据游戏规则明确:这颗子弹是一次性消耗品,还是可以连续穿透多个目标的设定。搞混这两种逻辑,会让战斗表现非常奇怪。
最后再补充一个小技巧:调试碰撞时,把参与检测的 Rect 和 Mask 轮廓画到屏幕上,用颜色或者描边标出来,比打日志直观得多。Pygame 里有pygame.draw.rect可以临时勾勒矩形框,Mask 轮廓可以用pygame.mask.Mask.surfel转成 Surface 再贴到屏幕上。做好这一步,你会发现自己排查碰撞问题的速度快了不止一倍。