恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Python游戏碰撞检测全攻略:从几何原理到性能优化
首页
资讯中心
/
Python游戏碰撞检测全攻略:从几何原理到性能优化
Python游戏碰撞检测全攻略:从几何原理到性能优化
发布时间:2026/10/11 16:08:02
写Python游戏只要涉及精灵移动几乎绕不开“Python游戏中的碰撞检测实现”这个坎。我最早接触的时候以为这就是个if判断两个矩形叠没重叠返回True或False。但真正把一个接一个的碰撞需求堆进游戏里才发现这里面既有几何数学的硬功夫也有性能调优的软技巧。这篇东西想讲清楚的就是碰撞检测从“能判断”到“判断得准”再到“判断得快”的全过程。无论你是在做一个平台跳跃、弹球清版还是简单的鼠标点击小游戏理解碰撞检测的底层原理都会让你的调试和优化省下大把时间。这个课题适合两类人一类是刚接触pygame或pyglet的朋友需要一套能直接抄的碰撞逻辑另一类是已经写了几个小游戏正被“物体偶尔穿过去”“碰撞时灵时不灵”这类问题折磨的开发者。下文我会从几何原理、手写实现、性能优化、响应处理到常见坑位逐一展开尽量让每个知识点都能直接落到你的代码里。1. 碰撞检测的底层逻辑这不是物理题而是几何题1.1 碰撞本质是两个几何图形的相交判断游戏里的“碰撞”说穿了就是判断两个几何图形是否存在重叠区域。这个判断完全不关心物体材质、重量、速度也不关心它们之后会怎样运动。它只回答一个朴素的问题此刻这两个形状有没有相交。很多新手会把碰撞检测和物理引擎混为一谈其实它们完全是两层。检测负责“发现了撞”响应才负责“撞了之后怎么动”。如果一开始就把这两件事混在一个函数里写代码很快会变成一团乱麻改手感的时候更是无从下手。我见过不少小白项目角色撞到墙之后会卡在墙里疯狂抖动问题就出在检测之后没有把位置修正出来而是直接反向加速度结果下一帧又穿回去来回拉扯。所以先形成一个习惯碰撞检测只返回“撞了/没撞”最好还能附带一点方向信息比如法线方向或者重叠深度至于下一步怎么处理交给另外一套逻辑。这样你的代码既好调试也方便以后换方案。1.2 三种最常用的几何模型长什么样在2D游戏里绝大多数碰撞检测都建立在三种基本模型上。第一种是AABB也就是轴对齐包围盒。它表示为一个矩形四边分别平行于x轴和y轴。因为不需要处理旋转判断逻辑极其简单只需要比较坐标和宽高。这是最主流、最快速的方式pygame里的Rect就是典型的AABB。第二种是圆形碰撞体。它用一个圆心坐标加一个半径来表示判断方法是计算两个圆心之间的距离再和半径之和比较。圆形碰撞体的好处是各向同性无论哪个方向撞上来都一样很适合做球类、粒子甚至角色的粗略碰撞。第三种是像素级碰撞掩码。它把每个精灵图像上所有不透明的像素点变成一个bit数组然后逐像素比较两个掩码是否重叠。这是最精确的方式特别适合形状极不规则的物体比如从图片素材里抠出来的一条云、一把剑。但它也是最慢的不适合大量使用。实际游戏里很少只用一种模型。比如平台跳跃游戏角色主体用AABB做地面碰撞捡金币时用圆形做拾取判定特殊技能发动时再用像素掩码。模型选得合适效率能差好几倍。1.3 为什么游戏引擎总要单独做碰撞检测层物理引擎固然好用但它的代价也很明显。一个完整物理引擎通常要处理碰撞检测、碰撞响应、约束求解、摩擦力、阻尼等一系列问题每帧计算量很大而且它的碰撞响应往往带有强烈的物理惯性。对一个操作手感要求很高的游戏来说物理惯性反而碍事。平台跳跃里的角色应该立刻起跳、瞬时转向而不是像现实中的纸箱子一样带着惯性滑来滑去。所以多数2D游戏需要的不是物理模拟而是一套可以自定义判定规则、响应方式极度可控的碰撞层。这也是为什么很多游戏引擎会把碰撞检测做成独立模块。你可以先用AABB快速排除大部分不相交的对象再对剩余候选对象做更精细的检测这样既保证准确性又不会让性能随着对象数量飙涨。2. 从零手写三种能用上的碰撞实现2.1 AABB矩形成交判断四个条件的“与”运算AABB碰撞判断的原理非常直观两个轴对齐矩形A和B只要在水平方向上有重叠同时在垂直方向上也有重叠就一定相交。反过来只要水平或者垂直任意一个方向没重叠就一定没撞。用pygame.Rect举例判断代码如下def check_aabb(rect1, rect2): return ( rect1.x rect2.x rect2.width and rect1.x rect1.width rect2.x and rect1.y rect2.y rect2.height and rect1.y rect1.height rect2.y )看起来就是四个比较大小但里面有两个容易被忽略的细节。第一pygame的坐标系原点在左上角y轴向下增长。这意味着“rect1.y rect1.height”是矩形的下边界而不是数学课上习惯的上边界。方向一搞反平台跳跃里的落地判定会出错。第二边界到底算不算撞上面的代码用的是严格大于小于意味着两个矩形刚好“贴边”时不视为碰撞。但有些游戏里玩家踩到平台边缘那一瞬间就应该触发事件所以实际项目中我经常用“”和“”围住条件。记住边界是否算碰撞是个游戏性决定不是数学上的真理这正是需要自己控制的地方。举个实际例子。假设玩家矩形A在(100, 200)宽50高80金币矩形B在(150, 190)宽30高30。代进四个条件100 150 30 180成立100 50 150 150严格大于时刚好不成立说明二者右边界与金币左边界重合若用严格大于就判定未碰撞200 190 30 220成立200 80 280 190成立。所以严格模式下这帧不碰撞但如果你用的是宽容模式它就会立刻碰撞。这类差异在实际调试中会造成完全不同的表现。2.2 圆形碰撞用平方距离替代开根号圆形碰撞的数学定义很简洁两个圆只要圆心距离小于等于半径之和就算碰撞。直接按定义写会用到平方根函数sqrt但平方根计算在游戏循环里反复执行是很昂贵的。更聪明的做法是比较距离的平方和半径之和的平方这样值等价却省掉了开根号。def check_circle(c1, c2): # c1和c2分别是 (x, y, radius) dx c1[0] - c2[0] dy c1[1] - c2[1] distance_sq dx * dx dy * dy radius_sum c1[2] c2[2] return distance_sq radius_sum * radius_sum圆形碰撞经常被用来替代AABB做“更柔软的”拾取判定尤其当一个物体形状接近圆时。比如弹球游戏球与挡板、球与砖块都能用圆。这里有一个常见的困惑圆形精灵的贴图本身是方形的为什么碰撞还要用圆其实很多游戏里判定模型和视觉模型是分开的视觉上画一个方形贴图碰撞上却用内切圆能明显减少“明明没碰到却被弹开”的违和感。我把这个函数用在某个模拟项目X的弹球Demo里600多个小球每帧做两两检测使用平方距离优化之后帧率比直接开根号稳了不少。节省的不是一个量级的性能而是让代码在未来扩展更多对象时更有底气。2.3 像素级碰撞用pygame.mask拿到最准确的判定AABB和圆形都很“粗线条”一旦物体形状不规则就会出现明明视觉上隔着空气却被判定为碰撞的尴尬。想要精确可以使用像素掩码。pygame里实现这层逻辑主要靠pygame.mask模块。它会根据surface的alpha通道生成一个掩码不透明的像素在掩码中记为1透明的记为0。判断两个掩码是否重叠调用overlap方法mask1 pygame.mask.from_surface(player_surf) mask2 pygame.mask.from_surface(coin_surf) offset (coin_x - player_x, coin_y - player_y) hit mask1.overlap(mask2, offset)这里的offset是最容易写错的坑。mask1和mask2各自并不知道自己左上角在屏幕的哪个位置计算重叠时需要告知“它们两者之间的相对偏移”。我习惯计算方式是用目标对象的坐标减去当前对象的坐标offset (target_x - source_x, target_y - source_y)如果offset传反或者忘记计算坐标差就会出现“物体隔着半个屏幕也碰撞”的诡异现象。另外overlap会返回第一组重叠像素的坐标如果返回None就表示没有碰撞。需要注意的是每次对surface做旋转或者缩放原始掩码就失效了必须重新从新的surface生成掩码。做旋转动画时如果频繁调用from_surface性能会成为很大问题后面性能章节会细说。像素级碰撞也不是万能的。它精度高但开销大而且它只能判断“是否重叠”无法直接告诉你“从哪个方向撞击”。所以在大项目里像素碰撞通常只用于最终确认前期筛选依旧用AABB或圆形。比如先用AABB判断两个精灵的矩形包围盒是否重叠如果矩形都不重叠就直接返回False只有重叠了才进一步对掩码做精确匹配这种两阶段策略非常实用。3. 别拖累帧率空间分区、批量检测与数学优化3.1 两两检测的复杂度陷阱最直观的碰撞检测办法就是让所有物体两两配对两两判断。算法上来讲n个物体的两两比较次数是n*(n-1)/2复杂度O(n^2)。听起来不多但算一下就明白了。100个物体每帧需要4950次判断1000个物体直接飙到499500次。如果在每帧里还叠加多个层级的检测矩形粗筛、圆形细筛、像素确认这么大的调用量足够让你的游戏变成幻灯片。我见过一个自制塔防项目发射的子弹加上怪物加上炮塔屏幕上常驻300多个对象用最朴素的两两循环主循环直接掉到20帧。问题不是出在某一次碰撞计算本身而是计算量太多每帧都在重复做无用功。真正靠谱的思路是“先筛后精”先用一种廉价的方式快速删掉一大拨不可能碰撞的对象再对剩下的少量候选者做精确检测。空间分区就是一直以来的通用解法之一。3.2 空间网格把世界切成小格子空间网格的思路很朴素把整个游戏世界按固定尺寸划分成许多小格子每个物体根据它的位置放入一个或多个格子中。检测碰撞时只需要检查同一格子或相邻格子里的物体跟远处的物体完全绝缘。实现时可以维护一个字典键是格子坐标值是这个格子里的物体列表grid {} cell_size 64 def register_object(obj): left obj.x // cell_size top obj.y // cell_size right (obj.x obj.w) // cell_size bottom (obj.y obj.h) // cell_size for gx in range(left, right 1): for gy in range(top, bottom 1): grid.setdefault((gx, gy), []).append(obj)碰撞时遍历所有非空格子对格子里的物体两两检测。为了避免同一个配对被重复检测多次可以约定只比较当前格子内的物体但要注意一个物体跨多个格子时难免重复。如果物体很大而格子很小一个物体会塞进很多格子效率反而降低。这时候要么调大格子尺寸要么干脆换个更高级的结构如四叉树。网格尺寸的选择是个经验活。格子太大每个格子里的物体太多筛选效果变差格子太小物体跨格子个数增加重复检测飞涨。我的经验是让格子尺寸大致等于屏幕中常见精灵平均宽度在这个基础上微调效果一般都不错。3.3 只测该测的静态物体、分层与检测频率空间分区解决的是“横向”减少检测对象的问题但还有几个比它更简单、更便宜的前提条件。第一静态物体根本不参与移动碰撞检测。地板、墙壁、栏杆这类场景物体大致不动天然不用和彼此比较。把物体分为静态和动态两类动态物体会和静态物体碰撞但静态物体之间完全不检测往往直接砍掉一大半计算量。第二分层检测。玩家子弹只需要打敌人和墙壁不需要检测玩家自身玩家碰撞金币但金币不会和敌人碰撞。给物体打上layer属性在配对前快速检查层关系不匹配的直接跳过。第三控制检测频率。并非所有碰撞都必须每帧检测。比如拾取金币这种不太精细的游戏事件每隔一帧检测一次完全没感觉。反倒是那种高速移动且需要精准判定位置的物体才需要每帧都做。我习惯把碰撞检测本身抽成一个接口具体频率由物体属性控制慢速物体挂check_every_n_frames 2高速物体挂check_every_n_frames 1。3.4 把数学抠到极致除了减少检测次数碰撞计算本身也有优化空间。除了前面提到的避免开平方还有几个常被忽略的点。用局部变量缓存属性。在Python里obj.x这种属性访问的开销大于局部变量。碰撞检测函数一开始先把坐标、宽高、半径都取到局部变量里几百次循环下来能感觉到明显差异。我见过一个把碰撞循环里所有self.player.x改成局部变量px的项目帧率直接回升了8%左右。使用整数坐标。pygame的Rect对宽高和坐标做了取整天然偏向使用整数。如果你的物体位置是浮点数每次塞进Rect时都会取整过小的浮点误差在连续帧里积累起来就可能导致碰撞边界“偶尔差一像素”。如果能用整数或者适当的定点数表示坐标检测逻辑会更稳。最后不要在一个对象数组里频繁append和remove。碰撞检测过程中如果修改列表内容容易跳过或重复检测。先把该处理的碰撞记录下来统一在碰撞检测循环结束后再处理响应这样既安全又容易调试。4. 撞上之后怎么办碰撞响应与手感4.1 从检测到响应的基础框架碰撞检测返回了“撞了”之后游戏就该决定“接下来怎样”。最简单的响应是反弹检测到球碰到挡板后把球的y方向速度取反并乘上一个弹性系数。if ball_rect.colliderect(paddle_rect): ball.vy -ball.vy * 0.98 ball.vx paddle_velocity_x * 0.1但直接这样做会引入抖动问题。如果球在下一帧仍然和挡板重叠速度取反后理论上应该离开但实际上一帧的穿透深度可能已经让球卡进挡板内部下一帧又反弹回来最终表现为高频振动。所以更稳妥的做法是在检测到碰撞后先把物体位置“推出”重叠区域再改变速度。推出重叠区域的核心是计算最小平移向量MTV也就是找一个能让两个物体完全分离的最小位移。对AABB矩形而言分别计算水平重叠量和垂直重叠量哪个更小就往哪个方向推。这比简单反转速度准确得多也不容易出现卡墙抖动的毛病。overlap_x min(r1.x r1.w, r2.x r2.w) - max(r1.x, r2.x) overlap_y min(r1.y r1.h, r2.y r2.h) - max(r1.y, r2.y) if overlap_x overlap_y: if r1.x r2.x: r1.x overlap_x else: r1.x - overlap_x else: if r1.y r2.y: r1.y overlap_y else: r1.y - overlap_y实际游戏里MTV的计算会因为物体数量、层级不同变得复杂但原理就是这个。你只要在代码里能清晰写出重叠量与方向后续所有响应逻辑都有了一个稳定的基础。4.2 平台跳跃里最特殊的碰撞单向平台平台跳跃游戏里有一个非常特殊的碰撞对象单向平台。角色可以从下方直接跳上去但不能从上方穿下来也不能从左右侧面卡进平台。这种碰撞的控制逻辑正好体现了“碰撞响应不是单纯数学问题而是游戏设计问题”。实现单向平台最经典的方法是只让从上方落下的物体触发碰撞。具体来说在检测玩家和平台碰撞时额外判断两个条件玩家当前的底部坐标是否落在平台顶部之下以及玩家的垂直速度是否向下。def check_one_way_platform(player_rect, platform_rect): if player_rect.bottom platform_rect.top and player_rect.bottom platform_rect.top 20: if player_rect.x player_rect.w platform_rect.x and player_rect.x platform_rect.x platform_rect.w: if player_vy 0: player_rect.bottom platform_rect.top player_vy 0 return True return False这里的20像素是一个容忍区间用来缓冲玩家上一帧还没到位、这一帧突然掉下来一点的情况。这个值并不一定要精确但太大会导致角色脚还悬在半空就判定落地太小又会出现快速下落时直接穿过平台的问题。这种“手感参数”只能在实测里调理论算不出最佳值我一般从角色高度的十分之一开始试。4.3 高速移动物体的“隧穿”问题碰撞检测的另一个经典难题是高速物体穿墙而过。这是因为离散检测只在每个时间点采样一次如果物体在一帧内的位移超过了它自身的尺寸那么就可能跨过另一个物体所在的区域直接从一侧“隧穿”到另一侧。比如一颗速度600像素每秒的子弹在60帧每秒的游戏中每帧前进10像素。如果墙面厚度小于10像素子弹就有概率从墙的一边直接瞬移到另一边完全没触发碰撞。解决隧穿有几种思路。最直接的是把速度限制在一个合理范围让单帧位移不超过碰撞体厚度。但这限制了游戏的可能性极高速子弹就做不出来。更通用的方法是做连续碰撞检测说白了就是“沿着运动轨迹扫过去”。可以用上一帧位置和目标位置连线作为一条射线检查这条射线是否穿过任何物体。虽然实现复杂度高一些但对射击类游戏几乎是必需的。还有一种折中方案是细分时间步把一帧拆成几个小步每步都做一次碰撞检测。这种方法简单粗暴但计算成本随之上涨用之前一定要测量。5. 用现成工具还是自己造pygame与第三方方案5.1 pygame内置的碰撞接口pygame自身提供了不少现成碰撞函数日常开发完全可以先借用不必事事手写。最常用的是pygame.sprite模块里的spritecollidehit_list pygame.sprite.spritecollide(player, enemies, True)第三个参数表示检测到碰撞后是否将对方从精灵组中移除用来做子弹命中敌人后同时删除双方会非常方便。此外还有groupcollide用于两个精灵组之间找配对collide_rect、collide_circle、collide_mask分别对应AABB、圆形和像素掩码检测。你可以把检测函数作为参数传给spritecollide这样能在不重写结构的情况下切换碰撞精度。使用内置接口最大的好处是省心不用自己维护对象列表。但要注意spritecollide默认会把精灵的rect取出来做AABB检测如果精灵列表很长响应速度也不是特别理想。对几十个精灵的小游戏来说没问题上百个后建议自己做空间分区。5.2 什么时候该自己写碰撞逻辑内置接口解决了“能用”的问题但不代表所有场景都该用它。首先内置的spritecollide往往绑定在sprite和group的抽象上如果你不想用sprite类而是用普通对象存坐标反而会觉得别扭。其次当碰撞对象数量大、种类多或者需要自定义碰撞层时内置接口的灵活性就不够了。我在做模拟项目X的时候需要一种“踩到平台顶才算落地但从侧面擦过不算”的碰撞spritecollide没法直接做到最后还是自己写了一套基于空间网格的碰撞系统。自己写还有个好处就是能完全掌控检测顺序和处理时机不用担心Sprite在group里的内部顺序影响到碰撞结果。判断标准其实很简单如果只是做原型、小游戏直接上内置接口如果游戏对象数量动不动上百或者对碰撞手感有特殊要求就值得花时间自研。市面上很多独立游戏跑得飞快并不是因为用了多复杂的物理库而是他们的碰撞代码精简到了极致。5.3 选库之前想清楚的事情如果你还没锁定库想从零选一个适合做2D游戏的Python方案我建议这样判断。pygame是最稳的选择资料最多Rect、Sprite体系很成熟适合绝大多数个人项目。pyglet更轻量对OpenGL支持更好但碰撞相关功能需要自己搭。arcade和pgzero在pygame基础上封装得更好开箱即用适合兴趣开发。真正要做复杂物理交互时可以考虑Pymunk或Box2D这类2D物理引擎但记住引入物理引擎意味着你要接受它自带的一整套碰撞响应规则简简单单“做个弹球”很可能反而更麻烦。选库不是选最强大的而是选最匹配你需求的。我对纯小游戏的建议是先用pygame自己手写基础碰撞逻辑跑通整个项目确认性能瓶颈在哪再决定是否引入更重的依赖。过早优化和过早堆依赖都会让项目变得很难维护。6. 我在实际项目里踩过的四个坑6.1 坐标系混乱导致的“隔空碰撞”用pygame时它的坐标系原点在窗口左上角x向右增大y向下增大。这个方向跟数学课上的坐标系不一样很多人一开始把y方向写反结果角色明明在屏幕上方却跟下方金币发生了碰撞。排查这种问题最好的办法是给碰撞区域画一个半透明的调试矩形用pygame.draw.rect把每个碰撞体可视化了。看到调试框位置不对坐标系统的问题一眼就清楚。6.2 旋转精灵的碰撞框对不上很多人在给精灵做旋转动画后依然沿用旋转前生成的rect作为碰撞框。旋转后的图片实际占用的矩形空间变了但碰撞框还留在原来的位置和大小上自然会出现“子弹明明打到图像边缘却没触发碰撞”的情况。解决办法是每次旋转后都重新根据新图片的尺寸生成rect或者改用圆形碰撞体、像素掩码。另外如果使用pygame.mask也必须在旋转后重新from_surface旧掩码完全不适用。6.3 mask的offset算反像素碰撞里我把offset算错过至少有三次。这个参数必须语义明确你在对mask1调用overlap时第二个mask相对于第一个mask左上角的偏移是多少。简记法就是offset target_pos - source_pos。如果两个精灵都具备全局坐标这个减法几乎没有歧义。我建议把像素碰撞封装成一个函数只接收两个精灵对象和它们的位置内部统一计算offset避免每一处调用都重新算一遍。6.4 优化暴增的帧数骤降当游戏对象数量从几十个涨到几百个时如果不做空间分区帧率就开始明显下降。这通常不是某一帧卡顿而是每帧总计算量已经超出了CPU能承受的范围。碰到这个问题先打日志统计每帧碰撞检测的总调用次数和耗时别靠猜。定位到热函数以后再用空间网格、层过滤和精准掩码逐层优化。多数情况下单纯把两两检测改成“格子内检测”就能恢复流畅。为了以后复盘方便我把以前遇到过的状况整理成了速查表表现可能原因解决办法物体接触但判定为不碰撞边界条件用了严格比较改用/或在检测函数增加边界容差物体卡进墙壁后抖动只反转速度没有推出重叠区域先计算MTV并移动位置再改速度高速子弹穿过墙体单帧位移过大离散检测采样不够细分步长或使用连续检测射线旋转后碰撞位置不对碰撞框没随精灵旋转更新旋转后重新生成rect或mask碰撞检测太慢两两检测没有空间分区引入空间网格或分层过滤这张表覆盖了我见过的八成碰撞问题。真遇到没见过的先把“能不能复现”搞明白写一个最小测试用例往往很快就能定位到根因。最后再分享一个实战里特别管用的习惯。我项目里所有碰撞结果不会立刻去修改对象状态而是统一收集到一个事件队列里等碰撞检测这一轮全部跑完之后再逐条处理。这样做的好处非常多最直接的体验是调试时能清楚看到每帧到底发生了多少起碰撞、位置关系如何而不是面对一堆已经互相作用完的对象无从下手。写多的时候你甚至会感激这个小小的抽象它让“碰撞检测”和“碰撞响应”之间的边界无比清晰后续加新玩法也只是往队列里塞一种新事件而已。