☰
Python游戏开发中的碰撞检测:从AABB到像素级判定的完整指南
2026/10/14 4:07:58 网站建设 项目流程

1. 游戏里的“撞墙”是怎么被算出来的

两三年前我在一个模拟项目里写过一个马赛克风格的冒险小游戏,角色在一个由瓦片地图构成的房间里自由移动。功能不复杂,但开发过程中我最怕处理的就是碰撞检测:角色动不动卡在墙缝里、跳起来穿过天花板、或者站在两个砖块中间疯狂抖动。后来我把整套碰撞逻辑重写了一遍,才慢慢摸清这里面的门道。

Python游戏里的碰撞检测,说穿了就是“判断两个物体有没有重叠”。但实现方式决定了一个游戏是“手感扎实”还是“像在冰面上滑行”。作为游戏循环里每帧都会执行的核心逻辑,碰撞检测的选型和实现直接影响到帧率、操作手感、Bug数量,甚至后续如果要接物理引擎,还得为它留好接口。

这篇文章我按自己的踩坑经历来写,尽量把“为什么这么做”讲透,不是丢一段案例代码就完事。内容适合刚用Python写完生命游戏的初学者,也适合已经在用pygame写小游戏、想把自己的碰撞逻辑理清楚的开发者。

先说结论:绝大多数2D小游戏用不到高大上的物理引擎,老老实实把碰撞检测分成“检测”和“响应”两步,用最简单可靠的矩形/圆形判定,就能覆盖九成需求。但前提是你得把坐标系、物体尺寸、移动顺序这些细节想明白。

2. 三种主流碰撞检测方案的取舍

2.1 AABB矩形碰撞:最快上手的基础方案

AABB就是轴对齐包围盒,意思是“这个矩形的边永远和坐标轴平行”。不管你的角色是像素小人还是卡车模型,只要它是矩形且不旋转,就可以用一个左、右、上、下四个边界来描述。

两个AABB是否相交的判定极其简单:在x轴上投影看有没有重叠,在y轴上投影看有没有重叠,两个方向都重叠就是碰撞。

def rect_collide(rect1, rect2): return ( rect1.left < rect2.right and rect1.right > rect2.left and rect1.top < rect2.bottom and rect1.bottom > rect2.top )

这套逻辑的核心思想叫“分离轴定理的二维简化版”:只要存在任何一个方向上两个矩形是分开的,那它们一定没有重叠。反过来,两个方向都重叠,那就一定碰撞。

我特别想强调的是边界相等的情况。上面这个写法用的是严格不相等,表示“恰好挨着不算撞”。如果你的游戏判断人物站在地板上时用的是“角色底部小于等于砖块顶部”,那角色就不会被判定掉下去;但如果用“小于”,每一帧都会因为一像素的浮点误差导致抖动。这个细节在后面的Bug排查里还会展开讲。

AABB的优势是计算量小,一次判定只需要四次比较,性能开销可以忽略。劣势也很明显:假如角色的贴图是斜着画的,AABB会把整块矩形都算进去,实际看起来明明差半个身位却被判定撞上了。解决办法通常是把碰撞盒做小一点,比贴图窄几像素,也就是“视觉盒”和“碰撞盒”分离。

2.2 圆形碰撞:手感舒适但要处理半径差

圆形碰撞判定更直观:两个圆的圆心距离小于半径之和,就是碰撞。用距离公式,开根号这个操作比较费性能,所以标准做法是两边都平方,比较距离平方和半径和平方。

def circle_collide(c1, c2): dx = c1.x - c2.x dy = c1.y - c2.y dist_sq = dx * dx + dy * dy r_sum = c1.radius + c2.radius return dist_sq <= r_sum * r_sum

圆形碰撞的好处是方向无关,旋转、斜着走都不影响判定准确度。很多俯视角游戏里的角色用一个圆当碰撞体,手感明显比方块“滑顺”,因为圆不会卡在墙角。但代价是圆形不能精确表示狭长物体的形状,比如一把剑横着扫过来,用圆会有很大的误差区域。

