Python 2D游戏碰撞检测全攻略:从AABB到性能优化
2026/9/24 19:08:07 网站建设 项目流程

1. 从"物体重叠"到"游戏逻辑":碰撞检测到底在解决什么问题

做游戏开发的人大概率都有过这种经历:玩家明明没有碰到敌人,血条却掉了;子弹从怪物体内穿过去,判定却没触发;或者角色卡在墙里出不来。这些看似八竿子打不着的问题,最终排查下来,十有八九都出在碰撞检测上。

碰撞检测(Collision Detection),说白了就是判断两个物体有没有发生"重叠"或"接触",这件事听起来简单,做起来牵扯到的东西却不少。我在用Python写游戏的时候,尤其是从纯逻辑Demo过渡到真正能玩的游戏时,碰撞检测的精度和性能直接决定了手感的好坏。

这篇文章不打算堆理论,而是把我实际做项目时真正用得上、踩过坑的那些方案拿出来聊。包括常见的矩形碰撞(AABB)、圆形碰撞、像素级碰撞,以及处理大量物体时的性能优化思路。如果你正在用Pygame、Pyglet或者纯Python写游戏,或者在做一个需要"检测物体是否重叠"的交互应用,这篇文章应该能帮你少走不少弯路。

我默认你已经有了一点Python基础,会写类和函数,但对碰撞检测只有模糊概念。下面所有内容,我都是从"如何真正落地"的角度来讲。

2. 坐标系与"两个物体"的定义方式:碰撞检测的地基

在聊具体算法之前,必须先搞清楚一件事:代码里的物体是怎么"表示"的。不同表示方式,决定了后面用哪种碰撞检测算法,也决定了精度上限。

2.1 一切检测的前提:物体先得有"形状"

游戏里的物体,在内存里并不是一张连续的画面,而是一堆数据和属性。要做碰撞检测,首先要给物体定义一个可供计算的"形状"。最常用的有三种:

轴对齐包围盒(AABB,Axis-Aligned Bounding Box):用一个不旋转的矩形框住物体,存左上角坐标(x, y)和宽高(width, height)。这是最省计算量的表示方式,绝大多数2D游戏的基础碰撞都用它。

圆形(Circle):存圆心坐标(cx, cy)和半径(r)。旋转不影响判断,而且距离计算非常快。

像素遮罩(Mask):按像素级别记录物体的不透明区域,精度最高,但计算成本也最高。

这三种表示方式对应着三种核心判断算法:矩形与矩形、圆与圆、像素与像素(或者混合类型)。实际项目中,很少只用一种。比如角色用矩形,子弹用圆形,地形用矩形组合,特效用遮罩——"混合检测"才是常态。

提示:在做碰撞检测之前,最好先在代码里给物体建立统一的"位置-形状"数据结构。我习惯定义一个Entity基类,把绘制和碰撞属性分开存。

我在项目里通常这样初始化一个可碰撞的物体:

import pygame class Entity: def __init__(self, x, y, width, height): self.x = x # 左上角x坐标 self.y = y # 左上角y坐标 self.width = width # 宽度 self.height = height # 高度 @property def rect(self): # 每次动态生成pygame.Rect,方便复用pygame内置碰撞函数 return pygame.Rect(self.x, self.y, self.width, self.height)

用一个rect属性实时生成矩形,而不是在__init__里固定一个rect对象,是因为游戏里物体随时在移动,固定rect需要反复同步坐标,容易漏更。动态生成虽然多了一点计算,但在物体数量不多时几乎可以忽略。

2.2 为什么绝不要用"中心点距离"判断两个矩形是否碰撞

很多刚入门的朋友会犯一个错:计算两个矩形的中心点距离,如果小于某个阈值就认为碰撞了。这个思路对圆形勉强说得通,对矩形就行不通了——矩形不是各向同性的,宽高不同时,中心距离根本不能代表实际边界关系。

举个例子:两个100×10的长条矩形,上下叠在一起,中心点距离小于50的时候其实已经重叠了;但如果是100×100的正方形,重叠判定距离就到了70.7左右。用一个固定阈值去套所有矩形,结果必然是一会儿穿透一会儿误报。

正确的做法是:把判断问题转化为"区间重叠"问题。两个矩形在X轴上各自占据一个区间,在Y轴上也各自占据一个区间。只有X和Y方向上都重叠,两个矩形才算碰撞。

def check_aabb(rect1, rect2): # rect1, rect2 分别是 (x, y, width, height) x1, y1, w1, h1 = rect1 x2, y2, w2, h2 = rect2 overlap_x = (x1 < x2 + w2) and (x2 < x1 + w1) overlap_y = (y1 < y2 + h2) and (y2 < y1 + h1) return overlap_x and overlap_y

这套逻辑就是标准的AABB碰撞检测。pygame里有现成的Rect.colliderect()方法,但搞清楚底层逻辑很重要,因为后面做性能优化或者移植到其他框架时,你总要自己实现一遍。而且colliderect在某些极端情况下(比如一个物体完全包含另一个物体时)的行为,不同版本的pygame表现略有差异,懂原理才知道怎么排查。

2.3 坐标系里的"方向"坑:y轴正方向是往下

Python的2D图形库(pygame、tkinter等)遵循屏幕坐标系:原点在左上角,x轴向右递增,y轴向下递增。这和数学课上的坐标系是反的。做碰撞检测时,这个方向性影响很大,尤其是涉及"碰撞方向判断"时——比如角色头顶撞到砖块,或者脚底踩到地面。

