Python 2D游戏碰撞检测全攻略:从AABB到Mask与性能优化
做Python小游戏不管是Pygame还是Arcade有一个模块你迟早要正面硬刚就是碰撞检测。我见过不少朋友写游戏角色能跑能跳一到“撞上去”这个环节就卡住了要么子弹穿墙要么角色被卡进墙里出不来卡半天最后用一堆if硬凑。这篇文章就把我在实际项目里反复用到的碰撞检测实现方案、优化思路和踩坑记录整理出来给正在写Python游戏、或者从Unity转过来想快速上手的同学做个参考。先划个范围咱们聊的是2D游戏里的碰撞检测实现代码以Pygame为主但原理是通用的。就算你后面转到Unity或者Godot这套思路照样能用。1. 先搞清楚碰撞检测到底在干什么1.1 游戏里哪些地方需要碰撞检测很多新手以为碰撞检测就是“两个矩形碰到一起”其实它覆盖的场景比你想的多得多。给你列几个最常见的角色与地图边界防止玩家走出屏幕或者穿墙子弹与敌人这是射击游戏里最高频的碰撞玩家与道具/金币触发拾取、加分、弹窗敌人与障碍物AI寻路时要绕开墙玩家身体与敌人身体掉血或者击退反馈技能范围和敌人比如AOE伤害圈不同场景对碰撞精度的要求完全不一样。拾取金币可以粗一点差一两个像素无所谓但子弹打头还是打身体在讲究手感的游戏里就得精细得多。这也是为什么碰撞检测不能一劳永逸得按需选方案。另外碰撞检测不只是“有没有碰到”这个布尔结果。很多游戏还需要拿到碰撞发生的位置、法线方向、重叠深度这些信息用来做反弹、滑动、伤害判定和物理反馈。也就是说碰撞检测是“一进一出”的输入输出系统进去的是物体位置和形状出来的是“是否相交 相交信息”。后面写代码的时候我会专门提到怎么把这个信息带出来。1.2 为什么碰撞检测是“看起来容易做起来难”的模块说实话两个矩形是否相交四行代码就能写出来。if (a.right b.left and a.left b.right and a.bottom b.top and a.top b.bottom): print(碰撞发生了)但在真实游戏里问题会迅速复杂化。物体在动帧率在变地图有几十个物体子弹一秒飞几十个像素碰撞检测的性能和稳定性就开始暴露问题。我遇到过最典型的情况是“穿墙子弹”。子弹移动速度太快上一帧还在墙左边下一帧已经到了墙右边两个矩形根本没有重叠过碰撞就漏掉了。这就是游戏开发里常说的“隧道效应”。解决思路有好几种比如连续碰撞检测、射线扫掠、步进细分后面我会单独拿一节讲。另一个问题是性能。想象一下地图里有100个对象每个对象都跟另外99个判断一次就是100×99/2接近5000次检测。如果对象数量涨到1000个就是约50万次。哪怕单次检测很快累计起来也扛不住。这也是为什么大型游戏一定要做空间划分把“谁都跟谁比”变成“只跟附近的比”。所以我的观点很明确不要一上来就套物理引擎先理解碰撞检测的基本实现逻辑你才知道什么时候该用引擎、什么时候手写更划算。Pygame自带了一整套碰撞检测API但用它不等于懂它等你要做特殊判定的时候比如异形多边形、扇形攻击范围底层逻辑能救你。2. 从最简单的几何检测开始2.1 AABB矩形碰撞两句话写出来的核心逻辑AABB全称是Axis-Aligned Bounding Box轴对齐包围盒。意思就是矩形的四条边分别和X轴、Y轴平行没有旋转。这是游戏里最常用的碰撞检测形状。Pygame里直接用Rect的colliderect方法就行但你要理解它背后的判定逻辑否则调起bug来一头雾水。两个矩形相交等价于它们在X轴上的投影有重叠同时在Y轴上的投影也有重叠。如果水平方向和垂直方向只要有一个不重叠两个矩形就一定不相交。def check_collision(rect1, rect2): # 判断X轴方向是否重叠 if rect1.right rect2.left or rect2.right rect1.left: return False # 判断Y轴方向是否重叠 if rect1.bottom rect2.top or rect2.bottom rect1.top: return False return True注意边界条件两个矩形刚好边贴边、没有重叠面积算不算碰撞大多数游戏里不算。上面的代码用的是严格小于贴边时返回False。如果你希望“碰到就算”把改成即可。这个细节在实际开发中很重要因为角色贴墙行走时边贴边的情况几乎每一帧都会出现判定标准不同角色卡顿的手感差别很大。还有一个小技巧Pygame里Rect对象是以左上角坐标加宽高存储的这和你数学课上学的那种以中心点表示的矩形不一样。写通用算法的时候我习惯先转成left/top/right/bottom这套属性避免把自己绕晕。Pygame的Rect已经内置了left、right、top、bottom、centerx这些属性直接用就行。2.2 圆-圆碰撞检测省掉sqrt的写法圆形碰撞也很常见比如爆炸范围、子弹、角色脚底阴影。两个圆是否碰撞判定条件是“圆心距离小于等于半径之和”。很多人第一反应是算距离用math.sqrtimport math distance math.sqrt((x1 - x2) ** 2 (y1 - y2) ** 2) if distance r1 r2: print(碰上了)但sqrt是个比较费时的运算在每帧要做几千次检测的时候这个开销不小。因为半径之和是正数距离的平方和半径之和的平方比较大小关系是一样的所以我们可以直接比较平方值def circle_collision(x1, y1, r1, x2, y2, r2): dx x1 - x2 dy y1 - y2 dist_sq dx * dx dy * dy r_sum r1 r2 return dist_sq r_sum * r_sum省掉开方运算代码还更短了。Pygame里没有现成的圆碰撞函数但用上面的方式自己写性能完全够用。我实测过几十个圆形物体同时检测的时候这版比用math.sqrt快不少。还有一个细节如果你要拿碰撞点做后续效果比如爆炸粒子在碰撞位置生成那还是得算出标准距离和方向这时候sqrt就省不掉了。所以我一般是把“是否碰撞”和“计算碰撞细节”分成两个阶段只在需要细节时才去算完整的几何量。2.3 圆形与矩形用最近点法一次搞定圆形和矩形的碰撞判定比前面两个稍微绕一点但思路很固定找到矩形区域里离圆心最近的那个点然后看这个点和圆心的距离是否小于半径。所谓“最近点”其实就是把圆心的X坐标和Y坐标分别“夹”到矩形的边界范围内。如果圆心X小于矩形左边界最近点X就是左边界如果大于右边界就是右边界如果在左右边界之间就是圆心X本身。Y方向同理。def circle_rect_collision(circle_x, circle_y, radius, rect): 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 dist_sq dx * dx dy * dy return dist_sq radius * radius这个方案有个好处不管圆形处在矩形的哪个方位左上角、正上方、内部、穿越边判定公式都是同一套不需要分八种情况讨论。我最早写这个检测的时候傻乎乎地写了好多个if判断圆在矩形的哪个方向后来发现最近点法一行clamp就解决了代码量少一半还不出错。clamp这个词可以理解成“限位”。就像空调温度你设定多少度室温最高最低都在一个区间内不会无限跑偏。最近点法的本质就是先把圆心坐标限位到矩形范围里再量距离。3. 贴图不是几何体用像素遮罩检测兜底3.1 矩形检测解决不了的问题矩形和圆形都是规则的几何体但游戏里的贴图往往是奇形怪状的。比如一颗星星、一片云、一条弯弯曲曲的河流。用矩形框住星星四个角都是透明像素玩家明明没碰到星星本体却被判定撞上了手感会非常奇怪。这种时候就得用像素级别的碰撞检测。思路是这样的给每个贴图生成一张“遮罩”记录哪些像素是透明的、哪些像素是不透明的。检测碰撞时把两个遮罩按当前坐标叠在一起看有没有任何一个像素位置上两个遮罩都是不透明的。在Pygame里这个过程封装成了Mask对象。3.2 Pygame的Mask用法与注意事项生成遮罩很简单import pygame # 假设image是一个带透明通道的Surface mask pygame.mask.from_surface(image) # 两个mask之间做重叠检测 offset_x obj2_x - obj1_x offset_y obj2_y - obj1_y if mask1.overlap(mask2, (offset_x, offset_y)): print(像素级碰撞)overlap方法的第二个参数是偏移量表示第二个mask相对于第一个mask的位置。这个偏移量一定要用“物体2的坐标减去物体1的坐标”方向反了就会得到永远碰不到的诡异结果。我在这上面栽过好几次跟头后来养成了习惯统一用“目标物体坐标减去自身坐标”并且写成函数传参不在业务代码里直接算偏移。还有一个性能问题Mask的overlap调用是实打实的像素遍历比矩形检测慢一两个数量级。如果你的游戏里几十个对象同时用Mask检测每帧都跑很容易把帧率拖到个位数。我的经验是分级处理先用矩形检测做粗筛只有矩形重叠的对象才进入Mask精检。很多游戏引擎也是这个思路叫broad phase和narrow phase。3.3 什么时候必须用Mask什么时候千万别用必须用的场景角色和场景里弯曲的障碍物交互比如水管、藤蔓、山洞或者精确判定子弹和怪物体型不一致的部位。这时候Mask带来的手感提升是值得的。千万别用的场景画面里大量的细小物体互相碰撞比如一群粒子、一堆弹壳。这种时候用Mask就是给自己挖坑几何检测完全够用肉眼根本分辨不出那两三个像素的差别。记住一个原则碰撞检测的精度够用就行多出来的精度全是性能浪费。补充一个Mask的高级玩法它不只是“碰没碰”还可以调用overlap_area拿到重叠区域的大小用来做受击反馈的强度。比如重叠面积越大说明打得更实伤害可以按比例加成这个比单纯的布尔判定有表现力多了。4. 实战一个迷你跑酷游戏里的碰撞检测怎么写4.1 把碰撞分类玩家、障碍、道具各用各的方案光讲理论容易飘我拿一个实际的小项目来说话。假设你要做一个类似“奶娃快跑”的横版跑酷小游戏玩家在跑道上一直往右跑路上有障碍物要跳过或者躲开还有金币可以吃。这个项目里其实有三种完全不同的碰撞玩家和地面这个不算真正的碰撞角色站在地面上高度固定只要检测玩家是否“落地”就行玩家和障碍物需要精确到像素级因为障碍物可能是尖刺、箱子贴着边擦过去的手感很重要玩家和金币金币可以是旋转的四叶草或圆形用圆碰撞或者矩形碰撞都行玩家吃到金币判定可以放宽一点让手感更“慷慨”我把碰撞逻辑按这个分类拆开避免一套检测走天下class Player: def __init__(self, rect, mask): self.rect rect self.mask mask self.alive True class Obstacle: def __init__(self, rect, maskNone): self.rect rect self.mask mask class Coin: def __init__(self, rect): self.rect rect self.collected False每次玩家移动后先更新玩家Rect的位置然后分别跑不同的碰撞检测流程。4.2 关键代码流程与参数计算游戏主循环里我习惯按“移动 - 粗筛 - 精检 - 反馈”四步走。def update(player, obstacles, coins): player.move() # 更新位置 # 第1步玩家与障碍物的粗筛矩形级别 for obs in obstacles: if player.rect.colliderect(obs.rect): # 进入精检如果障碍物有Mask则用像素级判定 if obs.mask: offset_x obs.rect.x - player.rect.x offset_y obs.rect.y - player.rect.y if player.mask.overlap(obs.mask, (offset_x, offset_y)): player.alive False else: # 没有Mask的障碍物直接用矩形结果 player.alive False # 第2步玩家与金币的碰撞宽松判定 for coin in coins: if not coin.collected and player.rect.colliderect(coin.rect): coin.collected True注意这里我处理偏移量的方式offset取障碍物坐标减玩家坐标因为调用的是player.mask.overlap第一个mask是玩家的。你在写的时候一定盯住这个顺序。接下来是一个容易忽略的参数问题玩家的移动速度。如果玩家一帧移动的距离超过了障碍物的宽度就可能出现隧道效应。假设障碍物宽30像素玩家每帧移动20像素正常情况下不会穿过但如果你给玩家加了加速Buff速度变成每帧40像素那就危险了。解决思路有两个。第一种是“步进细分”把一次移动拆成几小步每一步都做碰撞检测。比如总位移40像素拆成4步每步10像素障碍物的宽度就能正常拦截。第二种是“扫掠检测”计算从起点到终点这一条线段和一个矩形是否相交用几何方法直接检测轨迹。Pygame里可以用Rect的clipline方法判断一个线段是否穿过矩形。我推荐情况复杂时用步进细分代码容易理解也不容易漏判。缺点是循环次数变多。但4到8次步进对Python来说压力不大大部分游戏场景都顶得住。4.3 移动和检测的顺序千万别搞反这个看起来是小问题实则影响巨大。我见过几种写法# 错误示范先检测再移动 if player.rect.colliderect(obstacle.rect): # 处理碰撞 player.rect.move_ip(dx, dy) # 正确示范先移动再检测 player.rect.move_ip(dx, dy) if player.rect.colliderect(obstacle.rect): # 处理碰撞先移动再检测你检测到的是物体移动之后的位置这样碰撞结果才是这一帧的最终状态。反过来先检测再移动相当于角色还没跑就提前被判罚了会出现“明明差两个像素没撞到却掉血了”的灵异事件。另外处理碰撞之后一定要把角色“推出去”。常见做法是计算重叠量然后把位置修正回不重叠的状态if player.rect.colliderect(obstacle.rect): # 假设从上方撞下来把玩家底部顶回障碍物顶部 if player.rect.bottom obstacle.rect.top: player.rect.bottom obstacle.rect.top这个“碰撞响应”环节的质量直接决定你游戏的手感。推多了角色会飘推少了会嵌进墙里。我一般会保留一个小的接触余量比如2像素避免由于浮点误差导致一卡一卡的抖动。5. 场景里物体多了碰撞检测怎么优化5.1 先明白性能瓶颈在哪里当游戏里的物体数量上来了你很快会发现帧率掉得厉害。前面我说过两两检测的复杂度是O(n²)100个物体要检测接近5000对1000个物体就是50万对。哪怕单次检测很快累计起来也吃不消。但现实情况里大部分物体离得很远根本不可能撞上。比如一个地图怪都在东边玩家在西边中间的空气墙把它们隔开了——这两组物体做碰撞检测完全是在浪费时间。优化的核心思想就一句话只检测可能碰到的物体。实现方式就是空间划分。5.2 用空间网格快速筛掉远处的物体网格方案是最容易理解和实现的。把地图划分成固定大小的格子每个物体根据位置放进对应格子里。检测碰撞时只检查同一个格子以及相邻格子里的物体。class SpatialGrid: def __init__(self, cell_size): self.cell_size cell_size self.cells {} def _key(self, rect): left rect.left // self.cell_size top rect.top // self.cell_size return (left, top) def add(self, rect, obj): key self._key(rect) if key not in self.cells: self.cells[key] [] self.cells[key].append(obj) def get_neighbors(self, rect): neighbors [] center_key self._key(rect) for dx in (-1, 0, 1): for dy in (-1, 0, 1): key (center_key[0] dx, center_key[1] dy) if key in self.cells: neighbors.extend(self.cells[key]) return neighbors格子大小的选择有讲究。太小了一个物体横跨多个格子得插到多个列表里太大了每个格子里的物体太多过滤效果变差。我一般把格子大小设为场景中主要物体尺寸的2倍左右这样大部分物体只在一个格子里碰撞对又限制在3×3的邻域内检测范围能缩小到原来的几分之一甚至几十分之一。这个方案改造成本低非常适合子弹密集的射击游戏。子弹数量一多不用网格纯靠两两检测基本必卡。5.3 四叉树思路网格的加强版网格的缺点是格子大小是固定的物体分布不均匀时有的格子空荡荡有的格子挤满一堆。四叉树可以根据物体密度动态划分区域把空间递归切成四块物体少的地方大块物体多的地方小块。Python里实现四叉树代码量比网格多不少但原理不复杂。四叉树每个节点保存一个矩形区域区域里物体数量超过阈值就分裂成四个子节点。查询时从根节点往下走只进入和查询区域相交的子节点。如果你的游戏地图是开放式的既有大范围探索的区域又经常在某个城镇里聚集大量NPC四叉树会比网格更均衡。但如果地图相对规则网格完全够用没必要一上来就上四叉树。我个人的经验是先上网格有数据证明网格撑不住了再升级四叉树不要在项目初期过度设计。5.4 减少检测频率与分帧处理除了空间划分还有一个容易被忽略的优化技巧不是每帧都需要做全量碰撞检测。很多游戏的碰撞不需要精确到1帧的粒度可以隔几帧检测一次或者把碰撞检测均匀分摊到多帧里。frame_counter 0 def update_with_throttle(): global frame_counter frame_counter 1 if frame_counter % 3 ! 0: return # 只在这里执行碰撞检测子弹可能不够精确但普通的地面敌人、静态道具完全够用。你需要的只是“大约每秒10次”的碰撞检查而不是“每帧必查”。用这个思路把碰撞检测频率降到原来的三分之一玩家根本感知不到区别但CPU占用会明显降下来。还有一招把碰撞检测放在FixedUpdate逻辑里跟渲染线程解耦。Pygame没有内置固定时间步但你可以用累加器模拟。这样可以保证不管帧率怎么波动碰撞逻辑的执行频率是稳定的也就减少了高速物体在某些帧被吞掉的问题。6. 常见问题与排查技巧实录6.1 隧道效应物体穿墙/子弹穿怪这是被问到最多的一个问题。症状就是物体速度较快时明明路径上应该撞到东西却直接穿过去了。原因前面说过这一帧和下一帧物体位置不连续两个位置之间没有交集。排查时先看速度再看物体尺寸。如果物体的尺寸小于它每帧移动的距离就一定有隧道风险。解决手段方案适用场景缺点步进细分小步移动各种场景改动简单循环次数变多射线/线段扫掠检测子弹等细长快速物体只能检测路径上有无阻挡不适合复杂形状连续碰撞检测CCD物理引擎内置的高级方案实现复杂性能开销大我个人的建议是大部分2D游戏用步进细分就够。步长取物体尺寸的一半是安全的这样无论怎么移动都会和路径上的障碍物产生重叠帧。6.2 碰撞抖动角色在墙边一卡一卡这种情况我折腾了很久才明白是浮点精度问题。角色不停向墙移动每帧被推回一个像素下一帧又因为浮点计算悄悄往前挪了半个像素表现就是画面抖。解决方案是把角色坐标存成浮点数只在绘制时转成整数碰撞检测用的是浮点坐标而不是Rect的整数坐标。class Entity: def __init__(self): self.x 0.0 self.y 0.0 self.w 32 self.h 32 property def rect(self): return pygame.Rect(int(self.x), int(self.y), self.w, self.h)这个方法能解决大部分抖动问题。另外碰撞推出时留一点余量比如1像素也能减少边界的反复磨擦感。6.3 Mask碰撞明明有重叠却检测不到如果Mask检测出现漏判优先检查偏移量算对没有。把两个物体的Rect坐标打出来手动口算一下offset和代码里传进去的值对比。很多情况下就是sign正负号搞反了。还有一个坑Mask是从Surface生成的如果Surface的尺寸里包含大量透明边距Mask也会包含这些透明边距导致视觉上和实际Mask范围不一致。解决方法是加载图片后用pygame.Surface.subsurface把透明边距裁掉或者干脆在美术资源里就做好裁切。6.4 碰撞检测的调试可视化技巧最后分享一个调试习惯。碰撞检测是纯逻辑问题光看画面很难定位。我习惯在Debug模式下把所有碰撞体的轮廓画出来for obj in objects: pygame.draw.rect(screen, (255, 0, 0), obj.rect, 1)矩形轮廓画出来你一眼就能看出判定区域是不是和贴图吻合。对于圆用pygame.draw.circle画圈对于Mask可以用mask.to_surface()生成一个可视化缩略图。这些调试绘制只在开发阶段开启线上版本直接注释掉不影响性能。日志也很有用。每帧记录关键碰撞的事件和位置打印到控制台跑一遍操作流程看看碰撞触发顺序和预想是否一致。这种方法调试“碰撞之后回调没有触发”的问题特别好使因为碰撞顺序错位一眼就能看出来。我个人在实际项目里最深的体会是碰撞检测没有万能药永远是根据需求做的取舍。做之前先想清楚精度要求、性能预算、手感预期再去选方案。简单游戏用矩形和圆组合就绰绰有余等出现穿模投诉再逐级升级也不迟。先跑起来再优化这比一上来就堆四叉树和Mask靠谱得多。