首页 / 资讯中心 / 文章详情

pygame背景mask碰撞检测:用颜色分层优化静态障碍性能

pygame背景mask碰撞检测:用颜色分层优化静态障碍性能 ★ FEATURED ARTICLE
年初我在折腾一个 pygame 障碍游戏原型障碍物从几个慢慢涨到几百个每帧都要把它们全部塞进碰撞检测里去跑帧率肉眼可见地往下掉。后来某开发者跟我说反正都是静态障碍不如把障碍直接画在背景里用 mask 侦测背景中的障碍再用颜色区分不同障碍。一开始我还觉得这有点离经叛道跑起来才发现这个思路不仅代码量小碰撞结果也干净利落。这篇文章就把这套方法完整拆开讲清楚怎么把障碍物当作背景像素画进去怎么用pygame.mask从背景中精准读取出障碍区域以及用颜色给不同障碍分层后如何做差异化响应。适合在做迷宫、地图关卡、塔防或者任何“场景本身就是一大张图”的朋友哪怕你刚开始学 pygame只要照着代码走一遍也能理解这种基于像素的碰撞架构。1. 为什么要把障碍直接画进背景里——两种碰撞架构的取舍聊具体代码之前先花点时间说清楚这个方案的来源。很多人一提到碰撞第一反应就是“每个物体一个 Sprite、一套 rect、一个 mask”这当然没错但要看场景。我那个原型里地图是手工摆放的墙块、栅栏、毒液池、终点门混在一起静态障碍的数量很快就上去了然后我遇到了三个实际问题。1.1 纯精灵方案的瓶颈用精灵管理障碍麻烦主要来自三方面管理成本高。几百个静态障碍物全都得放进Group每帧都要遍历做colliderect或者 mask 碰撞。哪怕它们从生成到销毁一帧都没动过检测一个都少不了。检测复杂度线性增长。每个角色每帧要跟 N 个障碍做一次检测N 越大越慢。雪上加霜的是如果用了Spritemask组合碰撞前还要额外生成/维护 mask内存和 CPU 都吃亏。美术资产不匹配。很多游戏的地图本来就是一整张手工绘制的背景图墙壁、地形细节是图像的一部分你不可能把每个色块都单独抠成精灵对象那样做跟美术工作流完全脱节。这套方案在“动态物体少、静态障碍多”的项目里属于典型的用错了架构。1.2 背景 mask 方案适合什么场景反过来想如果障碍物永远不变它本质上就是背景信息和墙上的一幅画没有任何区别。于是可以把障碍直接blit或用pygame.draw画到背景 Surface 上初始化时从背景 Surface 提取一张 mask运行时只需要把“角色 mask”和“背景 mask”做一次overlap结果就是碰撞点。这一步非常关键背景的 mask 只需要生成一次之后每一帧的碰撞检测都是一次 C 级别的位运算复杂度只取决于角色和背景的重叠区域大小而不是背景的像素总量。这种方案最适合的场景就是静态地形、整图关卡、像素风地图。它不太适合做动态障碍比如会开关的门、移动平台、可破坏墙体这类还是得留给精灵管理。我的建议是两者结合背景用 mask 地层动态实体用精灵 mask后面我会专门讲这个混合架构。2. pygame.mask 是怎么工作的从像素到二进制碰撞地图要用好这套方案得先吃透 mask 本身。别把它想得多高深它本质上就是一张“布尔值地图”每个像素要么是True有实体要么是False空。底层用位打包存储一个像素只占一个 bit整张 800×600 的 mask 也就 60KB 左右小得离谱。2.1 mask 的结构与关键方法pygame 的 mask 模块最核心的用法就三个pygame.mask.from_surface(surface)根据一个 Surface 的 alpha 通道生成 mask。alpha 大于 0 的像素会被标记为 True完全透明的像素是 False。pygame.mask.from_threshold(surface, color, threshold)把颜色接近color的像素挑出来生成 mask。这是后文“用颜色区分障碍”的基础。Mask.overlap(othermask, offset)判断两张 mask 在给定偏移下是否重叠有重叠返回第一个重叠点的(x, y)没重叠返回None。顺带提一句如果角色本身是一张贴图生成 mask 的正确姿势是from_surface(角色_surface)。这时候有个经典坑如果角色 Surface 虽然是透明背景但你不小心把所有像素都填满了颜色那生成的 mask 就会变成一个整块矩形碰撞范围远超角色实际外形。我当年就在一个圆形角色身上吃过这亏碰撞判定四四方方的走廊边缘看起来像被空气挡住。2.2 为什么 overlap 很快别自己吓自己很多人看到“背景 mask 有 800×600 像素”第一反应是每次 overlap 都要扫描全图肯定慢。实际上overlap的实现在 C 层只计算两个 mask 的真实交叠矩形比如角色 32×32 站在背景某处overlap 实际扫描的范围基本就是那 32×32 一小块然后按位去与运算一次能处理几十个 bit。所以“背景 mask 很大”并不是性能问题。真正的问题反而是如果你为了省内存把背景切成小块或者每帧重新from_threshold裁剪局部区域那才是把简单问题复杂化了。3. 用颜色给背景障碍分语义层标题里“用颜色区分不同障碍”这句话值得展开说。很多教程教的是把所有碰撞物体混在一张 mask 里只做“挡不挡路”的检测。但真实游戏里不同障碍的逻辑完全不同墙拦路岩浆扣血终点门触发通关。如果都混在一个 mask 里你还得另外维护一个“这个点是什么类型”的查询逻辑麻烦。颜色方案说白了就是每种颜色约定一种碰撞语义然后为每种颜色分别生成一张 mask。这样碰撞检测的结果天然带语义不用额外查表。3.1 先定好调色板我习惯在项目开头把碰撞色集中声明WALL_COLOR (70, 130, 255, 255) # 墙挡路 LAVA_COLOR (255, 80, 60, 255) # 岩浆扣血 GOAL_COLOR (80, 255, 120, 255) # 终点通关这种做法有几层好处写代码时可读性强看到LAVA_COLOR就知道这地方要写伤害逻辑换关卡美术时只换颜色常量不需要改碰撞代码甚至可以让策划用画图软件直接涂背景把“地图即数据”贯彻到底。3.2 生成阶段一次扫描多层 mask有了颜色约定生成layers这个字典就行layers { wall: pygame.mask.from_threshold(collision_layer, WALL_COLOR, (8, 8, 8, 0)), lava: pygame.mask.from_threshold(collision_layer, LAVA_COLOR, (8, 8, 8, 0)), goal: pygame.mask.from_threshold(collision_layer, GOAL_COLOR, (8, 8, 8, 0)), }from_threshold会遍历整张 Surface 一次把颜色接近目标色的像素选中。由于它只在初始化时执行一次几百毫秒成本完全可以接受。而运行时的每帧检测只需要对每个图层分别做overlap一次 C 级运算开销极小。有一点要注意如果角色同时踩在墙和岩浆上你希望哪个优先这取决于你访问layers字典的顺序。我通常把“墙”放在最前面因为墙负责物理阻挡如果先处理岩浆角色可能已经被扣血了下一秒才被墙拦下来逻辑看着就会别扭。4. 跑通一个完整的小 demo理论说到底不如手写一遍。下面这个 demo 我尽量精简但保持完整可运行一张 800×600 的迷宫式背景有墙、岩浆池、终点门角色用方向键移动撞墙会停下碰到岩浆扣血碰到门则通关。4.1 准备背景把障碍画进图层关键点是分两个层一个碰撞层专门画碰撞色一个装饰层随便画美术内容。最后把碰撞层叠到装饰层上障碍物以“背景像素”的形式出现在最终画面里。import pygame pygame.init() SCREEN_SIZE (800, 600) screen pygame.display.set_mode(SCREEN_SIZE) clock pygame.time.Clock() WALL_COLOR (70, 130, 255, 255) LAVA_COLOR (255, 80, 60, 255) GOAL_COLOR (80, 255, 120, 255) # 碰撞层所有障碍直接画在这张 Surface 上 collision_layer pygame.Surface(SCREEN_SIZE) collision_layer.fill((30, 30, 40)) # 背景底色别和碰撞色接近 # 一圈墙 中间挡板 pygame.draw.rect(collision_layer, WALL_COLOR, (100, 100, 600, 20)) pygame.draw.rect(collision_layer, WALL_COLOR, (100, 480, 600, 20)) pygame.draw.rect(collision_layer, WALL_COLOR, (100, 100, 20, 400)) pygame.draw.rect(collision_layer, WALL_COLOR, (680, 100, 20, 400)) pygame.draw.rect(collision_layer, WALL_COLOR, (220, 200, 20, 200)) pygame.draw.rect(collision_layer, WALL_COLOR, (420, 150, 20, 300)) # 两个岩浆池 pygame.draw.rect(collision_layer, LAVA_COLOR, (300, 300, 120, 80)) pygame.draw.rect(collision_layer, LAVA_COLOR, (520, 220, 80, 80)) # 右下角的终点门 pygame.draw.rect(collision_layer, GOAL_COLOR, (680, 460, 80, 40)) # 装饰背景随意画格子反正不参与碰撞 background pygame.Surface(SCREEN_SIZE) background.fill((50, 50, 60)) for x in range(0, SCREEN_SIZE[0], 40): pygame.draw.line(background, (60, 60, 70), (x, 0), (x, SCREEN_SIZE[1])) for y in range(0, SCREEN_SIZE[1], 40): pygame.draw.line(background, (60, 60, 70), (0, y), (SCREEN_SIZE[0], y)) # 把碰撞层叠上去障碍物从此就是背景的一部分 background.blit(collision_layer, (0, 0))这样写有个隐藏优势如果你之后想换美术风格装饰层随便画只要最后把碰撞层叠上去就行。碰撞层的颜色约定保持不变碰撞系统完全不受影响。4.2 角色与碰撞检测角色的做法保持不变还是用一个带透明通道的 Surface从它生成角色 maskplayer_surf pygame.Surface((32, 32), pygame.SRCALPHA) points [(16, 2), (30, 30), (2, 30)] pygame.draw.polygon(player_surf, (255, 255, 255, 255), points) player_mask pygame.mask.from_surface(player_surf) player_rect player_surf.get_rect(center(160, 160))接下来从碰撞层生成三张 maskwall_mask pygame.mask.from_threshold(collision_layer, WALL_COLOR, (8, 8, 8, 0)) lava_mask pygame.mask.from_threshold(collision_layer, LAVA_COLOR, (8, 8, 8, 0)) goal_mask pygame.mask.from_threshold(collision_layer, GOAL_COLOR, (8, 8, 8, 0))这里的(8, 8, 8, 0)含义是RGB 三个通道允许 8 的误差alpha 通道不允许误差。因为碰撞层是不透明 Surface所有像素 alpha 都是 255alpha 容差为 0 不会有问题。如果以后换成带透明通道的图层请把这里的四通道含义记清楚后面避坑部分会细讲。4.3 不同颜色障碍的差异化响应移动部分我用了分轴碰撞回退这是 2D 平台游戏里的经典技巧先移动 X 轴撞墙就退回再移动 Y 轴撞墙再退回。这样斜着顶墙的时候不会抖动也不会莫名其妙穿墙。hp 100 running True win False while running: for event in pygame.event.get(): if event.type pygame.QUIT: running False keys pygame.key.get_pressed() speed 3 dx (keys[pygame.K_RIGHT] - keys[pygame.K_LEFT]) * speed dy (keys[pygame.K_DOWN] - keys[pygame.K_UP]) * speed # 先水平移动撞墙就回退 player_rect.x dx offset (player_rect.x, player_rect.y) if wall_mask.overlap(player_mask, offset): player_rect.x - dx # 再垂直移动撞墙就回退 player_rect.y dy offset (player_rect.x, player_rect.y) if wall_mask.overlap(player_mask, offset): player_rect.y - dy # 岩浆与终点不需要“回退”直接触发效果 offset (player_rect.x, player_rect.y) if lava_mask.overlap(player_mask, offset): hp - 5 print(踩到岩浆当前 HP:, hp) if hp 0: print(角色死亡) running False if goal_mask.overlap(player_mask, offset): win True running False screen.blit(background, (0, 0)) screen.blit(player_surf, player_rect) pygame.display.flip() clock.tick(60) if win: print(通关成功) pygame.quit()运行之后你就能看到墙是蓝色碰到就是被挡住岩浆是红色碰到持续扣血终点是绿色走进去直接宣布通关。颜色区分在这里不是花架子而是真的让“碰撞类型”这件事变成了“像素颜色”这件事。5. 避坑实录offset 方向、抗锯齿和 alpha 问题这套方案我踩过的坑不少挑三个最典型的写出来。尤其第一个 offset基本每个上手的人都要晕一阵。5.1 overlap 的偏移量到底怎么算Mask.overlap(othermask, offset)的参数offset表示othermask 的左上角相对当前 mask 左上角的偏移。当我们调用wall_mask.overlap(player_mask, offset)时wall_mask 是背景 maskplayer_mask 是角色 maskoffset 应该填“角色左上角相对背景左上角的位置”。背景的 rect 一般就是(0, 0)所以 offset 直接填(player_rect.x, player_rect.y)就能得到正确答案。这也是刚才 demo 里的写法。很多人会写反比如player_mask.overlap(wall_mask, (player_rect.x, player_rect.y))这就错了因为这时 current mask 是角色 maskoffset 应该填背景左上角相对角色左上角的偏移即(-player_rect.x, -player_rect.y)。看着别扭但数学上就是这样。我给你的建议很简单统一用背景mask.overlap(角色mask, (角色rect.x, 角色rect.y))别两边混着写。如果实在不放心调试阶段可以打印wall_mask.get_at((150, 150))看看那个位置是不是正好有一块墙顺便验证你的 offset 方向。5.2 抗锯齿和颜色阈值导致的 mask 断裂如果你用pygame.draw.aaline或者带抗锯齿的圆来画碰撞层边缘会产生过渡色墙的蓝色会渐变成背景色中间出现一排半透明过渡像素。此时from_threshold的容差稍微设小一点这些边缘像素会漏掉碰撞区域就会出现“锯齿缺口”。表现就是角色明明看起来还没碰到墙却被挡在空气里或者反之穿过了一个不该穿的缝隙。解决办法有三个按推荐排序碰撞层里只画无抗锯齿的纯色图形pygame.draw.line、pygame.draw.rect默认都是无抗锯齿的尽量用它们来画碰撞层如果确实有抗锯齿边缘把 threshold 调大一点比如(15, 15, 15, 0)把过渡色也纳入 mask最彻底的方案碰撞层和美术层彻底分开美术层随便画碰撞层保持纯色块风格。5.3 alpha 通道带来的隐形墙from_threshold的第四个通道容差很容易被忽略。如果你给碰撞层 Surface 加了SRCALPHA那么没有绘制过的地方 alpha 是 0绘制过的地方 alpha 是 255。默认 threshold 对 alpha 的容差可能是 255等于说 alpha 通道完全不参与匹配这时候只要 RGB 接近目标颜色透明像素也会被算进 mask。结果就是背景的透明区域里只要有一点颜色靠近墙色就凭空多出一堵隐形墙。我的习惯是碰撞层用普通的不透明 Surface不让 alpha 参与匹配也就是 threshold 第四位写 0。如果必须用带 alpha 的 Surface那目标颜色和 threshold 里的 alpha 都要写清楚比如目标色 alpha 必须是 255threshold alpha 最好给 0。5.4 装饰层混入碰撞层这是最后一条也是最容易犯的装饰用的地砖、贴花、光效如果颜色跟碰撞色接近就会被from_threshold当成障碍。所以我在前面的 demo 里特意把装饰层和碰撞层分开画。装饰层可以五颜六色碰撞层保持极其朴素的色块风格。最后虽然视觉上它们合成了一张背景图但在逻辑上碰撞层永远是那几张纯色块的集合。这是这套方案的工程要点千万别把装饰颜色混进来否则后期找“隐形墙”会找到崩溃。6. 进阶玩法overlap_area、overlap_mask 和混合架构基础功能跑通之后如果你还想榨出更多价值mask 还有两个方法值得关注overlap_area和overlap_mask。它们跟overlap的参数完全一样但返回内容不一样。6.1 用重叠面积做伤害和减速overlap_area返回重叠像素的数量。我不是只在岩浆里扣固定血量而是按接触面积动态算伤害——踩进去越深掉血越快area lava_mask.overlap_area(player_mask, (player_rect.x, player_rect.y)) if area 0: hp - max(1, area // 10)同样逻辑可以放在沼泽、毒雾、冰面上。冰面可以用面积做减速系数接触面积越大摩擦越小站在边缘只擦到一点则只减少少量速度。这样角色在特殊地形上的体验会自然很多比一个固定 debuff 来得细腻。6.2 用 overlap_mask 提取重叠区域做特效如果你想知道“角色到底哪一部分碰到了岩浆”overlap_mask可以直接返回重叠区的 mask。配合to_surface就能把重叠区域画出来hit_mask lava_mask.overlap_mask(player_mask, (player_rect.x, player_rect.y)) hit_surface hit_mask.to_surface(setcolor(255, 200, 0, 255), unsetcolor(0, 0, 0, 0)) screen.blit(hit_surface, (player_rect.x, player_rect.y))这样角色每次踩到岩浆身上会精准地亮起一块黄色高亮表示“就是这部分接触到了伤害源”。用来做受击闪白、碰撞火花、水花飞溅都很方便。调试碰撞区域时也建议这么干能直接看到 mask 的边界到底在哪个像素。6.3 静态背景 mask 与动态精灵 mask 的混合架构最后推荐一个比较完整、可以长期用的架构静态地形墙、岩浆、门、平台全部画进碰撞层初始化时生成多张颜色语义 mask运行时每帧只做几次overlap动态实体敌人、子弹、可拾取道具保留精灵 各自 mask数量通常不大每帧遍历次数可控角色精灵 mask移动时先对静态背景层做分轴检测再对动态精灵做常规检测。这个方案把“大量静态 少量动态”的性能问题分得很清晰静态碰撞几乎不花时间动态碰撞也只跟动态物体数量有关。如果你做的游戏里敌人数量很多那敌人之间的碰撞该上四叉树或空间网格再去优化但背景碰撞已经不会再拖后腿了。最后分享一个小技巧调试 mask 阶段不要靠猜直接把 mask 可视化到屏幕上。用mask.to_surface(setcolor, unsetcolor)生成一张带颜色的图然后叠加到背景上看看。我之前有一次怎么调都感觉墙多了半格一可视化才发现是 alpha 容差的问题透明像素被误算成了碰撞体。肉眼看到问题比对着文档猜原因快得多。这套背景 mask 方案只要严格遵守“颜色即语义、碰撞层独立、offset 方向统一”这三个习惯后面基本不会再出幺蛾子。
阅读完成 · 觉得有帮助?
咨询建站