我早期写平台跳跃游戏时,判断"角色是否落地"用的是player_rect.bottom >= ground_rect.top,但没考虑到y轴向下增长,"bottom"其实是数值更大的那一边。结果角色全程浮空,怎么都落不了地。后来把所有判断都换成"数值比较"而非"视觉上、下"来理解,才彻底理顺。

这是一个很基础但非常容易阴沟翻船的细节。建议你在代码里加注释的时候,不要写"上、下",直接写"y值小的一侧、y值大的一侧",避免自己把自己绕晕。

3. 逐帧检测还是实时检测:为什么你的子弹会"穿透"墙壁

玩过FPS游戏的读者对"子弹穿透"应该不陌生,但2D游戏里同样有这个问题。一个高速移动的子弹,每帧移动了50像素,而一面墙只有30像素厚。如果只在每帧结束时检查有没有重叠,子弹可能上一帧还在墙左边,下一帧就到了墙右边,中间的碰撞过程完全没被捕捉到。

3.1 离散检测的局限

游戏循环的本质是"离散的"——每一帧计算一次位置,然后刷新画面。碰撞检测天然有盲区:它只检查当前时刻的重叠状态,而不关心两个物体之间某个瞬间是否交叉而过

这个问题在物理模拟里有个专门的名字:隧穿效应(Tunneling)。物体速度越快、帧率越低、障碍物越薄,隧穿越容易发生。

3.2 常用的三种应对方案

方案一:限制最大速度

最简单粗暴,让物体的每帧位移小于最小碰撞体的厚度。比如墙壁厚度是30像素,帧率恒定60FPS,那么物体速度要控制在30 × 60 = 1800像素/秒以内。这种方案适合控制节奏的游戏,但遇到加速、冲刺技能就失灵了。

方案二:细分时间片(子步进,Sub-stepping)

把一帧内的移动拆成几个小步,每一步都做一次碰撞检测。帧之间位移为50像素,那就拆成5步,每步只移动10像素,这样就不会跳过30像素厚的墙了。代价是碰撞检测调用次数变多,性能开销上升。

def move_with_collision(entity, dx, dy, obstacles, substeps=5): step_x = dx / substeps step_y = dy / substeps for _ in range(substeps): entity.x += step_x entity.y += step_y for obs in obstacles: if check_aabb(entity.rect, obs.rect): # 处理碰撞,把物体推回去 resolve_collision(entity, obs)

这个方案的优点是逻辑简单,容易理解,几乎所有2D游戏引擎(包括一些3D引擎)都支持类似配置。缺点是需要预估最大可能位移,步长太密会浪费性能,太疏又可能漏检。

方案三:连续碰撞检测(CCD,Continuous Collision Detection)

把物体的运动轨迹看作一条线段,检测这条线段是否与障碍物相交。这是物理引擎(如Box2D、Bullet)的常规做法,精确度高,但实现复杂度也高。2D游戏里,如果要用射线与矩形的相交判断,会牵扯到参数方程和区间求解。

我在真实项目里的建议是:大部分情况下用方案一配合方案二,只在子弹、激光这类极高速物体上用CCD。不要一上来就全场景CCD,计算量翻倍不说,调试起来也麻烦。

3.3 修正后的子弹移动检测模板

我用pygame写过一套高速子弹检测的模板,核心思路就是"细分步进 + AABB检测 + 碰撞点修正":

import pygame def move_bullet(bullet, dx, dy, solid_objects): # bullet.rect 是子弹当前矩形,bullet.speed 越大,越需要细分步进 distance = max(abs(dx), abs(dy)) if distance == 0: return False # 每步移动不超过 2 像素,避免隧穿 steps = max(1, int(distance / 2)) step_dx = dx / steps step_dy = dy / steps for _ in range(steps): bullet.rect.x += step_dx bullet.rect.y += step_dy hit_index = bullet.rect.collidelist(solid_objects) if hit_index != -1: # 碰到的物体是 solid_objects[hit_index] # 这里可以记录碰撞点,用于生成特效或者伤害判定 return True return False

collidelist是pygame提供的方法,返回第一个碰撞的物体索引,没有则返回-1。但要注意:它返回的是"第一个"碰撞对象,如果物体重叠严重,可能不是你想撞的那个。在高精度需求下,我建议遍历所有固体对象,找出重叠面积最大的那个作为实际碰撞对象。

4. 圆形碰撞与混合碰撞:当球撞上矩形,数学就不一样了

很多游戏里,角色和子弹的碰撞体不都是矩形。比如玩家角色用圆形更贴合,子弹用小矩形或小圆,地形又是矩形。这时就需要混合碰撞检测。

4.1 圆与圆:直接算距离

圆和圆的碰撞检测是最直观的:两个圆心之间的距离小于等于半径之和,就算碰撞。

import math def check_circle_collision(cx1, cy1, r1, cx2, cy2, r2): dx = cx1 - cx2 dy = cy1 - cy2 distance_sq = dx * dx + dy * dy radius_sum = r1 + r2 return distance_sq <= radius_sum * radius_sum

注意我用的是平方距离比较,没有调用math.sqrt()。半径之和的平方不会超过距离平方的比较结果,省掉开根号在高频调用里能省不少计算量。这一点在下面讲性能优化时还会再提。

4.2 圆与矩形:把矩形"膨胀"成圆角矩形

圆和矩形的碰撞检测比圆与圆复杂一点,但也不难。思路是:找到矩形上离圆心最近的点,算这个点到圆心的距离,如果小于半径就算碰撞

