做Python游戏开发,绕不开的话题就是碰撞检测。我最早在Pygame里写一个小射击游戏,角色贴图已经撞上了敌机,血条却纹丝不动,后来才发现,是我把碰撞检测实现成了“两者中心点距离小于某个值”,忽略了矩形边界重叠才是最直观的判定方式。那次兜圈子排查让我意识到,碰撞检测看似简单,但牵扯到形状选型、坐标精度、响应处理、性能优化一系列问题。这篇东西我就把自己的实践过程、代码思路和踩过的坑一起整理出来,给正准备动手写Python游戏,或者对碰撞检测实现细节有疑问的朋友做个参考。
1. 先搞清楚碰撞检测到底在解决什么问题
很多人第一次接触碰撞检测,以为就是一个“判断两个物体是否相交”的函数。实际在游戏项目里,碰撞检测从来不只是一个几何判断,而是“检测”和“响应”两个环节的合称。搞混这两个环节,就会出现我开头说的那种情况:检测到了,但游戏没反应;或者反应了,但角色卡在墙里疯狂抖动。
1.1 检测只是第一步,响应才是游戏逻辑的开始
几何相交判断解决的是“是不是碰上了”,比如两个矩形是否重叠、两个圆是否相交。这一步的结果通常是布尔值,True或者False。但游戏需要的是“碰上了之后要干什么”,这就是碰撞响应。
举个最简单的例子,玩家角色撞到墙壁,检测结果返回True,接下来需要做的事是把角色从墙里推出去,同时把速度置零。如果只做检测不做响应,角色就会卡进墙里;如果响应做得不对,比如每次都把角色往反方向推一大截,就会出现“角色在墙边抖个不停”的经典Bug。
我这里整理了一个简单的处理流程,供参考:
python复制# 伪代码:碰撞检测 + 响应
if player.rect.colliderect(wall.rect):
# 1. 得到重叠区域
overlap = player.rect.clip(wall.rect)
# 2. 判断从哪个方向推出
if player.rect.centerx < wall.rect.centerx:
player.rect.x -= overlap.width
else:
player.rect.x += overlap.width
# 3. 速度清零
player.vx = 0
实际项目中,响应逻辑往往比这复杂,要考虑地面摩擦力、跳起状态、是否处于无敌帧等。但核心思路不变:检测负责“发现问题”,响应负责“解决问题”。
1.2 碰撞检测在游戏循环中的执行时机
一个标准的2D游戏循环长这样:处理输入、更新逻辑、检测碰撞、渲染画面。碰撞检测要放在物体移动之后、渲染之前,这个顺序基本是固定的。
我在初期犯过的错误是把移动和碰撞检测放在同一个update函数里,导致物体移动完了立刻检测,但物体速度太快时,一帧内可能从目标的一侧直接穿到另一侧,碰撞检测根本没机会触发,这就是“隧穿效应”。
隧穿效应的本质是离散时间步长带来的采样盲区。物体速度是每帧几十像素,两帧之间物体位置是跳变的,如果碰撞对象很薄,比如一颗子弹宽度只有4像素,而物体移动速度是每帧20像素,那子弹完全可能“跳过”目标。
处理隧穿有几个常用思路:
- 限制单帧最大位移,比如每帧位移不超过物体宽度的1/2;
- 把一帧拆成多个子步长,逐步移动、逐步检测;
- 用射线检测或者连续碰撞检测(CCD),专门处理高速物体。
对于大多数2D小游戏,限制单帧最大位移就够用了。我自己写平台跳跃游戏时,把所有物体的最大速度限制在每帧10像素以内,再配合固定帧率的游戏循环,极少出现隧穿问题。千万别一上来就上物理引擎,先用最简单的方式跑通,遇到问题再升级。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 矩形碰撞的三种写法与精度死角
矩形碰撞是2D游戏里最常用的碰撞形状,原因无他,实现简单、计算量小、足够覆盖绝大多数视觉表现。但矩形碰撞也分好几种,选择不对,精度和性能都会出问题。
2.1 AABB:最朴素也最常用的矩形碰撞
AABB的全称是Axis-Aligned Bounding Box,意思是边与世界坐标轴对齐的包围盒。它要求矩形不能旋转,四条边分别平行于x轴和y轴。
判断两个AABB是否相交,只需要检查它们在x轴和y轴上的投影是否都重叠。这个逻辑用代码表达很简单:
python复制def aabb_collide(ax, ay, aw, ah, bx, by, bw, bh):
# ax, ay 是矩形A的左上角坐标,aw, ah 是宽高
# 判断x轴方向是否重叠
if ax + aw <= bx or bx + bw <= ax:
return False
# 判断y轴方向是否重叠
if ay + ah <= by or by + bh <= ay:
return False
return True
注意这里用的是“不重叠就返回False”的排除法,比直接写重叠条件更直观,也不容易漏掉边界情况。两个矩形刚好边缘相切时,重叠宽度为0,算不算碰撞要看游戏需求,我通常算碰撞。
2.2 用Pygame内置Rect还是手写
如果做的是Pygame项目,pygame.Rect自带colliderect方法,几行代码就能解决碰撞检测:
python复制rect1 = pygame.Rect(10, 10, 50, 50)
rect2 = pygame.Rect(40, 40, 50, 50)
if rect1.colliderect(rect2):
print("碰撞")
方便是真的方便,但有一个坑必须注意:pygame.Rect只能存整数坐标。你在update逻辑里用浮点数维护位置,一赋值给Rect,小数部分就被截断了。角色速度是0.5像素/帧时,矩形坐标每两帧才动1像素,视觉上会有明显的卡顿感,碰撞判定也会因为坐标取整产生一两个像素的误差。
我自己的做法是,角色的逻辑坐标永远用float维护,需要渲染或碰撞检测时,再从float坐标构造出一个临时的Rect。这样既保证了精度,又不丢失pygame.Rect带来的便利。
| 场景 | 推荐做法 | 原因 |
|---|---|---|
| 原型开发、快速迭代 | pygame.Rect.colliderect | 代码少,效率足够 |
| 需要浮点精度 | 手写AABB,比较float坐标 | 避免Rect取整误差 |
| 性能敏感的大规模场景 | 手写AABB + 空间分区 | 避免创建大量临时Rect对象 |
2.3 旋转矩形与OBB的坑
AABB最大的限制是不能处理旋转。当角色或者障碍物旋转一定角度后,AABB会变大一圈,变成“包围这个旋转矩形的最大正交矩形”,导致视觉上明明没碰到,程序却判定碰撞了。
解决办法是使用OBB(Oriented Bounding Box)或者分离轴定理(SAT)。分离轴定理是一个通用算法:两个凸多边形不相交,当且仅当存在一条分离轴,使得两个多边形在这条轴上的投影不相交。对两个矩形来说,只需要检查两个矩形的四条法线方向上的投影。
python复制def project_polygon(axis, polygon):
# 计算多边形在某个轴上的投影区间
dots = [axis.x * p.x + axis.y * p.y for p in polygon]
return min(dots), max(dots)
def sat_collide(vertices_a, vertices_b):
axes = get_axes(vertices_a) + get_axes(vertices_b)
for axis in axes:
min_a, max_a = project_polygon(axis, vertices_a)
min_b, max_b = project_polygon(axis, vertices_b)
if max_a < min_b or max_b < min_a:
return False
return True
这段代码里get_axes返回每条边的法线方向,具体细节你可以在搜索引擎找到完整实现。SAT的理解门槛不高,但需要小心处理浮点数精度,临界情况下会出现抖动。
不过说实话,我在实际项目中很少用OBB。大多数2D游戏的角色、子弹、箱子,用AABB就够了。只有飞机子弹打斜向旋转的敌机之类场景,我才会用圆形碰撞体替代OBB,因为圆形没有方向,旋转不影响判定。
3. 圆形与混合碰撞体:让检测贴合视觉表现
矩形碰撞虽然简单,但碰上圆形的子弹、圆润的敌人,矩形检测的误差就很明显了。圆形碰撞检测在数学上更简单,而且天然支持旋转。
3.1 圆形碰撞的数学与代码
两个圆是否碰撞,判断条件是圆心距离是否小于两个半径之和。为了避免开方运算,实际代码里比较的是距离的平方:
python复制import math
def circle_collide(x1, y1, r1, x2, y2, r2):
dx = x1 - x2
dy = y1 - y2
dist_sq = dx * dx + dy * dy
radius_sum = r1 + r2
return dist_sq <= radius_sum * radius_sum
这样省去了math.sqrt,在大量圆形碰撞检测时能省不少CPU时间。
圆和矩形的碰撞稍微复杂一点,核心思路是:找到矩形区域内离圆心最近的点,然后判断这个点到圆心的距离是否小于半径。
python复制def circle_rect_collide(cx, cy, cr, rx, ry, rw, rh):
# 计算圆心到矩形的最近点
nearest_x = max(rx, min(cx, rx + rw))
nearest_y = max(ry, min(cy, ry + rh))
dx = cx - nearest_x
dy = cy - nearest_y
return dx * dx + dy * dy <= cr * cr
最近点的计算很巧妙:先用min把圆心x坐标限制在矩形x范围内,再用max把结果限制在矩形x范围的另一侧,y坐标同理。这样得到的点一定是矩形内或边界上离圆心最近的点,平滑处理了圆在矩形四个角附近的情况。
3.2 复合碰撞体:用多个简单形状逼近复杂轮廓
单个形状很难精确表达一个复杂的角色轮廓。解放双手的通用方案是复合碰撞体:把角色的碰撞区域拆成几个简单形状,分别检测,任何一个命中就算碰撞。
一个典型的人形角色可以这样设计:
- 躯干:一个矩形
- 头部:一个圆
- 腿部:两个小矩形
python复制class Player:
def __init__(self):
self.body = pygame.Rect(0, 0, 32, 24)
self.head = (16, 0, 8) # 圆心坐标和半径
self.legs = [
pygame.Rect(0, 24, 12, 10),
pygame.Rect(20, 24, 12, 10)
]
def collides(self, other_rect):
for shape in [self.body] + self.legs:
if shape.colliderect(other_rect):
return True
if circle_rect_collide(self.head[0], self.head[1], self.head[2],
other_rect.x, other_rect.y,
other_rect.w, other_rect.h):
return True
return False
复合碰撞体的代价是每次检测要遍历多个子形状,检测次数线性增加。我的经验是,每个角色的子碰撞体控制在3到5个以内,性能完全不是问题。数量太多了,还不如用像素级检测。
3.3 像素级检测的代价与正确打开方式
像素级碰撞检测是最精确的,但也是性能开销最大的。Pygame提供了pygame.mask.Mask,可以把精灵表面转换成位掩码,然后调用overlap方法判断两个掩码是否有重叠像素。
python复制mask1 = pygame.mask.from_surface(player_surface)
mask2 = pygame.mask.from_surface(bullet_surface)
offset = (bullet_rect.x - player_rect.x, bullet_rect.y - player_rect.y)
if mask1.overlap(mask2, offset):
print("像素级碰撞")
这个方案对形状复杂的精灵非常友好,比如飞机大战里的子弹打到敌机机翼边缘。但要注意,像素级检测不能直接作为唯一方案。我见过有人让几百个物体互相做像素检测,结果帧率直接掉到个位数。
正确用法是分层:先用AABB做粗筛,两个矩形的包围盒都不相交就直接跳过;只有粗筛通过,才进行像素级精确检测。这样既能享受像素级的精度,又能把性能开销控制住。
| 碰撞类型 | 计算量 | 精度 | 适用场景 |
|---|---|---|---|
| AABB | 极低 | 低 | 绝大多数2D游戏 |
| 圆形 | 极低 | 中 | 子弹、球类、角色碰撞体 |
| OBB/SAT | 中 | 高 | 旋转矩形、凸多边形 |
| 像素掩码 | 高 | 极高 | 形状复杂、需要精确判定 |
4. 瓦片地图与网格碰撞:2D平台游戏的标准解法
做2D平台游戏的人都知道,地图往往是由一块块瓦片拼出来的。这时候如果让角色和所有瓦片逐一做碰撞检测,地图一大就卡死。正确思路是只用角色周围的瓦片做检测。
4.1 瓦片碰撞的基本思路
假设地图用一个二维数组存储,每个元素代表一种瓦片类型,0表示空白,1表示实心墙。世界坐标换算成瓦片坐标很简单:
python复制tile_x = int(player_rect.centerx // TILE_SIZE)
tile_y = int(player_rect.centery // TILE_SIZE)
检测碰撞时,不要遍历地图里所有瓦片,只看角色周围的瓦片就够了。一般检查角色中心点周围3x3的瓦片区域,只有角色很大或者瓦片很小时才需要扩大范围。
python复制def get_surrounding_tiles(rect, tile_map):
left = int(rect.left // TILE_SIZE) - 1
right = int(rect.right // TILE_SIZE) + 1
top = int(rect.top // TILE_SIZE) - 1
bottom = int(rect.bottom // TILE_SIZE) + 1
tiles = []
for ty in range(top, bottom + 1):
for tx in range(left, right + 1):
if tile_map.get_tile(tx, ty) != 0:
tile_rect = pygame.Rect(tx * TILE_SIZE, ty * TILE_SIZE,
TILE_SIZE, TILE_SIZE)
tiles.append(tile_rect)
return tiles
这样每帧最多检查十几个瓦片,和地图大小完全无关,性能非常稳定。
4.2 碰撞后处理:贴墙滑行与落地检测
瓦片检测到了,怎么响应也是个讲究活。我早期直接“碰了就把角色往回推”,结果角色在墙角特别容易卡住。后来才明白,平台游戏的碰撞响应要分轴处理。
核心做法是:先水平移动,检测碰撞,处理水平响应;再垂直移动,检测碰撞,处理垂直响应。这样角色在贴墙时,水平方向被挡住,但垂直方向还能下落,实现“贴墙滑行”。反过来,落地时垂直方向被挡住,但水平方向还能移动。
python复制# 水平移动
player.x += player.vx
hit_list = get_surrounding_tiles(player.rect, tile_map)
for tile in hit_list:
if player.rect.colliderect(tile):
if player.vx > 0:
player.rect.right = tile.left
elif player.vx < 0:
player.rect.left = tile.right
player.vx = 0
# 垂直移动
player.y += player.vy
hit_list = get_surrounding_tiles(player.rect, tile_map)
for tile in hit_list:
if player.rect.colliderect(tile):
if player.vy > 0:
player.rect.bottom = tile.top
player.on_ground = True
elif player.vy < 0:
player.rect.top = tile.bottom
player.vy = 0
关键点是把原来的合速度向量拆成x和y两个方向,分别处理。这样无论角色朝哪个方向运动,都不会出现“卡墙角”的死局。
4.3 单向平台与浮空平台怎么实现
单向平台是平台游戏的标配:角色可以从下方跳上去,也能从上方落下来,但从下方跳跃时不会撞到头。
实现思路不复杂:只有满足“角色速度向下”且“角色上一帧脚部位置在平台上方”这两个条件,才做碰撞响应。
python复制def check_one_way_platform(player, platform):
if player.vy <= 0:
return False # 速度不向下,忽略
if player.prev_foot_y > platform.top:
return False # 上一帧脚在平台下方,说明是从下面穿上来
return player.rect.bottom >= platform.top and player.rect.bottom - platform.top < 16
那个16像素的容差是防止角色下落速度过快时,一整帧穿过平台却检测不到。这个容差取值需要根据游戏速度调整,速度越快,容差越大。
我第一次实现单向平台时出现了“站上去疯狂抖动”的问题。原因是角色落地后,物理引擎又把它向下推,检测到平台又往上抬,一帧内位置反复横跳。后来在落地时把角色的on_ground状态设为True,并强制vy=0,抖动就消失了。
5. 性能优化:碰撞对太多时的三种降维打法
游戏物体数量上来了,碰撞检测的计算量会飞速膨胀。100个物体两两检测,一共要检测4950对;1000个物体就接近50万对,这个量级对Python来说已经很有压力了。需要从算法层面把检测数量降下来。
5.1 先粗筛再精测:Broad Phase与Narrow Phase
物理引擎里通常把碰撞检测分为两个阶段:Broad Phase(粗筛阶段)和Narrow Phase(精测阶段)。
粗筛阶段的目标是用最廉价的方式排除掉明显不可能碰撞的物体对,得到一个候选对列表。精测阶段再对候选对做精确的碰撞检测,比如AABB、圆形、像素级检测。
如果没有粗筛阶段,每对物体都要做精测,浪费大量计算。有了粗筛,大多数物体对在第一步就被排除了。
5.2 网格分区与四叉树:空间划分的核心思路
最简单的空间划分是均匀网格。把游戏世界切成等大的格子,每个格子维护一个物体列表。每帧先根据物体的位置,把它塞进对应格子。检测时,只检查同一个格子和相邻格子里的物体即可。
python复制def find_collision_candidates(grid, obj):
candidates = []
obj_cells = get_cells_covered(obj.rect, grid.cell_size)
for cell in obj_cells:
candidates.extend(grid.get_objects_in_cell(cell))
return set(candidates) # 去重
均匀网格适合物体分布比较均匀的场景,比如弹幕游戏、粒子系统。
如果物体分布极不均匀,比如地图上大多数物体挤在一个角落,其他地方很空旷,均匀网格就会产生大量空格子,浪费内存。这时可以用四叉树:把空间递归四等分,每个节点存储其中的物体,遇到物体密集的区域自动递归细分,稀疏区域则不再细分。
四叉树的实现比均匀网格复杂,需要处理节点分裂、物体跨节点归属、树的动态更新。我的建议是,项目里物体数量超过500个且分布不均时再考虑四叉树;否则就用均匀网格,省心省力。
| 方案 | 实现难度 | 适用场景 |
|---|---|---|
| 暴力两两检测 | 极低 | 物体数量 < 100 |
| 均匀网格 | 中 | 物体分布比较均匀 |
| 四叉树 | 较高 | 物体分布不均匀、数量大 |
| 八叉树 | 高 | 3D游戏专用 |
5.3 避免每帧重复计算的工程技巧
算法之外,Python代码本身的性能也需要打磨。我总结了几个实际项目里反复用到的优化习惯:
第一,避免每帧创建新对象。pygame.Rect的构造函数虽然轻量,但每帧创建上千个也会拖慢速度。可以复用已有的Rect对象,需要修改坐标时直接改属性。
第二,静态物体和动态物体分离。地图上的墙体、障碍物基本不动,不需要每帧重新计算它们的碰撞体或者重新插入空间分区。把它们单独缓存,只让动态物体去匹配静态物体。
第三,合理使用精灵组。Pygame的pygame.sprite.Group提供了collide_rect等碰撞函数,内部实现是两两调用colliderect。底层性能并没有特别优化,但对中小型项目来说足够用。项目再大,还是要自己实现空间分区。
性能优化有个重要原则:先测量,再优化。不要凭感觉去优化,先用timeit或者pygame.time.Clock测出每帧耗时的瓶颈在哪里。比如你发现碰撞检测只占了2毫秒,渲染占了10毫秒,那优化的重点就不在碰撞上。
6. 项目实战总结:各方案怎么选,以及我踩过的坑
前面把碰撞检测的主要方案讲了一遍,最后做个总结,也把我实战中踩过的坑挑几个典型的分享出来。
6.1 方案选型对照表
不同项目类型、不同开发阶段,适合的碰撞方案是不一样的。我做了个选型表,方便你直接对号入座。
| 项目场景 | 推荐方案 | 理由 |
|---|---|---|
| 开发初期、原型验证 | AABB + pygame.Rect | 最快跑通逻辑 |
| 平台跳跃游戏 | AABB + 瓦片分轴响应 | 配合瓦片地图天然合适 |
| 弹幕射击游戏 | 圆形碰撞 | 子弹、敌机大多圆形,判定快 |
| 形状复杂的精灵 | AABB粗筛 + 像素掩码精测 | 精度高,性能可控 |
| 物体数量多的大场景 | 均匀网格/四叉树 + AABB | 大幅减少无效检测对 |
| 需要物理模拟 | Pymunk/Box2D | 别自己造物理引擎 |
这个表不绝对,但它能帮你避开最常见的“一开始就用高大上方案,结果把自己绕进去”的坑。
6.2 我踩过的几个典型坑
浮点坐标与整数Rect混用导致的碰撞“时灵时不灵”。这个问题在2.2节提过。具体表现是:角色用float坐标,Boss也用float坐标,碰撞检测时都用pygame.Rect,结果两个Rect都做了取整。角色和Boss的视觉间距明明只有0.5像素,Rect却显示它们重叠了半个像素,导致玩家觉得“没碰到就死了”。后来我全部改用float坐标手写AABB检测,问题才消失。
碰撞响应里直接修改坐标引发的连锁抖动。角色碰撞墙体后直接把rect.x重置到墙边,但下一帧又被速度推回墙里,反复横跳。解决思路是把碰撞检测放在移动之后,并且碰撞响应时直接把速度分量清零,而不是只调整坐标。
碰撞精灵组的递归调用导致栈溢出。用pygame.sprite.groupcollide时,如果把参与碰撞的Group同时迭代了,容易在你自己的回调里再次触发碰撞检测,形成递归。这个坑比较隐蔽,我当时的解法是给碰撞处理函数加一个processing标志位,正在处理的碰撞不重复进入。
旋转的精灵忘记同步碰撞体。给角色加了旋转动画后,视觉上转了个大角度,但碰撞体还是原来的AABB,结果敌机隔着老远就被“空气墙”挡住了。如果确实需要精确的旋转碰撞,要么用圆形碰撞体,要么在每次旋转后重新计算OBB的顶点。
6.3 后续还可以怎么扩展
如果你的项目渐渐复杂,碰撞检测这块需要更强大的支撑,我建议直接引入成熟的物理引擎。Pymunk是Pymunk是Box2D的Python绑定,提供了刚体、关节、碰撞回调全套功能,而且自带碰撞形状的类型很丰富。自己写碰撞检测练手是一回事,正式项目里还是少造轮子。
如果想在现有方案上继续优化,可以考虑连续碰撞检测。对于高速子弹、高速飞行的角色,把运动轨迹拆成多个子步长,每步做一次碰撞检测,能显著减少隧穿问题。
最后再分享一个调试习惯:把碰撞检测的结果可视化。在渲染阶段用pygame.draw.rect分别画出每个物体的碰撞盒,用不同颜色区分是否处于碰撞状态。这样你一眼就能看出问题出在检测阶段还是响应阶段,排查效率能翻一倍。我在项目里加了这个可视化开关之后,很多疑难问题都在几分钟内定位了。你写碰撞检测的时候,也建议先把这个调试工具做出来,后面会省太多事。
