
1. 為什么說《永恒工具》是終端渲染的天花板1.1 這個(gè)作品到底做了什么終端渲染這門手藝圈子里玩的人不少但能把一套動(dòng)畫做到讓人停下來多看兩眼的確實(shí)不多?!队篮愎ぞ摺愤@個(gè)作品之所以花時(shí)間打磨是因?yàn)樗瑫r(shí)撞上了現(xiàn)代終端能提供的兩條硬約束色彩和幀率。它是一個(gè)完全跑在控制臺(tái)里的位圖動(dòng)畫作品用半塊字符當(dāng)像素用 ANSI 真彩色當(dāng)顏料講述錘子、齒輪、鑰匙、沙漏這類工具在時(shí)間里的循環(huán)、磨損與延續(xù)。代碼本身也是作品的一部分所以我把它叫“技術(shù)詩”。它不是常見的 ASCII 藝術(shù)那些用字符拼出靜態(tài)貓狗的玩法太常見了。這里用的是真正的幀緩沖一塊一塊的字符在終端上合出一幀一幀的畫面。你可以臨時(shí)把終端理解成一塊低分辨率的彩色屏幕我的渲染器負(fù)責(zé)把像素?cái)?shù)據(jù)變成一串轉(zhuǎn)義序列推給終端模擬器。畫面里能看到一把鐵錘反復(fù)敲打鐵砧火花從字符間隙里迸出去齒輪相互咬合每轉(zhuǎn)一圈顏色就往色環(huán)上偏一度鑰匙和鎖孔在幾個(gè)節(jié)點(diǎn)上做形態(tài)穿插最后是沙漏倒轉(zhuǎn)流沙化成光標(biāo)移動(dòng)的軌跡?!队篮愎ぞ摺纷罱K呈現(xiàn)出來的是這些意象的循環(huán)組詩。如果你以前只把終端當(dāng)成執(zhí)行命令的地方這個(gè)作品可能會(huì)讓你改觀。一段printf能輸出彩色方塊這沒什么稀奇但一整段精心調(diào)度的像素流能畫出會(huì)動(dòng)的齒輪、會(huì)發(fā)熱的鐵砧那就是另一件事了。對(duì)剛接觸終端編程的人它的門檻在于理解顏色編碼、字符寬度和刷新策略對(duì)已經(jīng)寫過幾年命令行工具的人它能幫你把“終端 UI”的邊界再往外推一格。1.2 為什么挑“工具與永恒”這個(gè)題材最初想不出用什么主題來驗(yàn)證渲染引擎。單純跑一個(gè)色塊旋轉(zhuǎn)沒意思跑一張美女圖又太俗最后把主題定在“工具”上因?yàn)楣ぞ呤侨撕蜋C(jī)器之間最誠實(shí)的中介物。錘子不會(huì)騙人齒輪也不懂騙人它們?cè)跁r(shí)間里的狀態(tài)只有三種正在工作、已經(jīng)磨損、等待重啟。這種氣質(zhì)和終端太像了——終端也是工具命令行里的每個(gè)進(jìn)程都有生命周期跑完就退出留下日志和退出碼。于是《永恒工具》就有了雙重含義。表層含義是畫面內(nèi)容鐵錘、齒輪、鑰匙、沙漏以及由它們組合出的機(jī)械裝置。深層含義是媒介本身終端作為人類操作計(jì)算機(jī)最持久的界面已經(jīng)存在了幾十年大概率還會(huì)存在很久它本身就是一件“永恒的工具”。作品里沙漏流沙的軌跡最后落在命令行提示符上就是為了把這兩層含義接起來。題材定了之后視覺敘事也順了。我把整組動(dòng)畫分成四幕第一幕是鍛造強(qiáng)調(diào)力量和熱量第二幕是傳動(dòng)強(qiáng)調(diào)節(jié)奏和循環(huán)第三幕是開啟強(qiáng)調(diào)鑰匙和鎖孔的位置關(guān)系第四幕是計(jì)量強(qiáng)調(diào)沙漏和光標(biāo)的流逝感。每一幕用兩條關(guān)鍵幀曲線控制一條管位置一條管顏色。顏色色相在每個(gè)循環(huán)里都會(huì)整體偏移給人“同一件工具在不同時(shí)刻的狀態(tài)”的感覺比直接復(fù)制粘貼更能體現(xiàn)“永恒”背后的變化感。2. 底層渲染原理半塊像素與 ANSI 真彩色2.1 半塊字符怎么成為終端的最小像素終端上最細(xì)的顯示單元不是字符是兩個(gè)字符疊在一起。這句話乍一聽有點(diǎn)繞其實(shí)原理很簡(jiǎn)單。半角字符占據(jù)一個(gè)單元格寬度大約是高度的二分之一所以一個(gè)完整字符的格子看起來是豎長(zhǎng)的。如果把這個(gè)格子上下拆成兩份上半部分用一種顏色下半部分用另一種顏色就能得到一個(gè)近似方形的像素塊。具體實(shí)現(xiàn)靠 Unicode 的半塊字符。常用的是 U2580?、U2584▄和 U2588█。其中 U2580 是“上半塊”顯示時(shí)整個(gè)字符區(qū)域的上半部分填色下半部分留空U2584 正好反過來下半部分填色上半部分留空。用 ANSI 轉(zhuǎn)義序列分別設(shè)置前景色和背景色就能讓這個(gè)字符呈現(xiàn)“上一種顏色、下一種顏色”的效果。比如想畫一個(gè)上紅下藍(lán)的像素塊就設(shè)置紅色前景、藍(lán)色背景再輸出一個(gè) U2584如果習(xí)慣用 U2580那就把前景和背景對(duì)調(diào)。這個(gè)技巧的價(jià)值在于分辨率。終端一行如果顯示 120 個(gè)字符理論上高度能拆出 240 個(gè)半塊層疊像素水平方向則是 120 像素。于是 120x60 字符的終端畫面可以渲染出約 120x120 像素的位圖比傳統(tǒng)按字符拼圖的刻板街機(jī)畫面細(xì)膩得多。色塊邊緣不會(huì)再是“一整塊字符都是同一種顏色”而是能出現(xiàn)鋸齒形邊緣、漸變過渡、細(xì)線這類更像真實(shí)位圖的東西。用數(shù)學(xué)語言描述屏幕輸出在邏輯上是一個(gè)二維數(shù)組橫向?qū)挾鹊扔谧址袛?shù)縱向高度等于字符行數(shù)的兩倍。渲染器把幀數(shù)據(jù)按照這個(gè)空間分辨率組織每次渲染時(shí)循環(huán)遍歷列每?jī)尚邢M(fèi)一次def blit_pixel(fg_rgb, bg_rgb): return f\x1b[38;2;{fg_rgb[0]};{fg_rgb[1]};{fg_rgb[2]}m \ f\x1b[48;2;{bg_rgb[0]};{bg_rgb[1]};{bg_rgb[2]}m\u2584\x1b[0m2.2 TrueColor 轉(zhuǎn)義序列與前景背景混色終端顏色有過好幾個(gè)時(shí)代。最早是基于 16 色調(diào)色板的 ANSI 顏色之后擴(kuò)展了 256 色索引再后來才有真彩色?,F(xiàn)在主流終端模擬器基本都支持 24 位真彩色也就是 RGB 每個(gè)通道 0 到 255總共 1670 萬種顏色。轉(zhuǎn)義序列的寫法是設(shè)置前景色ESC[38;2;R;G;Bm設(shè)置背景色ESC[48;2;R;G;Bm在 Python 里寫就是\x1b[38;2;255;120;60m。中間的三組數(shù)字分別控制紅、綠、藍(lán)亮度。要注意終端模擬器對(duì)真彩色的標(biāo)稱支持不代表實(shí)際輸出正確很多兜兜轉(zhuǎn)轉(zhuǎn)的終端配置仍然會(huì)把真彩色降級(jí)成 256 色甚至 16 色所以項(xiàng)目里建議做一次終端能力探測(cè)輸出一段測(cè)試色塊再在用戶側(cè)肉眼確認(rèn)。半塊字符的先背景混色還有一個(gè)隱藏收益同一行輸出里前面字符的背景色會(huì)決定前一個(gè)字符的下半塊后面字符的前景色會(huì)決定前一個(gè)字符的上半塊不對(duì)這里需要謹(jǐn)慎描述。實(shí)際規(guī)則是每個(gè)半塊字符本身同時(shí)攜帶前景和背景所以顏色邊界不會(huì)跨字符串?dāng)_。用 U2584 時(shí)前景色是字符上半塊背景色是字符下半塊。下一列的字符擁有自己獨(dú)立的前景和背景顏色混洗不會(huì)影響相鄰列。這保證了每個(gè)字符單元都像獨(dú)立像素一樣可控。順帶提一個(gè)細(xì)節(jié)輸出完字符后最好追加ESC[0m復(fù)位樣式否則下一個(gè)字符會(huì)沿用上一個(gè)字符的顏色屬性。忘了復(fù)位會(huì)在行尾出現(xiàn)一條意想不到的顏色尾巴很多新手第一次跑都會(huì)踩到這個(gè)。另一個(gè)細(xì)節(jié)是編碼字符集終端必須是 UTF-8 模式否則像 U2584 這類字符可能顯示成問號(hào)或者亂碼塊。2.3 雙緩沖與幀輸出策略終端渲染最大的敵人是閃爍。直接在屏幕上逐行打印每次刷新都等于讓終端一邊清屏一邊重繪人眼會(huì)看到明顯的黑白交替閃光。解決思路好辦先構(gòu)建完成一整幀的文本字符串再一次性提交給標(biāo)準(zhǔn)輸出不要在中間穿插clear。這種辦法在原理上跟圖形 API 的雙緩沖機(jī)制一樣渲染時(shí)寫后臺(tái)緩沖區(qū)完成后整體切換到前臺(tái)。更進(jìn)一步的做法是“按行緩存”。每幀渲染完把若干行文本組成列表再用\033[H把光標(biāo)移回左上角按順序輸出這些行。這里不用clear命令因?yàn)榍迤習(xí)腥灵W爍直接把新內(nèi)容覆蓋到舊內(nèi)容上面很多區(qū)域如果顏色一致眼睛甚至感知不到刷新動(dòng)作。為了提升性能我實(shí)際使用的是差分幀保存上一幀的二維顏色數(shù)組下一幀只對(duì)顏色變化明顯的字符重新輸出沒有變化的區(qū)域直接留舊顯示。差分能顯著降低字符輸出量尤其是靜態(tài)背景的動(dòng)畫整體輸出量常常能縮減一半以上。動(dòng)畫循環(huán)里再包一層時(shí)間控制用time.sleep(1 / fps)決定節(jié)奏流暢度視終端吞吐量而定。3. 從關(guān)鍵幀到完整渲染器實(shí)操全過程3.1 幀數(shù)據(jù)模型與關(guān)鍵幀插值做動(dòng)畫之前先定義幀格式。我用的結(jié)構(gòu)很簡(jiǎn)單每一幀是一個(gè)二維數(shù)組尺寸固定為像素寬和高數(shù)組里的值元組是(r, g, b)。像素位圖可以直接來源于圖像素材也可以來源于程序生成的參數(shù)化圖形。為了《永恒工具》我更多用參數(shù)化圖形因?yàn)辇X輪、錘子、鑰匙這些幾何形狀用數(shù)學(xué)式描述比手繪更精準(zhǔn)也方便后續(xù)做形變動(dòng)畫。關(guān)鍵幀插值是讓畫面“動(dòng)起來”的主要手段。定義幾個(gè)時(shí)間點(diǎn)上的位置和形狀參數(shù)比如齒輪的圓心坐標(biāo)、齒數(shù)、旋轉(zhuǎn)角度鐵錘的抬起角度、下落速度然后用線性插值或緩動(dòng)函數(shù)填充中間狀態(tài)。插值結(jié)果再經(jīng)過光柵化變成像素位圖。這樣生成的動(dòng)畫比逐幀手繪工作量要小得多同時(shí)又能保持行為的物理一致性。光柵化階段需要用到一些基礎(chǔ)幾何算法。畫圓用中點(diǎn)畫圓法畫直線用 Bresenham 算法填充用掃描線。每個(gè)像素的顏色還可以疊加光源效果比如鐵砧受熱區(qū)域跟隨鐵錘落點(diǎn)亮度升高隨后逐漸降溫這個(gè)用“距離衰減”很容易實(shí)現(xiàn)def heat_glow(x, y, center_x, center_y, strength): d ((x - center_x) ** 2 (y - center_y) ** 2) ** 0.5 falloff max(0, 1 - d / radius) return (255 * falloff * strength, 90 * falloff * strength, 20 * falloff * strength)3.2 核心渲染循環(huán)實(shí)現(xiàn)渲染循環(huán)是整個(gè)引擎的骨架。我選用 Python 做快速原型因?yàn)?Python 的字符串處理和標(biāo)準(zhǔn)庫控制臺(tái)交互足夠方便。項(xiàng)目的核心循環(huán)只負(fù)責(zé)四件事計(jì)算當(dāng)前時(shí)間點(diǎn)、解析關(guān)鍵幀、合成像素位圖、輸出到終端。下面是去掉無關(guān)裝飾后的核心流程#!/usr/bin/env python3 import os, sys, time, shutil cols, rows shutil.get_terminal_size() pixel_w, pixel_h cols, rows * 2 # 半塊像素虛擬分辨率 os.system() # 讓 Windows 終端進(jìn)入 VT 模式 sys.stdout.write(\x1b[2J\x1b[?25l) # 清屏并隱藏光標(biāo) last_frame [[(0, 0, 0)] * pixel_w for _ in range(pixel_h)] def render_frame(frame): global last_frame lines [] for py in range(0, pixel_h, 2): line_parts [] for px in range(pixel_w): top frame[py][px] bottom frame[py 1][px] if py 1 pixel_h else (0, 0, 0) if top last_frame[py][px] and bottom last_frame[py 1][px]: line_parts.append( ) # 實(shí)際不應(yīng)輸出空格這里簡(jiǎn)寫為跳過舊內(nèi)容 continue line_parts.append( f\x1b[38;2;{top[0]};{top[1]};{top[2]}m f\x1b[48;2;{bottom[0]};{bottom[1]};{bottom[2]}m\u2584\x1b[0m ) lines.append(.join(line_parts)) sys.stdout.write(\x1b[H \n.join(lines)) last_frame frame try: while True: frame compose_frame(time.time()) # 由關(guān)鍵幀插值生成 render_frame(frame) time.sleep(1 / 30) finally: sys.stdout.write(\x1b[?25h) # 恢復(fù)光標(biāo) sys.stdout.write(\x1b[0m)實(shí)際工程里差分判斷不能像上面那樣留一個(gè)空格占位因?yàn)榭崭駮?huì)落在上一幀的顏色延續(xù)上面導(dǎo)致顏色污染。正確的做法是判斷完全相同才跳過否則輸出新的轉(zhuǎn)義序列。我為了敘事簡(jiǎn)潔省略了無變化的占位細(xì)節(jié)但真實(shí)代碼里這個(gè)分支要小心處理否則會(huì)出現(xiàn)明顯的殘影錯(cuò)位。3.3 具體一幕的動(dòng)畫控制以“鍛造”為例用“鍛造”一幕演示參數(shù)控制比較直觀。畫面中有兩個(gè)元素鐵錘和鐵砧。鐵錘的運(yùn)動(dòng)是一條帶緩動(dòng)的正弦曲線錘頭角度在撞擊瞬間歸零鐵砧在撞擊點(diǎn)周圍產(chǎn)生熱光熱光強(qiáng)度隨時(shí)間指數(shù)衰減。關(guān)鍵幀并不需要做成數(shù)組直接用公式描述動(dòng)作更簡(jiǎn)單def hammer_y(t): # 0 到 1下落1 到 1.6抬升支持重復(fù)循環(huán) phase t % 1.6 if phase 0.8: u phase / 0.8 return 1 - (1 - u) ** 2 # 下落加速 else: u (phase - 0.8) / 0.8 return u ** 0.7 # 抬升較慢 def anvil_heat(t_since_hit): return 1.2 * math.exp(-t_since_hit * 2.2)顏色計(jì)算也由參數(shù)驅(qū)動(dòng)。錘頭顏色由溫度決定被打得越多顏色越偏橙紅鐵砧主體的顏色保持冷灰只在接觸面產(chǎn)生暖色高光。這一個(gè)場(chǎng)景全部由代碼生成沒有外部素材完全符合“技術(shù)詩”的氣質(zhì)——每個(gè)視覺結(jié)果都能追溯到一行公式或一次函數(shù)調(diào)用。動(dòng)畫在終端里的實(shí)際表現(xiàn)取決于刷新頻率。終端輸出并不是立刻生效的stdout 默認(rèn)有緩沖機(jī)制要走sys.stdout.flush()或者構(gòu)造帶flushTrue的print()來確保數(shù)據(jù)推送到終端。在循環(huán)里每幀都 flush否則動(dòng)畫會(huì)像卡住一樣積壓很久才突然跳出一段畫面。4. 實(shí)測(cè)避坑指南終端渲染的常見問題與排查4.1 常見問題速查表下面這張表是幾十次實(shí)測(cè)里反復(fù)踩到的坑基本覆蓋了終端動(dòng)畫項(xiàng)目的多數(shù)故障點(diǎn)現(xiàn)象可能原因解決辦法畫面閃爍嚴(yán)重清屏后重繪沒有使用整幀緩沖移除clear直接覆蓋輸出用\x1b[H歸位畫面上下滾動(dòng)終端顯示行數(shù)超過窗口高度先查行數(shù)輸出前裁剪用 ANSI 光標(biāo)控制替代滾動(dòng)顏色明顯偏色或變霧終端模擬器不支持 TrueColor跑一次真彩色測(cè)試圖或降級(jí)為 256 色近似顯示的一列字符錯(cuò)位字體或編碼導(dǎo)致字符寬度異常確認(rèn) UTF-8 環(huán)境避免混合全角符號(hào)幀率不穩(wěn)忽快忽慢輸出量過大或 sleep 被系統(tǒng)調(diào)度打斷使用差分幀壓縮輸出量將 sleep 改成固定節(jié)拍退出后光標(biāo)消失中斷時(shí)沒有恢復(fù)光標(biāo)用 try/finally 或 atexit 恢復(fù)\x1b[?25h字符串拼接很慢Python 大循環(huán)里的字符串加法改用生成器和.join()拼接4.2 字節(jié)輸出量與性能優(yōu)化之間的權(quán)衡終端輸出再快也有上限。終端模擬器要對(duì)接后端 shell還要處理字體渲染、字符寬度算法、顏色編碼轉(zhuǎn)換每秒能承受的輸出字節(jié)數(shù)不是無限的。實(shí)測(cè)下來在常用終端模擬器里一秒鐘刷 30 幀、每幀 100 行、每行 120 個(gè)半塊字符、每字符附帶約 20 字節(jié)轉(zhuǎn)義序列總輸出量大概率會(huì)突破幾十萬字節(jié)。這個(gè)量級(jí)對(duì)多數(shù)機(jī)器并不致命但會(huì)明顯吃掉 CPU尤其在低功耗設(shè)備上可能出現(xiàn)掉幀。我采納的優(yōu)化方式有三個(gè)差分幀跳過不變區(qū)域盡量復(fù)用上一次輸出的轉(zhuǎn)義序列因?yàn)?ANSI 狀態(tài)是持續(xù)性的可以在顏色不變時(shí)省略重復(fù)的轉(zhuǎn)義把幀率控制在 24 到 30 FPS人眼對(duì)這類低分辨率動(dòng)畫的流暢感要求沒有 60 FPS 那么高。還有一點(diǎn)容易被忽略清空顏色狀態(tài)。如果上一行的字符帶有背景色下一行輸出時(shí)沒有及時(shí)重置就會(huì)沿用到后面導(dǎo)致奇怪的色帶。所以行首最好顯式設(shè)置背景色或者在全行輸出末尾統(tǒng)一\x1b[0m。4.3 光標(biāo)、輸入回顯和退出清理終端動(dòng)畫運(yùn)行期間用戶如果隨便敲鍵盤字符會(huì)被回顯到屏幕上破壞動(dòng)畫畫面。處理辦法是在動(dòng)畫啟動(dòng)時(shí)把終端切換到非回顯模式。用 Python 可以直接調(diào)用os.system(stty -echo)退出時(shí)恢復(fù)stty echo。如果擔(dān)心跨平臺(tái)也可以只做 ANSI 光標(biāo)隱藏并在輸出時(shí)把光標(biāo)固定到動(dòng)畫區(qū)域盡量不依賴系統(tǒng)命令。退出清理是好習(xí)慣。動(dòng)畫進(jìn)程被 CtrlC 打斷后如果光標(biāo)仍然是隱藏的用戶會(huì)陷入一個(gè)沒有光標(biāo)的終端體驗(yàn)極差。我的做法是用try包裹主循環(huán)在finally里恢復(fù)光標(biāo)、重置顏色、恢復(fù)回顯。最好再使用atexit注冊(cè)一個(gè)清理函數(shù)防止某些異常路徑漏掉。寬字符是另一個(gè)容易踩的視覺問題。作為 Unicode 字符U2584 的寬度理論上應(yīng)該是 1但某些終端字體或者 locale 配置會(huì)把它按全角字符處理導(dǎo)致后續(xù)所有列向右錯(cuò)位。穩(wěn)妥的檢測(cè)方法是在動(dòng)畫開始前輸出一行已知字符組合比如abc▄def再判斷輸出后的列坐標(biāo)是否等于 6。如果不等于 6應(yīng)該立即關(guān)掉圖形輸出退回到純文本提示。4.4 敘事節(jié)奏與視覺表達(dá)的經(jīng)驗(yàn)技術(shù)問題說完再聊聊內(nèi)容層的坑。純動(dòng)畫容易陷入“好看但無意義”尤其當(dāng)畫面只是技術(shù)演示時(shí)觀眾很快會(huì)失去興趣?!队篮愎ぞ摺吩诟髂恢g加入節(jié)奏對(duì)比鍛造幕速度快、沖擊力強(qiáng)傳動(dòng)幕勻速回轉(zhuǎn)開啟幕精細(xì)緩慢計(jì)量幕接近靜幀。節(jié)奏從快到慢再回升整個(gè)作品才不容易膩。錘子的撞擊聲我沒有做成真實(shí)音頻而是用幀內(nèi)閃光模擬聽覺反饋。每次撞擊瞬間整個(gè)畫面亮度跳升一小截再慢慢回落到正常值讓觀眾即使不看錘頭位置也能感知到“一下、兩下、三下”的節(jié)拍。這個(gè)手法在終端里實(shí)現(xiàn)很簡(jiǎn)單卻比單純堆砌圖像更有延續(xù)性。從某種角度看畫面里真正“永恒”的不是鐵錘或齒輪而是這套通過像素輸出直抵感知的反饋循環(huán)。5. 給想上手的人幾條實(shí)在建議如果你也想做自己的終端動(dòng)畫《永恒工具》這個(gè)項(xiàng)目的擴(kuò)展空間其實(shí)很大??梢韵扔冒雺K字符畫一個(gè)旋轉(zhuǎn)立方體練手做出來之后再加入顏色變化、物體碰撞、場(chǎng)景切換。不要急著堆特效先把幀緩沖、差分輸出、光標(biāo)恢復(fù)這三件事落實(shí)好這三件事做好了終端動(dòng)畫的骨架就穩(wěn)了。性能測(cè)試時(shí)注意看兩個(gè)數(shù)每秒輸出字節(jié)數(shù)和占用 CPU 百分比。字節(jié)數(shù)高說明優(yōu)化壓力大CPU 高說明轉(zhuǎn)義序列解析或字符串拼接需要優(yōu)化。專門提一句Python 里用array或numpy存像素?cái)?shù)組確實(shí)能降低內(nèi)存和運(yùn)算開銷但如果幀率固定在 30 FPS不追求復(fù)雜物理模擬普通列表一般也夠用。最后再分享我個(gè)人的一個(gè)體會(huì)終端動(dòng)畫最難的環(huán)節(jié)其實(shí)是“讓人第一次看到時(shí)不覺得這是命令行故障”。很多人看到控制臺(tái)快速刷新一大片彩色字符會(huì)下意識(shí)以為是崩潰日志。所以開場(chǎng)最好保留 3 到 5 秒的靜態(tài)畫面讓觀眾意識(shí)到“有東西在動(dòng)”再逐步展開動(dòng)作。這個(gè)過渡細(xì)節(jié)幫助很大。折騰完《永恒工具》之后我對(duì)“工具”的理解也變了好的工具應(yīng)該像命令行一樣安靜、可靠、可預(yù)測(cè)然后在不經(jīng)意間把復(fù)雜性藏在簡(jiǎn)單的界面之下。