懶人包
- Threads 作者 @jelenaaigc 用 Codex 只寫程式、不用 AI 生圖,做出有宣紙紋、半透明疊色和乾筆飛白的水彩動畫;原文沒寫用了哪套框架。
- 我用 Claude Code 寫了一個單檔 HTML 復刻〈溪山晚照〉:Canvas 2D、368 行、20,601 bytes、沒有外部依賴,32 秒動畫。
- 暈染用 Tyler Hobbs 的多邊形變形法,大面積每層只上 1.6%~4.5% 的透明度、疊 26~46 層;乾筆飛白是把一筆拆成 16~90 根「毛」,各自用噪聲決定哪裡斷墨。
- 畫面只由時間 t 和 seed 決定:同一個 seed 第 32 秒的畫面跑兩次,md5 完全相同;換 seed 777 就是另一幅。
- 逐格截圖再交給 ffmpeg 就是影片:769 格合成 32.04 秒、3.2 MB 的 MP4,影片長度只看你願意算多久。
AI 生圖給你一張成品,要換就重抽一次。這篇講的是另一條路:讓 AI 寫演算法,由演算法一筆一筆畫出來。成品是一個網頁,打開就會看到宣紙暈開、遠山一層層疊上去、乾筆掃過梅枝留下飛白。
作品已經上線,可以先看再讀:https://html.yanchen.app/code-watercolor/
這則 Threads 在講什麼?
起點是 Threads 上 @jelenaaigc 的一則貼文。作者說用 Codex 只寫程式碼,不靠 AI 繪圖、也不靠提示詞抽卡,就做出有宣紙紋理、半透明疊色暈染、乾筆飛白的水彩動畫,並且強調這種「用程式碼『算』出來的影片,不限時長」。
原文只標了 #codex,沒提到 Canvas、WebGL、Remotion 或任何具體技術,留言我也抓不到。所以下面的做法是我自己的復刻,不代表原作者的實作。
程式碼畫水彩跟 AI 生圖差在哪?
| AI 生圖 | 程式碼算出來 | |
|---|---|---|
| 產出 | 一張成品圖 | 一段程式,執行時才畫出畫面 |
| 過程 | 看不到 | 每一筆都能做成動畫 |
| 重現 | 同一段提示詞每次結果不同 | 同一個 seed 每次一模一樣 |
| 改一個細節 | 重新生成,其他地方也可能變 | 改那個參數,其他不動 |
| 影片長度 | 受生成工具限制 | 想算幾秒就算幾秒 |
〈溪山晚照〉怎麼做出來的?
整個作品是一個 HTML 檔,畫布 1280×720,動畫 32 秒,依序是:
| 時間 | 畫面 |
|---|---|
| 0.3~4.5 秒 | 宣紙上暈開一層暖色天空 |
| 2.2~11.5 秒 | 三層遠山由淺到深疊上去 |
| 9.5~15.6 秒 | 夕陽、水面、水中倒影 |
| 15.2~21.2 秒 | 乾筆畫梅枝,五筆,有飛白 |
| 21.2~25.6 秒 | 十二朵梅花依序開、小舟靠岸 |
| 26.4~30.2 秒 | 直書標題、落款印章 |
水彩暈染:Tyler Hobbs 的多邊形變形法
程序式水彩最常被引用的作法來自 Tyler Hobbs 2017 年的文章〈A Guide to Simulating Watercolor Paint with Generative Art〉。核心是一句話:疊很多層幾乎透明的形狀,每層形狀都稍微不一樣。
每一層的形狀這樣來:
- 把多邊形的每條邊從中點切成兩段。
- 中點沿高斯分布隨機位移,位移量跟邊長成正比。
- 重複幾輪,邊緣就變得不規則。
我的參數跟 Hobbs 原文不一樣,下表是兩邊的對照:
| 參數 | Hobbs 原文 | 我的 POC |
|---|---|---|
| 基底形狀變形輪數 | 約 7 輪 | 先把邊切到每段 ≤70px,再變形 2 輪 |
| 每層再變形 | 4~5 輪 | 3 輪 |
| 層數 | 30~100 層 | 大面積 26~46 層,花瓣 16 層 |
| 每層透明度 | 約 4% | 大面積 1.6%~4.5% |
| 紋理遮罩 | 每層一張隨機圓點遮罩 | 沒做,改成整張畫最後疊一層紙紋顆粒 |
function makeWash({ base, color, layers = 45, alpha = 0.04, baseDepth = 2, baseVar = 0.3, layerDepth = 3, layerVar = 0.3, edge = '', maxLen = 70 }) {
const b = deform(subdivide(base, maxLen), baseDepth, baseVar);
const paths = [];
for (let i = 0; i < layers; i++) paths.push(toPath(deform(b, layerDepth, layerVar)));
return { kind: 'wash', paths, color, alpha, edge, c: centroid(base) };
}
播放時的「暈開」效果,是把層數跟著時間慢慢加上去:進度 30% 就只畫前 30% 的層。
乾筆飛白:一筆拆成幾十根毛
飛白是毛筆墨快乾時,筆毛之間留下的白絲。我的作法是把一筆拆成很多根「毛」:
- 筆跡先用 Catmull-Rom 曲線補成平滑路徑,每 2.5px 取一個點。
- 一筆拆成 16~90 根毛,每根毛在筆寬內有自己的偏移量和墨量。
- 每根毛沿著筆跡取一維噪聲,噪聲值低於門檻的地方就斷開不畫。
- 門檻從筆畫的某個位置(梅枝主幹設在 35% 處)開始往筆尾升高,模擬墨越來越少;筆緣的毛門檻更高,會比中間先乾。
第二版的乾筆像幾條平行的電線:毛太少、太粗,噪聲頻率太低,斷開的地方整齊得不自然。把毛加多、變細、噪聲頻率調高,再讓筆緣先乾,才有現在的飛白感。
宣紙:顆粒、纖維、暗角
紙是開場時一次算好、之後重複使用的一張底圖:
- 底色
#f3ead8,每個像素加一點隨機亮度。 - 畫 2,600 條隨機彎曲的短纖維,六成偏褐、四成偏白,透明度 4%~13%。
- 四周加一圈暗角。
- 所有顏料畫完之後,最後再用 multiply 疊一層細顆粒,讓顏料看起來吃進紙紋裡。
為什麼能邊播邊畫:multiply 疊色可以快取
所有顏料都用 multiply 混色。multiply 的結果跟疊的順序無關,所以已經畫完的元素可以先合成到一張快取畫布上,每一格只重畫「正在畫」的那幾個元素。
function renderAt(t) {
// Multiply blending is commutative, so finished elements can be cached in any order.
const done = [];
ELEMENTS.forEach((el, i) => { if (t >= el.end) done.push(i); });
// ...
}
同一個 seed 為什麼每次都畫出同一幅?
程式裡所有隨機數都來自一個用 seed 初始化的偽隨機產生器(mulberry32),噪聲函式的雜湊也帶入 seed。所以畫面只由兩個值決定:時間 t 和 seed。
我在 2026-10-26 對線上版重新驗證一次:
| 測試 | 結果 |
|---|---|
| seed 20261025,第 32 秒,第一次 | md5 dd7d9685dbd6844d0a6c6b9561ed9b33 |
| seed 20261025,第 32 秒,第二次 | md5 dd7d9685dbd6844d0a6c6b9561ed9b33 |
| seed 777,第 32 秒 | md5 7c891e62387b8133d9167cd32b8a8178 |
?seed=777 就能看另一幅,頁面上的「換一幅」按鈕會隨機給一個新 seed。
構圖元素(梅枝位置、太陽、小舟)是寫死的座標,所以兩幅的佈局一樣;變的是山的稜線、每層暈染的形狀、筆毛的斷點。想要構圖也跟著變,就要把座標也交給 seed 產生。
程式碼怎麼變成影片?
因為 renderAt(t) 只看 t,不看真實經過了多久,所以輸出影片不需要即時播放,逐格算就好:
- 用 Playwright 開頁面,網址帶
?render=1隱藏操作介面。 - 對每一格呼叫
renderAt(f / 24),再用toDataURL把畫布存成 PNG。 - 交給 ffmpeg 合成。
ffmpeg -framerate 24 -i frames/f%05d.png -c:v libx264 -pix_fmt yuv420p -crf 20 watercolor-seed20261025.mp4
32 秒 × 24fps 是 769 格,合成出來的 MP4 是 32.04 秒、3,231,021 bytes、1280×720。中間產物 PNG 有 1.5 GB,合成完就刪了,要的話隨時可以重算。
這也是原文「不限時長」的意思:影片多長,取決於你願意讓電腦算多少格,跟生圖工具的秒數上限無關。
效能跟限制
- 即時播放會掉格:我量過兩次,
renderAt每格平均 41.3ms(第一次實作時邊算邊存 PNG)和 122.2ms(今天在 headless Chromium 連續呼叫 769 次),都超過或貼著 24fps 需要的 41.7ms。逐格輸出影片不受影響,因為影片的時間軸由 t 決定。 - 拖時間軸會頓一下:跳到新的時間點要重建快取,單格最多約 1 秒。
- 風格受演算法限制:這套只會畫多邊形暈染和筆觸。要人物、建築細節,還是 AI 生圖或手繪比較合適。
- 調參很花時間:〈溪山晚照〉經過三輪修正(全黑、電線、字幕數字對不上參數)。每個元素的層數、透明度、毛數都是看截圖一個一個調出來的。
想看「AI 直接生動畫」那條路的實測,可以對照我寫過的 PixelMotion AI 像素 Sprite Sheet 實測。
動手試試
線上版底下有四張說明卡,各對應上面一個技術點:
| 網址參數 | 作用 |
|---|---|
?seed=777 | 換一幅 |
?t=20 | 停在第 20 秒 |
?render=1 | 只顯示 1280×720 畫布,給截圖用 |
makeWash 和 makeBrush 兩個函式。整份程式碼一個檔案,存下來就能改。