工程實作 13 min read

程式碼畫水彩動畫:不用 AI 生圖,Canvas 2D 單檔 HTML 算出 32 秒〈溪山晚照〉

〈溪山晚照〉是我用 Claude Code 寫的單檔 HTML 水彩動畫:Canvas 2D、368 行、沒有一張 AI 生成圖,宣紙暈開、遠山疊色、乾筆飛白都是演算法一筆一筆畫出來,起點是 Threads 作者 @jelenaaigc 用 Codex 做的同類作品。同一個 seed 每次畫出同一幅(兩次 md5 相同),逐格算成 32 秒 MP4——代價是只能畫演算法寫得出來的東西。

程式碼畫水彩動畫:不用 AI 生圖,Canvas 2D 單檔 HTML 算出 32 秒〈溪山晚照〉
本文目錄 · 7
懶人包
  • 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/

〈溪山晚照〉從第 0.5 秒到第 32 秒,每 4 秒取一格:宣紙、遠山疊色、夕陽、水面、乾筆梅枝、梅花、小舟、落款依序出現

這則 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〉。核心是一句話:疊很多層幾乎透明的形狀,每層形狀都稍微不一樣。

每一層的形狀這樣來:

  1. 把多邊形的每條邊從中點切成兩段。
  2. 中點沿高斯分布隨機位移,位移量跟邊長成正比。
  3. 重複幾輪,邊緣就變得不規則。
每層都重新隨機變形一次,再用很低的透明度疊上去。中心每層都蓋到,所以顏色濃;邊緣每層位置不同,所以自然變淡、變軟,像顏料在紙上往外滲。

我的參數跟 Hobbs 原文不一樣,下表是兩邊的對照:

參數Hobbs 原文我的 POC
基底形狀變形輪數約 7 輪先把邊切到每段 ≤70px,再變形 2 輪
每層再變形4~5 輪3 輪
層數30~100 層大面積 26~46 層,花瓣 16 層
每層透明度約 4%大面積 1.6%~4.5%
紋理遮罩每層一張隨機圓點遮罩沒做,改成整張畫最後疊一層紙紋顆粒
「先切邊再變形」是踩出來的。第一版整張畫面幾乎全黑:遠山多邊形的底邊一段就有 740px,位移量又按邊長比例算,第一刀就偏移好幾百 px,多邊形直接炸開蓋滿畫面。先把長邊切成短邊,每段的位移量就受控了。
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 就不同,跟 10-25 第一次實作時記下的 md5 也一致。網址帶 ?seed=777 就能看另一幅,頁面上的「換一幅」按鈕會隨機給一個新 seed。 同一份程式碼、兩個不同 seed 在第 32 秒的完成畫面:左邊 seed 20261025、右邊 seed 777,山形與水面不同,構圖元素相同

構圖元素(梅枝位置、太陽、小舟)是寫死的座標,所以兩幅的佈局一樣;變的是山的稜線、每層暈染的形狀、筆毛的斷點。想要構圖也跟著變,就要把座標也交給 seed 產生。

程式碼怎麼變成影片?

因為 renderAt(t) 只看 t,不看真實經過了多久,所以輸出影片不需要即時播放,逐格算就好:

  1. 用 Playwright 開頁面,網址帶 ?render=1 隱藏操作介面。
  2. 對每一格呼叫 renderAt(f / 24),再用 toDataURL 把畫布存成 PNG。
  3. 交給 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 生圖或手繪比較合適。
  • 調參很花時間:〈溪山晚照〉經過三輪修正(全黑、電線、字幕數字對不上參數)。每個元素的層數、透明度、毛數都是看截圖一個一個調出來的。
如果想要現成框架,HeyGen 開源的 HyperFrames(Apache-2.0,用 HTML 寫畫面再算成影片)和 Remotion(用 React 寫影片)都是同一個思路。Remotion 對個人、3 人以下的營利公司和非營利組織免費,更大的營利公司要買公司授權(以 repo 的 LICENSE.md 為準)。我的 POC 沒用框架,因為只有一個 Canvas,Playwright 加 ffmpeg 就夠了。

想看「AI 直接生動畫」那條路的實測,可以對照我寫過的 PixelMotion AI 像素 Sprite Sheet 實測。

動手試試

線上版底下有四張說明卡,各對應上面一個技術點:

〈溪山晚照〉線上頁面截圖:播放到第 17.9 秒正在畫乾筆梅枝,下方有播放、時間軸、換一幅、下載 PNG 按鈕與四張技術說明卡
網址參數作用
?seed=777換一幅
?t=20停在第 20 秒
?render=1只顯示 1280×720 畫布,給截圖用
最快的入門是打開 https://html.yanchen.app/code-watercolor/,按幾次「換一幅」,再用瀏覽器「檢視原始碼」看 makeWash 和 makeBrush 兩個函式。整份程式碼一個檔案,存下來就能改。
author
陳
陳彥彤

AI 工程師 · AI 顧問。Java 後端 8 年、AI 工程師 2 年。AI 內訓 · AI 導入顧問 · 前後端與雲端培訓。

support

覺得文章有用可以到 GitHub 給個 star,或是透過信箱聊聊 AI 內訓、AI 導入顧問或前後端 / 雲端培訓。