这个"最近点"怎么找?把圆心坐标分别钳位(clamp)到矩形的左右边界和上下边界之间:

def check_circle_rect(circle_cx, circle_cy, radius, rect_x, rect_y, rect_w, rect_h): # 钳位:将圆心坐标限制在矩形范围内 closest_x = max(rect_x, min(circle_cx, rect_x + rect_w)) closest_y = max(rect_y, min(circle_cy, rect_y + rect_h)) dx = circle_cx - closest_x dy = circle_cy - closest_y distance_sq = dx * dx + dy * dy return distance_sq <= radius * radius

这段代码听起来玄乎,用生活化的类比就是:你在一个房间里找离窗外某棵树最近的点,树在窗左边,最近点就是窗框左缘;树在窗上方,最近点就是窗框上缘;树正好在窗子里,最近点就是树本身的位置。

如果圆心的x坐标在矩形x范围内,且圆心的y坐标也在矩形y范围内,那最近点就是圆心自己,距离为0,必然碰撞——这种情况对应圆心在矩形内部。

4.3 圆形碰撞体在实际项目中的应用

我在做俯视角2D射击游戏时,玩家和敌人的碰撞体都用了圆形,原因是角色的各个朝向视觉宽度差异不大,圆形判定比矩形更公平,玩家操控时不容易出现"明明看着躲过了却被判定击中"的憋屈感。后来加Boss战,Boss是一个大型矩形机甲,玩家子弹是圆形,就用check_circle_rect来做子弹与Boss的判定,实测稳定。

实战中,碰撞体类型的选择有个经验法则:

  • 角色对角色、子弹对角色:优先用圆形,判定平滑、玩家体验好
  • 角色对地形、子弹对墙体:优先用矩形(AABB),贴合地图格子,便于做格子碰撞
  • 像素级美术的手绘物体(比如不规则洞穴):考虑像素遮罩,但只限于数量很少的场景要物体

5. 像素级碰撞检测:什么时候真的需要,怎么用才不卡

说了半天矩形和圆形,这两种方案的精度上限就是"形状近似"。当两个物体的视觉形状和矩形差异过大时(比如一个星形的收集品、一个不规则的云朵平台),用矩形碰撞就会出现明显违和:星星明明没碰到却判定收集,云朵有透明区域却像实心墙一样挡住角色。

5.1 pygame.mask:从Surface生成遮罩

pygame里提供了pygame.mask模块,可以从一张带透明通道的图片(Surface)中生成像素遮罩,Mask对象里记录了这个图片的哪些像素是不透明的。然后可以用overlap()方法检查两个遮罩有没有重叠的不透明像素。

import pygame sprite_surface = pygame.image.load('star.png').convert_alpha() sprite_mask = pygame.mask.from_surface(sprite_surface) enemy_surface = pygame.image.load('enemy.png').convert_alpha() enemy_mask = pygame.mask.from_surface(enemy_surface) # 放置两个精灵的位置(左上角坐标) sprite_pos = (100, 100) enemy_pos = (105, 102) # offset 是 enemy 相对于 sprite 的坐标偏移 offset = (enemy_pos[0] - sprite_pos[0], enemy_pos[1] - sprite_pos[1]) if sprite_mask.overlap(enemy_mask, offset): print("像素级碰撞发生!")

这里最关键的是offset的计算逻辑。overlap(other_mask, offset)方法中,offset表示的是"把other_mask放在当前mask的什么相对位置上"。官方文档里写的是:offsetother_mask相对于self_mask的偏移量。我每次写这个代码都要在草稿纸上画一遍坐标,不然方向很容易搞反。

如果你把精灵和敌人的位置都用左上角坐标表示,那么offset就是(enemy_x - sprite_x, enemy_y - sprite_y)

5.2 性能问题:两万个像素的代价

像素级碰撞最让人头疼的是性能。一张200×100的贴图,遮罩就有2万个像素。如果两个遮罩做overlap检测,最坏情况要逐像素比较,几千组物体同屏的时候能把CPU吃满。

所以我的经验是:像素级碰撞只用于"高频接触"中的少数关键物体

具体来说,可以做一个"两级检测"策略:

  1. 先用AABB粗检测。如果两个物体的矩形包围盒都不重叠,直接跳过,根本不做像素检测。
  2. 只有AABB检测通过了,才去做像素级overlap。这样可以过滤掉绝大多数"远距离不相干"的物体对。
def precise_collide(sprite1, pos1, sprite2, pos2): # 第一级:AABB粗判断 rect1 = pygame.Rect(pos1[0], pos1[1], sprite1.get_width(), sprite1.get_height()) rect2 = pygame.Rect(pos2[0], pos2[1], sprite2.get_width(), sprite2.get_height()) if not rect1.colliderect(rect2): return False # 第二级:像素级精确判断 mask1 = pygame.mask.from_surface(sprite1) mask2 = pygame.mask.from_surface(sprite2) offset = (pos2[0] - pos1[0], pos2[1] - pos1[1]) return mask1.overlap(mask2, offset) is not None

5.3 每帧都生成masks是最大的性能杀手

一个常见的致命错误,是在每一帧里调用pygame.mask.from_surface()。这个函数的开销很大,因为它要遍历所有像素构建mask数据。同样的图,生成一次就够了,然后缓存起来反复使用。

