资讯动态

Pygame碰撞检测全解析:从矩形包围盒到空间网格

发布时间:2026/10/20 21:40:27 来源:尧图企业网站定制
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.widthrect.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 体系把每个对象托管到 Group3.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_rectAABB 矩形重叠方块类物体、广相粗筛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, threshold127) mask2 pygame.mask.from_surface(sprite2.image, threshold127) 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_size48): 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 再贴到屏幕上。做好这一步你会发现自己排查碰撞问题的速度快了不止一倍。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