CPU 跑模擬、GPU 只做折射取樣。半徑要編進貼圖的 B 通道,否則大水滴變黑半月、小水滴變泡泡。附首屏延後啟動與三層退路。
發布於 2026-09-06・約 8 分鐘
我在一個頁面的主視覺上做了雨滴打在窗玻璃的效果:水滴會生成、滑落、互相碰撞合併、留下拖尾,並且折射後面的景物。
整支東西的核心是一條公式,而那條公式我寫錯過兩次,兩次都死在同一個未知數上。
CPU 跑模擬:生成水滴、讓它滑落、碰撞時合併、拖尾、把經過路徑上的細水珠抹掉。輸出一張 RGBA 貼圖。
GPU 只做渲染:拿法線位移去取樣背景(折射),用 mipmap 層級當散焦(霧面玻璃)。
為什麼模擬不能放 shader 裡?因為合併需要「每顆水滴知道其他水滴在哪」,而 fragment shader 天生只看得到自己這一個像素。這種需要全域資訊的邏輯,GPU 做不了。
貼圖的四個通道這樣分配:
R, G = 球面法線的 x, y B = 這顆水滴的半徑 ← 關鍵,見下 A = 遮罩
想像一顆掛在玻璃上的水珠。它把後面整片景物縮小、上下顛倒,收進自己這個小圓裡。收進來的範圍大小,取決於它自己的半徑。
vec2 n = (uv - center) / R; // 法線,貼圖裡存的就是這個 vec2 sampleUV = uv - n * R * (1.0 + m); // 取樣點
唯一的未知數是 R。而 shader 拿到的貼圖只有法線,沒有半徑。
第一次寫錯:把 R 寫死成畫面寬度的 8.5%。
結果是半徑 2px 的細水珠去抓 18 倍遠的畫面,每顆變成一塊不相干的顏色——整片看起來像泡泡。而大水滴壓在深色窗框上時,把整條框吸進自己裡面,變成一道硬邊黑半月。
第二次寫錯:改成「左右各探一針,判斷這顆是大是小」。
只能二分,所以所有大水滴共用同一個折射量。水滴內部讀起來像扁掉的圓盤,因為中心和邊緣用了同一個 R。
正確解:B 通道原本存法線的 z,但 z 可以從 x, y 反推,存它是浪費的。改成存半徑。
實作上有個限制:drawImage 沒辦法在畫的時候改顏色。所以要預先做 20 階的半徑貼圖(用 sqrt 分佈,因為小水滴比較多),畫的時候挑最接近的那一階。
drawImage
編碼上限一定要寫成畫布寬度的比例,不能寫絕對像素:
const R_MAX_FRAC = 0.085 // shader 端要用同一個值 const encoded = Math.round(255 * radius / (width * R_MAX_FRAC))
模擬拿到的 width 是裝置像素。寫死絕對值的話,dpr=3 的手機跟 dpr=1 的桌機會差三倍。
一、時間縮放只擋上限不擋下限。
這個花了我最久。prefers-reduced-motion 的路徑要空跑暖機,暖機用自己的時間軸把內部時鐘推到 6663ms。接著我把真實的 performance.now()(通常比這個小)傳進去,於是得到負的時間差。
prefers-reduced-motion
performance.now()
而衰減寫成 Math.pow(0.4, dt):
Math.pow(0.4, dt)
0.4 ^ (-220) = 天文數字 → 水滴的展開值爆到 1e119 → 被畫成比畫布還大 → 整張玻璃變成一片實心的淡紫色 → 畫面看起來像「效果完全沒生效」
一個負數,最後的症狀是「什麼都沒發生」。夾一下就解決:
const dt = Math.max(0, Math.min(rawDt, MAX_STEP))
任何進入指數運算的值都要夾兩邊,不要只夾一邊。
暖機後來收進模擬物件自己管(sim.warm(n)),呼叫端不准拿 performance.now() 去湊。
sim.warm(n)
二、shader 編譯失敗是靜音的。
我的封裝是:編譯失敗回傳 null → 觸發 onLost → 元件回傳 null → canvas 整個不見。console 一行都沒有。
onLost
有一次我在 GLSL 註解裡少打一個 /*,就是這個下場。
/*
查的時候第一件事是確認元素在不在:
document.querySelector('canvas') // null 就是編譯掛了
比較好的作法是編譯失敗時明確印出來:
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { console.error('shader 編譯失敗:\n' + gl.getShaderInfoLog(shader)) }
WebGL 的 info log 通常會直接告訴你第幾行。它一直都在,只是沒人去讀。
三、一段從來沒被執行過的程式碼。
我寫了「水滴會慢慢蒸發」的邏輯,但控制它的變數從來沒被設成大於 0。後果是滑不動的水滴永遠不會消失,只會累積——第 9 秒畫面上就是一堆又大又暗的球。
改對之後還要調速率:0.0016 會讓畫面在 2.4 秒內蒸發光(整片空掉),0.0004 才對。
死程式碼在特效裡特別難發現,因為特效沒有「正確答案」可以比對,你只會覺得「怎麼有點怪」。
四、resize() 會清空所有水滴。
resize()
動態模式無所謂,下一幀就生回來了。prefers-reduced-motion 模式只畫一張靜態影格,resize 之後就是整張空的。
修法是讓 resize() 回傳布林,告訴呼叫端需要重新暖機。
mix(direct, bent, 0.74)
最重要的一條:底圖本身已經畫了雨絲和窗上的水痕,那才是它的質感所在。整片糊掉等於把底圖最值錢的部分拿掉。
水滴是點綴,覆蓋率一成就夠。這也是純 CSS 版做不到的地方——mask 只能「露出」不能「位移取樣」,而且要做霧面得整張 blur。
直接在元件掛載時初始化,實測 LCP 慢了 260ms。
成因不是 canvas 本身(它不是 LCP 候選元素),是建 context、編 shader、上傳 1080×1080 紋理再產 mipmap 這一串同步工作佔住主執行緒。
const start = () => { if ('requestIdleCallback' in window) requestIdleCallback(init) else setTimeout(init, 300) // 舊 Safari } if (document.readyState === 'complete') start() else window.addEventListener('load', start, { once: true })
改完是 +32ms。
一、拿不到 WebGL、context lost、紋理載不動 → 元件回傳 null,只剩原本的 <img>,版面完全不變。
<img>
二、prefers-reduced-motion → 只畫一張暖機 400 幀之後的靜態影格。靜止的水滴仍然是水滴,這個降級幾乎沒有損失。
三、正常 → 60fps 動態。headless 量到中位 16.7ms、p95 17.1ms。
特效沒有單元測試可寫,但可以做這幾件事:
A/B 截圖。 同一頁拍兩張,一張正常、一張把 canvas 設成 display:none。不然你分不清看到的是效果,還是底圖本來就畫了。
display:none
把模擬的畫布 dump 出來直接看。 canvas.toDataURL(),懷疑模擬不對的時候這比看最終畫面快十倍。
canvas.toDataURL()
量 alpha 直方圖。 覆蓋率應該落在 8% 上下。接近 1 就是爆了(就是上面那個負時間的症狀)。
用高 deviceScaleFactor 截局部。 設 3 到 4 倍去看細節,肉眼在 1x 下分不出「扁圓盤」跟「球」。
deviceScaleFactor
網路上這類效果最有名的實作多半是 CC BY-NC-SA(禁止商用)。它們的各種移植版本即使掛著 MIT,移植者也無權轉授原作者的條款。
商用的站要用的話,得找授權真的允許的來源,或者自己重寫。這件事在寫程式之前就要確認,不然做完才發現不能用。
標籤:WebGL、GLSL、特效、前端、效能