class Sprite: _mask_cache = {} def __init__(self, image_path, x, y): self.image = pygame.image.load(image_path).convert_alpha() self.x = x self.y = y if image_path not in Sprite._mask_cache: Sprite._mask_cache[image_path] = pygame.mask.from_surface(self.image) self.mask = Sprite._mask_cache[image_path]

用类级别的字典做缓存,同一张图的mask只生成一次。如果你的游戏里有大量重复素材(大量相同的小怪、大量相同的弹壳),这个缓存能省掉巨量的CPU开销。

5.4 像素级碰撞的调试技巧

像素级碰撞的bug很隐蔽,因为肉眼几乎看不出到底是哪里判定重叠了。我调试的时候会在碰撞发生的位置画一个标记点:

# 找到重叠区域的中心点(粗略) overlap_mask = mask1.overlap_mask(mask2, offset) # 得到重叠区域的mask overlap_rect = overlap_mask.get_bounding_rects() for rect in overlap_rect: pygame.draw.rect(screen, (255, 0, 0), (rect.x + pos1[0], rect.y + pos1[1], rect.w, rect.h), 1)

overlap_mask返回的是两个mask重叠的那一部分mask,然后get_bounding_rects()拿到重叠区域的边界矩形列表,绘制成红色线框。这样在屏幕上可以直观看到碰撞点到底在哪,排查精度问题会很高效。

6. 分离轴定理(SAT):多边形碰撞的进阶方案

如果你做的不只是矩形和圆的碰撞,比如三角形状的敌人、六边形的地形格,或者任意凸多边形物体之间的碰撞,前面几节的内容就不够用了。这时需要引入游戏物理中更通用的方案——分离轴定理(Separating Axis Theorem,SAT)

6.1 SAT的核心思想

SAT的内容用一句话概括:如果两个凸多边形不相交,那么一定存在一条直线(分离轴),使得两个多边形在这条直线上的投影互不重叠

如果对所有候选轴都检查一遍,都能找到一条分离轴,那这两个多边形就不碰撞。反过来,只要有任何一条轴上投影重叠,并且所有轴都重叠,那么两个多边形就是碰撞的。

听起来抽象,实际操作起来就是:

  1. 取出两个多边形的所有边,计算每条边的法向量(垂直向量),作为候选轴。
  2. 把两个多边形的所有顶点分别投影到每条轴上,得到两个区间(min, max)。
  3. 比较两个区间是否重叠。如果任何一条轴上区间不重叠,则没有碰撞;如果所有轴都重叠,则碰撞。

为什么是"所有边"而不是"所有顶点"?因为只有边的法向量才能表示出两个多边形的"物理边界方向"。顶点本身不是方向,不能作为分离轴。

6.2 SAT的代码实现(基础版)

下面是一份简化的SAT实现,用于检测两个凸多边形是否碰撞:

import math def project_polygon(axis, vertices): # 把多边形所有顶点投影到axis上,返回(min, max) dots = [vec_dot(v, axis) for v in vertices] return min(dots), max(dots) def vec_dot(v1, v2): return v1[0] * v2[0] + v1[1] * v2[1] def get_axes(vertices): axes = [] n = len(vertices) for i in range(n): p1 = vertices[i] p2 = vertices[(i + 1) % n] edge = (p2[0] - p1[0], p2[1] - p1[1]) # 法向量(垂直向量) normal = (-edge[1], edge[0]) axes.append(normal) return axes def check_polygon_collision(verts1, verts2): axes1 = get_axes(verts1) axes2 = get_axes(verts2) axes = axes1 + axes2 # 合并两个多边形的所有候选轴 for axis in axes: min1, max1 = project_polygon(axis, verts1) min2, max2 = project_polygon(axis, verts2) if max1 < min2 or max2 < min1: return False # 存在一条分离轴,未碰撞 return True # 所有轴都重叠,碰撞

这份代码里,我又用了一个平方根都省掉的版本吗?没有。这里的法向量不需要归一化,因为区间重叠判断只关心相对位置,法向量的长度不影响"是否重叠"的布尔结果。但如果需要计算碰撞深度和方向,就必须把法向量归一化。

6.3 SAT的边界条件:凹多边形怎么办

SAT只适用于凸多边形。如果你有"L"形、"U"形这种凹多边形,直接套SAT可能会漏报。原因在于,凹多边形的分离轴不一定是边的法向量,它可能有"缺口"导致无法用单一投影轴表示。

处理方式有两种:

  1. 把凹多边形拆分成多个凸多边形(三角剖分或矩形分解),分别做碰撞检测。
  2. 用像素遮罩替代。

第一种方案的精度高、性能也可控,但需要额外的几何分解工具;第二种方案实现简单,但性能开销大。我项目里的做法是:地图碰撞体都构建成凸多边形组合,角色、道具保持圆形或矩形,尽量不引入凹多边形碰撞。

6.4 SAT的工程实践

理论上说,SAT适合任意凸多边形碰撞,但我实际使用中很少把所有游戏物体都做成凸多边形。一个例外是六边形网格游戏(比如战棋、模拟经营里的六边形地块)。

六边形地块之间的碰撞判断,如果用AABB,两个倾斜的边会有明显的多余判定区域;如果用圆形,又没法贴合六边形的形状。这时SAT就非常合适,六边形只有6条边,投影轴最多12条(两个六边形各6条),性能开销完全可以接受。

