做游戏的同学应该都遇到过这种画面:角色明明撞到了墙,却直接穿模过去;子弹看着打中敌人了,伤害却纹丝不动;或者更魔幻的——两个物体碰在一起之后开始疯狂抖动,像触电一样,怎么推都推不开。这些问题的根源,几乎都指向同一个环节:碰撞检测。
用 Python 写游戏(尤其是 Pygame 这类库)的时候,碰撞检测是最容易上手、也最容易写出“能用但到处是坑”的功能。这篇文章我会从最常用的矩形碰撞、圆形碰撞讲起,一直讲到像素级 Mask 碰撞、空间哈希优化和高速穿透问题的处理。无论你是在做平台跳跃、弹幕飞行,还是小体量的 Roguelike,这篇文章的内容基本都能直接迁移过去用。
先说明一下,正文里的代码我默认基于 Pygame 2.x 版本,Python 3.8+。就算你没怎么用过 Pygame,只要把 friend 的绘图部分替换成你自己的渲染层,碰撞逻辑本身是通用的。
1. 碰撞检测方案的整体设计思路
1.1 碰撞检测的本质:两个问题
碰撞检测看起来是在问“两个物体在不在同一个位置”,但真正落地的时候,其实是在解决两个独立的问题:
第一个是“有没有碰”(检测)。也就是几何层面上的判断。两个矩形是否重叠、两个圆是否相交、某个点是否落在多边形内部,这些都属于检测范畴。
第二个是“碰了之后怎么办”(响应)。是反弹、穿透、贴墙停住,还是触发伤害结算?响应策略会直接影响玩家手感,而且响应写不好,比检测写不好更容易让游戏显得“廉价”。
我见过很多新手项目卡在第二步。检测逻辑算得特别精确,结果角色一碰到墙就开始原地抽搐,或者卡进墙里出不来。原因多半是把检测和响应揉在了一起,没有给“移动”和“校正”留出清晰的层次。
所以做碰撞检测之前,先想清楚两件事:
- 你的物体是“谁撞谁”?角色撞静态地形,还是子弹撞敌人,还是两类都在动?
- 碰撞之后物体应该停在表面、弹开,还是直接穿过去触发逻辑?
这两个问题决定了后面的算法选型和代码结构。
1.2 主流碰撞检测方案的选型对比
Python 游戏里最常见的碰撞检测方案有这么几类:AABB 矩形检测、圆形检测、矩形与圆形混合检测、像素级 Mask 检测,以及适用范围更广的分离轴定理(SAT)。
选型上我一般遵循一个原则:先满足游戏性需求,再考虑精度。玩家不会用游标卡尺去看两个物体是否差了半个像素,但他们会立刻感觉到“子弹穿过敌人没伤害”或者“明明没碰到却掉血了”的不公。
| 检测方案 | 精度 | 计算成本 | 典型场景 |
|---|---|---|---|
| AABB 矩形 | 中 | 极低 | 墙体、平台、攻击范围 |
| 圆形碰撞 | 中 | 低 | 道具拾取、角色受伤判定、炮弹 |
| 矩形-圆形混合 | 中高 | 低 | 角色与障碍、近战攻击判定 |
| 像素级 Mask | 高 | 高 | 不规则美术资源、弹幕精细碰撞 |
| SAT 分离轴 | 高 | 中 | 旋转矩形、凸多边形 |
很多新手会第一时间冲像素级碰撞,觉得只有像素级才算“真碰撞”。但在实际项目中,像素级碰撞往往只用在最关键的地方,比如 Boss 的弱点判定、特殊弹幕的精确命中。普通的地板、墙壁、敌人碰撞,清一色用 AABB 或圆形,因为它们的计算成本低、代码简单、调试起来也直观。
拿我自己做过的某款小游戏来说,玩家角色是一艘外形不太规则的飞船,敌人和子弹也各有各的形状。最终用的组合是:飞船本体用圆形(手感好,四个方向移动时不会因为矩形角部产生“空气碰撞”),障碍物用 AABB,子弹与敌人之间用矩形碰撞,只有 Boss 的受击点上了 Mask 碰撞。每一层都够用,而且性能余量非常大。
2. 核心碰撞算法:原理与代码实现
2.1 AABB 矩形碰撞:最常用的基础方案
AABB 的全称是 Axis-Aligned Bounding Box,轴对齐包围盒。意思就是这个矩形不会旋转,坐标轴始终和世界坐标系对齐。它的碰撞条件非常直观:两个没有旋转的矩形,只要在 X 轴投影有重叠、在 Y 轴投影也有重叠,那么它们就相交了。
Pygame 里最省事的做法是直接用pygame.Rect自带的colliderect:
import pygame rect_a = pygame.Rect(100, 100, 40, 40) rect_b = pygame.Rect(120, 120, 50, 30) if rect_a.colliderect(rect_b): print("碰撞了")这个写法写 Demo 没问题,但如果你想搞清楚背后发生了什么,或者游戏里用的不是 Pygame 而是自定义渲染层,建议手写一个 AABB 判断:
def aabb_collision(x1, y1, w1, h1, x2, y2, w2, h2): return not ( x1 + w1 < x2 or x2 + w2 < x1 or y1 + h1 < y2 or y2 + h2 < y1 )这段代码的核心逻辑就一句话:只要有一对投影没有重叠,就不可能碰撞;否则一定重叠。注意我用的是“不满足分离条件”来判断相交,这比直接写四组交集条件要清晰得多。
实际项目中,我更喜欢把 AABB 封装成一个简单的数据结构,方便后续扩展:
class AABB: def __init__(self, x, y, width, height): self.x = x self.y = y self.width = width self.height = height @property def left(self): return self.x @property def right(self): return self.x + self.width @property def top(self): return self.y @property def bottom(self): return self.y + self.height def overlaps(self, other): return not ( self.right < other.left or other.right < self.left or self.bottom < other.top or other.bottom < self.top )提示:AABB 的精度瓶颈在于“斜角”。两个斜着错开的矩形,角部明明没有真正接触,AABB 也会判为碰撞。如果这类情况影响体验,可以把 AABB 作为粗筛,细筛交给圆形或 Mask。
2.2 圆形碰撞:避免开平方的运算技巧
圆形碰撞的数学定义很简单:两圆心的距离小于等于半径之和,就算碰撞。
import math def circle_collision(x1, y1, r1, x2, y2, r2): dx = x2 - x1 dy = y2 - y1 distance = math.sqrt(dx * dx + dy * dy) return distance <= r1 + r2但sqrt在 Python 里属于相对昂贵的运算。游戏主循环里每帧可能有几百上千对碰撞判断,全部开平方会产生明显开销。更好的做法是用距离的平方去比较:
def circle_collision_fast(x1, y1, r1, x2, y2, r2): dx = x2 - x1 dy = y2 - y1 radius_sum = r1 + r2 return dx * dx + dy * dy <= radius_sum * radius_sum这里省掉了开平方,因为不等式两边同时平方,大小关系完全不变。这个优化虽小,但在大量圆形碰撞的场景下(比如子弹、弹幕)节省的计算量非常可观。
圆形碰撞的另一个优点是没有“方向性”。角色用圆形碰撞,从任何角度贴墙都不会因为矩形角部而产生“明明看起来没碰上却被弹开”的违和感。这也是很多动作游戏的角色受击框选圆形的原因。
2.3 矩形与圆形混合碰撞:贴墙判定的正确姿势
实际游戏里,矩形碰撞和圆形碰撞往往会同时出现。比如玩家是一个圆,障碍物是一堵矩形墙。这时候不能简单调用矩形碰撞或圆形碰撞,因为两套判断的前提条件都不满足。
处理矩形和圆形的碰撞,核心方法是找圆在矩形上的最近点,然后计算圆心到该点的距离:
def rect_circle_collision(rect, circle_x, circle_y, circle_radius): closest_x = max(rect.left, min(circle_x, rect.right)) closest_y = max(rect.top, min(circle_y, rect.bottom)) dx = circle_x - closest_x dy = circle_y - closest_y return dx * dx + dy * dy <= circle_radius * circle_radius原理其实很朴素:把圆心坐标分别“夹”到矩形的左右边界和上下边界之间,得到矩形内部离圆心最近的点。然后算圆心到最近点的距离,如果小于等于半径,说明圆的边缘碰到了矩形区域。
我第一次看到这个算法时觉得有点绕,后来用几何直觉理解就通了。想象把矩形往四个方向各“膨胀”一个半径,得到一个新的圆角矩形区域。圆心落在这个区域里,就说明圆和矩形有接触。这个观察也解释了为什么圆碰到矩形角部时会更早触发碰撞——因为角部是弧形的,膨胀后的区域自然也是圆角的。
3. 实操过程:从零搭建一个可运行的碰撞检测示例
3.1 搭建基础的游戏循环与物体结构
为了让上面这些算法看得见摸得着,这里写一个可运行的小示例:玩家角色用方向键移动,场上有若干静态障碍物和一个可拾取的道具。玩法很简单,但碰撞检测逻辑是完整的。
import pygame pygame.init() screen = pygame.display.set_mode((800, 600)) clock = pygame.time.Clock() BLACK = (20, 20, 20) WHITE = (240, 240, 240) RED = (220, 60, 60) GOLD = (240, 200, 60) # 玩家:一个正方形,用 AABB 表示 player = pygame.Rect(100, 100, 36, 36) # 障碍物:静态方块列表 walls = [ pygame.Rect(300, 150, 40, 200), pygame.Rect(450, 400, 200, 40), pygame.Rect(200, 450, 100, 40), ] # 道具:圆形,用圆心和半径表示 coin = (560, 200, 16) collected = False speed = 240 # 像素/秒 running = True while running: dt = clock.tick(60) / 1000.0 for event in pygame.event.get(): if event.type == pygame.QUIT: running = False keys = pygame.key.get_pressed() dx = (keys[pygame.K_RIGHT] - keys[pygame.K_LEFT]) * speed * dt dy = (keys[pygame.K_DOWN] - keys[pygame.K_UP]) * speed * dt ...这个结构几乎所有 Pygame 游戏都一样:事件循环里处理退出,主循环里读取输入、更新状态、绘制画面。我故意把坐标直接用pygame.Rect存,是因为它能同时提供碰撞判断和渲染所需的矩形参数。
3.2 移动与碰撞响应:分离 X 轴和 Y 轴的正确姿势
玩家移动和墙体碰撞是项目里最容易出问题的部分。一个常见错误是同时更新player.x和player.y,然后统一做碰撞检测和修正。这样做会遇到一个很经典的 Bug:斜向移动时,角色会被墙“卡死”,或者沿着墙移动时一顿一顿的。
原因很简单。如果你同时移动两个轴,然后检测到碰撞,你没法判断到底是水平移动撞了墙还是垂直移动撞了墙。这时候无论你把角色往哪个方向推回去,都可能把另一个方向本不该发生的位移也抵消掉。
正确的做法是把两个轴的移动分开处理:
def move_and_collide(rect, dx, dy, obstacles): # X 轴移动并检测 rect.x += dx for wall in obstacles: if rect.colliderect(wall): if dx > 0: rect.right = wall.left elif dx < 0: rect.left = wall.right # Y 轴移动并检测 rect.y += dy for wall in obstacles: if rect.colliderect(wall): if dy > 0: rect.bottom = wall.top elif dy < 0: rect.top = wall.bottom这个写法的关键在于:先做 X 轴的位移和校正,再做 Y 轴的位移和校正。每次碰撞修正都把坐标贴到障碍物的对应边缘,这样角色既不会穿透墙体,也不会卡进墙里。贴墙滑动时手感也自然,因为水平移动被墙挡住后,垂直移动仍然会正常执行。
主循环里这样调用:
move_and_collide(player, int(round(dx)), int(round(dy)), walls)注意dx和dy在传入前要做取整。pygame.Rect只接受整数坐标,如果你把浮点速度直接赋给rect.x,Pygame 会直接截断小数,导致角色移动速度在低帧率下有肉眼可见的卡顿。保留浮点坐标、只把整数部分给 Rect,是更稳妥的做法。后续如果项目复杂度上来,可以给实体维护独立的float x和float y,Rect 仅作为渲染参考。
3.3 道具拾取与圆形碰撞的接入
道具拾取是非常典型的圆形碰撞场景。这里检查角色矩形与圆形道具是否接触:
if not collected: # 矩形 vs 圆形碰撞 closest_x = max(player.left, min(coin[0], player.right)) closest_y = max(player.top, min(coin[1], player.bottom)) dxc = coin[0] - closest_x dyc = coin[1] - closest_y if dxc * dxc + dyc * dyc <= coin[2] * coin[2]: collected = True print("拾取了道具")Pygame 的Rect也自带collidepoint和colliderect,但没有矩形对圆的现成方法,所以手写这段逻辑是完全值得的。
把初始化、移动、拾取判定都串起来之后,一个带碰撞检测的最小可玩游戏框架就成立了:
# 绘制 screen.fill(BLACK) for wall in walls: pygame.draw.rect(screen, WHITE, wall) pygame.draw.rect(screen, RED, player) if not collected: pygame.draw.circle(screen, GOLD, (coin[0], coin[1]), coin[2]) pygame.display.flip() pygame.quit()实际跑一下会发现:角色碰到墙体后不会穿模,也不会卡进去;斜向贴墙移动时滑动手感顺滑;圆形道具的拾取范围明显比矩形更自然,不会在角色还没碰到道具角部时就判定成功。
3.4 性能优化:空间哈希让碰撞检测不再卡顿
上面这个过程,每帧要做的是“玩家 vs 所有墙体”的碰撞检测,墙体只有几堵,完全没压力。但如果你做一个弹幕游戏,场上同时存在 500 个子弹、50 个敌人、若干道具,再用 O(n²) 的遍历去互相检测,帧率会立刻崩掉。
优化思路是“空间哈希”。简单说就是先把屏幕划分成一个个格子,每个格子记录下哪些物体落进了这个区域。检测某个物体的碰撞时,只遍历它所在格子以及相邻格子里的物体,而不是全场景所有物体。
def build_spatial_hash(objects, cell_size=64): grid = {} for obj in objects: rect = obj.rect min_cx = rect.left // cell_size max_cx = rect.right // cell_size min_cy = rect.top // cell_size max_cy = rect.bottom // cell_size for cy in range(min_cy, max_cy + 1): for cx in range(min_cx, max_cx + 1): key = (cx, cy) grid.setdefault(key, []).append(obj) return grid有了网格之后,检测一个物体是否和场景中其他物体碰撞,逻辑就变成:
def check_collisions_with_grid(obj, grid, cell_size=64): rect = obj.rect min_cx = rect.left // cell_size max_cx = rect.right // cell_size min_cy = rect.top // cell_size max_cy = rect.bottom // cell_size checked = set() result = [] for cy in range(min_cy, max_cy + 1): for cx in range(min_cx, max_cx + 1): for candidate in grid.get((cx, cy), []): if candidate is obj: continue if id(candidate) in checked: continue checked.add(id(candidate)) if rect.colliderect(candidate.rect): result.append(candidate) return result空间哈希的效果非常立竿见影。我做过一个粒子推荐的模拟项目,2000 个粒子的两两碰撞检测,朴素 O(n²) 每秒只有十几帧;上了空间哈希之后,稳定跑满 60 帧。代价只是每帧重建一次网格,而重建网格本身就是一趟 O(n) 的遍历,比 O(n²) 的省太多了。
注意:空间哈希的格子大小很关键。格子太大会退化成 O(n²),格子太小则物体会同时跨很多格子,增加重复遍历开销。经验值是让格子大小和场景中大多数物体的直径相当,效果最均衡。
4. 常见问题与排查技巧实录
4.1 高速物体穿透:隧道效应与解决方法
你可能遇到过这种情况:子弹的速度调快之后,明明朝着墙射过去,结果子弹直接飞到了墙的另一边。这就是“隧道效应”,也叫穿透问题。
原因在于碰撞检测是逐帧进行的。如果这一帧子弹还在墙的左边,下一帧子弹已经移动到墙的右边,而中间的位移距离大于墙的厚度,两帧之间子弹和墙根本没有“同时”重叠过,检测自然就漏掉了。
解决隧道效应的常见方案有三种。
第一种是扫掠碰撞(Swept Collision)。把移动路径抽象成一条线段,然后检测线段是否和障碍物相交。这需要用到线段与矩形相交的数学计算,复杂度稍高。
第二种是细分步进(Sub-stepping)。把一帧里的位移拆成若干小步,每一步都做一次普通碰撞检测,只要步长小于最小障碍物的厚度,就能避免穿透:
def move_with_substeps(rect, dx, dy, obstacles, max_step=4): steps = max(1, int(max(abs(dx), abs(dy)) / max_step)) step_dx = dx / steps step_dy = dy / steps for _ in range(steps): move_and_collide(rect, step_dx, step_dy, obstacles)第三种更简单粗暴:给快速物体加“上一帧位置”的标记,检测时多判断一次“上一帧到这一帧之间是否穿过了障碍物”。这个方案实现起来不如细分步进直观,但好处是对已有代码侵入小。
我个人的习惯是:弹幕类游戏用细分步进,因为子弹的移动方向比较单一,步进逻辑清晰;动作类游戏的高速位移技能用扫掠碰撞,因为位移路径可能非常长,细分步进的次数会爆炸。具体用哪种,取决于你的物体速度和场景尺度。
4.2 卡墙抖动与吸附:碰撞校正顺序的陷阱
角色靠墙时疯狂抖动的现象,十有八九出在碰撞响应的校正逻辑上。最常见的一个写法错误是:检测到碰撞后,直接把坐标反向推进,而不是把坐标“吸附”到障碍物的边缘。
举个例子。角色向右移动撞到了墙,如果代码写成“碰撞后把角色的 x 减回去”,那这一帧角色会被推回原位;但下一帧角色再次向右移动,又穿过了墙的边缘,再次被推回来。每帧重复这个过程,视觉上就是抖动。
正确做法我之前已经演示过了——碰撞时把rect.right直接设为wall.left。这个操作可以理解为“把角色的右边缘吸附到墙的左边缘”,角色和墙之间保持零间隙,下一帧无论怎么移动都不会反复横跳。
还有一个细节:吸附校正之后,最好顺手把实体的浮点坐标同步成校正后的 Rect 坐标。不然浮点坐标和 Rect 坐标产生分歧,下一帧移动时会把角色“拉回”到旧的浮点位置,同样造成抖动。
4.3 多物体碰撞的误判与调试手段
多物体同时碰撞时,误判的另一个常见来源是“对象自身碰撞自己”。如果场景里的物体都放在同一个列表中,两两检测时不做“跳过自身”的判断,就会出现一个方块检测到自己和自己的包围盒重叠,永远处于碰撞状态。
解决方案是我之前代码里写过的:检测时判断candidate is obj,直接跳过。如果列表里可能出现重复对象,再补一个id集合去重。
如果你遇到的碰撞误判肉眼很难定位,我强烈推荐一个调试习惯:画出所有碰撞判定框。
# 调试模式:用不同颜色画出每个对象的判定区域 for obj in all_objects: pygame.draw.rect(screen, (0, 255, 0), obj.collision_rect, 2)把判定框可视化之后,很多问题一眼就能看出来。比如你美术上画了一个圆形角色,但判定框是矩形,你会发现角色的左耳位置明明没有敌人,却触发了受伤。这时候把判定框画出来,立刻就能找到问题:矩形框的角部超出了角色美术的边缘。
4.4 碰撞检测性能瓶颈的定位思路
最后再说一下性能排查。如果你的游戏碰撞检测开始掉帧,不要急着换算法,先定位瓶颈到底在哪。
我的排查顺序很固定。第一步,看物体数量。几百对碰撞是压力很小的事,但几千上万对就危险了。第二步,看碰撞调用的频率。很多新手习惯在绘制循环里多次调用colliderect,同样的检测做两遍甚至三遍,白白浪费算力。第三步,看是否有不必要的像素级碰撞。Mask 碰撞的计算成本比普通矩形高一个数量级,如果在大量普通子弹上用了 Mask,掉帧几乎是必然的。
从工程角度来说,碰撞检测永远应该分成粗筛和细筛两个阶段。粗筛用 AABB 或圆形,把明显不可能碰撞的配对过滤掉;细筛只对粗筛剩下的少量配对做精确判定(比如 Mask 或 SAT)。这样既保证了精度,又控制了成本。
5. 再多说两句我自己踩过的坑
写了这么多年 Python 游戏,如果让我浓缩成一句话,就是:碰撞检测的设计比实现重要一百倍。算法本身都不是很难,难的是想清楚游戏里到底需要什么样的碰撞,以及碰撞之后的响应行为。我早期做过一个项目,为了追求“完美碰撞”,给所有物体都上了 Mask 检测,结果帧率惨不忍睹,最后不得不全部回退成 AABB。后来我学乖了,做任何碰撞设计之前,先在纸上画清楚哪些地方必须精确、哪些地方差不多就行,再动手写代码。
另外,调试碰撞碰撞确实是一件容易让人抓狂的事情,但你永远可以相信可视化调试的力量。把判定框、格子边界、命中特效画出来,绝大多数问题都会原形毕露。最后一个小技巧:做碰撞检测的时候,给所有物体留一个“调试绘制开关”,发布版本关掉,开发版本开着。这个开关看起来不起眼,实际项目里救过我无数次。