一萬四千顆粒子的座標從哪來:Path2D 取樣筆記
本站首頁那顆會呼吸的粒子永恆之結,座標不是設計師手排的,是程式讀 SVG 路徑、光柵化到 canvas、再逐像素取樣算出來的。這篇拆開整條管線:fetch、正規表達式抓 path、Path2D 上色、掃描不透明像素、丟進 Three.js shader 成形。也記下踩過的兩個雷:粒子胖成棉花糖、手機上整團縮到看不見。
先講結論再倒帶。首頁那顆永恆之結,是一萬四千個 vec3 座標,漂在 GPU 裡由一支 shader 拉扯聚散。問題從來不是「怎麼讓粒子動」——sin 加時間就會動。真正卡住我一整個下午的是上游那個更笨的問題:這一萬四千個座標,到底從哪裡生出來?
答案是:不是手排的,也不是誰在 3D 軟體裡烘焙好匯出的。是程式在瀏覽器打開的那一瞬間,現場讀我們的品牌符號、把它「拍扁成一張黑白圖」,再一格一格數哪裡有顏色,數出來的。
一條把向量拆成點雲的管線
品牌符號本來就是一個 SVG——設計定稿時它是向量,乾淨、可縮放、一個 <path>。粒子系統要的卻是離散的點。中間這段轉換,拆開來是五步:
fetch把 SVG 檔當純文字抓進來- 正規表達式從字串裡挖出
d屬性那串路徑指令 - 用
Path2D把這串指令畫到一張離屏 canvas 上,填白 getImageData讀回整張點陣,逐像素掃,挑出不透明的那些- 每個中選的像素換算成一個空間座標,整包丟進 Three.js 的
BufferGeometry
沒有中介檔、沒有預先算好的 JSON 點陣資料。SVG 改一筆,粒子的形狀跟著改一筆,不用重新烘焙任何東西。這是選這條路最爽的地方。
第二步的醜真相:正則抓 path
正經作法應該是把 SVG 當 XML 解析,DOMParser 拿 <path> 節點再讀 getAttribute('d')。我沒這麼幹。我們的符號只有一條路徑,結構是自己畫的、自己控制的,於是我直接對字串下手:
const svg = await fetch('/knot.svg').then(r => r.text());
const d = svg.match(/\sd="([^"]+)"/)?.[1];
要老實承認:這行在別人的 SVG 上會出事——多條路徑只會抓到第一條,d 屬性換成單引號或多行就漏抓。它能用,純粹因為我完全掌握輸入端。這不是通用解,是一個知道自己邊界在哪的捷徑。 換成來源不受控的檔案,老實用 DOMParser,別學我。
第三步:讓向量變成一張可以「數」的圖
拿到 d 字串後,Path2D 是這整條管線的樞紐。它讓你不必自己解析貝茲曲線,直接把路徑餵給 canvas 上色:
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 512;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#fff';
ctx.fill(new Path2D(d));
const { data } = ctx.getImageData(0, 0, 512, 512);
到這一步,一個乾淨的向量圖形已經被拍扁成 512×512 的點陣。接下來只是苦力:每四個 byte 是一個像素的 RGBA,掃過去,alpha 大於門檻的就記下它的 x、y,順手把畫布座標平移到中心、翻轉 y 軸(螢幕 y 向下、3D 世界 y 向上),再灑一點隨機 z 讓它有厚度。
const points = [];
for (let i = 0; i < data.length; i += 4) {
if (data[i + 3] > 128) {
const px = (i / 4) % 512;
const py = Math.floor((i / 4) / 512);
points.push(px - 256, 256 - py, (Math.random() - 0.5) * 20);
}
}
512×512 掃出來的候選點遠不只一萬四千個,所以最後按固定間隔抽稀到目標數量——太密,GPU 沒必要吃這麼多;太疏,結的線條會斷。一萬四千是來回試出來的、線條剛好連續又不浪費的數。
兩個雷,都是自己踩的
棉花糖事件。 第一版跑起來,結是有了,但整團糊成一朵白雲,線條全黏在一起。查了半天不是取樣的錯,是 shader 裡 gl_PointSize 給太大——每顆粒子的貼片互相重疊,遠看就是一片實心的糖。把 point size 壓小、加上一張中心亮邊緣透明的圓形貼圖當遮罩,線條才「散」回來,結的走向才看得出來。取樣負責形狀,顆粒大小負責質感,這兩件事我一開始混為一談了。
手機上它消失了。 桌機上好端端的結,一到手機整團縮在畫面中央一小塊,像遠方一顆髒掉的星。原因很蠢:座標是照 512 這個畫布尺寸算死的,我沒有隨視窗尺寸(還有那個常常被忘記的 devicePixelRatio)去換算縮放。等於粒子雲的物理大小是寫死的,螢幕一小,它不會跟著變小——是相機和它的相對比例整個跑掉。補上一個跟著容器寬高走的縮放係數才收拾好。「桌機能跑」和「跑對了」中間,隔著整個行動裝置。
為什麼繞這麼一大圈
大可以直接放一張結的圖、或錄一段影片。但那樣它就是死的——換個顏色要重出圖,改個造型要重錄影片,而且它永遠不會回應滑鼠、不會呼吸。
現在這條管線把「符號」和「呈現」拆開了:符號還是那個乾淨的、設計師能改的 SVG;呈現是程式現場長出來的一團活粒子。改上游一筆,下游自己重算。這正好是我們一直在練的那件事——讓內容留在最乾淨、最好維護的那一層,把繁瑣的中間轉換交給程式,而最後長什麼樣、聚成什麼形狀,始終是人說了算。
一顆首頁的結,和一批田野資料的整理,骨子裡是同一道題。