去年年底,我在一次小范围的开发者聚会上演示了一个“只会往终端里输出字符”的程序。演示结束后,好几个人凑过来问我同一个问题:你是不是提前渲染好了视频,然后在终端里播放的?我承认,这个反馈让我挺得意。因为那正好说明我打磨了大半年的作品——《永恒工具》,已经达到了足以“欺骗眼睛”的终端渲染效果。
《永恒工具》不是视频。它是一个纯字符输出、没有任何图形后端依赖的终端渲染项目。你可以把它理解成一件“技术诗”:运行后,屏幕中央会出现一块由字符组成的巨石,一把同样由字符构成的古老工具会不断敲击它,每一下都溅出成百上千粒光点,这些光点会像墨水一样在空气中汇聚成诗句,再像灰烬一样落下。整个过程循环往复,就像一件永远不会停止工作的工具。
这篇文章不打算贴出完整源码——那实在太长了。我会把真正核心的几块拆开讲透:渲染器是怎么设计帧缓冲的,粒子系统如何作为“流动的墨水”工作,性能优化要压到哪几个瓶颈上,以及我在不同终端上踩过的那些坑。如果你也想在终端里做点“看着不太像终端该做的事”,这篇应该能帮上忙。
1. 终端里流动的那条星河:先认识《永恒工具》
1.1 为什么要在终端里做“诗”
先回答一个最常被问的问题:为什么不去做网页特效、不去写桌面应用,偏偏要在终端里做视觉?
我的答案很简单:终端是一个带有严格限制的画布——固定网格、有限字符、没有硬件加速,连“半透明图层”这种东西都不存在。恰恰是这些限制,逼着你用完全不同的方式思考视觉表达。像用马赛克瓷砖拼画,每块瓷砖还只能从字符表里挑。在这个被削到极窄的表达空间里,还能让人看出情绪、节奏和诗意,这件事本身就很迷人。
《永恒工具》从名字上就是一首技术诗。它不是讲“一把锤子”或者“一把尺子”,而是把一个抽象概念变成了可视化的循环:一件工具在时间里不断被使用,也不断被重新锻造。我刻意不让画面重复,因为每次循环的粒子轨迹、诗句顺序、颜色相位都不完全一样。这更像一首生成诗,而不是一段录制的动画。
1.2 项目选型:从 Python 原型到 Rust 核心
项目最初的原型是用 Python 写的。Python 拼接 ANSI 转义序列、维护二维字符数组非常顺手,我把所有算法都先在 Python 里验证了一遍。但跑到后面问题来了:当粒子数量超过两三千、帧率目标 30fps 时,Python 的字符串拼接和循环更新开始成为明显瓶颈。于是我把核心渲染引擎重写成了 Rust,Python 只用来离线生成诗句文本和配色方案。
选 Rust 还有一个原因:终端渲染的天花板不在 GPU,而在系统调用、内存分配和字符串处理上。Rust 对这块控制力更强,效果优化到极限后不容易被语言运行时拖后腿。下面的代码片段都以 Rust 为主,关键思路我会用自然语言讲清楚,不懂 Rust 的读者也能理解它在干什么。
2. 字符为什么能成为像素:终端渲染的基本盘
2.1 终端首先是字符网格,不是像素画布
很多第一次接触终端渲染的人会搞错一件事:以为终端像屏幕一样有“像素”,可以控制单个发光点。实际上终端渲染的最小单位是“字符单元格”——屏幕被分割成若干行和列,每个位置放一个字符,而这个字符本身有自己的形状和颜色。
所以终端渲染的本质就是回答一个问题:在每一个行列坐标上,应该放哪个字符、用什么前景色、用什么背景色?
拿常见的终端尺寸来说,120 列乘以 40 行,总共 4800 个单元格。这比一张 1920×1080 的图像稀疏太多了。视觉上想要“细腻”,只能靠两个手段:一是字符本身的形状差异,二是颜色信息。如果你尝试过用字符画一个渐变的球体,就会明白可用的“亮度阶梯”远比像素世界的灰阶少,所以每一处设计都得精打细算。
这个模型也决定了帧缓冲(FrameBuffer)的设计方向:我们需要一个二维结构来存放每个单元格的状态,而不是一张标准位图。后面的所有渲染、粒子、文本分镜,全都围绕这个二维结构来展开。
2.2 ANSI 控制序列:给终端下指令的最小协议
终端之所以能被程序“画画”,靠的是一套 ANSI/VT 控制序列。它本质上是一些以 ESC(\x1b)开头的特殊字符串,终端看到后会解析并改变状态。
最常用的是这几类:
\x1b[2J:清空整个屏幕。\x1b[H:把光标移动到屏幕左上角。\x1b[?25l/\x1b[?25h:隐藏 / 显示光标。\x1b[38;2;R;G;Bm:设置文字前景色,R、G、B 取值 0-255,这就是常说的 TrueColor(真彩色)。\x1b[48;2;R;G;Bm:设置背景色。\x1b[m:重置所有属性。
举个例子,下面这行命令会在屏幕上画出一个橙色的方块:
printf "\x1b[38;2;255;128;0m█\x1b[0m"理解这些序列非常关键,因为最终渲染出来的“每一帧”,本质上就是在内存里构造一个很长的字符串,然后一次性交给终端解析。你可以把终端想象成一个解析器,把 ANSI 序列“翻译”成屏幕上的颜色和字符。优化的时候,我们优化的就是这个字符串的长度、构造方式和写入次数。
2.3 字符集和灰度近似:用字形制造明暗
终端里的字符并不是等宽的方块——不同字符的笔画密度完全不同。比如空格几乎不占面积,而█是实心方块。这意味着我们可以利用字符表,构造一条“灰度阶梯”来模拟亮度。
我常用的亮度渐进是:
.:-=+*#%@ 以及区块字符: ░▒▓█░只有约 1/4 的填充面积,▒是一半,▓是 3/4,█是满填充。配合前景色 RGB 值,一个粒子从亮到暗的衰减就可以表示成“颜色逐渐变暗 + 字符逐渐从█退化到空格”。
这里有一个容易栽跟头的细节:字符有全角和半角之分。█、░这些区块字符在不同终端下显示宽度可能不一致,有的终端把它当半角处理,有的当全角处理。如果混用全角字符和半角字符,画面会出现严重的“锯齿”和错位。我后面在踩坑章节会专门讲这个,这里先记住一个原则:要么全项目统一使用半角兼容字符,要么在计算坐标时统一用字符显示宽度作为单位。
2.4 帧缓冲:一行一行的内存,而不是一帧一个字符串
有了字符和颜色,下一步就是设计帧缓冲。每个单元格需要存储三样信息:一个字符、一个前景色、一个背景色。最简单的方式是定义一个结构体:
#[derive(Clone, Copy)] struct Cell { ch: char, fg: (u8, u8, u8), bg: (u8, u8, u8), } struct FrameBuffer { cols: usize, rows: usize, cells: Vec<Cell>, dirty: Vec<bool>, }dirty是一个“脏标记”数组,标记哪些单元格这一帧需要重新输出。没有脏标记的话,每一帧都要把整个屏幕重新输出一遍,浪费大量 IO。有了脏标记后,我们只把变化过的单元格字段拼进输出字符串,这样帧字节数会大幅下降。
帧缓冲设计上的关键取舍是:宁可多花一点 CPU 去维护脏标记,也要尽量减少最终写入终端的字节数。因为终端解析 ANSI 序列的速度,往往比程序构造数据慢得多。这一点在优化章节还会展开。
3. 技术诗的核心渲染器:粒子、双缓冲和文本分镜
3.1 一个渲染循环长什么样
大部分终端视觉作品都跑在一个循环里:更新逻辑状态、渲染进帧缓冲、把缓冲内容写入终端。我用固定时间步长来保证动画节奏稳定,目标帧率是 30fps,每一帧留给所有计算的预算大约是 33 毫秒。
核心循环的结构如下:
fn run(term: &mut Terminal) { let mut fbuf = FrameBuffer::new(term.rows, term.cols); let mut scene = EternalToolScene::new(); while term.running { scene.tick(app_time.delta()); fbuf.clear(); scene.render(&mut fbuf); term.flush(&fbuf); sleep_until_next_frame(FRAME_DURATION_MS); } }看起来简单,但真正写起来要考虑的细节非常多。比如不能每次循环都让 CPU 空转,否则程序会吃掉一个完整核心;再比如flush如何把所有 ANSI 序列拼接成尽量少的write调用。这些我都会在性能部分讲。
《永恒工具》的场景对象内部管理着三套子系统:粒子系统、文本分镜系统、背景噪声系统。其中粒子系统负责所有“流动”的元素,文本分镜系统负责诗句的节奏和位置,背景噪声系统则给巨石和工具表面增加细节。
3.2 粒子是流动的墨水
如果帧缓冲是画布,那粒子就是上面流动的墨水。一粒粒子的核心数据包括位置、速度、生命期、颜色和“亮度字符”:
struct Particle { x: f32, y: f32, vx: f32, vy: f32, life: f32, max_life: f32, color: (u8, u8, u8), }每一帧更新时,先根据重力和阻力修改速度,再根据速度修改位置;生命期逐渐减少,直到归零后粒子被回收。渲染时把粒子的浮点坐标取整成单元格坐标,根据剩余生命比例挑选对应的灰度字符:
fn particle_char(life_ratio: f32) -> char { if life_ratio > 0.8 { '█' } else if life_ratio > 0.6 { '▓' } else if life_ratio > 0.4 { '▒' } else if life_ratio > 0.2 { '░' } else { '.' } }粒子数量我控制在 2000~8000 之间。太少了画面显得空,太多了 CPU 会先扛不住。实际调参时发现,7000 粒左右的“爆裂感”最好,既能形成明显的流动轨迹,又不会让帧率跌破 30。
这里有一个重要的经验:粒子在终端里想要显得“顺滑”,不能光靠数量,还要靠颜色变化。让同一粒子的颜色在其生命周期内从亮白渐变到深红,再让后面的粒子随机偏移一点色相,整体看起来就会富有层次感。纯靠刷数量只会让终端CPU瞬间爆炸,画面却依然脏乱。
3.3 从工具到诗:文本分镜是怎么设计的
《永恒工具》不是满屏乱码。它的诗句是预先设计好意象库,再按分镜节奏抽取组合的。我把整首诗拆成了三个乐章:熔炉、尺度、余烬。每个乐章有固定的底色、工具形态和诗句倾向。
意象库大约是这样一个结构:
const IMAGERY: &[&str] = &[ "刃口", "刻度", "灰烬", "余温", "磨石", "水流", "断裂的风", "未完成的钟", ];渲染诗句时,不是简单地把文字印上去。我会把诗句里的每个字符当成一个“慢速粒子”:它有自己的坐标、起始时间、持续时间和目标颜色。这样文字才能做到逐字出现、整体飘动、淡出消失,而不是生硬地整段出现。比如“被敲打的石头,记得每一道风声”这一行,在我这版分镜里是从下往上浮现,每个字之间间隔 60 毫秒,像是被火花逐个点燃。
实现上,每一行诗句都是一个TextLine对象:
struct TextLine { begin_at: f32, chars: Vec<CharParticle>, x: usize, y: usize, }我把“文字”当成一种特殊粒子的好处是,不需要再写一套独立的状态机。所有入场、停留、退场效果都统一用生命周期曲线来描述。唯一的区别是文字粒子不参与物理运动,位置通常固定在一行上,只做垂直或水平的缓慢漂移。
3.4 没有 Alpha 通道的透明:怎么模拟淡入淡出
终端里没有真正的透明度。但“淡入淡出”这种效果又是视觉作品躲不开的。我的做法是用颜色插值加上字符退化来骗过眼睛。
比如要让一个字符从完全透明到完全可见,我不会真的去设置透明度,而是把它的颜色从“背景色”渐变到目标前景色,字符从空格逐渐变为░、▒、▓、█。因为字符填充面积越来越大、颜色和背景的对比越来越强,看起来就像在慢慢显影。反过来,淡出就是让字符逐步退回空格,同时颜色向背景色靠拢。
颜色插值函数是典型的线性插值:
fn lerp_color(a: (u8, u8, u8), b: (u8, u8, u8), t: f32) -> (u8, u8, u8) { let t = t.clamp(0.0, 1.0); ( (a.0 as f32 + (b.0 as f32 - a.0 as f32) * t) as u8, (a.1 as f32 + (b.1 as f32 - a.1 as f32) * t) as u8, (a.2 as f32 + (b.2 as f32 - a.2 as f32) * t) as u8, ) }这个思路还延伸到“拖尾效果”。我最初以为拖尾就是“上一帧的画面再画一遍”,结果只做了字符亮度降低,导致满地乱码。原因很简单:没有把颜色往背景色方向插值,字符虽然变暗了,但颜色饱和度仍然很高,叠加起来自然乱。正确的做法是颜色插值 + 字符退化双管齐下,最终让旧帧慢慢消失。
4. 把终端压榨到极限:性能优化与瓶颈
4.1 一帧只有几十 KB,为什么还会卡
很多写终端程序的人低估了“输出”的成本。默认情况下,程序每print一个字符串,就是一次系统调用。一帧如果分成几千次零碎输出来画,终端会被频繁打断,渲染自然卡。
我最初的原型没有做批量写入,粒子一多,帧率惨不忍睹。后来我把一整帧渲染结果拼成一个长字符串,再用一次write写出去,帧率立刻翻了几倍。这背后的道理是:系统调用和终端解析都是有成本的,把大量数据塞进一次调用里,远比分成多次调用更划算。
另一个容易被忽略的是字符串大小。一个 120×40 的网格,如果每个单元格都要输出背景色和字符,一帧很容易超过 80KB。当单帧超过 200KB 时,即使本地终端足够快,整体渲染延迟也会明显上升。我实测过不同终端软件的表现,大致整理成下表:
| 场景 | 平均帧字节数 | 某终端 A 实际帧率 | 某终端 B 实际帧率 |
|---|---|---|---|
| 全屏重绘 | 约 180KB | 约 22fps | 约 15fps |
| 带脏矩形 + 批量写入 | 约 45KB | 约 60fps | 约 40fps |
| 仅文字区域更新 | 约 8KB | 超过 100fps | 约 80fps |
这个表说明,优化 IO 结构比单纯调低帧率更有效。AB 两套环境的性能差异也提醒我,不能只在自己开发机上测一遍就完事。
4.2 脏矩形与写入缓冲区:减少每一帧的字节数
《永恒工具》的静态元素其实很多:巨石的位置、工具的姿态、文字背景区域在一定时间内都不变。真正每帧都在剧烈变化的主要是粒子区域。所以我不需要每一帧都把整块画布推给终端。
我维护了第二层脏标记:每一帧,粒子系统会把它们经过的单元格标记为“需要重绘”,文字和背景系统只在自己状态变化时标记。然后帧缓冲输出只遍历脏标记,把相关单元格的 SGR 序列和字符拼进输出字符串。
有了这一层优化后,平均只有 30%~50% 的单元格需要重绘。特别是在文字显示阶段,整个画面大量区域静止,字节数能被压得很低。
为了进一步减少内存分配,我会在使用频繁的“当前帧字符串”上复用同一个String缓冲区,每次先清空再追加。这样既避免了每帧分配新内存的抖动,也让 GC(垃圾回收)类语言之外的运行时压力降到最低。
4.3 适配不同终端:TrueColor 检测与尺寸变化
不同终端的解析能力天差地别。有的终端支持 24 位真彩色,有的只支持 256 色,还有的在遇到某些字符序列时处理异常缓慢。为了让作品在更多地方跑起来,我在启动时做了一次能力探测。
探测方式很简单:优先检查环境变量$COLORTERM,如果包含truecolor或24bit就按真彩色输出;否则降级到 256 色索引。降级后的画面色彩精度会差一些,但不会崩溃。
终端尺寸变化也要处理。用户调整窗口大小时,程序需要重新分配帧缓冲,否则画面会错位。在主流系统上可以通过ioctl拿到终端行列数,而且系统会在窗口尺寸变化时发送一个信号。我的做法是注册这个信号的处理器,在渲染循环外同步更新尺寸:
fn handle_winch(term_state: &mut TermState) { let new_size = terminal_size(); term_state.frame_buffer.resize(new_size.rows, new_size.cols); term_state.dirty_all = true; }窗口尺寸变化时,我还会触发一次全屏重绘,这可以有效避免“边缘残留”的脏乱效果。
4.4 帧率控制:别让 CPU 空转到冒烟
终端视觉程序最容易出现的毛病是“跑满 CPU 但还是卡”。没有帧率控制时,循环会拼命刷新,CPU 占用居高不下,终端解析队列也会被塞满,帧率反而上不去。
我用的是固定时间步长加睡眠策略:规定每帧时长,比如1000 / 30 ≈ 33.3ms;渲染完成后,计算到下一帧开始还剩多少时间,然后调用sleep让出 CPU。这样既不会忙等,也不会因为睡眠过久导致动画不连贯。
实际睡眠精度不一定非常准确,但终端动画对帧率稳定性要求没有那么苛刻。只要保证平均帧率稳定在 30fps 左右,视觉上是连贯的。我曾试过把目标帧率提高到 60fps,发现部分终端解析不过来,最终效果反而“卡顿感”更强。所以我最终把目标锁定在了 30fps。
5. 《永恒工具》的视觉构成:三个乐章拆给你看
5.1 第一乐章:熔炉
启动后,屏幕中央先是出现一块暗色巨石,它由密集的▓和▒字符堆叠而成,表面有持续抖动的“噪声”。过一会儿,一把由字符构成的工具从右侧浮现,开始反复敲击巨石的右上角。每次敲击都会在碰撞点生成一小片粒子爆发,粒子像火花一样向四周飞散,颜色从亮金逐渐过渡到暗红。
这一乐章的诗句是:“被敲打的石头,记得每一道风声。”文本以逐字点燃的方式从画面底部冒出来。为了让“敲击”有节奏感,我加入了一个简单的节拍状态机:每 900 毫秒触发一次敲击,每次触发同时播放视觉上的“震动”——让巨石的行偏移随机抖动 1~2 个字符,然后在 150 毫秒内恢复。
5.2 第二乐章:尺度
画面慢慢变暗,工具形态切换成一支发光的长尺。它从屏幕左侧开始,横向缓缓扫过整个画面。尺子扫过的区域,原本杂乱的字符会被重新排列成整齐的刻度线,看起来像时间有了坐标。
这一乐章的主色调是青蓝和浅灰,粒子不再是爆裂状,而是变成细长的流线,像水一样沿着刻度流动。诗句是“刻度不是限制,是余温的坐标”。为了让“画面变整齐”的过程可信,我使用了一个全局进度变量:从 0 到 1,表示尺子已经扫过的比例。在这个进度范围内,被扫过的区域强制使用同一灰度阶梯的字符,颜色被插值到青蓝色方向。
5.3 第三乐章:余烬
最后一个乐章是一部“倒转”:所有粒子不再向外飞,而是从四周向屏幕中央汇聚。汇聚的终点是一把锉刀形状的字符组件,刀身上缓缓浮现出项目名“永恒工具”四个字。背景从青蓝变为暗金和冷白混合,每一次粒子的汇入都会在刀身上增加一点亮度。
汇聚完成后,整个画面维持大约两秒的“定格”,然后从边缘开始一点一点暗下去,最终回到第一乐章的巨石形态。这个循环在程序退出前不会停止。为了让循环不是百分之百重复,我在每次循环开始时重新洗牌诗句顺序,粒子爆发的方向、速度和颜色相位也都会变化。
5.4 为什么叫“永恒工具”
“永恒工具”这个名字不是指某件具体的器具。它更像一个思辨意象:工具在使用中磨损,又被重新锻造,循环往复。这件作品本身也像一把工具,每次运行都会重新“锻造”自己的画面。我愿意把它理解成一首用算法写成的诗——诗的内容不是文字而已,而是文字、时间与字符视觉的交织。
这一点也影响了技术实现:我没有为作品写死一套动画文件,而是用生成式的场景状态机驱动画面。观众每次运行看到的细节都不完全相同,这正是“永恒”感的来源之一。
6. 踩坑手记:渲染效果不对的时候,我是怎么排查的
6.1 全角字符错位:画面撕裂的元凶
第一次做完粒子系统后,我在某个终端里看到了“锯齿状”画面:粒子轨迹明明是一条弧线,屏幕上却像是被狗咬过,一行宽一行窄。排查了半天,问题出在字符宽度不一致。
█这个字符在大多数现代终端里显示为半角宽,但在某些终端配置下是全角宽。同时我混用了.、*这些半角字符,导致同一个坐标系里有的格子占 1 列、有的占 2 列,画面自然撕裂。
修复办法很笨但有效:把项目中所有用于渲染图形的字符统一为半角宽度字符族,并做了启动时自检——拿到终端第一行,输出测试字符,测量其实际占用的列宽,再缓存这个值。之后所有位置计算都使用“测量出的宽度”而不是假设宽度。
6.2 透明背景导致的“假黑色”
另一个让人抓狂的问题是:在支持透明背景的终端里,我默认的“黑色背景”根本不显示纯黑,而是把终端窗口后面的壁纸透出来。结果整个画面像是被蒙了一层灰纱,颜色怎么调都不对。
起初我怀疑是颜色数值错乱,后来才发现是背景色语义问题。终端的默认背景色可能是“透明”的,需要显式设置为不透明颜色才能覆盖。我的修复方案是:每一帧开头都输出一次\x1b[48;2;0;0;0m,把背景色强制设为不透明纯黑。如果用户希望在黑色背景上运行,这一步就足够了。
6.3 拖尾效果变成雪花屏
实现拖尾效果时,我一开始觉得“只要把上一帧的字符再画一遍,亮度降低一点就行”。结果画面变成了雪花屏,到处都是残留的乱码字符。我后来复现并对比了正确版本,才想明白原因:拖尾残留不仅是字符变暗,颜色也必须向当前背景色方向插值。
如果只把字符从█改成░,但颜色还是鲜艳的橙色,它在视觉上依然是“亮”的。多个不同颜色的旧轨迹叠加后,终端里就出现了无数玻璃渣一样的小色块。修复方法是每次拖尾绘制时,同时执行字符退化和背景色插值,让旧帧逐渐“溶于”背景,而不是单纯变暗。
6.4 闪屏与卡顿:一次完整的性能排查链路
最后分享一次真实的排障过程。某天我在一台配置不错的机器上跑作品,画面却频繁闪屏,CPU 占用还特别高。我没有直接改代码,而是按下面的链路排查了一遍:
先看每帧输出字节数。我把输出字符串长度打印出来,发现每一帧都在 150KB 以上。这说明基本是全屏重绘。
再看
write调用次数。排查发现我用了一个不够优秀的写法,把每一行都单独write一次,等于一帧有 40 次系统调用。接着观察是否每一帧都调用了
\x1b[2J清屏。频繁清屏会让终端强制重绘整个界面,闪屏感就是这么来的。最后修复:去掉无意义的清屏序列,改为光标定位加覆盖;使用
BufWriter把整个帧字符串打包成一次write;加上脏矩形优化。
修复后,输出字节数降到约 40KB,系统调用从每帧 40 次降为 1 次,CPU 占用明显下降,闪屏也消失了。这个过程给我最大的启发是:终端渲染的瓶颈绝大多数时候不在“算法复杂度”,而在“IO 模式和终端交互方式”。优化前,先量化,再动手,比瞎猜要有效得多。
如果你也想在终端里做点什么,我的建议是从一个最小的“画板”开始:先学会用一次write让一个字符沿着轨迹移动,再研究颜色渐变,最后才去追求粒子、诗句和完整的叙事。终端渲染的门槛不高,但天花板很高——高到值得你花几个月时间,把一件工具打磨成一首诗。