def generate_hexagon(center_x, center_y, size): # 生成正六边形顶点坐标 vertices = [] for i in range(6): angle_deg = 60 * i - 30 # 平顶六边形偏移30度 angle_rad = math.radians(angle_deg) x = center_x + size * math.cos(angle_rad) y = center_y + size * math.sin(angle_rad) vertices.append((x, y)) return vertices

配合上面的SAT检测函数,就能实现六边形单位之间的精确碰撞判定。这也是我推荐的SAT使用场景:形状确定、数量可控、需要精确边界

7. 当碰撞检测遇上大量物体:空间分区与性能优化

你写的游戏如果只是几个物体互撞,那前面所有内容已经够用了。但一旦进入"子弹横飞、满地掉落物、大量敌人"的场景,性能问题会扑面而来。我印象最深的一次是,把一个俯视角射击游戏的子弹数量从100提升到500,帧率直接掉了一半,定位后发现全部耗在了两两碰撞检测上。

7.1 暴力两两检测的时间复杂度

假设有N个物体,两两检测的次数是N * (N - 1) / 2。当N=100时,是4950次;N=500时,是124750次,翻了25倍。每一帧要做12万多次碰撞检测,Python这样解释型语言根本扛不住。

这时候需要的,不是优化单次碰撞检测的算法,而是减少需要检测的物体对数量

7.2 空间网格(Spatial Hashing):最经典的2D空间分区

空间网格的思想非常朴素:把游戏世界划分成大小相等的格子,每个格子记录它包含的物体列表。物体移动时更新自己所在的格子列表。做碰撞检测时,只需要检查"同一格子内的物体"以及"相邻格子内的物体",不需要和全屏所有物体做两两检测。

