刚开始用 Pygame 做游戏那阵子我最常干的事就是盯着窗口发愣代码没报错循环也在跑print也照常输出可屏幕就是一片黑。后来把官方文档翻烂了才明白问题压根不在绘制逻辑上而在我没搞懂 Surface 和 blit 的关系。Pygame 里并没有画一张图片这种操作只有把一块像素块贴到另一块像素块上而 blit 就是执行这个动作的那个方法。它是整个渲染层里调用频率最高的函数一个稍微像样的项目每帧调用它几百次上千次都是常态。所以别看它只有四个参数写法和理解上差一点帧率就能差出一倍画面也随时可能因为透明度、坐标、刷新顺序出各种妖蛾子。这篇内容我会从 Surface 的本质讲起把 blit 的每个参数拆开、把坐标计算对齐的写法给全再用一个 9x9 三消棋盘的绘制流程串起来最后把踩过的坑整理成一份速查表。刚入门的新手可以照着抄作业已经能做完整项目的朋友也能在性能和坑位那块找到点东西。1. 先把Surface和blit的关系捋清楚1.1 Surface不是图片它是一块可寻址的像素缓冲很多人第一次接触 Surface会下意识把它理解成一张图片对象这个认知会带来一堆后续的困惑。Surface 的真实身份是内存里的一块像素缓冲区外加一份描述信息宽度、高度、每像素多少位、像素格式是什么、有没有独立的 alpha 通道、有没有设置色键。你从硬盘image.load(gem.png)进来的东西本质上只是一块被解码好的像素数据 一份格式描述它和磁盘上那个文件已经没多大关系了。这个区别为什么重要因为一旦你把它当成图片就会自然产生贴一次就没了的想法。实际上同一块 Surface 可以被反复 blit 到任意位置任意次数它自己是不会被消耗或修改的。一个 32x32 的宝石贴图我可以把它贴到屏幕的 81 个格子里也可以先贴到一个中间 Surface 上再整体贴到屏幕源对象始终原封不动。理解了Surface 是缓冲、不是图片后面那些关于透明、格式转换、性能优化的内容才有了落脚点。1.2 blit到底做了什么一次带混合运算的像素拷贝blit 这个名字来自 block image transfer块图像搬运。它的工作流程拆开看是三步先读源 Surface 上指定的一块矩形区域然后按一套规则和目标 Surface 上对应位置的像素做混合最后把结果写回目标的那块内存。中间按一套规则混合这几个字是关键规则由源 Surface 的 alpha 通道、色键设置和目标 Surface 的像素格式共同决定。如果两边都是不透明格式那它就是一次老老实实的内存拷贝速度极快。如果源带 alpha 通道就要逐像素算一次混合近似公式是dst src * a dst * (1 - a)注意 alpha 的参与还会让结果更复杂一点。如果设置了色键那么等于色键值的像素会被直接跳过不做写入。这就是为什么同样一句 blit有的写法一秒能跑几千次有的写法跑两百次就开始掉帧——差别全在这次搬运要不要算。顺带说一句blit 是 CPU 干的活除非你的 SDL 版本命中了某些硬件加速路径否则它就是在占 CPU 时间。所以能减少调用次数就减少能预计算就预计算这些在后面第 4 节会展开。1.3 display surface 的特殊身份以及为什么屏幕会黑pygame.display.set_mode((640, 480))返回的那个 Surface 是特殊的它是整个程序里唯一会被真正送到显示器上的对象。你在别的 Surface 上贴一千次、画一万笔屏幕都不会有任何反应直到你把那块 Surface 贴到 display surface 上并且调用一次pygame.display.flip()或pygame.display.update()。新人最常见的黑屏就这么来的费劲在一个透明 Surface 上拼好了一整屏内容然后忘了最后那一次 blit或者 blit 了但忘了 flip。还有一种情况是顺序反了——先flip()再 blit那这一帧显示的就是上一次翻页的内容表现出来就是永远慢一帧。我自己的习惯是把flip()固定放在主循环的最后一行中间所有绘制动作都排在它前面用这个物理位置来提醒自己别搞错顺序。另外要记住display surface 的像素格式是由set_mode决定的普通 Surface 如果不做转换就往上贴每次 blit 都要做一次隐式的格式转换这个开销在第 4 节会重点讲。2. blit的四个参数逐个拆开讲先把签名摆出来这是pygame.Surface.blit的完整形态Surface.blit(source, dest, areaNone, special_flags0) - Rect四个参数只有前两个是必填的但后面两个才是大多数效果的来源。2.1 source唯一必填参数但它不一定是图片加载来的source是被搬运的那块 Surface可以是任何 Surface 对象不一定非得是image.load的产物。我用得比较多的几种来源从文件加载的贴图最常见配合convert()或convert_alpha()。pygame.Surface((w, h))新建出来的色块用fill()涂色做遮罩层、半透明高亮、调试用的碰撞框特别方便。pygame.Surface((w, h), pygame.SRCALPHA)新建的带 alpha 通道的层用来做模糊、发光、粒子合成的中间画布。另一块 Surface 的subsurface()结果也就是从大图上切下来的视图。甚至是fonts.render()渲染出来的文字表面这是 Pygame 里显示文字的标准姿势。有一个用法我建议你尽量别用把 display surface 自己当 source 贴给自己。虽然语法上完全合法可以做出镜像叠加运动模糊这类效果但它会触发自读自写的边界情况在部分平台上结果不稳定而且性能很差。要做残影效果正确的做法是每帧把画面拷贝到一个中间 Surface 上再处理。2.2 dest元组和Rect的区别以及那个经典的左上角陷阱dest决定贴到目标的哪个位置它接受两种类型(x, y)元组或者一个Rect对象。传元组的时候语义很直白这个坐标就是源图形左上角落下的位置。传Rect的时候就要小心了blit 只会取出这个 Rect 的topleft也就是(rect.x, rect.y)Rect 的宽度和高度会被完全忽略而且 blit 不会去修改这个 Rect。这个设计一半是坑一半是福利。先说坑。很多人写完screen.blit(img, rect)之后发现图片位置和自己算的居中效果对不上就是因为他们的rect是用rect.center (cx, cy)或者rect.midbottom ...定位的——这些操作只改变了 Rect 的四角坐标topleft也跟着变了但如果你想的是以某个点为锚点那就得用 Rect 自己算好topleftblit 不会替你换算。我在早期项目里就因为rect.center和rect.topleft混用导致一排按钮永远往右下偏半个身位。再说福利。因为 Rect 会被读但不被改同一个 Rect 对象可以安全地反复传给 blit不用担心它被内部逻辑改坏。所以做精灵布局时sprite.rect这个属性同时用于位置记录和 blit 参数是 Pygame 社区的标准做法pygame.sprite.Sprite类内部也是这么干的。还有一点得知道坐标可以是负数也可以超出屏幕范围blit 不会报错超出部分会被自动裁掉。做从屏幕外滑入的动画时可以直接给负坐标不用手动做裁剪计算。2.3 area只贴源图的一部分精灵表和裁剪的省事做法area接收一个Rect表示只从 source 上取这块矩形贴过去dest依然是落点的左上角坐标。这是所有精灵表sprite sheet动画的基础。sheet pygame.image.load(tiles.png).convert_alpha() tile_w, tile_h 32, 32 frame 3 # 想显示第 4 帧 area_rect pygame.Rect(frame * tile_w, 0, tile_w, tile_h) screen.blit(sheet, (100, 100), area_rect)area有一个容易忽略的细节如果 area 超出源图边界超出部分的像素会被当成透明处理不会报错也不会读取到相邻内存。做不规则尺寸的精灵表时这一点很实用但反过来说也容易掩盖素材尺寸算错的问题——你看到的是一块缺角的图而不是一个异常。另一个细节是area和special_flags在某些版本上存在互斥我自己在 2.x 的几个版本上实测过一旦special_flags带了非默认的混合模式area裁剪就会失效整张源图会被贴过去。稳妥的规避手段是先用source.subsurface(area_rect)切出子表面再对子表面做带混合模式的 blit两步走永远不会出错。subsurface返回的是视图共享同一块像素内存不会额外拷贝数据几乎没有性能负担。2.4 special_flags从发光到叠加混合模式的开关special_flags默认是 0也就是标准的 alpha 混合。当你想让这次 blit 用别的运算规则时就从这几个常量里挑常量含义典型用途BLEND_ADD像素值相加发光、光晕、火焰叠加BLEND_SUB相减暗角、阴影压深BLEND_MULT相乘正片叠底、染色、蒙版BLEND_MIN/BLEND_MAX取较小/较大值拓印、描边、剪影BLEND_RGBA_ADD连 alpha 通道一起相加半透明高亮层BLEND_PREMULTIPLIED预乘 alpha 混合高压缩素材、特效合成举个我常用的例子做格子选中高亮时不需要准备额外的图片highlight pygame.Surface((cell, cell)) highlight.fill((120, 200, 255, 70)) # 用加法混合叠上去画面会亮起来而不是盖一层灰 screen.blit(highlight, rect.topleft, special_flagspygame.BLEND_RGBA_ADD)要注意BLEND_RGBA_ADD会连 alpha 通道一起加如果目标是带 alpha 的中间层alpha 会越加越接近不透明反复叠加会让整块区域变成实心。处理办法是每次重绘前把中间层用fill((0, 0, 0, 0))彻底擦干净或者换用BLEND_ADD只作用于 RGB。混合模式的性能开销比普通 blit 高一截因为它不能走整块内存拷贝的快路径必须逐像素做算术。发光层这种大面积叠加我一般会先烘焙成一张静态图片而不是每帧实时算。3. 返回值与对齐计算把贴在哪变成算术题3.1 blit返回的那个Rect不只是给你看看的blit()会返回一个Rect表示这次搬运实际覆盖的区域。绝大多数人写代码时直接忽略它但这个返回值有两个很实用的场景。第一是碰撞与命中检测。当你需要判断某个东西有没有被画到屏幕可见范围内直接读返回 Rect 和屏幕 Rect 做相交判断就行不用自己算一遍。第二是调试。给返回的 Rect 画个描边框就能一眼看出实际落点和自己的预期差了多少定位对齐问题时特别管用drawn screen.blit(gem, pos) pygame.draw.rect(screen, (255, 0, 0), drawn, 1) # 调试描边需要留意的是返回的 Rect 不一定等于你传进去的 dest。如果源图有一部分超出了屏幕返回的 Rect 是裁剪之后的结果如果源带色键被跳过的像素也不会影响这个矩形范围。做精确对齐校验时用它比用自己的计算值可靠得多。3.2 居中、网格排布、相对定位的三种写法把东西贴正这件事新手最容易写成一堆加减法然后调半天。实际上有几种固定套路可以直接抄。居中的正确姿势是先用get_rect()拿到尺寸再设置锚点最后传topleftcard pygame.image.load(card.png).convert_alpha() rect card.get_rect() rect.center (screen.get_width() // 2, screen.get_height() // 2) screen.blit(card, rect.topleft) # 注意是 topleft不是 rect其实传rect也行因为 blit 取的就是topleft但显式写出来更不容易被后来改代码的人误读。网格排布是三消、棋盘、背包这类界面的基础。假设要在一个 9x9 的棋盘上均匀摆放宝石先把格子边长算出来再用两层循环board_margin 40 board_size 9 cell 44 for row in range(board_size): for col in range(board_size): x board_margin col * cell y board_margin row * cell screen.blit(gem, (x, y))这里如果想让宝石在格子里居中显示而宝石图片本身比格子小就再补一次偏移x (cell - gem.get_width()) // 2。相对定位适合 UI 元素用一个 Rect 作为父容器子元素用parent_rect.move(dx, dy)或child.topleft parent.topleft之类的方式挂上去。这样当父容器整体移动时子元素跟着走不用手改一堆绝对值。3.3 每帧调用get_rect()的代价以及坐标预计算Surface.get_rect()每次调用都会新建一个 Rect 对象。单个看没什么但你在 81 个格子上每帧调用就是每秒 4860 次对象创建Python 层面的对象分配会直接体现在帧率曲线上。我的做法是初始化阶段把静态布局的坐标一次性算好存成一个列表主循环里只做遍历# 初始化阶段 cell_positions [ (board_margin c * cell, board_margin r * cell) for r in range(9) for c in range(9) ] # 主循环里 for pos, gem in zip(cell_positions, board): screen.blit(gem, pos)这种改法看起来朴素但在中低端设备上实测能抠出明显的帧率。同理如果你用的图片全是同一尺寸那就没必要每个实例都存一份get_rect()结果共享一个尺寸常量就够了。4. 性能blit写法的微小差别帧率上的巨大差距4.1 convert和convert_alpha加载之后的第一件事任何从文件加载的图片只要要参与频繁的 blit都应该在加载后立刻调用一次格式转换bg pygame.image.load(bg.png).convert() # 不透明图 gem pygame.image.load(gem.png).convert_alpha() # 带透明通道图没做这一步的话图片保持着文件里的原始像素格式而屏幕有它自己的格式。每次 blit底层都要先把源像素从旧格式逐像素翻译成屏幕格式再搬运这个翻译开销是白送的。convert()转成和显示表面一致的格式丢掉 alpha速度最快convert_alpha()保留每像素 alphablit 时依然需要混合运算所以比convert()慢一些但比完全不转换仍然快。选择标准很简单这张图有没有需要透出来的区域。整块不透明的背景、纯色面板用convert()带不规则透明边缘的精灵、图标、文字层用convert_alpha()。有个硬性前提这两个方法必须在set_mode()之后调用因为它们需要知道显示表面的像素格式。在set_mode之前调用会直接抛异常。4.2 blits批量贴图一次调用省掉大量Python开销Pygame 从 1.9.4 起提供了Surface.blits()可以一次性提交一批贴图任务中间那层 Python 循环被搬到 C 里去了draw_list [(gem, pos) for pos in cell_positions] screen.blits(draw_list)列表里每一项可以是(source, dest)、(source, dest, area)或者(source, dest, area, special_flags)四种形式混着放也行。当同一帧内需要贴几十次以上blits相比逐个blit的差距就显现出来了。它还有个doreturn参数默认是 1会返回每项实际落点的 Rect 列表如果不关心返回值传doreturnFalse能再省一点。我一般会把这一帧要画的所有东西先挂进一个列表按图层顺序排好最后一次提交。这样也顺便解决了绘制顺序的可见性问题——谁在列表里靠后谁就画在上面。4.3 脏矩形刷新什么时候值得做怎么做才不花屏pygame.display.flip()是整屏翻页pygame.display.update(rect_list)则只更新指定区域。后者就是脏矩形思路只重绘这一帧真正变化的地方其余像素保持不动。dirty [] # 本帧需要更新的区域 old gem_rect.copy() gem_rect.move_ip(dx, dy) screen.blit(bg_patch, old) # 先把旧位置用背景盖掉 screen.blit(gem, gem_rect.topleft) # 再画新位置 dirty.extend([old, gem_rect]) pygame.display.update(dirty)三个注意点第一必须先擦旧再画新顺序反了会留下一道拖影第二传给update的矩形列表最好先合并一下Pygame 内部有矩形合并逻辑但手动把零碎小矩形并成大块通常更省第三脏矩形方案和set_mode时的标志位有关用pygame.DOUBLEBUF之类的配置时行为会不一样换配置要重新验证。什么时候值得用静态界面多、动态元素少的场景棋盘、卡牌、仪表盘收益明显。整屏都在动的情况弹幕、粒子、卷轴就不值得折腾了老老实实整屏重绘反而更省心因为脏矩形换来的是少画几个像素付出的是每帧多算一堆矩形合并的 Python 开销。5. 实操一个9x9三消棋盘的绘制与刷新5.1 资源准备与格子尺寸的推导假设手上有两张图一张 32x32 的宝石图一张棋盘背景图。屏幕上要放 9x9 共 81 个格子我们先定几个常数把尺寸一次性推导出来别在循环里现算import pygame BOARD_SIZE 9 CELL 44 # 每格 44px比 32px 的宝石大一圈留出呼吸感 MARGIN 40 # 棋盘四周留白 BOARD_PX BOARD_SIZE * CELL # 396 WIN_W BOARD_PX MARGIN * 2 # 476 WIN_H WIN_W pygame.init() screen pygame.display.set_mode((WIN_W, WIN_H)) clock pygame.time.Clock() gem pygame.image.load(gem.png).convert_alpha() gem_offset ((CELL - gem.get_width()) // 2, (CELL - gem.get_height()) // 2) # 宝石在格子内居中用的偏移CELL之所以取 44 而不是 32一是给选中高亮、消除特效留出视觉空间二是避免贴图边缘紧挨着导致看起来糊成一片。gem_offset提前算好是因为宝石尺寸小于格子如果每次都现算(CELL - gem.get_width()) // 2主循环里就多了一堆纯重复的算术。这种初始化阶段把能定死的都定死的习惯是我从 30fps 挣扎到稳定 60fps 的过程中最有用的一条经验。5.2 主循环结构与绘制顺序顺序这件事本质上就是谁覆盖谁。背景第一棋盘网格第二静态元素第三动态元素第四选中高亮和粒子特效最后UI 覆盖层压轴。running True while running: for event in pygame.event.get(): if event.type pygame.QUIT: running False # 1) 背景铺底必须每帧都做 screen.blit(board_bg, (0, 0)) # 2) 81 个格子一次提交 batch [] for idx, pos in enumerate(cell_positions): x pos[0] gem_offset[0] y pos[1] gem_offset[1] batch.append((gem, (x, y))) screen.blits(batch, doreturnFalse) # 3) 选中格子高亮用加法混合叠在宝石上面 if selected is not None: screen.blit(highlight_layer, cell_positions[selected], special_flagspygame.BLEND_RGBA_ADD) pygame.display.flip() clock.tick(60)有个细节值得单独说背景那张board_bg覆盖了整个窗口所以每次 blit 它都等于把上一帧全部擦掉不需要额外清屏。如果你的背景带透明区域或者只需要覆盖棋盘范围那就要用screen.fill()或者只贴棋盘那块区域否则上一帧的残影会留在没被覆盖的地方。我早期写过一个只在棋盘区域贴背景的版本四周留白处堆了一层历史轨迹看起来像屏幕坏了。5.3 选中高亮与消除动画期间的局部重绘三消里最典型的动态过程是选中 → 交换 → 匹配 → 消除 → 下落。这四个阶段每秒可能只有几次状态变化但屏幕需要一直刷新这时候脏矩形就派上用场了。我的做法是维护一个effects列表每个特效记录它影响的格子坐标和剩余时长TILE CELL for fx in effects: r, c fx[row], fx[col] rect pygame.Rect(MARGIN c * TILE, MARGIN r * TILE, TILE, TILE) # 用旧背景擦掉这一格 screen.blit(board_bg, rect, rect) # 重画这一格的宝石 screen.blits([(gem, (rect.x gem_offset[0], rect.y gem_offset[1]))], doreturnFalse) dirty.append(rect) pygame.display.update(dirty)这里的擦除写法用了area参数board_bg是整张背景arearect只取对应那一小块贴过去等价于把背景的对应区域复印过来盖住。这种写法的好处是不用额外准备一堆小背景图源图只加载一张。需要提醒的是一旦进入整屏重绘和脏矩形更新混用的状态就要非常小心两类调用不能同时出现在一帧里。flip()是整屏翻页如果这一帧走了脏矩形通道就别再调flip()否则你精心算的更新区域会被整屏覆盖掉脏矩形的意义就没了。这个坑我在两个项目里各踩过一次。6. 踩坑实录blit最容易出问题的几个场景6.1 透明失效、边缘黑边、糊成一团的三种原因情况一加载 PNG 用了convert()透明背景变成黑色或白色。这是最高频的一个。convert()会把 alpha 通道丢掉原本透明的地方就露出底下的颜色。改成convert_alpha()立刻解决。情况二图片是色键透明的比如某些老素材、GIF 导出的图但用了convert_alpha()。这类图的边缘是硬切的没有半透明过渡通常需要的是.convert()之后加.set_colorkey((255, 0, 255))。判断标准很简单用图片查看器把背景换成深色边缘有平滑过渡的是 alpha 通道边缘像用剪刀剪的是色键。情况三缩放之后边缘发虚或有黑边。Pygame 的pygame.transform.smoothscale做平滑缩放会把原本紧贴边缘的透明像素和颜色像素混在一起产生一条半透明的脏边。规避方式是缩放之前给原图四周留 1-2 像素的透明边距也就是素材规范里常说的padding缩放后再按新的尺寸重新计算偏移。这条经验是硬啃出来的一次性画好的素材省不了这个麻烦但能省掉后面调试黑边的时间。注意一个项目里最好只用一种透明机制。混用 alpha 通道和色键会让某些素材的表现和预期完全不一致排查起来非常费劲。6.2 叠加越画越暗alpha通道的混合陷阱这个坑比较隐蔽。当你把一个带 alpha 的 Surface 反复 blit 到另一个同样带 alpha 的 Surface 上时颜色和 alpha 都会参与混合结果就是越叠越实、颜色越叠越怪。典型场景是粒子系统的拖尾层、地图的雾效层、UI 的渐变遮罩层。原因在于标准混合公式会同时作用于颜色和 alpha 通道源的半透明像素盖上去之后目标像素的 alpha 也被拉高了下一帧再盖同样的东西透明度就变了。表现出来就是粒子叠了十几帧后原本轻柔的雾变成了一块实心灰斑。我的两种处理方式如果只是要效果叠加用BLEND_RGBA_MAX替代默认混合取最大值不会累积如果要做真正的图层合成每次合成前把中间层用layer.fill((0, 0, 0, 0))彻底擦成完全透明再重新画。注意fill如果不带第四个参数在 Pygame 2.x 里会把 alpha 设成不透明等于白擦所以这个 0 必须写上。另外往一个不带 alpha 通道的普通 Surface 上贴带 alpha 的图混合是完全正常的不会出现这个问题。所以如果中间层能不做成 SRCALPHA 就尽量不做。6.3 常见问题速查表现象可能原因处理办法窗口全黑程序不崩没把内容 blit 到 display surface或忘了 flip检查最后是否screen.blitpygame.display.flip()画面永远慢一帧flip()写在了绘制之前把 flip 固定放在循环末尾上一帧内容残留每帧没重绘背景或重绘顺序颠倒每帧第一件事就是铺满背景透明区域变黑/白用了convert()丢掉了 alpha改convert_alpha()边缘有锯齿或黑边缩放引入的半透明脏边素材留 padding或改用整数倍缩放指定位置贴不上dest传了 Rect只取了topleft明确用rect.topleft或自己算左上角半透明层越叠越实SRCALPHA 目标上反复混合每帧fill((0,0,0,0))擦净或用BLEND_RGBA_MAX用了 area 但贴了整张special_flags非默认时 area 失效先subsurface再 blit帧率随对象数陡降每帧get_rect()和格式转换预计算坐标、加载时convert颜色和设计稿对不上目标未转换格式逐像素做了隐式转换统一在加载阶段 convert最后分享一个我调试 blit 相关问题的习惯先给每次 blit 的返回值画一个 1 像素的彩色描边全屏跑一遍。位置对不对、有没有画到屏幕外、图层顺序对不对一眼就能看出来比读代码猜位置快得多。这个调试层我一般用一个布尔开关控制排查完随手关掉不影响最终帧率。你要是准备动手写那个 9x9 的三消建议先把这套描边调试开着把棋盘摆平再往上叠高亮、特效和消除逻辑会省掉很多来回改坐标的时间。