錄製與重播

捕捉每一個手勢,然後 99.9% 精確地重新畫一遍

目錄

  1. 你的畫筆有一台隱形攝影機
  2. 錄了什麼?事件清單
  3. JSON 格式:錄影帶的儲存格式
  4. 重播的挑戰:為什麼不能直接播?
  5. Crandom:馴服隨機的工具
  6. 事件同步:每幀只做一件事
  7. 延遲收筆:最後一個 draw 不能漏
  8. 99.9% 一致性:怎麼達成的?
  9. Demo 模式:藏家的自動播放
  10. URL 參數:網址列遙控器
1

你的畫筆有一台隱形攝影機

想像你在畫畫的時候,有一台隱形攝影機在記錄你的每一個動作——手在哪裡按下、往哪裡移動、何時抬起、用了什麼顏色、什麼大小的筆刷。它不是錄螢幕畫面,而是像一個超級精確的筆記員,把你的「動作腳本」全部記下來。

InkField 的錄製系統就是這樣運作的。它不錄「圖片」,而是錄「動作」。重播的時候,程式按照這個腳本,把你的每個動作重新執行一遍——就像一個機器人照著你的動作重畫一次。

為什麼不直接錄影片?

因為錄影片檔案巨大,而且無法改變解析度或調整效果。錄動作的好處是:

  • 檔案超小:一幅畫的錄製可能只有幾十 KB(影片要幾十 MB)
  • 完美重現:重播出來的畫跟原本一模一樣
  • 可改變設定:用不同的畫布大小或背景色重播
  • 像藝術表演:觀眾可以看到整個創作過程,不只是成品
2

錄了什麼?事件清單

每個「動作」在程式裡叫做一個 事件(event)。錄製系統記錄的事件有這幾種:

事件類型總覽

代碼全名意思附帶資料
mpmousePressed滑鼠按下(起筆)位置 x, y + 筆觸設定
mdmouseDragged滑鼠拖曳(行筆)位置 x, y
mrmouseReleased滑鼠放開(收筆)位置 x, y
kpkeyPressed鍵盤按鍵按了什麼鍵
eceffectControl效果設定改變什麼設定、新值
想像你在教一個機器人畫畫。你不能給它一張照片說「照這個畫」,你得一步步指揮:「現在把筆放到這裡、往右拖、抬起來、換成白色筆…」這就是事件清單在做的事。

每個事件都會記錄一個時間戳(timestamp),精確到毫秒。這樣重播的時候,程式知道每個動作應該在什麼時候發生。

t = 0ms
mousePressed 在 (234, 567) — 開始第一筆
附帶:筆刷大小=3, 模式=1, 顏色=黑
t = 16ms
mouseDragged 到 (240, 560) — 往右上移動
t = 33ms
mouseDragged 到 (255, 548) — 繼續移動
t = 50ms
mouseDragged 到 (278, 532) — 加速中…
t = 200ms
mouseReleased 在 (412, 489) — 收筆
t = 800ms
keyPressed — 按下 'w'(切換白色筆刷)
t = 1200ms
mousePressed 在 (300, 400) — 開始第二筆

mousePressed 的特殊附件:strokeData

每次起筆(mousePressed)的時候,事件不只記錄位置,還會附帶一整包「筆觸設定」:

  • 筆刷大小(initialSize)
  • 筆刷模式(brushMode, brushModeB)
  • 筆刷顏色(brushColorMode)
  • 墨水效果(useSharpen)
  • 白色筆刷模式(whiteBrushMode)
  • Flow 設定(flowMode, flowSpeed)
  • Distort 設定(distortEnabled, distortMode)
  • 混合模式(keyBlendMode)
  • …等等更多設定

這樣即使中途換了筆刷設定,每一筆的開頭都有完整的「快照」,重播時可以精確還原。

3

JSON 格式:錄影帶的儲存格式

錄製完成後,所有動作會被存成一個 JSON 檔案。JSON 就像一個結構整齊的筆記本——用大括號 {} 和中括號 [] 把資料整理得很清楚,人類看得懂,電腦也看得懂。

一個最小的錄製檔大概長這樣:

{
  "version": "1.0",
  "randomSeed": 42857,     // 隨機數種子(超重要!)
  "canvasSize": { "width": 1920, "height": 1080 },
  "canvasBackgroundColor": [255, 255, 255],
  "initialBrushColorMode": 0,  // 起始顏色:黑色
  "initialEffectControl": {
    "shapeType": 0,
    "metallicStrength": 85,
    "metallicTintType": "copper"
  },
  "events": [
    { "m": "mp", "t": 0, "x": 234, "y": 567,
      "strokeData": { "initialSize": 3, "brushMode": 1, ... } },
    { "m": "md", "t": 16, "x": 240, "y": 560 },
    { "m": "md", "t": 33, "x": 255, "y": 548 },
    { "m": "mr", "t": 200, "x": 412, "y": 489 }
  ]
}

欄位解讀

欄位意思
version格式版本,方便未來升級
randomSeed隨機數種子——這是重播一致性的關鍵(第 5 章會詳細講)
canvasSize畫布尺寸,重播時會還原
canvasBackgroundColor背景色 RGB
events[].m事件類型(mp/md/mr/kp/ec)
events[].t時間戳(毫秒)
events[].x, .y滑鼠位置
events[].strokeData起筆時的完整筆觸設定快照

注意到事件類型用了縮寫:mp 代替 mousePressedmd 代替 mouseDragged。因為一幅畫可能有好幾萬個 mouseDragged 事件,每個欄位名省幾個字,加起來就省了很多檔案大小。

4

重播的挑戰:為什麼不能直接播?

聽起來很簡單對吧?照著事件清單重做一遍就好了。但魔鬼藏在細節裡:

挑戰一:隨機數問題

筆刷裡大量使用 random()——噴點的位置、飛白的分叉、紋理的方向…都有隨機成分。問題是:每次呼叫 random() 都會得到不同的數字

就像你讓兩個人同時丟骰子,即使規則一樣,丟出的結果也不一樣。錄製時丟出 3、6、1、4…,重播時可能丟出 5、2、6、3…,畫面就完全不同了。

挑戰二:幀率差異

你的電腦跑程式不會每次都同樣快。錄製時可能跑 60fps(每秒 60 幀),重播時可能因為其他程式佔用 CPU 只跑 45fps。每幀處理的事件數不同,筆觸的細節就會有差異。

挑戰三:條件分支

程式裡有很多「如果…就…」的判斷。比如「如果速度超過某個值,就多呼叫一次 random() 來加飛白效果」。錄製和重播時,因為微小的浮點數差異,可能一個走了 if、一個走了 else,從此 random() 的呼叫次數就不同步了——這叫做「隨機數序列偏移」。

想像兩個人在照著同一本食譜做蛋糕。食譜說「加一撮鹽」,但兩個人的「一撮」大小不同。到了後面「嚐嚐味道,如果太淡就再加一撮」的步驟,一個人加了、一個人沒加——從這裡開始,兩個蛋糕就越來越不一樣了。
5

Crandom:馴服隨機的工具

解決隨機數問題的關鍵叫做「種子(seed)」。想像你有一顆特殊的骰子,上面有個密碼鎖。只要你輸入同一組密碼(種子),這顆骰子丟出的順序就永遠一樣:3、6、1、4、2、5…。不管你丟幾次,只要重新輸入同一組密碼,序列就會從頭開始,完全重複。

這就是 js/crandom.js 做的事。Crandom 類別包裝了 p5.js 的 random(),加上計數和追蹤功能:

// 錄製開始時 randomSeed(recordingSeed); // 設定種子 → 鎖定隨機序列 noiseSeed(recordingSeed); // 噪波也用同一個種子 crandom.reset(); // 歸零計數器 // 每次呼叫 crandom.random() 時 // 1. 計數器 +1 // 2. 呼叫 p5.js 的 random() // 3. 回傳結果 // 重播開始時 randomSeed(recordingData.randomSeed); // 用同一個種子! noiseSeed(recordingData.randomSeed); crandom.reset(); // 歸零計數器 // 因為種子一樣,random() 序列就一樣 // 第 1 次 → 3,第 2 次 → 6,第 3 次 → 1…
3
6
1
4
2
5
8
7

同一個種子 = 同一串數字序列,錄製和重播完全相同

Crandom 的除錯超能力

除了包裝 random(),Crandom 還有計數功能——你可以知道到目前為止總共呼叫了幾次 random()。這在除錯的時候非常有用:

// 錄製結束時 console.log(`錄製用了 ${crandom.getCount()} 次 random()`); // → 錄製用了 12847 次 random() // 重播結束時 console.log(`重播用了 ${crandom.getCount()} 次 random()`); // → 重播用了 12847 次 random() ← 一樣就對了!

如果兩個數字不一樣,就表示某個地方有條件分支讓錄製和重播走了不同的路。CrandomDebugger 的 checkpoint 系統可以幫你找到是哪個階段開始偏移的。

6

事件同步:每幀只做一件事

想像一個人在讀一本故事書給你聽。如果他讀太快(一口氣讀三頁),你會跟不上;如果他讀太慢(一頁讀半天),節奏就不對。最好的方式是:每次翻一頁,讀完再翻下一頁

重播的時候也是一樣。最大的突破之一是:

每幀只處理一個 mouseDragged 事件

const maxMouseDraggedPerFrame = 1; // 不管時間對不對,每一幀畫面只處理一個拖曳事件 // 這保證了 draw() 循環次數跟錄製時一模一樣

為什麼這很重要?因為每次 draw() 循環裡,筆刷物理系統(彈簧、阻尼)都會更新一次。如果一幀處理了三個事件,物理系統只更新一次;錄製時如果一幀只處理一個事件,物理系統更新了三次——筆觸的彈性和粗細就不同了。

修復前 vs 修復後

指標修復前修復後
Draw 循環匹配率93%100%
Random 一致性93%99.9%
視覺一致性有明顯差異無法區分
7

延遲收筆:最後一個 draw 不能漏

另一個精巧的修復跟「收筆」有關。

想像你在寫書法,最後一筆收筆的瞬間,手還在紙上但已經在抬起了。如果機器人在你手還沒完全離開紙面時就停了,最後那一點「收筆韻味」就消失了。

問題:mouseReleased 太快執行

如果一幀裡同時收到了 mouseDragged 和 mouseReleased,先處理 mouseDragged 再馬上處理 mouseReleased——看起來合理,但問題是 mouseReleased 會觸發收筆邏輯,而最後一個 mouseDragged 對應的 draw() 還沒執行。

// 解決方案:延遲 mouseReleased 到下一幀 let processedMouseDraggedThisFrame = false; if (isMouseReleased && processedMouseDraggedThisFrame) { break; // 這幀已經處理了拖曳,收筆延到下一幀 } if (isMouseDragged) { processedMouseDraggedThisFrame = true; }

這確保了最後一個 draw 循環一定會執行,收筆的那一點點墨跡不會遺漏。

8

99.9% 一致性:怎麼達成的?

把前面的所有解決方案串起來,整個重播一致性是這樣達成的:

1. 還原初始狀態 畫布大小、背景色、筆刷設定、效果設定
2. 鎖定隨機種子 randomSeed() + noiseSeed() 用錄製時的同一個種子
3. 重置所有狀態 crandom 計數器、物理系統、Shader 初始化
4. 逐事件播放 每幀最多處理 1 個 mouseDragged,延遲 mouseReleased
5. 每筆起始還原 strokeData 每次 mousePressed 都從快照還原完整設定
6. 移除「條件隨機」 確保每個 random() 呼叫在錄製和重播時都會執行

第 6 步是什麼意思?

原本程式裡有一些這樣的程式碼:

// 壞的寫法:有時候呼叫 random(),有時候不呼叫 if (crandom.random(0, 1) > 0.8 && baseBrushSize >= 2.0) { currentSteps = int(crandom.random(20, 40)); // ↑ 這個 random() 只有 20% 機率被呼叫 // 如果錄製時呼叫了、重播時沒呼叫 // 後面所有 random() 的序號就差了一個 }

修復方式:直接刪除這類「條件隨機」,改成確定性的計算。或者確保不管條件成不成立,random() 都會被呼叫(只是結果可能不用)。

最終成績單

筆刷大小random() 差異一致性
Size 3.0(10 筆測試)±2599.9%
Size 5.0(4 筆測試)±15099.8%

差異 ±25 次,聽起來很多?但一幅畫總共呼叫了上萬次 random(),25 次的偏差幾乎看不出視覺差異。

9

Demo 模式:藏家的自動播放

