很多人第一次搜“Python 代码雨”心里想的其实很简单满屏绿色字符往下掉像老电影里那种数字雨。真动手写才发现三十行代码确实能出画面但离“炫酷”还差着一截字是往下掉了可整屏像被撒了一把豆子稀稀拉拉没有纵深感跑上十分钟帧率从 60 掉到 20换个字体片假名全变成了方块。我前后改过四五版把能踩的坑基本踩了一遍下面这份东西就是我的完整思路和最终能用的代码。这篇内容适合三类人完全没接触过图形库、想拿一个完整小项目练手的写过一版但效果不满意、想知道差在哪里的以及想把代码雨嵌到别的地方当背景用的。我会把选型、数据结构、绘制顺序、参数调节和性能排查一条线讲透代码是完整可运行的单个文件复制走就能跑。1. 一屏雨幕拆开看其实只有“列、速度、余晖”三件事1.1 代码雨看起来像雨靠的是哪几个视觉特征先把画面拆解成可描述的特征写代码时才知道该控制什么。第一个特征是纵向分列所有字符严格对齐在竖条里横向上互不干扰这正是它区别于普通“字符飘落”的地方。第二个特征是拖尾每一个下落的字符后面都跟着一串逐渐变暗的残影头部亮、尾巴淡构成了纵深。第三个特征是不均匀的节奏有的列掉得快有的列慢吞吞有的刚掉完立刻重来有的会停一会儿这种参差感是“像真的”和“像程序”的分水岭。所以整个实现的核心就是三件事把屏幕切成若干竖条、给每条一个下落速度、让画过的字符慢慢淡掉。剩下的都是在这三点上做加减法。我见过不少实现把精力全放在“随机字符”上结果画面规整得像个表格问题就出在速度分布太平均所有列几乎是同步下落一眼就看出来是循环。反过来说只要速度拉开差距、拖尾长度合适哪怕字符集只用十进制数字画面质感也能立住。这一点在我后来的修改里反复被验证调参数带来的观感提升远比换字符集明显。1.2 终端、窗口库、游戏引擎三条路各自的天花板动手前先选技术路线这决定了后面能走多远。我按自己的使用经验列一下路线代表方案上手成本画面上限适合场景终端字符界面curses低受限于字符网格与色数服务器上跑、快速验证窗口图形库各类二维绘图库中可做字体、色彩、特效桌面小工具、演示背景游戏引擎封装带硬件加速的框架中偏高可做后期处理、粒子需要嵌进更大的程序终端方案的优势是零依赖任何有标准库的环境都能跑缺点是刷新整屏容易闪颜色只有有限几组字号也没法调。窗口图形库的方案能做到逐像素控制、任意字体、任意颜色代价是要处理窗口事件循环和资源管理。引擎方案适合你已经有一个游戏项目顺手加个背景单独为代码雨引入它反而累赘。我最终选的是窗口图形库这条路因为它能在“代码量可控”和“画面好看”之间取到平衡。下面所有代码都基于这个方向。如果你只想在服务器终端上看个效果第 6 节有单独的终端版本。2. 用列作基本单位而不是用字符2.1 屏幕切条列宽不是随便定的新手最容易犯的错是给每个字符建一个对象然后让它们各自下落。这样写逻辑上能跑但很快会遇到两个问题一是碰撞感乱字符之间会互相穿插视觉上糊成一片二是数量爆炸960 宽的屏幕按 12 像素一个字算就有 80 列如果每个字符一个对象光是对象数量就上百而且随时在增删。正确的建模单位是列。屏幕宽度除以列宽得到固定的列数这个数字在整个运行期间不变。每一列维护自己的“头部”位置头部往下走走过的位置自然留下痕迹。这样对象数量是常数逻辑也清晰一列就是一根雨棒长短、快慢、什么时候重来全在这一列内部决定。列宽怎么定我的建议是直接用字体的实际字符宽度而不是拍脑袋写死一个数字。等宽字体下font.size(M)拿到的宽度就是每个字符占的横向空间用它当列宽字符之间既不重叠也不留缝。如果字体不是等宽的取一个常用字符的宽度近似处理画面会有一点点参差但能接受。col_w font.size(M)[0] or FONT_SIZE columns [Column(x, font_h, HEIGHT) for x in range(0, WIDTH, col_w)]顺手提一句行高也别用字号硬顶。字体的行高get_linesize()通常比字号大两三个像素用行高当纵向步进字符之间才会有呼吸感不会挤成一坨。2.2 Column 里每个字段的取值依据一列雨需要记住的东西其实很少横坐标、当前纵向位置、速度、当前字符、上一次绘制到的行号。我逐一说下取值逻辑。纵向位置用浮点数存不要用整数。因为速度是小数比如每帧 2.7 像素用整数存会让慢速列“卡住不动”——累加 0.7 三次才前进一个单位画面看起来就是一跳一跳的。浮点存位置绘制时才取整落到行上运动就顺滑了。速度的取值范围是整段代码里最影响手感的一个参数。我的经验区间是每帧 1 到 6 像素低于 1 会显得停滞高于 6 会在 30 帧下产生明显的跳行。而且要保证速度基本都小于一个行高否则头部一次跨过两行中间的字符就漏掉了拖尾会出现断点。这一点我在第 4 节会详细展开。当前字符单独存不是每次都随机。如果每帧都随机取字符头部会疯狂闪烁看着像坏掉的灯管。正确做法是保持字符稳定只在很低的概率下换掉让画面有一点点“电子噪点”的活性。上一次绘制到的行号是最容易被忽略的字段但它是性能的关键下面单独说。2.3 行锁步同一行不要重复绘制最开始我的主循环是这样的每帧遍历所有列每列算出当前的纵向位置画一个字符。跑起来画面是对的但我盯着某一列看发现慢速列的那一格特别亮亮到发白比周围的字符刺眼。原因很简单速度慢的时候同一列连续好几帧都落在同一行上我每帧都往同一个像素位置叠一次颜色叠加几次就饱和了。解决办法是行锁步只有当计算出的行号发生变化时才真正去绘制。行号没变说明这一帧这一列还停在同一行什么都不用做。row int(self.y // self.font_h) if row self.last_row: return None self.last_row row return row这个改动带来的收益是双份的。视觉上那格过曝的亮点消失了亮度均匀了性能上慢速列从“每帧一次绘制”降到“每两帧或三帧一次”屏幕上的绘制调用直接少了一截。我在这台老机器上测光是这一处改动平均帧耗时从 9 毫秒降到了 6 毫秒左右。这背后其实是一个通用的图形编程原则状态没变就不要重绘。听起来是废话但真正写循环的时候很容易忘掉。3. 完整代码一个文件跑起来的版本3.1 字体和字符集最容易翻车的两行代码雨对字体的要求比想象中高。首先必须等宽否则列会歪。其次必须包含你要用的字符否则会渲染成方块——就是那种空心豆腐块。我用过的那套半角片假名字符在部分精简版的系统字体里就是缺失的跑出来满屏方块一开始还以为是编码问题排查了半天才发现是字体没字形。所以代码里做了两手准备。加载字体时给一串候选名让字体系统自己挑第一个可用的字符集则准备了两套一套是片假名一套是纯 ASCII 的十六进制字符加符号后者几乎不可能缺字。切换只需要注释掉一行。font pygame.font.SysFont( consolas,couriernew,dejavusansmono,liberationmono,monospace, FONT_SIZE )还有一点字体的渲染结果要缓存。同一个字符、同一个颜色渲染出来的图像是完全一样的没必要每帧重新渲染。字符集固定的话缓存总数就是“字符数”那么多个表面几十上百个内存完全无压力。但如果哪天你把字符集改成完全随机的字符缓存就会无限膨胀这时候得加上限或者干脆不缓存这是个后面会踩到的坑。3.2 主循环的绘制顺序与渐隐层拖尾效果是这份代码的灵魂它的实现方式决定了一切。有两种做法一种是每帧清空屏幕然后自己维护每列的历史字符列表把整条尾巴重新画一遍另一种是不清屏每帧在屏幕上盖一层半透明黑色让旧的像素自然衰减。我选的是第二种。原因有两个一是代码短二是衰减是连续的几百级灰阶过渡比手动分几个亮度档位柔和得多。整条尾巴的长度由衰减速度和下落速度共同决定非常自然。盖黑层的做法有个细节要注意不能直接对着显示表面填半透明色因为很多显示表面本身不带透明度通道填进去的透明度会被忽略。正确做法是提前建一个和屏幕等大的普通表面填满黑色用set_alpha设置整体透明度然后每帧把它整块贴上去。fade pygame.Surface((WIDTH, HEIGHT)) fade.fill((0, 0, 0)) fade.set_alpha(FADE_ALPHA)绘制顺序不能乱先盖黑层再画字符。反过来就变成“刚画的字符立刻被压暗一次”头部会发灰失去那种锐利的亮绿。3.3 完整代码与逐段说明整个程序只有一个文件除了图形库本身不需要别的依赖。import random import sys import pygame WIDTH, HEIGHT 960, 640 FONT_SIZE 18 FPS 30 FADE_ALPHA 26 FADE_EVERY 1 BG (0, 0, 0) HEAD_COLOR (215, 255, 215) # 半角片假名区间若字体缺字换成下面那行 ASCII 版本 CHARS [chr(c) for c in range(0xFF66, 0xFF9E)] # CHARS list(0123456789ABCDEF#$%*-/\\|[]{}) class GlyphCache: 把字符渲染成小表面并缓存避免每帧重复渲染。 def __init__(self, font): self.font font self.cache {} def get(self, ch): surf self.cache.get(ch) if surf is None: surf self.font.render(ch, True, HEAD_COLOR) self.cache[ch] surf return surf class Column: 一列雨横向位置固定纵向位置随时间推进。 __slots__ (x, y, speed, char, last_row, font_h, screen_h) def __init__(self, x, font_h, screen_h): self.x x self.font_h font_h self.screen_h screen_h self.y 0.0 self.speed 0.0 self.char CHARS[0] self.last_row None self.reset(initialTrue) def reset(self, initialFalse): self.speed random.uniform(1.0, 6.0) # 像素/帧 self.char random.choice(CHARS) if initial: # 首次铺满全场避免开场所有列同时从顶部出发 self.y random.uniform(-self.screen_h, self.screen_h) else: self.y random.uniform(-300.0, -self.font_h) self.last_row int(self.y // self.font_h) def step(self): 推进一帧返回需要绘制的行号无需绘制返回 None。 self.y self.speed if self.y self.screen_h self.font_h * 24: self.reset() return None row int(self.y // self.font_h) if row self.last_row: return None # 行没变跳过绘制 self.last_row row if random.random() 0.10: # 低概率换字制造细碎跳动 self.char random.choice(CHARS) return row def main(): pygame.init() screen pygame.display.set_mode((WIDTH, HEIGHT)) pygame.display.set_caption(code rain) clock pygame.time.Clock() font pygame.font.SysFont( consolas,couriernew,dejavusansmono,liberationmono,monospace, FONT_SIZE, ) font_h font.get_linesize() col_w font.size(M)[0] or FONT_SIZE cache GlyphCache(font) fade pygame.Surface((WIDTH, HEIGHT)) fade.fill(BG) fade.set_alpha(min(255, FADE_ALPHA * FADE_EVERY)) columns [Column(x, font_h, HEIGHT) for x in range(0, WIDTH, col_w)] frame 0 running True while running: for event in pygame.event.get(): if event.type pygame.QUIT: running False elif event.type pygame.KEYDOWN and event.key pygame.K_ESCAPE: running False frame 1 if frame % FADE_EVERY 0: screen.blit(fade, (0, 0)) # 先衰减 for col in columns: row col.step() if row is not None and row 0: screen.blit(cache.get(col.char), (col.x, row * font_h)) pygame.display.flip() clock.tick(FPS) pygame.quit() sys.exit(0) if __name__ __main__: main()有几个地方值得单独解释。Column.reset里的initial参数是给第一次用的让各列的初始位置散布在整个屏幕高度上而不是全挤在顶部否则程序一启动你会看到一整排字符齐刷刷地往下掉像瀑布开了闸很不自然。之后的重置则把位置放到屏幕上方更远处这样新的一列会从画面外进入不会凭空出现。__slots__是个小优化把每个实例的字典去掉属性访问改成固定偏移。列数上百的时候内存占用和访问速度都有一点改善代价是这个类不能再动态加属性。对这种结构固定的小对象我觉得值。step里判断重置的条件是“头部已经超出屏幕底部再往下二十多个行高”这个余量是留给拖尾的——尾部还没走完就重置画面会出现突兀的截断。3.4 跑起来之后先调这两个参数代码跑起来第一件事不是看效果是确认字体对了。如果满屏方块把字符集那两行换个位置如果列是歪的说明挑到的字体不是等宽把候选列表里加上你系统里确定等宽的字体名。确认画面正常之后先调FADE_ALPHA它决定拖尾长短。想看清楚它的作用可以把它改成 10你会看到尾巴拖得很长整个屏幕糊成一片改成 80尾巴几乎消失只剩一串孤立的点在掉。26 是我反复试下来比较平衡的值但不同分辨率、不同字号下最舒服的值会浮动建议自己扫一遍 15 到 45 之间的几个值。再调速度区间。把random.uniform(1.0, 6.0)里的 6.0 改小到 3.0所有列的速度集中在一个窄区间画面马上变得很“规矩”也立刻变丑了。这就是我在 1.1 里说的参差感。4. 从“能跑”到“好看”中间隔着这几个参数4.1 拖尾长度的计算与手感对应关系拖尾到底有多长是可以算出来的。每帧盖一层透明度为 a 的黑色一个已经画上去的像素经过 n 帧后剩余的亮度比例是 (1 - a/255) 的 n 次方。衰减到 10% 以下基本就看不清了解这个方程得到帧数再乘以每帧下落速度就是拖尾的像素长度。以FADE_ALPHA 26为例1 - 26/255 ≈ 0.898要让亮度降到 10%需要 ln(0.1)/ln(0.898) ≈ 21 帧。在 30 帧每秒下一个字符从最亮到几乎消失大约 0.7 秒。这个数字决定了拖尾的视觉长度速度快的列0.7 秒内掉落距离远尾巴拉得长速度慢的列尾巴短。也就是说拖尾长度会自动跟着速度变化这是整屏渐隐方案比手动指定长度更自然的地方。手动指定长度的话所有列的尾巴一样长画面会显得很规整缺少变化。反过来说如果你的观感需求是“每列的尾巴长度一致”那就得走手动维护历史字符的那条路。两种方案没有优劣看你要什么。这里有个隐性约束要提醒拖尾长度不能太长长到覆盖整个屏幕高度时屏幕中部的字符密度会饱和最终整个屏幕变成一片均匀的绿色看不到任何运动。出现这种情况说明衰减太慢了把透明度调大或者把帧率调高。4.2 速度分布要拉开但不能乱速度区间我定在 1 到 6为什么是这个范围上限 6 是被“不能跳行”约束住的。行高约 20 像素一帧走 6 像素的话三帧多一点才跨一行字符是连续的下落感如果上限放到 25一帧跨一行还多中间的行直接跳过尾巴上会出现一节一节的空白看起来像虚线。下限 1 是被“不能停滞”约束的。小于 1 的速度在视觉上接近静止尤其配合行锁步之后好几帧才画一次你会觉得那一列卡住了。1 像素每帧在 30 帧下是每秒 30 像素虽然慢但能看出在动。还有一个容易忽略的点速度不能是均匀分布。random.uniform是均匀的落在 1 到 6 之间每个值的概率相等大部分列速度都在 3.5 附近。视觉上你需要的是“大部分列中等速度少部分列特别快或特别慢”。这时候可以把均匀分布的样本做一次变换比如取三次随机数的中位数或者用平方让分布偏向一端。我目前的处理很简单保持均匀分布但把区间拉得足够宽1 到 6 已经跨了六倍实际效果已经够参差了。如果你追求更极致的层次可以再分两档80% 的列在 1 到 3.5 之间20% 的列在 4 到 6 之间快慢对比会更强烈。4.3 观感问题对照表调参数的过程中会遇到一堆“看着不对但说不上哪不对”的情况我整理了一份对照表基本覆盖了我遇到过的全部现象最可能的原因调整方向整屏像撒豆子没有纵深感拖尾太短衰减太快调小 FADE_ALPHA 到 15 至 20屏幕糊成一片绿看不出运动拖尾太长密度饱和调大 FADE_ALPHA 到 40 以上某些格子特别亮像灯泡慢速列在同一行重复绘制确认行锁步生效速度下限提高到 1.5尾巴上有虚线断点速度超过一个行高速度上限压到行高以下所有列动作整齐划一初始位置没打散检查 reset 的 initial 分支字符疯狂闪烁每帧都换字符把换字概率降到 0.1 以下满屏方块字体缺字形换成 ASCII 字符集列之间有缝隙或重叠列宽和实际字符宽度不匹配用 font.size 计算列宽这张表里“所有列动作整齐划一”是最隐蔽的一个。如果你的初始化写成了self.y -random.randint(0, 100)看起来是随机的但范围太窄所有列的起点差异只有 100 像素在 640 高的屏幕上相当于几乎同时出发。正确做法是让初始位置覆盖整个屏幕高度甚至更大范围。5. 帧率掉下来时我在查这几处5.1 三个真实瓶颈的定位方法代码雨跑到第二十分钟开始掉帧这个现象我遇到过不止一次。定位的办法不复杂在循环里打点计时把耗时拆成“事件处理、衰减层、更新逻辑、字符绘制、画面翻转”五段各打一个累计计时器跑一百帧输出一次。哪一段占大头一目了然。我实测下来绝大多数情况瓶颈集中在这三处。第一是字形渲染如果没做缓存每帧几百次字体渲染调用这是最贵的。第二是整屏 alpha 混合那块和屏幕等大的黑色表面每帧贴一次走的是逐像素混合61 万像素的开销在集成显卡上相当可观。第三是绘制调用次数列数乘以平均每帧需要绘制的列比例如果列宽设置得过小列数翻倍调用次数也翻倍。有个反直觉的现象字符绘制本身其实不贵贴一个十几像素见方的小表面成本很低。真正贵的是那次整屏混合。我在一台老机器上测把衰减层去掉直接清屏帧耗时从 8 毫秒掉到 3 毫秒剩下的主要是窗口翻转的固定开销。5.2 实测能拿回多少帧的几处改动针对上面三个瓶颈我的处理按收益排序如下。第一字形缓存。这个改动是必须的不做的话在小字符集下收益巨大。同样是 80 列、30 帧不做缓存时帧耗时约 14 毫秒做完缓存降到 8 毫秒左右。缓存本身代码就十行性价比最高。第二降低衰减层频率。这是我在老机器上用的招。既然整屏混合贵那就别每帧都混合改成每两帧混合一次同时把透明度翻倍。物理上等价开销减半。FADE_EVERY 2 fade.set_alpha(min(255, FADE_ALPHA * FADE_EVERY)) # 主循环里 if frame % FADE_EVERY 0: screen.blit(fade, (0, 0))代价是拖尾的灰度过渡会粗一档从几百级降到一百多级。在 30 帧下肉眼几乎看不出区别我觉得完全值。如果改成每三帧一次土褐色阶就开始明显了能看出条带。第三行锁步。前面讲过除了修掉亮点还顺带省了绘制调用。慢速列占比越高收益越大。第四适当加大列宽。列宽从 12 加到 16列数直接从 80 降到 60所有跟列数相关的开销同比降 25%。代价是画面变“稀疏”这个要看你的审美取向。我个人更偏好列密一点所以宁愿牺牲帧率也不加宽你可以根据自己的机器取舍。5.3 慢机器上的自适应策略如果你的程序要发给别人用机器性能不可控可以做一层简单的自适应。思路是记录最近若干帧的平均耗时如果持续超过某个阈值就自动降低配置先把衰减层频率从每帧改成每两帧还不够就把帧率上限从 30 降到 24再不够就减少列数。avg sum(recent) / len(recent) if avg 40 and fade_every 1: # 平均每帧超过 40 毫秒 fade_every 2 elif avg 55 and fps_limit 24: fps_limit 24这种降级策略在游戏里很常见搬到小工具上同样有效。关键是降级要平滑别出现“突然变卡然后突然变流畅”的抖动所以判断条件里加了持续多帧的门槛。另外提一句垂直同步。如果运行环境支持开启垂直同步能让画面翻转和显示器刷新对齐撕裂感会消失同时也天然限制了帧率不需要额外做时钟控制。开不了也没关系普通的帧率限制足够。6. 没有图形库的场合终端版代码雨6.1 curses 的坐标体系与颜色对有时候你只有一台终端或者想把它做成远程连上去就能看的小玩意这时候就得用终端方案。标准库里有个跨平台的终端控制模块核心概念是“屏幕”对象用addch(行, 列, 字符, 属性)往指定位置写字符。它的坐标是先行后列这点和图形库的 (x, y) 刚好颠倒写的时候特别容易弄反。我第一版就是按照 (x, y) 的顺序写的结果画出来是一条斜线。颜色方面终端用的是“颜色对”也就是前景色加背景色的组合需要先注册再用。import curses import random import time CHARS [chr(c) for c in range(0xFF66, 0xFF9E)] def run(stdscr): curses.curs_set(0) stdscr.nodelay(True) curses.start_color() curses.use_default_colors() curses.init_pair(1, curses.COLOR_GREEN, -1) curses.init_pair(2, curses.COLOR_WHITE, -1) green curses.color_pair(1) head curses.color_pair(2) | curses.A_BOLD h, w stdscr.getmaxyx() speed [random.uniform(0.05, 0.4) for _ in range(w)] pos [random.randint(-h, h) for _ in range(w)] length [random.randint(4, 12) for _ in range(w)] while True: key stdscr.getch() if key in (ord(q), ord(Q), 27): break stdscr.erase() for x in range(w): pos[x] speed[x] if pos[x] - length[x] h: pos[x] -random.randint(0, h) length[x] random.randint(4, 12) for i in range(length[x]): y int(pos[x]) - i if 0 y h - 1: try: stdscr.addch(y, x, random.choice(CHARS), head if i 0 else green) except curses.error: pass stdscr.refresh() time.sleep(0.05) curses.wrapper(run)终端版是手动维护拖尾长度的每列记一个长度从头部往上画这么多格超出范围的直接不画。这样就不需要渐隐层了因为终端做半透明衰减本来也不现实。6.2 终端版的三个硬限制第一个限制是右下角不能写。终端的最后一行最后一列写字符会触发滚动很多实现直接抛异常。所以循环里要判断y h - 1并且包一层异常捕获兜底。这行 try 看着丑但不加的话程序在特定窗口尺寸下会直接崩。第二个限制是字符宽度。终端里中文字符占两列半角字符占一列。如果字符集里混了全角字符后面所有字符的位置都会错开整列歪掉。上面用的半角片假名在 Unicode 里属于半角区宽度是一列但能不能正常显示取决于终端的字体和区域设置配置换个环境可能就变成问号或者方块。这一点没有通用解法只能实测。第三个限制是刷新成本。每次erase加整屏重绘八十列乘以十几格一帧要写上千次字符。终端模拟器的吞吐量有限帧率一高就开始闪。我的做法是把帧间隔提到 50 毫秒也就是 20 帧肉眼已经够用。如果还是闪可以试着只重绘变化的部分不整屏擦除但那样代码复杂度会上一个台阶我觉得不值。终端版的观感注定比图形版粗糙但它的优势是随处可跑几个朋友连同一台机器看个乐子的时候很方便。7. 改造成自己需要的样子7.1 换字符集和配色带来的气质变化字符集是成本最低、效果最明显的改造点。我试过的几套纯二进制 0 和 1气质偏“数据流”十六进制加运算符号偏“终端界面”纯 ASCII 可打印字符全量画面非常稀碎适合做背景片假名是最经典的绿色调性也最贴。如果想混排中文字符要注意纵向对齐会被破坏等宽字体里中文占两格列宽得按中文宽度算否则列会歪。配色方面改一行就能换风格冷白加冰蓝像科幻界面橙红偏暗配黑底有种旧显示器的感觉纯白单色最干净的极简风。改的时候注意头部颜色要比拖尾亮这是纵深感的基础。我试过把头部设成和拖尾一样的颜色画面立刻变成一片均匀的色块完全没有