做Python游戏绕不开的一件事就是碰撞检测。不管你是准备写一个打砖块、跳跳乐、俯视角射击,还是横版闯关,角色撞墙、子弹命中、道具拾取、脚下踩到平台……这些你习以为常的效果,拆到底全是同一套几何判断。我见过不少新手,画面画得挺漂亮,精灵也跑起来了,一到碰撞检测就开始翻车:子弹贴脸都打不到人、角色走着走着卡进墙里、两个矩形明明叠在一起却显示没碰到。这篇文章我会以Pygame为主,从底层原理讲到具体API,再带你把一个完整的“打砖块”碰撞系统写出来,最后把那些坑爹问题挨个拆开给你看。适合刚接触Pygame、想彻底搞懂碰撞检测的同学,也适合已经写得动小游戏、但总被诡异碰撞问题折磨的开发者。
1. 碰撞检测的底层逻辑与方案选型
1.1 游戏循环里,碰撞检测到底插在哪一步
大多数Pygame小游戏的主循环都长一个样:处理事件、更新逻辑、绘制画面。更新逻辑里最核心的事情有两件:按速度移动物体、处理运动之后的碰撞结果。位置移动完之后,必须马上做碰撞检测和位置修正,修正完的结果再交给绘制阶段。顺序一旦反了,画面就会出现穿模一两帧再弹开的情况,严重的时候物体直接穿透。
这里有一个非常关键的基本事实:游戏世界是离散的,不是连续的。计算机并不是真的让物体“平滑地飞过去”,而是每隔一帧把物体的坐标加上速度向量。帧与帧之间,物体其实“跳”了一大段距离,碰撞检测要回答的问题是:这段距离有没有和别的物体发生重叠。
用生活来类比,汽车以120km/h在公路上行驶,每秒大约移动33米。如果前方有个路障,而你恰好每秒只眨一次眼,睁开眼睛的瞬间车可能已经在路障后面了。游戏里也一样,一帧内物体位移越大,越过目标的可能性越高。这也是后面“高速穿透”问题的最根本原因,只要理解了这一点,很多灵异现象你就能自己想明白。
1.2 四种主流碰撞模型:精度与成本的权衡
游戏里没有任何人会用数学上绝对精确的物体外形来做碰撞运算,而是用几个简单的简化形状去近似真实轮廓,这些简化形状统称为碰撞体。你写碰撞检测,本质上是在设计一套碰撞体模型。
| 碰撞方案 | 核心思想 | 精度 | 性能 | 典型场景 |
|---|---|---|---|---|
| AABB矩形 | 判断两个矩形的x和y方向区间是否同时重叠 | 中 | 高 | 砖块、子弹、平台、按钮 |
| 圆形碰撞 | 比较两个圆心距离平方与半径和的平方 | 中 | 高 | 小球、角色的“碰撞圈” |
| 凸多边形SAT | 在每条边的法线方向投影,找是否存在分离轴 | 高 | 中 | 旋转物体、复杂地形、物理引擎内部 |
| 像素Mask | 逐像素比对不透明区域 | 很高 | 低 | 不规则美术素材、精细打击判定 |
AABB全称是Axis-Aligned Bounding Box,轴对齐包围盒,意思是碰撞矩形的四条边永远和屏幕坐标系对齐,不旋转。它最大的优点是计算极快,只需要四次比较。Pygame内置的Rect对象就是AABB的现成实现。圆形碰撞也很常用,因为人形角色的俯视图或侧视图很多时候用圆去包,手感反而比矩形舒服。像素Mask精度最高,但因为要逐像素扫描,性能开销最大,通常只用在美术素材特别不规则、玩家又特别在意打击手感的游戏场景里。
1.3 Pygame生态里怎么选方案
Pygame本身没有内置物理引擎,它提供的是最基础的几何检测能力。这对学习反而是好事,你能把原理看得清清楚楚。对Pygame来说,默认碰撞体就是Rect矩形,这是AABB思想的具象化。Pygame的坐标和Rect都使用整数,处理浮点时坑不少,这个后面会专门讲。
我个人的选型建议比较务实:刚开始写小游戏,百分之九十用AABB就够了,代码简单、调试方便。角色如果是圆的,就在AABB基础上自己写个圆形检测,那也只是十几行的事。只有美术素材非常不规则、需要刀刀到肉的手感时,才考虑上Mask。至于SAT这种多边形方案,我不建议从零自己实现,除非你是做物理引擎相关的学习项目,否则直接引入Pymunk之类的物理库,比自己写一堆边边角角的投影求交划算得多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心API与关键计算:先把底子打牢
2.1 Rect对象:你以为的矩形不是矩形
Pygame里最常用也最容易被低估的一个类是pygame.Rect。它保存了x、y、width、height四个整数属性,而left、right、top、bottom、centerx这些其实都是根据这四个基本值动态算出来的属性。right等于left加width,bottom等于top加height。
这里有一个值得警惕的细节:Rect内部使用的是半开区间,边界刚好贴上的两个矩形,colliderect返回的是False。什么意思?你用pygame.Rect(0, 0, 10, 10)和pygame.Rect(10, 0, 10, 10)做检测,结果会显示没碰,因为它们只是右边界和左边界贴在一起。对某些游戏来说,物体刚好贴边到底算不算碰撞,会直接影响手感和判定结果。如果希望贴边就算碰,有一个偷懒的办法,把其中一个Rect用inflate(1, 1)做一次扁平化扩展,四周各外扩半个像素再检测。
比边界问题更隐蔽的是整数截断。Pygame里给Rect的x赋值一个浮点数,它会直接截断成整数,而不是四舍五入。测试一下就知道,把x赋成3.9,再读出来是3。这意味着如果你直接用Rect去存小球的坐标,小球每帧的位置都会因为截断而漂移,碰撞结果也会变得断断续续。正确做法是:物体的“真实坐标”用外部float变量保存,Rect只作为绘制和检测用的临时快照,检测完就丢掉。这个习惯我后面会反复强调。
2.2 圆形碰撞:不要让CPU去做开平方
两个圆是否碰撞,数学上就一句话:两圆心的距离是否小于等于半径之和。但直接算距离要开根号,开根号是相对昂贵的运算。更聪明的做法是比较距离的平方和半径和的平方,因为平方本身是严格单调的,大小关系和原距离保持一致。
python复制def circle_hit(cx1, cy1, r1, cx2, cy2, r2):
dx = cx1 - cx2
dy = cy1 - cy2
return dx * dx + dy * dy <= (r1 + r2) ** 2
在几十个物体的小游戏里,用不用sqrt的差别微乎其微。但碰撞检测一旦涉及几百上千个对象,每一帧都能省一点,积少成多就很可观。这种“能用平方比较就绝不轻易开根号”的思路,是游戏开发里的基本素养。
圆和矩形的碰撞要稍微绕一下,最常用的方案是“最近点法”。从圆心向矩形上找距离最近的那个点,如果圆心到这个点的距离小于等于半径,就说明圆和矩形相交。最近点的x坐标就是把圆心的x夹在矩形左边界和右边界之间,y坐标同理。
python复制def circle_rect_hit(cx, cy, r, rect):
closest_x = max(rect.left, min(cx, rect.right))
closest_y = max(rect.top, min(cy, rect.bottom))
dx = cx - closest_x
dy = cy - closest_y
return dx * dx + dy * dy <= r * r
这个方法虽然看起来要算一个“最近点”,但其实就是三行比较逻辑,性能完全可以接受。后面打砖块Demo里,小球和挡板就是靠它来检测的。
2.3 Mask像素级检测:精度最高的底牌
当矩形和圆形都满足不了精度要求时,就用pygame.mask。Mask本质上是一个和Surface同尺寸的二维位图,每个像素标记为“实心”或“空心”。从图创建Mask用pygame.mask.from_surface(图片),默认情况下,图片alpha通道值大于等于127的像素被视为实心,这个阈值也可以手动调。
Mask之间的精确碰撞用overlap方法:
python复制mask1 = pygame.mask.from_surface(image1)
mask2 = pygame.mask.from_surface(image2)
offset = (rect2.x - rect1.x, rect2.y - rect1.y)
hit_pos = mask1.overlap(mask2, offset)
if hit_pos:
# 命中了,hit_pos 是重叠点在 mask1 上的坐标
offset是两个Mask对应Rect的左上角坐标差,这个千万别算错,否则明明重叠了却检测不到。overlap返回非None说明有像素重叠,返回的坐标还可以用来生成命中特效,比如在碰撞点放一个火花粒子。
我对Mask的态度一直是:它是底牌,不是默认武器。逐像素比对的速度天然比矩形慢一两个数量级,直接用很容易把帧率拖垮。现实项目中几乎不会让所有物体都两两做Mask,常见做法是先做一次Rectangle粗判,两个Rect压根没重叠就直接跳过,等Rect确认重叠了,再调用Mask精测。这就是经典的两级碰撞检测。
2.4 Sprite与Group:把碰撞处理变成批量活
很多人写游戏一开始就自己维护一个物体list,然后嵌套for循环做检测。Pygame其实提供了封装好的精灵系统和碰撞API,能用现成的就别重复造轮子。
pygame.sprite.spritecollide(单个精灵, 精灵组, dokill, 碰撞函数)专门用来判断一个精灵和同组里所有精灵的碰撞。dokill表示碰撞到的精灵是否直接从组里删除,这在子弹命中敌人、敌人消失的场景里非常方便。第四个参数collided默认是spritecollide会调用Rect的colliderect,你也可以换成pygame.sprite.collide_circle或者pygame.sprite.collide_mask,它兼容不同的碰撞体模型。
两个组之间批量检测用pygame.sprite.groupcollide(组A, 组B, dokillA, dokillB, 碰撞函数),返回一个字典,键是组A中的精灵,值是所有和它碰撞的组B精灵列表。写射击游戏时,这一行就能搞定“所有子弹 vs 所有敌人”的判定和清理,比自己写的双重循环干净得多。
要说明的是,spritecollide内部本质上还是双重循环,只是把脏活封装好了。数量上来之后,该优化还是得优化,这个在第4章和第5章会展开。
3. 实操:从零搭一个“打砖块”碰撞系统
3.1 数据建模:碰撞体怎么存才不坑
一个打砖块游戏里有三类碰撞体:玩家控制的挡板、不断弹动的小球、屏幕上方排列的砖块。挡板用pygame.Rect最合适,它本身是个标准的轴对齐矩形,而且挡板不需要亚像素精度。砖块本质上是同一行同一列的一堆AABB,直接放进一个list里,每个元素就是一个Rect。小球的结构要特别一点,我强烈建议不要用Rect去存它,而是维护两个float变量作为球心坐标,另外再存一个半径。
原因前面提过:Rect只能存整数,而小球每帧都在做浮点速度叠加,一旦截断,运动轨迹就会变得肉眼可见地“一顿一顿”,碰撞点也会偏。正确姿势是,需要检测碰撞的瞬间,根据浮点坐标和半径临时生成一个Rect:
python复制ball_pos = [400.0, 300.0] # 浮点坐标
ball_radius = 9
ball_rect = pygame.Rect(
int(ball_pos[0] - ball_radius),
int(ball_pos[1] - ball_radius),
ball_radius * 2,
ball_radius * 2,
)
速度我建议初始给到每帧4像素左右,在60帧每秒下就是大约240像素/秒。这个数值不是拍脑袋定的,有一个工程经验:物体单帧位移最好不要超过自身碰撞体最短边的一半。挡板宽度110像素,小球每帧最多移动4像素,单帧穿透挡板的概率就非常低。很多碰撞Bug其实就是“速度太大”这一个原因,调参就能救回来一大半。
3.2 球与挡板:圆-矩形碰撞的正确姿势
小球和挡板的组合,一个圆,一个矩形,直接用第2章写的circle_rect_hit函数就可以判断。但打砖块里有个特殊需求:不能永远是物理反射,否则游戏很快就会变得枯燥。经典做法是根据小球撞到挡板时的水平位置来控制出射角度——撞在中间就垂直向上,撞在左边缘就往左上方偏,撞在右边缘就往右上方偏。
python复制def ball_paddle_hit():
global ball_vel
px = max(paddle.left, min(ball_pos[0], paddle.right))
py = max(paddle.top, min(ball_pos[1], paddle.bottom))
dx = ball_pos[0] - px
dy = ball_pos[1] - py
if dx * dx + dy * dy <= ball_radius * ball_radius:
offset = (ball_pos[0] - paddle.centerx) / (paddle.width / 2)
offset = max(-1.0, min(1.0, offset))
angle = offset * (math.pi / 3)
speed = math.hypot(ball_vel[0], ball_vel[1])
ball_vel[0] = speed * math.sin(angle)
ball_vel[1] = -abs(speed * math.cos(angle))
ball_pos[1] = paddle.top - ball_radius
offset算出来是-1到1的小数,代表小球击中点在挡板上的相对位置。把它乘上60度角,就得到了出射角。speed通过math.hypot求当前速度向量的模长,然后按新角度重新分配水平和垂直分量。最后把球的位置强制放到挡板顶边上,这一步是你千万别省略的“位置修正”,不然小球会有一两帧嵌进挡板里,视觉上就是抖。
如果只想做最朴素的物理反弹,直接把垂直速度分量取反就行。但一旦加上角度控制,玩家就能通过移动挡板控制球的落点,手感立刻提升一个档次。这就是你在那些成熟打砖块游戏里感受到的“球是被你打出去的”,而不是球自己在乱弹。
3.3 球与砖块:批量矩形检测与方向反弹
球撞砖块的数量比较多,逐个手写if就是不专业了。Rect自带一个collidelist方法,传入一个Rect列表,返回第一个与自身发生碰撞的索引,没有碰撞就返回-1。
python复制def ball_bricks_hit():
global score
ball_rect = pygame.Rect(
int(ball_pos[0] - ball_radius),
int(ball_pos[1] - ball_radius),
ball_radius * 2,
ball_radius * 2,
)
idx = ball_rect.collidelist(bricks)
if idx == -1:
return
brick = bricks[idx]
overlap_left = ball_pos[0] + ball_radius - brick.left
overlap_right = brick.right - (ball_pos[0] - ball_radius)
overlap_top = ball_pos[1] + ball_radius - brick.top
overlap_bottom = brick.bottom - (ball_pos[1] - ball_radius)
min_overlap = min(overlap_left, overlap_right, overlap_top, overlap_bottom)
if min_overlap == overlap_top or min_overlap == overlap_bottom:
ball_vel[1] = -ball_vel[1]
else:
ball_vel[0] = -ball_vel[0]
bricks.pop(idx)
score += 10
判断反弹方向用了一个叫“最小重叠轴法”的思路:分别计算球和矩形在四个方向上的重叠量,哪个方向的重叠量最小,就说明球是从这个面撞进去的,然后反转对应轴的速度分量。这段代码是所有打砖块类游戏手感的核心,方向判断错一次,球就会往砖块内部钻,然后出现连续乱弹的诡异行为。
这里有个小细节要提醒一下:collidelist一次只返回第一个碰撞砖块。如果球同时和两个砖块重叠,这个函数返回的是列表中索引更靠前的那一个,另一块砖这一帧就不管了。在大多数正常手感下,这个简化是可以接受的,但如果你的游戏里有特别大体积的球,最好改成收集全部碰撞结果再统一处理,后面第4章会专门讲这类问题。
3.4 完整可运行Demo与运行说明
把上面几个部分拼起来,再补上边界反弹、移动控制和绘制,就是一个完整可玩的打砖块游戏。完整代码贴在下面,可以直接复制运行,需要安装pygame,命令是pip install pygame。
python复制import pygame
import math
pygame.init()
WIDTH, HEIGHT = 800, 600
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("Collision Demo")
clock = pygame.time.Clock()
WHITE = (245, 245, 245)
BLUE = (60, 120, 255)
RED = (235, 90, 90)
ORANGE = (255, 170, 60)
BLACK = (30, 30, 30)
paddle = pygame.Rect(WIDTH // 2 - 55, HEIGHT - 40, 110, 16)
ball_pos = [WIDTH // 2, HEIGHT // 2]
ball_radius = 9
ball_vel = [4.0, -4.0]
bricks = []
bw, bh = 64, 20
for row in range(6):
for col in range(10):
bricks.append(pygame.Rect(55 + col * (bw + 8), 45 + row * (bh + 6), bw, bh))
score = 0
font = pygame.font.SysFont("consolas", 26)
running = True
def move_paddle():
keys = pygame.key.get_pressed()
if keys[pygame.K_LEFT]:
paddle.x -= 7
if keys[pygame.K_RIGHT]:
paddle.x += 7
paddle.clamp_ip(pygame.Rect(0, 0, WIDTH, HEIGHT))
def move_ball():
ball_pos[0] += ball_vel[0]
ball_pos[1] += ball_vel[1]
if ball_pos[0] - ball_radius <= 0:
ball_pos[0] = ball_radius
ball_vel[0] = abs(ball_vel[0])
elif ball_pos[0] + ball_radius >= WIDTH:
ball_pos[0] = WIDTH - ball_radius
ball_vel[0] = -abs(ball_vel[0])
if ball_pos[1] - ball_radius <= 0:
ball_pos[1] = ball_radius
ball_vel[1] = abs(ball_vel[1])
def ball_paddle_hit():
global ball_vel
px = max(paddle.left, min(ball_pos[0], paddle.right))
py = max(paddle.top, min(ball_pos[1], paddle.bottom))
dx = ball_pos[0] - px
dy = ball_pos[1] - py
if dx * dx + dy * dy <= ball_radius * ball_radius:
offset = (ball_pos[0] - paddle.centerx) / (paddle.width / 2)
offset = max(-1.0, min(1.0, offset))
angle = offset * (math.pi / 3)
speed = math.hypot(ball_vel[0], ball_vel[1])
ball_vel[0] = speed * math.sin(angle)
ball_vel[1] = -abs(speed * math.cos(angle))
ball_pos[1] = paddle.top - ball_radius
def ball_bricks_hit():
global score
ball_rect = pygame.Rect(
int(ball_pos[0] - ball_radius),
int(ball_pos[1] - ball_radius),
ball_radius * 2,
ball_radius * 2,
)
idx = ball_rect.collidelist(bricks)
if idx == -1:
return
brick = bricks[idx]
overlap_left = ball_pos[0] + ball_radius - brick.left
overlap_right = brick.right - (ball_pos[0] - ball_radius)
overlap_top = ball_pos[1] + ball_radius - brick.top
overlap_bottom = brick.bottom - (ball_pos[1] - ball_radius)
min_overlap = min(overlap_left, overlap_right, overlap_top, overlap_bottom)
if min_overlap == overlap_top or min_overlap == overlap_bottom:
ball_vel[1] = -ball_vel[1]
else:
ball_vel[0] = -ball_vel[0]
bricks.pop(idx)
score += 10
def draw():
screen.fill(WHITE)
pygame.draw.rect(screen, BLUE, paddle)
pygame.draw.circle(screen, RED, (int(ball_pos[0]), int(ball_pos[1])), ball_radius)
for brick in bricks:
pygame.draw.rect(screen, ORANGE, brick)
txt = font.render(f"Score: {score} Left: {len(bricks)}", True, BLACK)
screen.blit(txt, (10, 10))
while running:
for event in pygame.event.get():
if event.type == pygame.QUIT:
running = False
move_paddle()
move_ball()
ball_paddle_hit()
ball_bricks_hit()
if ball_pos[1] - ball_radius > HEIGHT:
running = False
print("Game Over")
draw()
pygame.display.flip()
clock.tick(60)
pygame.quit()
运行起来你会发现手感很直接:左右方向键移动挡板,球从初始角度出发,每次撞到砖块都会得分,砖块消完可以自己再加行数和列数。建议你改两个参数感受一下变化:把ball_vel改成[8.0, -8.0],立刻可以看到球开始出现偶尔穿透;再把brick数量改多,就能直观体会到批量检测的存在意义。这种“改一参数看现象”的练习,比背一百遍理论都管用。
4. 新手最容易踩的坑:现象、原因、修法
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 子弹穿过敌人 | 单帧位移太大,离散采样漏检 | 拆分步长、限制最大速度、轨迹检测 |
| 物体撞墙后疯狂抖动 | 只改了速度方向没改位置 | 先做位置修正,再做速度反弹 |
| 透明贴图乱撞 | 用矩形近似不规则图形 | 用Mask做精细检测 |
| 小球越弹越飘、坐标掉精度 | 用Rect存浮点坐标 | float保存真实坐标,Rect做临时快照 |
| 同帧多个碰撞结果不稳定 | 处理碰撞时修改了列表 | 先收集全部结果再统一处理 |
4.1 高速物体直接穿透
穿透的本质就是离散采样漏掉了中间过程。假设子弹每帧移动10像素,而敌人的宽度只有6像素,前一帧子弹在敌人左边,后一帧直接跳到了右边,连续两次检测都没碰到,子弹就“飞过去了”。
最简单粗暴的修法是拆步长:把一帧内的一次大位移拆成几个小位移,每走一小步就检测一次。比如速度10,拆成5步,每步只移动2像素,穿透概率就大大降低。代价是检测次数成倍增加,但这个方案在小游戏里非常好用,代码改动也小。
另一个思路是限制游戏内物体的最大速度,保证单帧位移不超过碰撞体最短边的一半。这其实是在设计阶段就规避问题,很多商业游戏对子弹速度都有限制,不是物理模拟不了,而是不值得为极端速度付出性能代价。如果你做的是狙击枪那种超高速子弹,还可以考虑用Rect.clipline做线段轨迹检测,把上一帧位置和当前帧位置连成一条线段,判断线段是否穿过碰撞矩形,这就是连续碰撞检测思想的简化应用。
4.2 撞完之后疯狂抖动或粘墙
这个问题在我见过的项目里出现频率极高。很多新手写碰撞响应的第一反应是:检测到碰撞了,把速度向量取个反。下一帧物体继续朝新方向移动,但因为它上一帧已经有一小部分嵌进了墙里,这帧还是重叠,于是再反转。结果就是物体在墙边来回抽搐、发出蜂鸣一样的抖动,视觉效果非常糟糕。
正确的碰撞响应必须分两步走:第一步是位置修正,把物体沿最小重叠方向推出来,确保物体不再重叠;第二步才是速度反弹,把对应轴的速度方向反转。拿角色跳上平台来举例,如果这一帧角色脚部深入平台3像素,就先把这个3像素的Y坐标抬回去,再判断落地反弹。大多数刚接触碰撞的新手都只做了第二步,所以抖动问题几乎人人都会碰到一次。
4.3 透明贴图到处乱撞
用AABB去包一个圆形或者异形角色,视觉上最直接的后果就是明明人物的披风旁边全是透明像素,子弹还是被“挡”住了。玩家对这种现象很敏感,因为它破坏了“看起来应该是镂空的却能穿过”的直觉。
解决办法就是用Mask做碰撞,但不要无脑全局用。我的建议是维护两层检测:先对一个宽松Rect做快速粗判,两个Rect都不交就直接放行;一旦粗判通过,再用两个Mask做overlap精判。这样既能拿到不规则形状的真实碰撞轮廓,又不至于让逐像素扫描拖垮游戏。在Pygame里,spritecollide的collided参数直接传pygame.sprite.collide_mask,就可以把Sprite的检测逻辑整体切换成像素级。
还有一个小提醒:Mask是在加载图片时生成的,不要在每帧的update里反复调用pygame.mask.from_surface。那相当于每帧重新扫描一遍整张图片的alpha通道,性能开销巨大。正确做法是在加载阶段预生成Mask,之后每帧只用它。
4.4 一改分辨率就全乱:Rect的整数陷阱
很多人在窗口模式800x600下跑得好好的,一改成全屏就出现大量的碰撞错位和物体抖动,第一反应是分辨率代码写错了,但真正的问题往往藏在Rect的整数属性里。
Pygame的Rect一旦接收浮点数就会截断,当你把游戏的分辨率扩大,坐标值变大,而物体的移动速度依然用的是小数值,比如1.5,Rect内部会不断丢掉小数部分,导致实际移动距离和设计值不一致。小球每帧积累一点误差,弹跳轨迹就会越来越歪。
最稳妥的做法是:逻辑坐标里的位置全部用float变量独立保存,Rect只用于检测和绘制这两个“需要整数快照”的瞬间。另外,分辨率适配最好不要直接改逻辑坐标大小,而是用一个统一的缩放比例,把逻辑坐标映射到窗口坐标。逻辑坐标保持稳定,碰撞检测就不会因为分辨率切换而乱套。
4.5 同帧多物体碰撞结果飘忽
还有一种特别隐蔽的问题:同一帧内物体同时碰上了好几个目标,结果会因为处理顺序不同而给出完全不同的表现。我见过一个角色同时踩到两块平台,代码里先处理了左边那块,把角色弹了起来,等处理右边那块时,因为位置已经被改过了,检测状态全变了,角色一下子被斜着弹飞。
解决这类问题的思路是“先检测,后处理”。先把这一帧内所有碰撞对收集到一个列表中,比如用collideobjectsall或者手动遍历收集,然后统一处理。处理时要注意:位置修正可能会让某些原本的碰撞对变得不成立,所以要按优先级排序,比如玩家和地面的碰撞优先于玩家和敌人的碰撞;或者把碰撞响应设计成幂等,重复处理同一个碰撞对不会产生副作用。这类问题的排查思路,本质上是在问你:你的“检测阶段”和“处理阶段”是不是完全分离了,如果混在一起,出bug只是时间问题。
5. 性能优化:从O(n²)到接近实时
5.1 O(n²)的代价:一算吓一跳
碰撞检测的朴素写法永远是双重循环:每个物体和每个其他物体都做一次检测。如果场景里有500个物体,每帧要做差不多12.5万次两两比较;1000个物体就是接近50万次。注意这是每帧,按60帧每秒算,一秒钟就是3000万次比较。
单纯比较Rect的colliderect其实非常便宜,也就是几次整数比较,但问题在于你的碰撞响应代码通常不只是一个if。你要判断命中类型、调用回调、播放特效、更新状态、处理死亡掉落……这些逻辑加起来成本就上去了。一旦场景里物体数量过千,即使不做任何Mask检测,也会开始明显卡顿。优化碰撞检测,本质上是降低“无意义的两两比较次数”。
5.2 空间网格:入门性价比最高的优化
空间网格是我最推荐小游戏使用的碰撞加速方案,原因很简单:实现一个函数就够了,而且不用改变现有的碰撞体数据结构。核心思想是把游戏世界切成固定大小的格子,每帧把物体登记到它们所在的格子里,检测时只检查同一格子和相邻格子里的物体,远处格子的物体直接跳过。
伪代码的思路长这样:
python复制def compute_keys(rect, cell_size):
gx0, gy0 = rect.left // cell_size, rect.top // cell_size
gx1, gy1 = rect.right // cell_size, rect.bottom // cell_size
for gx in range(gx0, gx1 + 1):
for gy in range(gy0, gy1 + 1):
yield gx, gy
cell_size的选择有讲究。太小,物体占据多个格子,更新和遍历的开销上升;太大,每个格子里塞太多东西,退化成弱化版的全量比较。经验值一般取物体最大尺寸的1.5到2倍。比如砖块游戏里最大物体是挡板或砖块,宽度60到110像素,那cell_size取128左右就比较合适。格子大小不唯一,自己跑一下测试数据看看就行。
网格的优势在于,500个物体如果分布在10x10的网格里,平均每个格子只有5个物体,检测次数从12.5万次直接掉到几千次级别,性能提升是肉眼可见的数量级。
5.3 Mask预计算与更专业的加速方向
Mask的逐像素碰撞比矩形慢得多,所以针对Mask的优化主要围绕两点:一是预计算,二是在重叠像素上做文章。预计算是指在加载Sprite时一次性生成Mask,存入对象属性反复使用。重叠检测时,可以先判断两个Sprite的Rect是否相交,再把检测范围限定在两个Mask的重叠区域,用overlap_area或者overlap_mask可以拿到重叠面积和重叠形状。如果只是想知道“碰没碰”,用overlap就够了,别去算面积,虽然overlap_area能给出更丰富的碰撞反馈,但它开销更大。
再往深走一步,四叉树适合物体分布很不均匀的场景,比如地图上一半物体挤在一个角落,另一半分散在广阔区域。四叉树会把空间递归四等分,只对同一层格子里的物体做检测,查询效率比均匀网格更高,但插入和删除节点要维护树的平衡,实现复杂度也高一个等级。如果你只是在小游戏阶段,强烈不建议上四叉树,先把空间网格用明白,之后再考虑要不要升级。
如果游戏需要的不是检测,而是完整的物理模拟,比如刚体运动、碰撞冲量、摩擦力、关节连接,那就不该继续手写碰撞了。直接上Pymunk这类物理引擎,人家内部已经实现了空间哈希、连续碰撞检测、接触求解这些全套机制,你只需要定义物体的形状属性和回调函数。自己手写一套物理引擎不是不行,是很久以后的事。
6. 写在最后:几个实战经验
开发阶段就打开“碰撞体可视化”,这是我踩了无数次坑之后形成的最强习惯。在Debug模式下把每个物体的Rect、圆形范围和Mask轮廓直接画到屏幕上,很多所谓灵异碰撞一眼就能看出来,其实是你画给他的Rect比贴图大了一圈,或者圆心坐标算错了。可视化调试成本极低,收益却极高。
第二,把碰撞检测代码和业务逻辑代码分开。不要在一个主循环里堆一百个if,先写一个碰撞管理器统一处理,让“检测到碰撞”和“碰撞后做什么”两个事情彼此独立。后面想加新的碰撞对象,或者调整碰撞规则,都不需要把整个主循环推翻。
第三,不要过早优化。先把AABB和合理的速度调参做好,90%的游戏都已经够用了。等真的出现性能瓶颈,再用空间网格和Mask也不迟。很多初学者一上来就手搓Mask检测,结果游戏里只有五个物体,反而把自己绕晕进去。
如果你正被某个碰撞问题折磨,我建议先把视线从代码上移开,拿笔画一张图:把物体这一帧的移动轨迹画成一条线段,把碰撞体画成线框,绝大多数诡异问题,图纸上看一眼就能明白是怎么回事。经验就是在这种一次次“画图、拆解、验证”里攒出来的。希望这篇内容能让你少走几步弯路。