想像一個畫廊裡的電視螢幕,不斷循環播放藝術家的創作過程。觀眾走過來就能看到一幅畫從白紙到完成的整個旅程。這就是 Demo 模式。

Demo 模式的流程:

載入錄製檔 (JSON) 可以從 URL 自動載入,不需要手動選檔案
還原畫布大小與背景色 如果尺寸不同,會自動 resize 並重新載入
自動開始播放 500ms 延遲後開始,等畫布初始化完成
播放完成 自動儲存結果 PNG

startPlayback() 的初始化清單

重播開始時,程式會做一大堆初始化工作(就像開演前的準備工作):

  • 清空畫布
  • 還原背景色
  • 設定隨機種子
  • 還原筆刷顏色模式
  • 還原特效設定(金屬光澤、形狀等)
  • 重新生成力場(forceMap)
  • 重新初始化 Shader
  • 重置各種狀態變數(路徑、Bug 標記、模糊值…)
  • 設定相機追蹤模式

每一項都不能少,少了任何一個都可能導致重播結果不同。這也是為什麼程式碼裡有一長串看起來很囉嗦的 if (typeof xxx !== 'undefined') 檢查——它在確保每個狀態都被正確重置。

整個錄製/重播系統的設計哲學

不錄結果,錄過程。
不靠運氣,靠種子。
不一次多做,一次一步。
不忽略細節,全部重置。

這四條原則,讓一個充滿隨機性的藝術程式,達到了 99.9% 的重播一致性。

10

URL 參數:網址列遙控器

想像你有一個遙控器,可以在打開電視之前就預設好頻道、音量、畫質。URL 參數就是這個遙控器——在網址列加上特定指令,頁面載入時就自動套用設定。

基本語法

參數放在 ? 之後,用 _ 分隔,格式為 key:value

https://your-site.com/?_pix:2.0_wd:0.5_gr:0.3_console:1
重要規則:一旦有任何 URL 參數,系統會先將所有開關關閉,只啟用你明確指定的項目。所以 ?_wd:0.5 代表「只開 White Dot,其他全關」。

開關類參數(值為 1 啟用 / 0 關閉)

參數 功能 範例
path未來路徑預覽_path:1
grid格線覆蓋_grid:1
console螢幕文字(Art System Log)_console:1
paper紙張紋理_paper:1
camera鏡頭移動(doMoving)_camera:1
loop循環播放_loop:1
distort扭曲 Shader_distort:1
rsRS 效果_rs:1
clCellular 效果_cl:1

數值類參數

這些參數接受數值,值 > 0 時自動啟用對應效果:

參數 功能 Slider 顯示 實際值 範例
pix Pixel Density 直接使用(0.5~5.0) _pix:2.0
wd White Dot Density 0~1.0 顯示值 × 0.1 _wd:1.0 → 實際 0.1
gr Grain Amount 0~1.0 顯示值 × 0.1 _gr:0.5 → 實際 0.05

常用組合範例

// 展示模式:2x 畫質 + White Dot + Grain + 循環播放 + 螢幕文字
?_pix:2.0_wd:1.0_gr:0.5_loop:1_console:1

// 行動裝置省電:1x 畫質 + 只開 Grain
?_pix:1.0_gr:0.3

// 高畫質錄製:低 pix 錄、高 pix 播(路徑點密度由錄製 FPS 決定)
// 錄製時: ?_pix:1.0
// 播放時: ?_pix:3.0_wd:0.8_gr:0.4_loop:1

關於 pix 與播放順暢度

錄製事件(mouseDragged)是在 draw() 迴圈中產生的,所以錄製時的 FPS = 路徑點密度

在高 _pix(如 4.0)下錄製,FPS 低 → 路徑點稀疏 → 播放時筆刷走走停停。解法:用低 pix 錄製、用任意 pix 播放

行動裝置自動覆寫

行動裝置(iPhone/iPad/Android)會自動將 pixel density 降至 1.0 以提升效能。URL 的 _pix 參數優先級最高,可以覆寫這個預設值。

優先級:URL _pix > 行動裝置覆寫 > 桌面預設值

← 上一篇:混色與力場   |   下一篇:AI 日誌 →

InkField 教學系列 — 用國中生也能懂的方式,認識數位水墨的秘密