1. 为什么碰撞检测是游戏开发里最容易被低估的环节如果让我列一个看起来简单、做起来全是坑的榜单碰撞检测绝对排前三。很多刚开始写游戏的朋友第一个版本做出来的碰撞检测往往是这样两个矩形用if x1 x2 w2这种层层嵌套的判断跑起来也能用但等到角色多了、物体复杂了、地图开始有坡度了代码就变得没法看bug也一个接一个往外冒。我最初做游戏时也走过这条弯路。当时做一个小型平台跳跃Demo角色碰撞直接用轴对齐包围盒也就是常说的AABB代码倒是写完了结果角色站在斜坡上会陷入地面跳起来撞到头顶的砖块时判定总是慢半拍。那时候才意识到碰撞检测不是一个能检测到就行的问题它涉及精度、性能、代码结构、物理表现等多个层面而且不同游戏类型对碰撞的需求差异极大。这篇文章我想系统梳理一下Python游戏开发中碰撞检测的几种主流实现方式从最基础的矩形碰撞讲起一直聊到像素级检测、空间分区优化再穿插一些我在实际项目中踩过的坑和优化经验。适合正在写自己的第一款小游戏、或者在已有项目里觉得碰撞逻辑越来越难维护的朋友不管你是用Pygame、Arcade还是纯自己写渲染循环这篇文章的思路都能直接用。很多人会问碰撞检测不就是判断两个物体有没有重叠吗有什么好讲的确实判断重叠本身很简单但真实游戏里的碰撞检测要处理的是另一堆问题物体动得太快导致直接穿过墙壁、圆形和矩形混合碰撞的精度、大量物体同时碰撞时的性能开销、碰撞之后物体应该怎么弹开而不是互相嵌进去。这些问题每一个单独拿出来都能让一个游戏从能玩变成好玩。这篇文章不会只给代码片段我会把每个方案的适用场景、计算思路、优缺点都讲清楚。你学完之后至少能做到三件事第一能根据自己游戏的类型选择合适的碰撞检测方案第二能看懂并修改别人写的碰撞检测代码第三遇到碰撞相关的诡异bug时有清晰的排查思路。2. AABB矩形碰撞基础方案与它解决不了的问题2.1 轴对齐包围盒的判定原理轴对齐包围盒英文叫Axis-Aligned Bounding Box简称AABB是游戏开发里最基础也最常用的碰撞检测方案。所谓轴对齐意思是这个盒子的四条边分别平行于坐标系的x轴和y轴它不会旋转所以判定逻辑非常简单。两个AABB是否重叠判断条件可以写成这样在x轴上两个物体的区间有交集在y轴上两个物体的区间也有交集。只有两个轴上都有重叠才说明两个矩形真正发生了碰撞。def check_aabb_collision(rect1, rect2): # rect: (x, y, width, height) x1, y1, w1, h1 rect1 x2, y2, w2, h2 rect2 if x1 x2 w2 and x2 x1 w1: if y1 y2 h2 and y2 y1 h1: return True return False这段代码很短但值得逐行理解。第一个if判断的是水平方向x1 x2 w2表示rect1的左边界在rect2右边界的左边x2 x1 w1表示rect2的左边界在rect1右边界的左边两个条件同时成立说明水平方向一定有重叠区域。垂直方向同理。Pygame里的Rect对象内置了colliderect方法用法和上面的代码本质上是一样的只不过Pygame已经帮你把性能和边界条件都处理好了import pygame player_rect pygame.Rect(100, 100, 50, 50) wall_rect pygame.Rect(130, 120, 40, 40) if player_rect.colliderect(wall_rect): print(碰撞发生)2.2 为什么静止状态没问题一跑起来就穿透AABB方案在慢速运动的场景下表现良好但有一个非常经典的bug物体移动速度过快时可能会直接穿过另一个物体完全没有触发碰撞。这个问题叫隧道效应也叫隧穿。隧道效应的根源在于你的游戏循环是离散的每个帧计算一次位置而不是连续计算。假设一个子弹的移动速度是每秒600像素游戏运行在60帧每秒那么每帧子弹移动10像素。如果这帧子弹在墙的左边下一帧位置更新后子弹到了墙的右边子弹在这两帧之间没有一帧的位置与墙重叠碰撞检测自然就漏掉了。用代码说明更直观# 帧1子弹在墙壁左边 bullet_x 100 wall_x 107 bullet_width 4 # 帧2子弹移动10像素到了墙壁右边 bullet_x 110 # 此时 bullet_x wall_x wall_width没有重叠区间解决隧道效应有三种常见思路我按推荐程度排序第一种是连续碰撞检测在移动前计算物体从起点到终点的路径与障碍物的交点但实现复杂度较高。第二种是细分步长把一帧的大位移拆成几个小位移每次小位移都做一次碰撞检测def move_with_collision(obj, dx, dy, obstacles, steps10): step_x dx / steps step_y dy / steps for _ in range(steps): obj.x step_x obj.y step_y for obstacle in obstacles: if obj.colliderect(obstacle): # 处理碰撞 return第三种是我实际项目里最常用的限制最大速度保证单帧位移不超过最小物体的尺寸。在子弹这种高速场景下通常还是用细分步长更可靠。2.3 AABB的固有缺陷旋转物体和斜坡AABB还有一个天然局限它无法准确表达旋转物体的形状。一个旋转了45度的长条形物体它的AABB会比实际物体大一圈甚至两圈导致玩家明明没有碰到障碍物却被判定为碰撞这在游戏手感上表现就是明明躲开了还是受伤了。斜坡问题更麻烦。由于AABB的底边始终是水平的当角色站在斜坡上时AABB和斜坡的碰撞点无法提供正确的支撑逻辑。角色会滑落、陷入斜坡、或者站在斜坡上微微震颤。解决斜坡问题的一般做法是将斜坡拆分成多个小台阶逐级检测或者改用下一节要讲的分离轴定理。3. 分离轴定理与圆形碰撞几何精度升级之路3.1 分离轴定理的核心思路当游戏里的物体开始有旋转、有不规则形状时AABB就力不从心了。这时候就该请出分离轴定理即Separating Axis Theorem简称SAT。SAT的基本原理可以这样理解两个凸多边形不相交当且仅当存在一条直线能够把两个物体完全分开。这条直线对应的法线方向就是一条分离轴。在实际计算中不需要考虑无穷多条轴只需要检查两个多边形每条边的法线方向即可。只要在任何一个轴方向上两个物体的投影区间不重叠就说明两个物体没有碰撞如果所有轴的投影都重叠则一定有碰撞。def project_polygon(vertices, axis): # 将多边形所有顶点投影到轴上返回最小值和最大值 dots [v.x * axis.x v.y * axis.y for v in vertices] return min(dots), max(dots) def check_sat_collision(vertices1, vertices2): axes get_axes(vertices1) get_axes(vertices2) for axis in axes: min1, max1 project_polygon(vertices1, axis) min2, max2 project_polygon(vertices2, axis) if max1 min2 or max2 min1: return False return Trueget_axes函数需要提取每条边的法线向量。每条边的方向向量是(x2-x1, y2-y1)它的法线就是(-(y2-y1), x2-x1)。3.2 实现SAT时容易出错的两个细节我实现过多次SAT有两个细节每次都能坑到人。第一个细节是法线方向要归一化。如果不把法线向量归一化投影计算的结果会被放大导致区间判断出错。归一化方法就是除以向量的长度length math.sqrt(axis.x**2 axis.y**2) axis.x / length axis.y / length第二个细节是分离轴只需检查两条边的法线方向。这句话看起来简单但实际操作时很多人会误以为需要检查所有顶点两两组合的方向导致计算量爆炸。实际上两个凸多边形的分离轴只会出现在其中一个多边形的某条边的法线方向上。SAT的代码量比AABB大不少但它能精确处理旋转矩形、三角形、任意凸多边形之间的碰撞。做物理沙盒、休闲竞技游戏、或者地图编辑器里的碰撞判断时这套方案非常可靠。3.3 圆形碰撞便宜又实用的方案圆形碰撞是精度和性能的绝佳平衡点。判定方式特别直观计算两个圆心之间的距离如果这个距离小于两个半径之和就说明碰撞了。def check_circle_collision(x1, y1, r1, x2, y2, r2): dx x1 - x2 dy y1 - y2 distance_sq dx*dx dy*dy radius_sum r1 r2 return distance_sq radius_sum * radius_sum注意这里比较的是距离平方而不是距离本身。原因是math.sqrt的开方运算开销不小在大量物体同时检测时省掉这一步能显著提升性能。圆形碰撞特别适合用于玩家角色与收集品、子弹与怪物、爆炸范围判定等场景。很多商业游戏里的精确模型碰撞实际上也是用多个圆组合拼接出来的比如一辆赛车可以用三到四个圆表示车身主体和轮胎区域这样既保留了碰撞的基本形状特征计算成本又远低于多边形碰撞。还有个小技巧圆和矩形的碰撞可以拆成三种情况。如果圆心在矩形的四个角区域就按圆心到角点的距离判断如果圆心在矩形的四条边区域就按圆心到最近边的距离判断如果圆心就在矩形内部直接判定碰撞。这个方法在处理玩家圆形碰撞体与方形平台边缘的场景中非常实用。4. 像素级碰撞检测精确到每个像素的代价与优化4.1 什么时候必须上像素级检测如果你的游戏里有大量不规则形状又不希望把碰撞模型简化成几个粗笨的矩形或圆形那就要考虑像素级碰撞检测。这种方案在射击游戏、解谜游戏、或者美术风格独特的独立游戏里比较常见。像素级碰撞检测的思路是两个图像都带有一个透明通道每帧把两张图的非透明区域做重叠判断。具体实现上可以先用AABB做一个粗筛如果AABB都不重叠那肯定没碰撞如果AABB重叠了再进入像素级别逐点检查。Pygame中可以用pygame.mask模块import pygame # 从Surface创建Mask mask1 pygame.mask.from_surface(sprite1.image) mask2 pygame.mask.from_surface(sprite2.image) # 先粗筛判断是否有AABB重叠 offset (sprite2.rect.x - sprite1.rect.x, sprite2.rect.y - sprite1.rect.y) # 再精判像素级重叠检测 if mask1.overlap(mask2, offset): print(像素级碰撞)4.2 像素级检测的隐藏陷阱用pygame.mask的时候有一个非常容易掉进去的坑offset的计算方向弄反。overlap方法里的offset是指第二个Mask相对于第一个Mask的偏移量。如果你直接把两个物体在屏幕上的坐标相减得到的结果可能是反的导致明明两个Sprite贴在一起却检测不到碰撞。正确的做法是以第一个Mask的rect为基准计算第二个Mask的rect在这个基准坐标系中的偏移。也就是说offset (sprite2.rect.x - sprite1.rect.x, sprite2.rect.y - sprite1.rect.y)前提是sprite1的坐标为(0,0)的参考点。另一个问题是性能瓶颈。像素级检测在最坏情况下需要遍历两个Mask重叠区域内所有的像素点。如果两个Mask都是100x100的尺寸重叠区域又是全满的那就要检查一万个像素。如果有几十对物体同时在做这种检测帧率会立刻崩掉。我的建议是像素级检测永远要和粗筛配合使用。先用AABB、圆形或空间分区把可能碰撞的物体对缩小到一个很小的范围再对这个范围内的物体做像素级精判。千万不要一上来就对所有物体做像素级检测。4.3 多帧Mask优化与实际效果Mask创建本身也有开销尤其是图片尺寸大、角色数量多的情况下。pygame.mask.from_surface需要遍历整个Surface的像素来生成Mask数据如果一个角色有几十帧动画每帧动画的Mask都不同全部预生成会占用大量内存实时生成又会拖慢游戏速度。折中方案是按需缓存每个Sprite维护一个字典key是当前帧的图像IDvalue是对应的Mask。动画切帧时先查缓存命中就直接用没命中才生成class MaskedSprite: def __init__(self): self._mask_cache {} def get_mask(self): image_id id(self.image) if image_id not in self._mask_cache: self._mask_cache[image_id] pygame.mask.from_surface(self.image) return self._mask_cache[image_id]在实际测试中用了这个缓存后一个同时存在30个带Mask动画角色的场景碰撞检测耗时从每帧接近8毫秒降到了1.5毫秒左右效果非常明显。5. 空间分区当物体数量上来了怎么办5.1 暴力遍历与四叉树的对比当场景里的物体数量超过几十个之后两两配对检测的暴力做法就开始力不从心。假设有100个物体需要互相检测每帧要进行的配对数量就是100*99/24950次。如果有200个物体这个数字会涨到19900次增长是平方级别的。空间分区的核心思想是把大世界切分成小块只检测同一块或相邻块里的物体。最常见的有网格分区、四叉树、空间哈希三种方法。网格分区最简单适合物体分布比较均匀的场景。把整个地图固定划分成若干个格子每个格子记录落在里面的物体ID检测时只遍历同一格和相邻格的物体。四叉树适合物体分布极不均匀的场景它会递归地把空间切成四个象限让密集区域获得更细的划分。5.2 网格分区的Python实现我用网格分区做过一个子弹射击小游戏效果很好。实现思路不复杂class SpatialGrid: def __init__(self, cell_width, cell_height, world_width, world_height): self.cell_width cell_width self.cell_height cell_height self.grid {} def get_cell_keys(self, rect): min_cx rect.left // self.cell_width max_cx rect.right // self.cell_width min_cy rect.top // self.cell_height max_cy rect.bottom // self.cell_height keys [] for cx in range(min_cx, max_cx 1): for cy in range(min_cy, max_cy 1): keys.append((cx, cy)) return keys def add_object(self, obj): for key in self.get_cell_keys(obj.rect): if key not in self.grid: self.grid[key] [] self.grid[key].append(obj) def get_neighbors(self, obj): neighbors [] for key in self.get_cell_keys(obj.rect): if key in self.grid: for other in self.grid[key]: if other is not obj and other not in neighbors: neighbors.append(other) return neighbors注意get_cell_keys里处理了物体跨越多个格子边界的情况。一个物体可能同时落在两个甚至四个格子里所以需要用列表收集所有相关的格子键。每帧更新时先清空整个网格把所有物体重新插入然后再进行碰撞检测def update_collisions(grid, objects): grid.grid.clear() for obj in objects: grid.add_object(obj) for obj in objects: neighbors grid.get_neighbors(obj) for other in neighbors: if check_collision(obj, other): handle_collision(obj, other)网格大小的选择很关键。格子设得太小每个物体占多个格子插入和查询的开销变大格子设得太大每个格子里塞太多物体退化成暴力遍历。我一般把格子边长设成场景中最大物体尺寸的1.5倍左右这是一个不错的起点。5.3 四叉树值得了解但不一定值得自己写四叉树的思路说起来不难每个节点代表一个矩形区域如果区域里的物体数量超过阈值就把这个节点分裂成四个子节点把物体重新分配到子节点里。查询时从根节点递归向下只访问与查询矩形相交的子节点。自己实现一个四叉树要处理节点分裂、物体跨节点分配、树深度控制、动态插入删除等问题代码量不小调试起来也费时间。如果你的项目里物体数量还没到几百上千网格分区完全够用了没必要一开始就上四叉树。如果确实需要可以先用现成的库比如pygame生态里的一些空间索引实现或者直接用scipy的cKDTree处理大量点状碰撞体。说到这里插一句性能优化要按需做不要觉得别人用四叉树很酷就强行上。先用最简单的方案把游戏跑通然后用性能分析工具找出瓶颈在哪再决定要不要做空间分区。6. 碰撞响应检测完了怎么让物体正确地弹开6.1 位置修正与速度反弹检测到碰撞只是第一步真正影响游戏手感的是碰撞响应也就是碰撞之后物体应该怎么运动。两个矩形重叠时如果不做任何处理物体就会相互穿透画面看起来很假。最基本的碰撞响应分为两步位置修正和速度反弹。位置修正的目的是把重叠的物体推分开让它们回到互不重叠的状态速度反弹的目的是模拟物理效果让物体在碰撞后改变运动方向。def resolve_rect_collision(player_rect, obstacle_rect): # 计算两个矩形重叠部分的宽度和高度 overlap_x min(player_rect.right, obstacle_rect.right) - max(player_rect.left, obstacle_rect.left) overlap_y min(player_rect.bottom, obstacle_rect.bottom) - max(player_rect.top, obstacle_rect.top) if overlap_x overlap_y: # 横向重叠更小说明碰撞主要发生在垂直方向 if player_rect.centerx obstacle_rect.centerx: player_rect.right obstacle_rect.left else: player_rect.left obstacle_rect.right else: # 纵向重叠更小说明碰撞主要发生在水平方向 if player_rect.centery obstacle_rect.centery: player_rect.bottom obstacle_rect.top else: player_rect.top obstacle_rect.bottom这里判断overlap_x和overlap_y的大小是为了确定从哪个方向修正物体最合理。想象一个角色从高处落下落在平台上方此时水平方向的重叠量通常小于垂直方向所以应该从垂直方向修正也就是把角色的底部贴到平台的顶部。速度反弹要分方向处理。碰撞发生在哪个方向就反转那个方向的速度分量if collision_happened: if overlap_x overlap_y: player.vel_x -player.vel_x * restitution else: player.vel_y -player.vel_y * restitutionrestitution叫恢复系数取值范围是0到1之间。0表示完全非弹性碰撞碰完就停1表示完全弹性碰撞碰完速度完全反向。真实的游戏里一般取0.3到0.8之间具体看游戏手感需求。6.2 按顺序处理多个碰撞体的经验一个角色同时靠近多个障碍物时如果处理不当会出现抖动和卡死问题。我最早做平台游戏时遇到过严重bug角色贴墙移动时脚底和墙面同时产生碰撞两边的修正方向冲突角色在墙边疯狂抖动。解决办法是每帧把碰撞修正设计成一个循环过程。先移动角色然后检测所有碰撞修正位置之后再次检测如果还有碰撞就继续修正直到没有碰撞为止或者修正次数达到上限防止死循环。def move_and_resolve(player, obstacles, max_iterations8): player.rect.x player.vel_x player.rect.y player.vel_y for _ in range(max_iterations): collision_resolved False for obstacle in obstacles: if player.rect.colliderect(obstacle): resolve_rect_collision(player.rect, obstacle) collision_resolved True if not collision_resolved: break这个循环会反复处理所有接触到的障碍物直到角色完全脱离重叠状态。实际测试下来最多两到三次循环就能收敛基本不可能跑满八次。这个方案能稳定处理墙角、多物体夹击的场景。6.3 角色与斜坡的响应处理斜坡是AABB碰撞响应的大敌。我最后采用的方案是不单独对斜坡做几何计算而是把斜坡拆成多个倾斜的线段碰撞体用线段与圆的碰撞来做角色支撑。角色用一个圆形碰撞体代替方形碰撞体这样在斜坡上滑动时圆形和斜面接触自然连续不会有陷进去再弹出来的生硬感。如果觉得圆形加线段组合太复杂还有一个简单替代方案把斜坡从视觉上做成阶梯状每一级台阶用一个小矩形表示用AABB处理。视觉效果差一点但在某些俯视角游戏里完全能接受。7. 性能优化实战一个弹幕游戏的碰撞系统重构7.1 最初的实现与性能瓶颈说实话上面的所有方案我都是在实际项目中反复对比之后才真正理解的。有段时间我在做一个小型弹幕射击游戏场景里有玩家、四五十发子弹、十来个敌人子弹以及若干可破坏障碍物。第一版碰撞检测写得很直白每帧遍历所有子弹对每个子弹再遍历所有障碍物用AABB检测。当时没想太多因为子弹数量不多。但真正跑起来之后帧率经常掉到45帧以下。用性能分析工具查了一下发现碰撞检测占了单帧耗时的将近六成。问题的根源在于每个子弹都要和每个障碍物做一次AABB检测子弹是50发障碍物是15个每帧就是750次检测而且很多子弹和障碍物离得很远根本不可能是碰撞候选。7.2 网格分区加Mask精判的组合方案我把碰撞系统改成了两层结构。第一层用网格分区做粗筛先找出可能重叠的候选对第二层对候选对做进一步的精确检测。具体到项目和子弹子弹的碰撞模型用圆形障碍物的碰撞模型用AABB如果圆形与AABB确实重叠了再用Mask做像素级确认。def check_circle_rect(circle_x, circle_y, circle_r, 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 return dx*dx dy*dy circle_r*circle_r这个check_circle_rect函数很精炼它利用了矩形内部离圆心最近的点来简化判断。如果这个最近点和圆心的距离小于半径就说明圆碰到了矩形。优化之后同一场景下的碰撞检测耗时从每帧约9毫秒降到了1.2毫秒左右游戏帧率稳定在60。重点不在具体数字而在于思路先用便宜的计算剔除绝大多数不可能碰撞的物体对把昂贵的精确检测留给少数真正可疑的候选对象。7.3 分享几个实测有效的优化小技巧第一个技巧运行时不修改可变对象的尺寸信息。比如不要每帧重新生成新的Rect对象尽量复用已有的Rect只改它的坐标属性。第二个技巧对静态物体做预计算。地图里的墙壁、柱子这类不会移动的障碍物它们的碰撞数据可以在游戏初始化时就计算好不要每帧重复生成。第三个技巧碰撞检测的粒度分级。距离很远的物体用粗粒度的圆形碰撞先过滤掉距离较近的才升级到精确的AABB、SAT或像素检测。这种多级检测策略能有效避免性能浪费。第四个技巧善用性能分析工具。Python生态推荐用cProfile或者直接用内置的time.perf_counter手动测量关键代码段耗时。不要靠感觉判断瓶颈在哪数据永远不会骗人。8. 不同游戏类型的碰撞方案选型建议我做了这么多碰撞方案之后最大的体会是没有最好的碰撞检测方案只有最合适的。做选型时先想清楚游戏的核心玩法和手感要求再来决定用哪一套。对平台跳跃游戏来说AABB加位置修正是最稳妥的起点。角色的碰撞体可以选圆形或方形平台用矩形斜坡要么做成阶梯状要么用线段支撑。这个组合实现简单、调试直观手感也比较好调整。对俯视角射击游戏来说子弹用圆形碰撞体障碍物用AABB配网格分区做空间过滤这一套就够用了。如果子弹速度特别快记得做细分步长或者连续碰撞检测来避免隧道效应。对格斗游戏或弹幕游戏来说圆形碰撞是主力。格斗游戏里判定框这个概念本质上就是多个圆形或矩形的组合体把每个攻击动作拆成若干个判定框分别检测。对解密游戏或者追求美术精细度的独立游戏来说可以用AABB粗筛加Mask精判。Mask方案处理不规则美术形状效果很好但控制好使用范围避免全场景物体都做像素级检测。最后补一个整体性的经验无论选哪种方案都建议把碰撞检测和游戏逻辑解耦。也就是碰撞检测只负责判断是否碰撞、生成碰撞事件游戏逻辑负责收到碰撞事件后决定做什么。这样后续替换碰撞库、调整碰撞精度、添加新的碰撞需求时不需要改动游戏逻辑代码。一个简单的事件回调接口就够了def on_collision_event(obj1, obj2, collision_data): if obj1.type player and obj2.type enemy: player.take_damage() elif obj1.type bullet and obj2.type wall: obj1.kill()写到这里想起了最初在某个项目里被碰撞bug折腾得连续排了很久的排查过程。那次的教训是碰撞检测看似简单但真正难的是把它放进一个完整的游戏循环里和移动、渲染、物理一起协同工作。不过也正是那些踩坑的经历让我对碰撞检测的每个细节都多留了一个心眼。希望这篇整理能帮你少走一些弯路做出自己的游戏时能更踏实地处理碰撞相关的逻辑。 SEO 优化官网定制响应式建站教育培训建站