我自己的经验是:俯视角小游戏,角色用圆形,墙体和物块用AABB;横板平台跳跃,角色用AABB,因为你需要精确知道脚底踩在哪块砖上。混合使用完全没问题,圆对矩形的碰撞也就是先找圆心到矩形最近点,再比较距离。

def circle_rect_collide(circle, 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.radius * circle.radius

这段代码是许多小游戏里“角色圆碰撞、地图矩形”的标准答案。本质上是在矩形边界上找离圆心最近的点,如果这个点离圆心的距离不超过半径,那就是碰撞。

2.3 像素级碰撞:万不得已才选的方案

像素级碰撞是拿两张精灵图的像素透明度逐个比较,碰到不透明像素就判定撞上。这个方法能做到“边缘完全贴合”,非常适合弹幕游戏的受击判定、或者精细的2D格斗游戏。

但性能开销和实现复杂度都是三个方案里最高的。真实项目里几乎没人直接去比像素,而是先用AABB做快速排除,只有两个框重叠了才进入像素级判定。pygame里提供了Mask类来做这件事,用mask.from_surface()生成碰撞掩膜,然后overlap()判断。

mask1 = pygame.mask.from_surface(sprite1) mask2 = pygame.mask.from_surface(sprite2) offset = (sprite2.left - sprite1.left, sprite2.top - sprite1.top) if mask1.overlap(mask2, offset): handle_collision()

我需要提醒的是,from_surface()的默认阈值为1,但实际用下来很多半透明边缘会被算进去,建议把阈值调到128或更高,只把比较实的像素当作碰撞体。否则你会看到角色“明明没碰到画出来的边缘却被挡在天上”。

这三种方案不是互斥的,合理的分层策略是:先用AABB粗筛,再用圆或Mask精确判定。游戏里的碰撞体不会只有一个,而是由多个基础碰撞体组合出来的复杂形状。

3. 实操过程:从零实现一个稳定的碰撞系统

3.1 数据模型与移动顺序是地基

很多初学者写碰撞检测的唯一代码就是“移动完再判断是否重叠,重叠就退回去”。这能跑,但手感极其拉胯。真正稳定的做法是把移动和碰撞分成两套坐标系计算。

我建议用三组数据描述一个游戏物体:位置坐标、速度向量、碰撞盒尺寸。不要直接用屏幕坐标算,而是维护一个“逻辑坐标”,渲染时再转换。这样碰撞检测的结果不依赖当前渲染状态,也方便你以后加慢动作、回放这类功能。

核心思路是:把一帧的位移拆成“水平方向移动”和“垂直方向移动”两步,分别执行、分别碰撞。这样能解决掉九成平台跳跃类游戏的卡墙问题。

# 伪代码:分轴移动 old_x, old_y = self.x, self.y self.x += self.vx * dt resolve_horizontal_collisions() self.y += self.vy * dt resolve_vertical_collisions()

这一步的意义在于:当你水平撞墙时,只有x方向被修正,y方向不受影响,之后y的正常下落不会被“卡在墙角”打断。如果是先移动再统一修正,极容易出现撞墙后角色被往后推回一整个对角线位移的情况,表现就是“碰到墙边会瞬移”。

我自己职业生涯里见到的最常见的碰撞Bug,不是判定错了,而是移动顺序错了。一个简单的顺序调整,抵得上加一整套复杂修正算法。

3.2 碰撞响应:不是说“退回去”就完了

检测到碰撞之后,接下来“如何响应”更考验功底。新手做法是直接把角色位置改回碰撞前的位置,结果就是对角撞墙时角色原地卡死,再也动不了。

正确做法是依据碰撞方向做“穿透深度修正”。横版游戏的标准姿势:如果角色在水平移动中撞到左墙,就把角色的left设为墙的right;撞右墙就把right设为墙的left。垂直方向同理,撞地就把bottom设为砖块top,撞天花板就把top设为砖块bottom。

def resolve_collision(self, tile_rect): # 水平方向 if self.vx > 0: self.right = tile_rect.left self.vx = 0 elif self.vx < 0: self.left = tile_rect.right self.vx = 0 # 垂直方向 if self.vy > 0: self.bottom = tile_rect.top self.vy = 0 self.on_ground = True elif self.vy < 0: self.top = tile_rect.bottom self.vy = 0

注意这里有个小细节:把穿透修正和速度置零同时做。如果你只改位置不把速度清零,下一帧又会穿回去。这也是我调试时必查的第一个点。

更进阶一点的方案是计算“分离向量”,找到一个把两个碰撞体推开的极小位移。实现的通用姿势是计算重叠的宽度和高度,取较小的一维来分离。这在俯视角游戏里更好用,因为俯视角没有“地面”概念,任意方向的碰撞响应都一样。

3.3 瓦片地图碰撞:逐块查还是空间划分

瓦片地图是2D横版游戏的标配。碰撞体是很多个小方块,最简单的实现是角色每帧遍历所有瓦片判断。地图小的时候无所谓,大了就废了,比如100x100的地图,每帧做一万次矩形判断,Python撑不住多久。

所以要用空间划分:把地图按瓦片网格存好,每个瓦片大小为固定值(比如32x32),角色移动后只检查自己覆盖到的网格范围。用整数除可以算出角色覆盖了哪些行和列:

start_col = max(0, self.left // TILE_SIZE) end_col = min(map_width - 1, self.right // TILE_SIZE) start_row = max(0, self.top // TILE_SIZE) end_row = min(map_height - 1, self.bottom // TILE_SIZE) for row in range(start_row, end_row + 1): for col in range(start_col, end_col + 1): if map_collision[row][col]: resolve_collision(pygame.Rect(col * TILE_SIZE, row * TILE_SIZE, TILE_SIZE, TILE_SIZE))

这个做法的核心价值,是把“全地图一万次判定”降成“角色周围的几次判定”。同理,如果你在做的是有大量子弹的弹幕游戏,子弹之间互相检测时也可以用“将屏幕划分为多个格子,只检测同一格或相邻格的子弹”的方式优化。

我在实际项目里还测试过一个方案:把所有静态碰撞瓦片预先烘焙成几个“合并后的矩形列表”,运行时只和这些大矩形碰撞。效果也很明显,但实现复杂度偏高,普通小游戏用网格遍历就够了。

再提一个影响观感的细节:瓦片地图碰撞盒应与贴图对齐。很多美术同学画砖块时会画一些花边、挂饰,贴图上下会有几个像素的“假想延伸”。如果你直接拿贴图尺寸当碰撞盒,会有大量“视线没碰到却被挡住”的尴尬情况。建议在场景编辑阶段预留一层“碰撞图层”,只画纯色矩形格子,视觉贴图和碰撞盒彻底分离。

3.4 地板与屋顶:单方向平台与头顶判定

平台跳跃类游戏里有一种特殊的碰撞需求:单向平台,就是角色可以从下方跳上去、或者从侧面穿过去,但站在上面不会掉下来。这类平台的碰撞判定和普通砖块完全不同。

单向平台的典型实现逻辑:只在角色“从上往下落”且“角色的底部之前位于平台顶部的上方”时才触发碰撞。换句话说,你只能接住从上面落下来的物体,不能阻挡从下面往上顶的物体。

if (player.vy > 0 and player.bottom >= platform.top and player.bottom + player.vy <= platform.top + FALL_THROUGH_TOLERANCE): player.bottom = platform.top player.vy = 0 player.on_ground = True

注意这里比较的是“上一帧的底部”和“当前帧的底部”,不是当前帧的位置。如果角色下落速度特别快,一帧跨越了整个平台厚度,只靠位置判断会直接漏过判定,也就是常说的“隧道效应”。解决方式有两种:把一帧的运动拆分成多个子步长逐段检测,或者记录物体的“前一帧位置”,看运动线段是否与平台相交。

头顶判定也是同样的思路。很多平台上角色顶到天花板后,如果刚好贴着天花板横向移动,会因为“头顶的瓦片和侧面的瓦片同时碰撞”而出问题。解决方法是按“先水平、后垂直”的顺序检测,并且水平碰撞修正后立即转向垂直碰撞检测,确保两个方向互不干扰。

我自己的习惯是:把“脚底地面”和“头顶天花板”分别用一个薄碰撞矩形表示,和主体碰撞盒分开。好处是你可以在游戏里实现“下蹲时缩小碰撞盒、站立时恢复碰撞盒”这类复杂的角色体型变化,而不用担心主体碰撞逻辑被牵扯。

4. 常见Bug与排查技巧实录

4.1 角色在地板上疯狂抖动

这个Bug我见过太多次了。现象是角色站在地面上,但画面里一直在上下轻微颤动,或者左右滑移。根本原因是重力每帧给角色增加向下的速度,下一帧碰撞检测发现角色穿透地板,又把y坐标拖回去,再下一帧又被重力拉下去……形成一个几像素的循环振荡。

解决办法有两个方向,一个是在“站在地面上”的帧把重力产生的速度清零,另一个是设置“地面容忍阈值”,允许角色底部和地板顶部有几像素的误差不触发重新判定。

if self.on_ground: self.vy = 0 # 不累加重力 else: self.vy += GRAVITY * dt self.vy = min(self.vy, MAX_FALL_SPEED)

这里必须注意顺序:先更新水平位置和速度,再检测水平碰撞;然后更新垂直速度和位置,再检测垂直碰撞。如果重力累加速度、移动到新位置、碰撞修正这三件事的顺序乱了,抖动就很难根治。另外,浮点误差也不要小看。用整数坐标存储角色位置是个笨但有效的办法,pygame的Rect会自动取整,反而能避免很多肉眼可见的微抖动。

4.2 高速移动直接穿墙

子弹以每帧100像素的速度飞行,而墙壁厚度只有32像素,常见结果是子弹“嗖”一下穿过了墙,完全没触发碰撞。这就是前面提到的隧道效应。

处理方式按项目规模分三档:第一档是简单粗暴地把速度限制在最小物体厚度以下,这种方法只适合偶尔一次的小Demo;第二档是把移动拆成多个小步长,比如每帧位移100像素,那就拆成10次,每次移动10像素并检测碰撞,质量不错且实现简单;第三档是用“扫掠矩形”或者射线检测,把移动路径看作一条线段,判断线段与碰撞体的相交性。

我个人推荐第二档优先。因为拆步长的方案不改变你已有的碰撞判断函数,本质上是把“一帧”拆细为“多个子步”,遇到复杂场景也好排查。扫掠矩形的数学推导写起来好看,但圆扫、矩形扫、旋转扫每种都要单独处理,业务代码会越来越复杂。

还有一个实际经验:很多“穿墙”问题不是速度太快导致的,而是“上一帧的位置记录”没保存好。碰撞检测用旧的上一帧坐标去判断路径穿越,结果上一帧坐标在移动前就被覆盖了。排查时可以打印最近三帧的坐标快照,一眼就能看出问题在哪一帧发生的。

4.3 角落卡死与墙角斜滑

角色斜着跑向一个凸角时,经常出现的情况是:身体已经探进角落,两个方向都同时碰撞,修正逻辑按“先后再左”顺序处理,角色直接被卡在原地动弹不得,表现为“吸在墙角”。

高级感一点的解法是“滑动修正”:先计算水平方向的碰撞修正,然后在修正后的新位置基础上再做垂直方向检测。因为水平修正已经不让角色嵌入墙壁,垂直检测通常就不会再和同一堵墙发生碰撞。如果还是卡,说明碰撞体形状或贴图比网格大,需要缩小角色的碰撞盒。

简易但有效的做法是:给角色碰撞体加一个“嘴角圆角”效果,也就是让碰撞盒的四角各去掉一小块。真做起来并不复杂:把AABB稍微缩小几像素,必要时用圆形碰撞替代四个角附近的检测。

这里也要提一个我之前犯过的错:为了让角色能“刚好挤过”两堵墙之间的缝隙,我把碰撞盒调小了,结果导致角色半个身子探进墙里还能继续走,看起来像是“穿模”。调碰撞盒胖瘦时,宁可手感略保守,也不能让视觉穿帮。

4.4 碰撞体与渲染画面错位

碰撞盒是对的,但打击判定的位置明显偏离画面里的刀光,这种情况多半是因为用了不同来源的坐标:一部分碰撞体用世界坐标,另一部分用相机偏移后的屏幕坐标。特别是引入摄像机跟随系统之后,两个坐标系混用会让Bug变得非常随机,每次角色走到屏幕不同位置,偏移量都不同。

我踩过这个坑后定下一条规矩:所有逻辑层统一使用世界坐标,渲染层在每帧结束时做一次坐标转换。碰撞检测、AI寻路、拾取判断全部走世界坐标系,只有最后绘制时才减去相机位置。

screen_x = world_x - camera.x screen_y = world_y - camera.y

如果调试时发现碰撞框和渲染图像错位,第一件事就是检查转换函数有没有重复应用,比如在渲染函数里减了一次相机偏移,在碰撞函数里又减了一次。第二个常见原因是精灵图原点设置不一致,有的贴图以左上角为锚点,有的以中心为锚点,碰撞盒跟着锚点走就偏了。美术资源和代码的坐标约定必须在一开始就锁死。

5. 再往下深挖:空间索引与物理引擎的边界

前面聊了各种碰撞检测的写法和排错,但如果你的游戏规模再大一点,角色数量上百、互相之间也要检测碰撞,那就该考虑空间索引了。

最简单的选择是网格空间哈希。把整个地图切割成固定大小的格子,每个格子记录“当前存在于本格子的物体ID列表”。每帧更新物体时重新登记归属,检测碰撞时只取出同一格子和相邻几个格子的物体来做两两判定。这里需要考虑清楚的问题是:网格大小要怎么取?经验法则是以世界中物体的平均尺寸作为网格边长。如果物体大小分布特别不均匀,就会退化,比如一个巨型怪物占了上百个格子,每次检测都要和它比对,性能损耗直接拉满。

更精细的做法是四叉树或BVH树,按物体实际分布来划分空间。这些数据结构的手写实现需要一定的算法功底,pygame生态里也没有特别通用易用的库。大多数人做到网格哈希这步就已经够了,再复杂的直接用现成物理引擎更划算。

物理引擎是另一个话题。pygame自带的sprite.Sprite本身不带物理模拟,只有碰撞检测的辅助方法。如果你想要连续的物理行为——弹性、摩擦力、关节连接、刚体堆叠——就应该考虑接入现成的物理引擎。Python生态里典型的方案是一个2D物理库,它支持刚体、碰撞形状、关节和触发器,用起来比自己在原子上硬堆手感要好一个量级。

不过我需要提醒一句:物理引擎不是银弹。它的坐标系、单位(像素/米)、时间步长都有自己的约定,接入初期会有一段不小的学习成本。而且物理引擎更擅长模拟“受力的物体”,并不擅长你手写的“平台跳跃手感”。如果你做的是需要精确跳跃高度、精确滞空时间的平台游戏,建议还是保留自己写的碰撞系统;如果你要模拟一堆圆球砸碎一堆方块,那就果断接引擎。

我在一个模拟项目里验证过混用模式:玩家角色用自研碰撞逻辑,其余物理物体交给引擎模拟,角色不会穿模。做法是把引擎的物理世界单独跑在一个“模拟空间”里,每帧把角色位置同步成引擎世界中的刚体位置,但不参与它的动力学计算。

这套方案在性能调优上有额外消耗,但换来的是“自己的角色自己做主”的手感优势。真心建议开发者在做选型之前,先把自己游戏的核心玩法定义清楚:需要的是“精确的操作感”,还是“丰富的物理交互”。游戏项目开发中,碰撞检测不是孤立的判定代码,而是一条贯穿坐标系、数据结构、游戏循环的骨架。把基础算法原理弄懂,比盲目堆代码要重要得多。

如果你也在做Python小游戏,我建议你从最简单的AABB开始,先把手感调到不卡墙、不穿墙、不抖动的状态,再逐步引入更复杂的形状和优化策略。这条路踏实走完,你踩过的坑会变成你最强的技术储备。

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

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

立即咨询