class SpatialGrid: def __init__(self, cell_size): self.cell_size = cell_size self.grid = {} def _cell_coords(self, x, y): return (int(x // self.cell_size), int(y // self.cell_size)) def add(self, entity): coords = self._cell_coords(entity.x, entity.y) self.grid.setdefault(coords, []).append(entity) def clear(self): self.grid.clear() def get_nearby(self, entity): # 获取物体所在格及周围8个格子里的所有物体 cx, cy = self._cell_coords(entity.x, entity.y) nearby = [] for dx in (-1, 0, 1): for dy in (-1, 0, 1): cell = self.grid.get((cx + dx, cy + dy)) if cell: nearby.extend(cell) return nearby

每个格子的大小怎么定?一般是取"场景中物体平均尺寸的1到2倍"。格子太小,物体跨格频繁,更新开销大;格子太大,每个格子里塞的物体太多,加速效果减弱。

我项目中有一个俯视角射击关卡,地图全尺寸是2000×2000,子弹和敌人数量合计约400个,用80像素的格子划分后,每帧碰撞检测次数降到不到原来的十分之一,帧率恢复稳定。

7.3 四叉树(Quadtree):更精细的动态分区

如果物体尺寸差异特别大(有全屏Boss,也有微小的子弹),均匀网格就不太合适了。大物体会霸占很多格子,导致相邻格子重复计算严重。这时候可以考虑四叉树(Quadtree)

四叉树把空间递归分成四等份,每个节点最多存储一定数量的物体,超过阈值就继续分裂。查询时从根节点往下走,只检查与查询区域相交的子树。

四叉树实现比网格复杂很多,调试也费劲。我的建议是:除非物体尺寸差异确实很大,否则先上空间网格。网格简单、可预测性强、出bug好排查。四叉树属于"有必要再上"的优化方案,普通2D游戏90%的场景用网格就够了。

7.4 避免重复检测:只检测一次每一对

就算用了空间分区,同一个格子里仍然可能存在A和B互相检测两次的情况——A检测B,B又检测A。对于布尔碰撞检测来说,这是纯浪费。

一个经典做法是给物体加一个自增ID,只让"ID较小"的物体负责对"ID较大"的物体做检测:

def check_pair(entity_a, entity_b): if entity_a.id >= entity_b.id: return False # 由ID小的负责检测,避免重复 # 执行具体碰撞判断 return do_collision(entity_a, entity_b)

7.5 优化不要过早进行

最后说一句经验之谈:性能优化一定要用数据说话,不要凭感觉"优化一切"。我用cProfile分析后发现,很多时候卡顿的根源不在碰撞检测本身,而在于每帧刷新了太多不需要刷新的像素级surface,或者每帧都创建了新对象导致垃圾回收频繁。

做性能优化的正确顺序是:

  1. 先用cProfile找到热点函数
  2. 确认碰撞检测确实是瓶颈
  3. 再上空间分区、避免重复检测等手段
  4. 每做一步都重新测量帧率变化

不要一上来就写几百行四叉树代码,最后发现碰撞检测只占CPU的10%,白白浪费时间。

8. 碰撞发生之后:响应与反弹

检测到碰撞只是第一步,游戏手感好不好,全看碰撞之后怎么"响应"。如果你只是打印一行日志告诉玩家"你撞到墙了",那这个游戏肯定没法玩。碰撞响应要解决的核心问题是:碰撞之后物体应该停在哪儿、速度怎么变、是否反弹或销毁

8.1 最小平移向量(MTV)

碰撞后要把物体"推出去",需要知道两个物体重叠部分的最小平移方向,这个向量叫最小平移向量(Minimum Translation Vector,MTV)。方向是分离的方向,长度是最小的穿透深度。

计算MTV需要知道两个碰撞体的详细几何信息。AABB计算MTV相对简单——比较X轴和Y轴的重叠量,哪个轴重叠更少,就沿哪个轴方向推开:

def get_mtv_aabb(rect1, rect2): overlap_x = min(rect1.right - rect2.left, rect2.right - rect1.left) overlap_y = min(rect1.bottom - rect2.top, rect2.bottom - rect1.top) if overlap_x < overlap_y: # 沿X轴推开 direction = 1 if rect1.centerx < rect2.centerx else -1 return (direction * overlap_x, 0) else: # 沿Y轴推开 direction = 1 if rect1.centery < rect2.centery else -1 return (0, direction * overlap_y)

不过我实际用下来,在平台跳跃游戏里,用MTV直接推容易让角色卡在墙边,尤其是同时撞到两面墙的时候。更常用的做法是分轴处理:先沿X轴移动并检测碰撞,解决X方向碰撞后再沿Y轴移动并检测。

def move_and_collide(entity, dx, dy, obstacles): # X轴移动 entity.x += dx entity.rect.x = entity.x for obs in obstacles: if entity.rect.colliderect(obs.rect): if dx > 0: entity.x = obs.rect.left - entity.width elif dx < 0: entity.x = obs.rect.right entity.rect.x = entity.x # Y轴移动 entity.y += dy entity.rect.y = entity.y for obs in obstacles: if entity.rect.colliderect(obs.rect): if dy > 0: entity.y = obs.rect.top - entity.height elif dy < 0: entity.y = obs.rect.bottom entity.rect.y = entity.y

这个分轴方案虽然比MTV"笨",但在2D平台游戏里非常稳,不会出现斜向移动时被卡在墙角的情况。原因在于它把二维碰撞问题拆成了一维问题,每次只处理一个方向,逻辑清晰且基本没有歧义。

8.2 反弹与能量衰减

如果做的是打砖块、弹球这类游戏,碰撞后还需要反弹。边界反弹很简单:

if ball.left <= 0 or ball.right >= screen_width: ball.vx = -ball.vx if ball.top <= 0: ball.vy = -ball.vy if ball.bottom >= screen_height: # 球掉了,处理生命或游戏结束

但如果球和挡板、砖块之间是任意角度碰撞,就需要按碰撞法线方向做反射:

def reflect(velocity, normal): # velocity 是当前速度向量 (vx, vy) # normal 是碰撞法向量(单位向量) dot = velocity[0] * normal[0] + velocity[1] * normal[1] return (velocity[0] - 2 * dot * normal[0], velocity[1] - 2 * dot * normal[1])

这个反射公式是反射定理的直接应用:把速度向量沿法线方向"翻转"一次,就能得到反弹后的速度。记得加上能量衰减(乘以0.8之类),否则球越弹越快,游戏会变得不可控。

8.3 避免"抖动":碰撞后的位置修正

碰撞响应里最烦人的问题是物体抖动或穿模。物体被推出碰撞体后,如果下一帧又因为重力或移动量再次陷入,就会产生高频的抖动。

解决思路是:在碰撞修正后,给物体的速度加上一个"沿碰撞方向的分量清零"处理。比如角色在平台上落地时,把y方向速度归零,否则下一帧重力又把角色拉回平台内部。

if entity.rect.bottom > ground.rect.top and entity.vy > 0: entity.y = ground.rect.top - entity.height entity.vy = 0 # 落地后竖直速度清零 entity.on_ground = True

这个细节看起来简单,却是平台跳跃游戏手感好与坏的巨大分水岭。很多新手写完碰撞检测后没做速度归零,角色会在接触地面的一瞬间反复弹跳,动作极不自然。

9. 七条我踩过的坑和调试技巧

这一节我把自己这几年做Python游戏碰撞检测踩过最深的坑和对应的解决技巧集中整理一下,全部是能直接帮你少加班的东西。

9.1 Rect对象是整型坐标,注意精度损失

pygame的pygame.Rect有一个"隐藏特性":它的坐标和大小都是整数。当你把entity.x赋值为100.7时,entity.rect.x会变成100,小数部分被截断。在做高精度物理模拟时,这个误差会积累,导致物体位置长期运行后漂移。

解决方法是:核心逻辑用浮点数存坐标,只在需要绘制和碰撞检测时才同步到Rect。我实际做项目时的做法是,Entity类维护自己的x, y浮点坐标,rect属性每次都从浮点坐标生成整数Rect,这样既保留了物理精度,又兼容了pygame的整型Rect。

9.2 collidelist的"第一个碰撞对象"陷阱

前面提过collidelist只返回第一个碰撞对象,但在物体密集场景下,"第一个"很可能是你根本不想撞到的那个。比如子弹应该撞到最近的墙,但因为墙A在列表里排前面,子弹先和毛茸茸的队友碰撞了。

稳妥做法是,遍历所有候选碰撞体,计算重叠面积或穿透深度,选择最小的那个作为碰撞对象。

def get_closest_collision(rect, obstacles): best_index = -1 best_overlap = float('inf') for i, obs in enumerate(obstacles): if rect.colliderect(obs.rect): overlap_area = compute_overlap_area(rect, obs.rect) if overlap_area < best_overlap: best_overlap = overlap_area best_index = i return best_index

9.3 碰撞回调里不要修改正在遍历的列表

这是Python开发里特别经典的一个坑:你在遍历bullet_list做碰撞检测,发现子弹碰到敌人后直接bullet_list.remove(bullet),然后Python的for循环可能会跳过下一个元素,或者直接抛RuntimeError: dictionary changed size during iteration

我的经验做法是:碰撞处理不立即修改列表,先记录需要销毁的物体,遍历结束后统一处理

to_remove = [] for bullet in bullet_list: if bullet_collides_with_enemy(bullet): to_remove.append(bullet) # 给敌人减血、生成特效等 for bullet in to_remove: bullet_list.remove(bullet)

9.4 使用分层碰撞掩码避免不相关物体做检测

如果游戏有"玩家子弹只攻击敌人、敌人子弹只攻击玩家、道具只能被玩家拾取"这些规则,你的碰撞检测循环里会做大量无效判断。一个轻量级方案是给物体加一个collision_layer属性,检测前先判断两层是否匹配。

class Layer: PLAYER = 1 ENEMY = 2 PLAYER_BULLET = 4 ENEMY_BULLET = 8 ITEM = 16 # 使用位掩码表示响应的碰撞层 entity.collision_mask = Layer.PLAYER | Layer.ITEM def can_collide(a, b): return bool(a.collision_layer & b.collision_mask)

这种分层方案在处理复杂交互时优势明显,也方便后续扩展(比如增加"陷阱只伤害玩家和敌人"的新规则,只需添加一个层位)。

9.5 帧率独立下的碰撞表现:使用delta time

如果游戏循环的帧率不稳定(有的电脑跑144帧,有的只有30帧),碰撞检测在高帧率下会"过于灵敏",低帧率下又"过于迟钝"。这涉及一个根本问题:物理模拟的步长和渲染帧率的耦合。

标准解法是引入delta time(上一帧到这一帧的实际时间),把速度的单位从"像素/帧"改成"像素/秒"。高速物体的位移就变成了speed * dt,再配合前面讲到的子步进方案,可以保证不同帧率下碰撞行为基本一致。

dt = clock.tick(60) / 1000.0 # 单位:秒 bullet.x += bullet.speed_x * dt bullet.y += bullet.speed_y * dt

9.6 可视化调试器才是碰撞检测的好朋友

碰撞检测是个看不见摸不着的逻辑过程,纯靠print输出调试效率极低。我这几年养成的习惯是,在游戏窗口里直接画出所有碰撞体

# 调试模式:画出所有碰撞矩形 for entity in all_entities: pygame.draw.rect(screen, (0, 255, 0), entity.rect, 1) # 画出碰撞点(标记为红色) for collision_point in collision_points: pygame.draw.circle(screen, (255, 0, 0), collision_point, 3)

把碰撞体可视化后,很多问题一目了然:碰撞体比贴图大一圈导致"空气判定"、碰撞体位置偏移导致"模型和判定错位"、多个碰撞体重叠导致"多层碰撞"等等。建议在项目的开发期始终保留这个可视化开关,调试起来非常方便。

9.7 负数坐标与屏幕外边界

如果你的游戏世界比屏幕大(有滚动的摄像机),物体可能出现在负坐标或超出屏幕边界的位置。pygame的Rect处理负数坐标完全没问题,但空间网格和碰撞检测代码里,用整数除法//处理负数坐标时要小心方向。

# 当 x = -10, cell_size = 80 时 # int(-10 // 80) = -1,在Python里是向下取整 # 如果在C语言里,x / 80 = 0(向零取整),结果完全不同 # 所以用Python时,负数坐标的格子索引是负数,这是正常的 # 但如果你把索引传给数组,记得做偏移或归一化

空间网格的字典key存的是元组(cx, cy),不要直接把cx当数组下标用,用字典就不会有负数索引问题。

10. 一个完整的小例子:玩家避开圆形陷阱并拾取道具

为了把这些内容串起来,我写了下面这个自认为是"最小可用"的示例。场景:玩家(矩形)需要移动,避开圆形陷阱(圆形),碰到圆形道具(圆形)时拾取。用到了AABB碰撞、圆形碰撞、圆与矩形混合碰撞、空间网格(简单版)、delta time移动。

import pygame import math import random pygame.init() screen = pygame.display.set_mode((800, 600)) clock = pygame.time.Clock() class Player: def __init__(self, x, y): self.x = float(x) self.y = float(y) self.width = 40 self.height = 40 self.speed = 200 # 像素/秒 @property def rect(self): return pygame.Rect(int(self.x), int(self.y), self.width, self.height) class Trap: def __init__(self, x, y, radius): self.x = float(x) self.y = float(y) self.radius = radius def collide_with_rect(self, rect): return check_circle_rect(self.x, self.y, self.radius, rect.x, rect.y, rect.width, rect.height) class Item: def __init__(self, x, y, radius): self.x = float(x) self.y = float(y) self.radius = radius self.collected = False def collide_with_rect(self, rect): # 找到矩形离圆心最近的点 closest_x = max(rect.x, min(self.x, rect.x + rect.width)) closest_y = max(rect.y, min(self.y, rect.y + rect.height)) dx = self.x - closest_x dy = self.y - closest_y return dx * dx + dy * dy <= self.radius * self.radius def check_circle_rect(cx, cy, r, rx, ry, rw, rh): closest_x = max(rx, min(cx, rx + rw)) closest_y = max(ry, min(cy, ry + rh)) dx = cx - closest_x dy = cy - closest_y return dx * dx + dy * dy <= r * r # 生成道具和陷阱 items = [] for _ in range(5): items.append(Item(random.randint(100, 700), random.randint(100, 500), 20)) traps = [] for _ in range(4): traps.append(Trap(random.randint(100, 700), random.randint(100, 500), 35)) player = Player(50, 300) running = True font = pygame.font.SysFont("Arial", 24) collected_count = 0 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, dy = 0, 0 if keys[pygame.K_LEFT]: dx = -player.speed * dt if keys[pygame.K_RIGHT]: dx = player.speed * dt if keys[pygame.K_UP]: dy = -player.speed * dt if keys[pygame.K_DOWN]: dy = player.speed * dt # 分轴移动,并限制在窗口内 player.x += dx player.x = max(0, min(player.x, 800 - player.width)) player.y += dy player.y = max(0, min(player.y, 600 - player.height)) # 陷阱碰撞检测:碰到则重置位置 player_rect = player.rect for trap in traps: if trap.collide_with_rect(player_rect): player.x, player.y = 50, 300 break # 道具碰撞检测:拾取 for item in items: if not item.collected and item.collide_with_rect(player_rect): item.collected = True collected_count += 1 # 绘制 screen.fill((30, 30, 30)) for trap in traps: pygame.draw.circle(screen, (220, 50, 50), (int(trap.x), int(trap.y)), trap.radius) for item in items: if not item.collected: pygame.draw.circle(screen, (50, 220, 100), (int(item.x), int(item.y)), item.radius) pygame.draw.rect(screen, (70, 130, 240), player.rect) # 显示得分 text = font.render(f"Collected: {collected_count}/5", True, (255, 255, 255)) screen.blit(text, (10, 10)) if collected_count == 5: end_text = font.render("You Win! Press R to restart", True, (255, 255, 255)) screen.blit(end_text, (250, 250)) pygame.display.flip() pygame.quit()

这段代码的运行逻辑不复杂:玩家用方向键移动,碰红色圆陷阱就回到起点,碰绿色圆道具就拾取。注意道具和陷阱的碰撞检测用的是同一种check_circle_rect函数,只是应用场景不同。实际项目里,你可以给不同的碰撞事件挂不同的回调,这样代码组织会更清晰。

在添加新的碰撞组合时,我的建议是:先把所有碰撞体的"形状类型"抽成枚举,然后在统一的地方分发到对应的检测函数。这样后续要加"三角形陷阱""胶囊形状的角色",只需要新增一个分支,不需要改动上层逻辑。

11. 版本、引擎与库的选择建议

聊了这么多实操,最后简单说说Python做游戏碰撞检测可以借助的底层库和引擎。很多人纠结于"我用pygame好,还是用arcade,还是要上Godot"?

11.1 pygame:自己动手的经典选择

pygame是我用得最多的库,它的RectSpritemask等模块提供了碰撞检测的基础工具,但不会替你处理复杂物理。适合想理解游戏底层逻辑、对碰撞检测有定制需求的开发者。缺点是你需要自己实现空间分区、碰撞响应等高级功能。

11.2 arcade:内置更高级的物理和碰撞处理

arcade是一个比pygame更现代化的2D游戏库,内置了精灵列表(SpriteList)的碰撞检测,支持空间哈希加速,也提供了简单的物理引擎(重力、摩擦力、平台支持)。如果你不想从头写碰撞响应,arcade能帮你省掉不少时间。但它的社区和教程数量比pygame少一些,遇到冷门问题排查起来会费劲。

11.3 pygame + pymunk:需要真实物理效果时的组合

如果你的游戏需要更真实的物理模拟——有质量、速度、弹性系数、摩擦力——自己手写碰撞响应的成本会急剧上升。这时可以用pymunk(一个2D物理引擎,底层是Chipmunk2D)配合pygame绘制。把pymunk当作物理计算核心,pygame当作渲染层。这也是我做物理类原型时常用的组合。

11.4 自定义碰撞检测 vs 游戏引擎

很多刚接触游戏开发的朋友会问:我用Pygame还是Unity/Godot?我的建议是:如果你学习Python的目标是理解编程逻辑和算法,用Pygame自己实现碰撞检测是宝贵的训练。它逼着你把数学、数据结构和算法串起来,一本书读三遍都不如亲手实现一次AABB和SAT来得深刻;但如果你只想快速做出一个游戏Demo,不想在底层细节上花时间,直接用Godot或Unity的现成物理系统效率高得多。

不要有"我用Pygame写碰撞检测所以我很硬核"的虚荣心,选择工具的标准始终是"项目目标和时间成本"。我自己做商业项目时,如果时间紧,会直接用Unity;做教学演示或算法验证,才会用Python手写。

12. 写在最后的个人体会

碰撞检测这个模块,技术上不算难,但做好它涉及的面非常广——数学、数据结构、性能优化、用户手感、代码架构,每一项都能单独写一篇文章。我在做项目时发现,真正让游戏"好玩"的,很多时候不是那些炫酷特效,而是碰撞手感打磨得是否细腻。角色撞墙会不会卡住、子弹飞行稳不稳定、敌人攻击判定是否公平,这些微小的体验最终决定了玩家对这个游戏的评价。

在我自己写的所有碰撞检测方案里,最简单可靠的反而是AABB加分轴移动,复杂方案只在特定场景才值得引入。遇到碰撞问题,我会提醒自己先做最小复现、画可视化调试框、确认几何表示方式,再考虑要不要上更复杂的算法。大多数时候,问题并不是算法不够高级,而是坐标算错了一个像素或者忘记清零了速度。

希望这篇文章里那些踩坑记录和调试思路,能给你的Python游戏开发省下几个加班的晚上。动手写起来,把例子跑起来,再改成你自己的游戏规则——碰撞检测这东西,只有亲手调过才知道里面的门道。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询