你的畫筆有一台隱形攝影機
InkField 的錄製系統就是這樣運作的。它不錄「圖片」,而是錄「動作」。重播的時候,程式按照這個腳本,把你的每個動作重新執行一遍——就像一個機器人照著你的動作重畫一次。
為什麼不直接錄影片?
因為錄影片檔案巨大,而且無法改變解析度或調整效果。錄動作的好處是:
- 檔案超小:一幅畫的錄製可能只有幾十 KB(影片要幾十 MB)
- 完美重現:重播出來的畫跟原本一模一樣
- 可改變設定:用不同的畫布大小或背景色重播
- 像藝術表演:觀眾可以看到整個創作過程,不只是成品
錄了什麼?事件清單
每個「動作」在程式裡叫做一個 事件(event)。錄製系統記錄的事件有這幾種:
事件類型總覽
| 代碼 | 全名 | 意思 | 附帶資料 |
|---|---|---|---|
mp | mousePressed | 滑鼠按下(起筆) | 位置 x, y + 筆觸設定 |
md | mouseDragged | 滑鼠拖曳(行筆) | 位置 x, y |
mr | mouseReleased | 滑鼠放開(收筆) | 位置 x, y |
kp | keyPressed | 鍵盤按鍵 | 按了什麼鍵 |
ec | effectControl | 效果設定改變 | 什麼設定、新值 |
每個事件都會記錄一個時間戳(timestamp),精確到毫秒。這樣重播的時候,程式知道每個動作應該在什麼時候發生。
附帶:筆刷大小=3, 模式=1, 顏色=黑
mousePressed 的特殊附件:strokeData
每次起筆(mousePressed)的時候,事件不只記錄位置,還會附帶一整包「筆觸設定」:
- 筆刷大小(initialSize)
- 筆刷模式(brushMode, brushModeB)
- 筆刷顏色(brushColorMode)
- 墨水效果(useSharpen)
- 白色筆刷模式(whiteBrushMode)
- Flow 設定(flowMode, flowSpeed)
- Distort 設定(distortEnabled, distortMode)
- 混合模式(keyBlendMode)
- …等等更多設定
這樣即使中途換了筆刷設定,每一筆的開頭都有完整的「快照」,重播時可以精確還原。
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 代替 mousePressed、md 代替 mouseDragged。因為一幅畫可能有好幾萬個 mouseDragged 事件,每個欄位名省幾個字,加起來就省了很多檔案大小。
重播的挑戰:為什麼不能直接播?
聽起來很簡單對吧?照著事件清單重做一遍就好了。但魔鬼藏在細節裡:
挑戰一:隨機數問題
筆刷裡大量使用 random()——噴點的位置、飛白的分叉、紋理的方向…都有隨機成分。問題是:每次呼叫 random() 都會得到不同的數字。
就像你讓兩個人同時丟骰子,即使規則一樣,丟出的結果也不一樣。錄製時丟出 3、6、1、4…,重播時可能丟出 5、2、6、3…,畫面就完全不同了。
挑戰二:幀率差異
你的電腦跑程式不會每次都同樣快。錄製時可能跑 60fps(每秒 60 幀),重播時可能因為其他程式佔用 CPU 只跑 45fps。每幀處理的事件數不同,筆觸的細節就會有差異。
挑戰三:條件分支
程式裡有很多「如果…就…」的判斷。比如「如果速度超過某個值,就多呼叫一次 random() 來加飛白效果」。錄製和重播時,因為微小的浮點數差異,可能一個走了 if、一個走了 else,從此 random() 的呼叫次數就不同步了——這叫做「隨機數序列偏移」。
Crandom:馴服隨機的工具
這就是 js/crandom.js 做的事。Crandom 類別包裝了 p5.js 的 random(),加上計數和追蹤功能:
同一個種子 = 同一串數字序列,錄製和重播完全相同
Crandom 的除錯超能力
除了包裝 random(),Crandom 還有計數功能——你可以知道到目前為止總共呼叫了幾次 random()。這在除錯的時候非常有用:
如果兩個數字不一樣,就表示某個地方有條件分支讓錄製和重播走了不同的路。CrandomDebugger 的 checkpoint 系統可以幫你找到是哪個階段開始偏移的。
事件同步:每幀只做一件事
重播的時候也是一樣。最大的突破之一是:
每幀只處理一個 mouseDragged 事件
為什麼這很重要?因為每次 draw() 循環裡,筆刷物理系統(彈簧、阻尼)都會更新一次。如果一幀處理了三個事件,物理系統只更新一次;錄製時如果一幀只處理一個事件,物理系統更新了三次——筆觸的彈性和粗細就不同了。
修復前 vs 修復後
| 指標 | 修復前 | 修復後 |
|---|---|---|
| Draw 循環匹配率 | 93% | 100% |
| Random 一致性 | 93% | 99.9% |
| 視覺一致性 | 有明顯差異 | 無法區分 |
延遲收筆:最後一個 draw 不能漏
另一個精巧的修復跟「收筆」有關。
問題:mouseReleased 太快執行
如果一幀裡同時收到了 mouseDragged 和 mouseReleased,先處理 mouseDragged 再馬上處理 mouseReleased——看起來合理,但問題是 mouseReleased 會觸發收筆邏輯,而最後一個 mouseDragged 對應的 draw() 還沒執行。
這確保了最後一個 draw 循環一定會執行,收筆的那一點點墨跡不會遺漏。
99.9% 一致性:怎麼達成的?
把前面的所有解決方案串起來,整個重播一致性是這樣達成的:
第 6 步是什麼意思?
原本程式裡有一些這樣的程式碼:
修復方式:直接刪除這類「條件隨機」,改成確定性的計算。或者確保不管條件成不成立,random() 都會被呼叫(只是結果可能不用)。
最終成績單
| 筆刷大小 | random() 差異 | 一致性 |
|---|---|---|
| Size 3.0(10 筆測試) | ±25 | 99.9% |
| Size 5.0(4 筆測試) | ±150 | 99.8% |
差異 ±25 次,聽起來很多?但一幅畫總共呼叫了上萬次 random(),25 次的偏差幾乎看不出視覺差異。
Demo 模式:藏家的自動播放
Demo 模式的流程:
startPlayback() 的初始化清單
重播開始時,程式會做一大堆初始化工作(就像開演前的準備工作):
- 清空畫布
- 還原背景色
- 設定隨機種子
- 還原筆刷顏色模式
- 還原特效設定(金屬光澤、形狀等)
- 重新生成力場(forceMap)
- 重新初始化 Shader
- 重置各種狀態變數(路徑、Bug 標記、模糊值…)
- 設定相機追蹤模式
每一項都不能少,少了任何一個都可能導致重播結果不同。這也是為什麼程式碼裡有一長串看起來很囉嗦的 if (typeof xxx !== 'undefined') 檢查——它在確保每個狀態都被正確重置。
整個錄製/重播系統的設計哲學
不錄結果,錄過程。
不靠運氣,靠種子。
不一次多做,一次一步。
不忽略細節,全部重置。
這四條原則,讓一個充滿隨機性的藝術程式,達到了 99.9% 的重播一致性。
URL 參數:網址列遙控器
基本語法
參數放在 ? 之後,用 _ 分隔,格式為 key:value:
https://your-site.com/?_pix:2.0_wd:0.5_gr:0.3_console:1
?_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 |
rs | RS 效果 | _rs:1 |
cl | Cellular 效果 | _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 |
常用組合範例
?_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 > 行動裝置覆寫 > 桌面預設值