每條游來游去的小魚,都是正在瀏覽這一頁的訪客,有一條是你自己 ( ´ ▽ ` )ノ 覺得干擾閱讀的話,可以把小魚關掉。
Starry Sea
background完成品請見 Starry Sea元件。
超時空輝耀姬中 Starry Sea 的演出背景真的好美,不過要在瀏覽器上畫出一萬條小魚並保持流暢,是個不小的挑戰。(´・ω・`)
先從最基本的噪聲概念開始,用 Canvas 2D 建立路徑和魚群物理,再一步步搬到 WebGL2 Shader 上,每一步只加一個新概念。
Step 1:Simplex Noise — 有機的隨機
Section titled “Step 1:Simplex Noise — 有機的隨機”做生物相關的動畫,第一步通常需要「隨機」讓東西動起來。
Math.random() 產生的亂數,每次呼叫的結果跟前一次完全無關。
物體看起來會像中邪一樣抖個不停。
Simplex Noise 不一樣,它產生的值是連續且平滑的,相鄰的輸入會得到相近的輸出,形成自然的起伏波動。
路人:「我覺得 random 很自然啊,和你平時一樣 ( ˙꒳˙)」
鱈魚:「不要瞎掰好嘛 ლ(╹ε╹ლ)」
查看範例原始碼
<script setup lang="ts">import { createNoise2D } from 'simplex-noise'import { onBeforeUnmount, onMounted, ref } from 'vue'
const CANVAS_HEIGHT = 200const DOT_RADIUS = 6const TRAIL_LENGTH = 80
const randomCanvasRef = ref<HTMLCanvasElement | null>(null)const noiseCanvasRef = ref<HTMLCanvasElement | null>(null)
const noise2d = createNoise2D()
interface Point { x: number; y: number;}
// Random dot statelet randomDot = { x: 0.5, y: 0.5 }const randomTrailList: Point[] = []
// Noise dot statelet noiseDot = { x: 0.5, y: 0.5 }const noiseTrailList: Point[] = []let noiseTime = Math.random() * 100
let animationFrameId = 0
function drawCanvas( canvas: HTMLCanvasElement, dot: Point, trailList: Point[], label: string, color: string,) { const context = canvas.getContext('2d') if (!context) return
const width = canvas.clientWidth const dpr = window.devicePixelRatio || 1
if (canvas.width !== Math.round(width * dpr)) { canvas.style.height = `${CANVAS_HEIGHT}px` canvas.width = Math.round(width * dpr) canvas.height = Math.round(CANVAS_HEIGHT * dpr) }
context.setTransform(dpr, 0, 0, dpr, 0, 0) context.clearRect(0, 0, width, CANVAS_HEIGHT)
// 背景 context.fillStyle = '#111827' context.fillRect(0, 0, width, CANVAS_HEIGHT)
// 軌跡 if (trailList.length > 1) { context.beginPath() context.moveTo(trailList[0]!.x * width, trailList[0]!.y * CANVAS_HEIGHT) for (let i = 1; i < trailList.length; i++) { context.lineTo(trailList[i]!.x * width, trailList[i]!.y * CANVAS_HEIGHT) } context.strokeStyle = `${color}40` context.lineWidth = 2 context.stroke() }
// 圓點 context.beginPath() context.arc(dot.x * width, dot.y * CANVAS_HEIGHT, DOT_RADIUS, 0, Math.PI * 2) context.fillStyle = color context.fill()
// 標籤 context.fillStyle = '#9ca3af' context.font = '13px sans-serif' context.textAlign = 'left' context.fillText(label, 12, 24)}
function animate() { // Random:每幀隨機跳動 randomDot = { x: randomDot.x + (Math.random() - 0.5) * 0.04, y: randomDot.y + (Math.random() - 0.5) * 0.04, } randomDot.x = Math.max(0.05, Math.min(0.95, randomDot.x)) randomDot.y = Math.max(0.05, Math.min(0.95, randomDot.y)) randomTrailList.push({ ...randomDot }) if (randomTrailList.length > TRAIL_LENGTH) randomTrailList.shift()
// Noise:用 simplex noise 驅動 noiseTime += 0.012 noiseDot = { x: noiseDot.x + noise2d(0, noiseTime) * 0.008, y: noiseDot.y + noise2d(noiseTime, 0) * 0.008, } noiseDot.x = Math.max(0.05, Math.min(0.95, noiseDot.x)) noiseDot.y = Math.max(0.05, Math.min(0.95, noiseDot.y)) noiseTrailList.push({ ...noiseDot }) if (noiseTrailList.length > TRAIL_LENGTH) noiseTrailList.shift()
const randomCanvas = randomCanvasRef.value const noiseCanvas = noiseCanvasRef.value if (randomCanvas) drawCanvas(randomCanvas, randomDot, randomTrailList, 'Math.random()', '#f87171') if (noiseCanvas) drawCanvas(noiseCanvas, noiseDot, noiseTrailList, 'Simplex Noise', '#60a5fa')
animationFrameId = requestAnimationFrame(animate)}
onMounted(() => { animationFrameId = requestAnimationFrame(animate)})
onBeforeUnmount(() => { cancelAnimationFrame(animationFrameId)})</script>
<template> <div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6 dark:border-gray-700"> <div class="grid grid-cols-2 gap-3"> <canvas ref="randomCanvasRef" class="w-full rounded-lg" /> <canvas ref="noiseCanvasRef" class="w-full rounded-lg" /> </div> </div></template>什麼是 Noise 函式?
Section titled “什麼是 Noise 函式?”Noise 函式接收一個座標(可以是 1D、2D、3D),回傳一個 -1 到 1 之間的浮點數。重點在於輸入越接近,輸出越相似。
import { createNoise2D } from 'simplex-noise'
const noise2d = createNoise2D()
// 傳入 (x, y) 座標,得到 -1 ~ 1 的值const value = noise2d(0.5, 1.0) // 例如 0.372...Math.random vs Simplex Noise
Section titled “Math.random vs Simplex Noise”| 特性 | Math.random() | Simplex Noise |
|---|---|---|
| 輸出範圍 | 0 ~ 1 | -1 ~ 1 |
| 連續性 | 完全不連續,每次獨立 | 平滑連續,相鄰值相近 |
| 可重現 | 不行(除非設定 seed) | 同輸入同輸出 |
| 適合場景 | 初始化、機率判斷 | 動畫、地形、自然運動 |
為什麼選 Simplex 而不是 Perlin?
Section titled “為什麼選 Simplex 而不是 Perlin?”其實兩者效果很接近,甚至老爸都是同一個人 ( ´ ▽ ` )ノ
不過 Simplex Noise 在高維度的效能更好,而且沒有 Perlin Noise 在軸對齊方向的格子感(axis-aligned artifact)。
感謝偉大的 npm,我們可以直接使用 simplex-noise 套件,一行就搞定了。ヾ(◍'౪`◍)ノ゙
用 Noise 製造動畫
Section titled “用 Noise 製造動畫”關鍵在於把時間當作噪聲的輸入軸:
const time = performance.now() * 0.001 // 秒
// X 方向的蜿蜒const offsetX = noise2d(phaseX, time * turnRate) * meanderStrength
// Y 方向的蜿蜒const offsetY = noise2d(time * turnRate, phaseY) * meanderStrengthphaseX、phaseY 是每條路徑的隨機偏移,讓不同路徑不會同步擺動。
turnRate 控制轉彎頻率,越小曲線越平緩。
Step 2:游動路徑 - 環狀緩衝區
Section titled “Step 2:游動路徑 - 環狀緩衝區”現在我們有了連續且自然的路徑了,接下來要讓魚群跟著路徑游。
領頭的魚沿著路徑前進,第二條魚跟著「路徑 0.5 秒前經過的位置」,第三條跟著「1 秒前的位置」以此類推。
每條魚讀取不同時間點的歷史位置,自然就排成了一列隊伍。
所以我們要把路徑記下來,不過現在我們有很多魚,而且不能無限記下去,不然可能跑個幾秒鐘,記憶體就爆炸了。ヽ(́◕◞౪◟◕‵)ノ
所以需要一個固定大小、寫滿就自動覆蓋最舊資料的結構,這就是 Ring Buffer(環狀緩衝區)。
<script setup lang="ts">import { onBeforeUnmount, onMounted, ref } from 'vue'
const BUFFER_SIZE = 24const CANVAS_HEIGHT = 220const canvasRef = ref<HTMLCanvasElement | null>(null)
// 環狀緩衝區const buffer = new Float32Array(BUFFER_SIZE)let head = 0let writeCount = 0
let animationFrameId = 0let lastWriteTime = 0
function draw() { const canvas = canvasRef.value if (!canvas) return
const context = canvas.getContext('2d') if (!context) return
const dpr = window.devicePixelRatio || 1 const width = canvas.clientWidth
if (canvas.width !== Math.round(width * dpr)) { canvas.style.height = `${CANVAS_HEIGHT}px` canvas.width = Math.round(width * dpr) canvas.height = Math.round(CANVAS_HEIGHT * dpr) }
context.setTransform(dpr, 0, 0, dpr, 0, 0) context.clearRect(0, 0, width, CANVAS_HEIGHT)
context.fillStyle = '#111827' context.fillRect(0, 0, width, CANVAS_HEIGHT)
const cellWidth = Math.min(30, (width - 60) / BUFFER_SIZE) const startX = (width - cellWidth * BUFFER_SIZE) / 2 const cellY = 40
// 標題 context.fillStyle = '#9ca3af' context.font = '13px sans-serif' context.textAlign = 'center' context.fillText(`環狀緩衝區(大小:${BUFFER_SIZE})`, width / 2, 24)
// 繪製格子 for (let i = 0; i < BUFFER_SIZE; i++) { const x = startX + i * cellWidth const isHead = i === head const isEmpty = buffer[i] === 0 && (writeCount < BUFFER_SIZE ? i >= writeCount : false)
// 格子背景 if (isHead) { context.fillStyle = '#f59e0b' } else if (isEmpty) { context.fillStyle = '#1f2937' } else { // 根據寫入時間產生漸層色(越舊越暗) const age = (head - i + BUFFER_SIZE) % BUFFER_SIZE const brightness = Math.max(0.15, 1 - age / BUFFER_SIZE) context.fillStyle = `rgba(96, 165, 250, ${brightness})` }
context.fillRect(x + 1, cellY, cellWidth - 2, cellWidth - 2)
// 格子邊框 context.strokeStyle = '#374151' context.lineWidth = 1 context.strokeRect(x + 1, cellY, cellWidth - 2, cellWidth - 2)
// 索引 context.fillStyle = '#6b7280' context.font = '9px monospace' context.textAlign = 'center' context.fillText(`${i}`, x + cellWidth / 2, cellY + cellWidth + 14)
// 數值 if (!isEmpty) { context.fillStyle = isHead ? '#111827' : '#e5e7eb' context.font = 'bold 10px monospace' context.fillText( buffer[i]!.toFixed(0), x + cellWidth / 2, cellY + cellWidth / 2 + 4, ) } }
// head 指標 const headX = startX + head * cellWidth + cellWidth / 2 context.fillStyle = '#f59e0b' context.font = 'bold 12px sans-serif' context.textAlign = 'center' context.fillText('▲ head', headX, cellY + cellWidth + 32)
// 說明 const infoY = cellY + cellWidth + 56 context.fillStyle = '#9ca3af' context.font = '12px sans-serif' context.textAlign = 'center' context.fillText( `已寫入 ${writeCount} 筆,head 位置 = ${head},寫滿後自動覆蓋最舊的資料`, width / 2, infoY, )
// 讀取說明 context.fillStyle = '#6b7280' context.font = '11px sans-serif' context.fillText( '亮度越高表示資料越新;黃色為下一個寫入位置', width / 2, infoY + 20, )}
function animate() { const now = performance.now()
if (now - lastWriteTime > 300) { buffer[head] = Math.round(Math.random() * 99) + 1 head = (head + 1) % BUFFER_SIZE writeCount++ lastWriteTime = now }
draw() animationFrameId = requestAnimationFrame(animate)}
onMounted(() => { animationFrameId = requestAnimationFrame(animate)})
onBeforeUnmount(() => { cancelAnimationFrame(animationFrameId)})</script>
<template> <div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6 dark:border-gray-700"> <canvas ref="canvasRef" class="w-full rounded-lg" /> </div></template>Ring Buffer 概念
Section titled “Ring Buffer 概念”想像一個固定長度的陣列,尾端接回頭部形成一個環。寫入時只需移動 head 指標:
const TRAIL_LENGTH = 2000const historyX = new Float32Array(TRAIL_LENGTH)const historyY = new Float32Array(TRAIL_LENGTH)let head = 0
function writePosition(x: number, y: number) { historyX[head] = x historyY[head] = y head = (head + 1) % TRAIL_LENGTH // 超過長度就回到 0}這個技巧我在以前寫單晶片計算平滑濾波器時很長會用到,因為單晶片記憶體很小,沒注意就會炸掉。
為什麼不用 Array.push + shift?
Section titled “為什麼不用 Array.push + shift?”| 操作 | Ring Buffer | push + shift |
|---|---|---|
| 寫入新資料 | O(1),改 head | O(1),push |
| 移除最舊 | 自動覆蓋,O(1) | O(n),shift 要搬全部 |
| 記憶體 | 固定,不會成長 | 會頻繁 GC |
| 讀取第 N 舊的資料 | O(1),算 index | O(1) |
Array.shift() 要把後面所有元素往前搬一格,2000 個元素每幀做一次,CPU 會不開心。
不過如果資料量真的很少,其實用 Array 也沒什麼問題就是了。乁( ◔ ௰◔)「
讀取歷史位置
Section titled “讀取歷史位置”從 ring buffer 讀「N 步之前」的資料:
function readHistory(stepsBack: number): { x: number; y: number } { // head 指向「下一個要寫入」的位置 // head - 1 是最新寫入的,head - 1 - stepsBack 就是 N 步之前 const index = (head - 1 - stepsBack + TRAIL_LENGTH) % TRAIL_LENGTH return { x: historyX[index]!, y: historyY[index]!, }}加上 TRAIL_LENGTH 再取餘數是為了避免負數索引,JavaScript 的 % 運算不保證正數結果。
Step 3:噪聲路徑
Section titled “Step 3:噪聲路徑”把 Simplex Noise 和 Ring Buffer 結合,就能畫出一條不斷蜿蜒上升的路徑。
<script setup lang="ts">import { ref, onMounted, onBeforeUnmount } from 'vue'import { createNoise2D } from 'simplex-noise'
const CANVAS_HEIGHT = 300const TRAIL_LENGTH = 500const RISE_SPEED = 0.0005const MEANDER_STRENGTH = 0.003const TURN_RATE = 0.04
const canvasRef = ref<HTMLCanvasElement | null>(null)const noise2d = createNoise2D()
// 路徑環狀緩衝區const historyX = new Float32Array(TRAIL_LENGTH)const historyY = new Float32Array(TRAIL_LENGTH)let head = 0const phaseX = Math.random() * 100const phaseY = Math.random() * 100
// 初始位置在畫面下方const startX = 0.3 + Math.random() * 0.4historyX.fill(startX)historyY.fill(1.1)
let animationFrameId = 0
// 預模擬for (let step = 0; step < TRAIL_LENGTH; step++) { updateTrail(step * 0.016)}
function updateTrail(time: number) { const noiseX = noise2d(phaseX, time * TURN_RATE) * MEANDER_STRENGTH const noiseY = noise2d(time * TURN_RATE, phaseY) * MEANDER_STRENGTH * 0.75
const prevHead = (head - 1 + TRAIL_LENGTH) % TRAIL_LENGTH let newX = historyX[prevHead]! + noiseX let newY = historyY[prevHead]! + noiseY - RISE_SPEED
// X 軸環繞 if (newX < -0.1) newX += 1.2 if (newX > 1.1) newX -= 1.2
// Y 軸重生 if (newY < -0.1) { newY = 1.1 newX = 0.2 + Math.random() * 0.6 }
historyX[head] = newX historyY[head] = newY head = (head + 1) % TRAIL_LENGTH}
function draw() { const canvas = canvasRef.value if (!canvas) return const context = canvas.getContext('2d') if (!context) return
const dpr = window.devicePixelRatio || 1 const width = canvas.clientWidth
if (canvas.width !== Math.round(width * dpr)) { canvas.style.height = `${CANVAS_HEIGHT}px` canvas.width = Math.round(width * dpr) canvas.height = Math.round(CANVAS_HEIGHT * dpr) }
context.setTransform(dpr, 0, 0, dpr, 0, 0) context.fillStyle = '#111827' context.fillRect(0, 0, width, CANVAS_HEIGHT)
// 從最舊到最新畫出路徑 const wrapThresholdX = width * 0.3 const wrapThresholdY = CANVAS_HEIGHT * 0.3
context.beginPath() let prevPx = -1 let prevPy = -1 for (let i = 0; i < TRAIL_LENGTH; i++) { const idx = (head + i) % TRAIL_LENGTH const px = historyX[idx]! * width const py = historyY[idx]! * CANVAS_HEIGHT
// 距離過大代表環繞或重生,斷開線段 if (prevPx < 0 || Math.abs(px - prevPx) > wrapThresholdX || Math.abs(py - prevPy) > wrapThresholdY) { context.moveTo(px, py) } else { context.lineTo(px, py) } prevPx = px prevPy = py } context.strokeStyle = 'rgba(96, 165, 250, 0.5)' context.lineWidth = 2 context.stroke()
// 畫領頭點 const headIdx = (head - 1 + TRAIL_LENGTH) % TRAIL_LENGTH const headX = historyX[headIdx]! * width const headY = historyY[headIdx]! * CANVAS_HEIGHT
context.beginPath() context.arc(headX, headY, 5, 0, Math.PI * 2) context.fillStyle = '#f59e0b' context.fill()
// 標籤 context.fillStyle = '#9ca3af' context.font = '12px sans-serif' context.textAlign = 'left' context.fillText('● 領頭位置(head)', 12, 20) context.fillText(`路徑歷史長度:${TRAIL_LENGTH} 步`, 12, 38)}
function animate() { const time = performance.now() * 0.001 updateTrail(time) draw() animationFrameId = requestAnimationFrame(animate)}
onMounted(() => { animationFrameId = requestAnimationFrame(animate)})
onBeforeUnmount(() => { cancelAnimationFrame(animationFrameId)})</script>
<template> <div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6 dark:border-gray-700"> <canvas ref="canvasRef" class="w-full rounded-lg" /> </div></template>路徑更新邏輯
Section titled “路徑更新邏輯”每一幀做一件事:在「上一步的位置」加上噪聲偏移,得到新位置,寫入 ring buffer。
function updateTrail(time: number) { const noiseX = noise2d(phaseX, time * turnRate) * meanderStrength const noiseY = noise2d(time * turnRate, phaseY) * meanderStrength * 0.75
const prevHead = (head - 1 + TRAIL_LENGTH) % TRAIL_LENGTH const newX = historyX[prevHead]! + noiseX const newY = historyY[prevHead]! + noiseY - riseSpeed
historyX[head] = newX historyY[head] = newY head = (head + 1) % TRAIL_LENGTH}幾個細節:
riseSpeed:每步往上移動一點(Y 軸減少),讓路徑有穩定的上升趨勢- Y 方向的噪聲乘 0.75:水平蜿蜒比垂直稍大,看起來比較像在水中游
phaseX、phaseY:每條路徑獨立的噪聲種子
路徑游到畫面外要怎麼辦?
// X 軸:柔和環繞if (newX < -0.15) newX += 1.3if (newX > 1.15) newX -= 1.3
// Y 軸:從頂部消失後在底部重生if (newY < -0.15) { newY = 1.05 + Math.random() * 0.15 newX = 0.1 + Math.random() * 0.8}X 軸用環繞(wrap around),魚從左邊出去就從右邊回來。Y 軸則是在底部重新出發,因為魚群是往上游的。
直接啟動的話,所有路徑都擠在底部慢慢爬上來,要好幾秒才能填滿畫面。
解決方法很直接:初始化時先跑一輪完整的歷史模擬。
const fakeTimeOffset = Math.random() * 50for (let step = 0; step < TRAIL_LENGTH; step++) { updateTrail(fakeTimeOffset + step * 0.016)}這樣一開場路徑就已經蜿蜒穿過整個畫面了。(≖ᴗ≖✿)
Step 4:多條路徑與長龍隊形
Section titled “Step 4:多條路徑與長龍隊形”一條路徑太孤單了,真實的魚群會分成好幾條「長龍」,各自蜿蜒穿梭。
<script setup lang="ts">import { ref, onMounted, onBeforeUnmount } from 'vue'import { createNoise2D } from 'simplex-noise'
const CANVAS_HEIGHT = 300const TRAIL_LENGTH = 500const TRAIL_COUNT = 6const RISE_SPEED = 0.0005const MEANDER_STRENGTH = 0.003const TURN_RATE = 0.04
const canvasRef = ref<HTMLCanvasElement | null>(null)const noise2d = createNoise2D()
const trailColorList = [ 'rgba(248, 113, 113, 0.6)', // 紅 'rgba(251, 191, 36, 0.6)', // 黃 'rgba(96, 165, 250, 0.6)', // 藍 'rgba(52, 211, 153, 0.6)', // 綠 'rgba(251, 146, 60, 0.6)', // 橙 'rgba(192, 132, 252, 0.6)', // 紫]
interface Trail { historyX: Float32Array; historyY: Float32Array; head: number; phaseX: number; phaseY: number;}
const trailList: Trail[] = []
// 建立路徑for (let i = 0; i < TRAIL_COUNT; i++) { const historyX = new Float32Array(TRAIL_LENGTH) const historyY = new Float32Array(TRAIL_LENGTH) const startX = 0.1 + Math.random() * 0.8 historyX.fill(startX) historyY.fill(1.05 + Math.random() * 0.15)
trailList.push({ historyX, historyY, head: 0, phaseX: Math.random() * 100, phaseY: Math.random() * 100, })}
let animationFrameId = 0
function updateTrailList(time: number) { for (const trail of trailList) { const noiseX = noise2d(trail.phaseX, time * TURN_RATE) * MEANDER_STRENGTH const noiseY = noise2d(time * TURN_RATE, trail.phaseY) * MEANDER_STRENGTH * 0.75
const prevHead = (trail.head - 1 + TRAIL_LENGTH) % TRAIL_LENGTH let newX = trail.historyX[prevHead]! + noiseX let newY = trail.historyY[prevHead]! + noiseY - RISE_SPEED
if (newX < -0.15) newX += 1.3 if (newX > 1.15) newX -= 1.3
if (newY < -0.15) { newY = 1.05 + Math.random() * 0.15 newX = 0.1 + Math.random() * 0.8 }
trail.historyX[trail.head] = newX trail.historyY[trail.head] = newY trail.head = (trail.head + 1) % TRAIL_LENGTH }}
// 預模擬const fakeTimeOffset = Math.random() * 50for (let step = 0; step < TRAIL_LENGTH; step++) { updateTrailList(fakeTimeOffset + step * 0.016)}
function draw() { const canvas = canvasRef.value if (!canvas) return const context = canvas.getContext('2d') if (!context) return
const dpr = window.devicePixelRatio || 1 const width = canvas.clientWidth
if (canvas.width !== Math.round(width * dpr)) { canvas.style.height = `${CANVAS_HEIGHT}px` canvas.width = Math.round(width * dpr) canvas.height = Math.round(CANVAS_HEIGHT * dpr) }
context.setTransform(dpr, 0, 0, dpr, 0, 0) context.fillStyle = '#111827' context.fillRect(0, 0, width, CANVAS_HEIGHT)
// 畫出每條路徑 const wrapThresholdX = width * 0.3 const wrapThresholdY = CANVAS_HEIGHT * 0.3
for (let t = 0; t < TRAIL_COUNT; t++) { const trail = trailList[t]! context.beginPath() let prevPx = -1 let prevPy = -1
for (let i = 0; i < TRAIL_LENGTH; i++) { const idx = (trail.head + i) % TRAIL_LENGTH const px = trail.historyX[idx]! * width const py = trail.historyY[idx]! * CANVAS_HEIGHT
if (prevPx < 0 || Math.abs(px - prevPx) > wrapThresholdX || Math.abs(py - prevPy) > wrapThresholdY) { context.moveTo(px, py) } else { context.lineTo(px, py) } prevPx = px prevPy = py } context.strokeStyle = trailColorList[t]! context.lineWidth = 2 context.stroke()
// 領頭 const headIdx = (trail.head - 1 + TRAIL_LENGTH) % TRAIL_LENGTH context.beginPath() context.arc( trail.historyX[headIdx]! * width, trail.historyY[headIdx]! * CANVAS_HEIGHT, 4, 0, Math.PI * 2, ) context.fillStyle = trailColorList[t]!.replace('0.6', '1') context.fill() }
context.fillStyle = '#9ca3af' context.font = '12px sans-serif' context.textAlign = 'left' context.fillText(`${TRAIL_COUNT} 條路徑同時運行,各自獨立蜿蜒上升`, 12, 20)}
function animate() { const time = performance.now() * 0.001 updateTrailList(time) draw() animationFrameId = requestAnimationFrame(animate)}
onMounted(() => { animationFrameId = requestAnimationFrame(animate)})
onBeforeUnmount(() => { cancelAnimationFrame(animationFrameId)})</script>
<template> <div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6 dark:border-gray-700"> <canvas ref="canvasRef" class="w-full rounded-lg" /> </div></template>每條路徑都是一組獨立的 ring buffer,有自己的噪聲 phase:
interface Trail { historyX: Float32Array; historyY: Float32Array; head: number; phaseX: number; phaseY: number;}
const trailList: Trail[] = []
for (let i = 0; i < trailCount; i++) { trailList.push({ historyX: new Float32Array(trailLength), historyY: new Float32Array(trailLength), head: 0, phaseX: Math.random() * 100, phaseY: Math.random() * 100, })}phaseX 和 phaseY 的值差距夠大,噪聲就不會撞在一起。每條路徑獨立更新,彼此不干擾。
路徑數量的選擇
Section titled “路徑數量的選擇”| trailCount | 效果 |
|---|---|
| 1-2 | 單一方向感很強,像一條河流 |
| 4-6 | 自然交錯,有層次感 |
| 10+ | 路徑太密集,魚群散佈反而沒重點 |
預設 6 條是個不錯的平衡點,讓畫面有足夠的路徑交錯但又不至於太亂。
Step 5:跟隨路徑 — 魚群的隊形散佈
Section titled “Step 5:跟隨路徑 — 魚群的隊形散佈”路徑畫好了,接下來要讓魚群跟著路徑走。
每條魚被分配到一條路徑(trailIdx),並根據自己的延遲值(delay)去讀取路徑歷史中對應的位置。delay 越大的魚讀越舊的歷史,自然就排在隊伍越後面。
<script setup lang="ts">import { createNoise2D } from 'simplex-noise'import { onBeforeUnmount, onMounted, ref } from 'vue'
const CANVAS_HEIGHT = 300const TRAIL_LENGTH = 500const TRAIL_COUNT = 3const FISH_COUNT = 200const RISE_SPEED = 0.0005const MEANDER_STRENGTH = 0.003const TURN_RATE = 0.04
const canvasRef = ref<HTMLCanvasElement | null>(null)const enableSpread = ref(true)const noise2d = createNoise2D()
interface Trail { historyX: Float32Array; historyY: Float32Array; head: number; phaseX: number; phaseY: number;}
interface Fish { x: number; y: number; trailIdx: number; delay: number;}
const trailList: Trail[] = []const fishList: Fish[] = []
// 建立路徑for (let i = 0; i < TRAIL_COUNT; i++) { const historyX = new Float32Array(TRAIL_LENGTH) const historyY = new Float32Array(TRAIL_LENGTH) const startX = 0.1 + Math.random() * 0.8 historyX.fill(startX) historyY.fill(1.05 + Math.random() * 0.15) trailList.push({ historyX, historyY, head: 0, phaseX: Math.random() * 100, phaseY: Math.random() * 100, })}
function updateTrailList(time: number) { for (const trail of trailList) { const noiseX = noise2d(trail.phaseX, time * TURN_RATE) * MEANDER_STRENGTH const noiseY = noise2d(time * TURN_RATE, trail.phaseY) * MEANDER_STRENGTH * 0.75 const prevHead = (trail.head - 1 + TRAIL_LENGTH) % TRAIL_LENGTH let newX = trail.historyX[prevHead]! + noiseX let newY = trail.historyY[prevHead]! + noiseY - RISE_SPEED
if (newX < -0.15) newX += 1.3 if (newX > 1.15) newX -= 1.3 if (newY < -0.15) { newY = 1.05 + Math.random() * 0.15 newX = 0.1 + Math.random() * 0.8 }
trail.historyX[trail.head] = newX trail.historyY[trail.head] = newY trail.head = (trail.head + 1) % TRAIL_LENGTH }}
// 預模擬const fakeTime = Math.random() * 50for (let step = 0; step < TRAIL_LENGTH; step++) { updateTrailList(fakeTime + step * 0.016)}
// 建立魚群for (let i = 0; i < FISH_COUNT; i++) { const trailIdx = i % TRAIL_COUNT const trail = trailList[trailIdx]! const delay = Math.floor(i / TRAIL_COUNT) / Math.ceil(FISH_COUNT / TRAIL_COUNT) * 0.95 const stepsBack = (delay * (TRAIL_LENGTH - 1)) | 0 const histIdx = (trail.head - 1 - stepsBack + TRAIL_LENGTH * 2) % TRAIL_LENGTH
fishList.push({ x: trail.historyX[histIdx]!, y: trail.historyY[histIdx]!, trailIdx, delay, })}
let animationFrameId = 0
function animate() { const canvas = canvasRef.value if (!canvas) return const context = canvas.getContext('2d') if (!context) return
const time = performance.now() * 0.001 updateTrailList(time)
const dpr = window.devicePixelRatio || 1 const width = canvas.clientWidth
if (canvas.width !== Math.round(width * dpr)) { canvas.style.height = `${CANVAS_HEIGHT}px` canvas.width = Math.round(width * dpr) canvas.height = Math.round(CANVAS_HEIGHT * dpr) }
context.setTransform(dpr, 0, 0, dpr, 0, 0) context.fillStyle = '#111827' context.fillRect(0, 0, width, CANVAS_HEIGHT)
const spread = enableSpread.value ? 0.15 : 0 const followSpeed = 0.03
// 更新並繪製魚群 for (const fish of fishList) { const trail = trailList[fish.trailIdx]! const stepsBack = (fish.delay * (TRAIL_LENGTH - 1)) | 0 const histIdx = (trail.head - 1 - stepsBack + TRAIL_LENGTH * 2) % TRAIL_LENGTH
// 計算路徑方向與垂直向量 const histIdx2 = (histIdx - 30 + TRAIL_LENGTH) % TRAIL_LENGTH const dirX = trail.historyX[histIdx]! - trail.historyX[histIdx2]! const dirY = trail.historyY[histIdx]! - trail.historyY[histIdx2]! const invLen = 1 / (Math.sqrt(dirX * dirX + dirY * dirY) + 0.001) const perpX = -dirY * invLen const perpY = dirX * invLen
// 散佈偏移 const rawPerp = noise2d(fishList.indexOf(fish) * 0.37, time * 0.15) const perpOffset = rawPerp * spread const rawAlong = noise2d(time * 0.15, fishList.indexOf(fish) * 0.37) const alongOffset = rawAlong * spread * 0.3
const targetX = trail.historyX[histIdx]! + perpX * perpOffset + dirX * invLen * alongOffset const targetY = trail.historyY[histIdx]! + perpY * perpOffset + dirY * invLen * alongOffset
const jumpX = targetX - fish.x const jumpY = targetY - fish.y if (jumpX * jumpX + jumpY * jumpY > 0.25) { fish.x = targetX fish.y = targetY } else { fish.x += jumpX * followSpeed fish.y += jumpY * followSpeed }
// 繪製 context.beginPath() context.arc(fish.x * width, fish.y * CANVAS_HEIGHT, 2.5, 0, Math.PI * 2) context.fillStyle = 'rgba(255, 214, 160, 0.8)' context.fill() }
// 標籤 context.fillStyle = '#9ca3af' context.font = '12px sans-serif' context.textAlign = 'left' context.fillText(`魚群散佈:${enableSpread.value ? '開啟' : '關閉'}`, 12, 20)
animationFrameId = requestAnimationFrame(animate)}
onMounted(() => { animationFrameId = requestAnimationFrame(animate)})
onBeforeUnmount(() => { cancelAnimationFrame(animationFrameId)})</script>
<template> <div class="flex flex-col gap-3 border border-gray-200 rounded-xl p-6 dark:border-gray-700"> <canvas ref="canvasRef" class="w-full rounded-lg" /> <div class="flex gap-3"> <button class="rounded-lg px-4 py-1.5 text-sm text-white transition-colors" :class="enableSpread ? 'bg-blue-600 hover:bg-blue-500' : 'bg-gray-600 hover:bg-gray-500'" @click="enableSpread = !enableSpread" > 散佈:{{ enableSpread ? 'ON' : 'OFF' }} </button> </div> </div></template>// 分配路徑與延遲const trailIdx = i % trailCountconst delay = Math.floor(i / trailCount) / Math.ceil(fishCount / trailCount) * 0.95
// 讀取路徑歷史中的目標位置const stepsBack = (delay * (trailLength - 1)) | 0const histIdx = (trail.head - 1 - stepsBack + trailLength * 2) % trailLengthconst targetX = trail.historyX[histIdx]!const targetY = trail.historyY[histIdx]!delay 的值在 0 ~ 0.95 之間均勻分佈。乘以路徑歷史長度就是「往回看幾步」。
| 0 是 JavaScript 的取整技巧,效果等同 Math.floor 但更快。
Lerp 跟隨
Section titled “Lerp 跟隨”魚不會瞬間跳到目標位置,而是用 lerp 平滑移動過去:
const followSpeed = 0.03
fish.x += (targetX - fish.x) * followSpeedfish.y += (targetY - fish.y) * followSpeed每幀只移動「到目標距離」的 3%。離得遠就移動得多,離得近就移動得少,天生帶有減速效果。
如果所有魚都精準走在路徑線上,看起來比較像螞蟻隊伍,不是魚群,應該要讓魚沿路徑的垂直方向散開才對
下面的範例在路徑上標出每個點的法線向量(黃色短線),魚會沿著黃線方向偏移。
<script setup lang="ts">import { createNoise2D } from 'simplex-noise'import { onBeforeUnmount, onMounted, ref } from 'vue'
const CANVAS_HEIGHT = 300const TRAIL_LENGTH = 500const RISE_SPEED = 0.0005const MEANDER_STRENGTH = 0.003const TURN_RATE = 0.04
/** 每隔幾步畫一條法線 */const NORMAL_INTERVAL = 20/** 法線顯示長度(正規化座標) */const NORMAL_LENGTH = 0.04/** 用前後幾步計算方向 */const DIRECTION_STEP = 15
const canvasRef = ref<HTMLCanvasElement | null>(null)const noise2d = createNoise2D()
// 路徑環狀緩衝區const historyX = new Float32Array(TRAIL_LENGTH)const historyY = new Float32Array(TRAIL_LENGTH)let head = 0const phaseX = Math.random() * 100const phaseY = Math.random() * 100
// 初始位置在畫面下方const startX = 0.3 + Math.random() * 0.4historyX.fill(startX)historyY.fill(1.1)
let animationFrameId = 0
// 預模擬for (let step = 0; step < TRAIL_LENGTH; step++) { updateTrail(step * 0.016)}
function updateTrail(time: number) { const noiseX = noise2d(phaseX, time * TURN_RATE) * MEANDER_STRENGTH const noiseY = noise2d(time * TURN_RATE, phaseY) * MEANDER_STRENGTH * 0.75
const prevHead = (head - 1 + TRAIL_LENGTH) % TRAIL_LENGTH let newX = historyX[prevHead]! + noiseX let newY = historyY[prevHead]! + noiseY - RISE_SPEED
if (newX < -0.1) newX += 1.2 if (newX > 1.1) newX -= 1.2
if (newY < -0.1) { newY = 1.1 newX = 0.2 + Math.random() * 0.6 }
historyX[head] = newX historyY[head] = newY head = (head + 1) % TRAIL_LENGTH}
function draw() { const canvas = canvasRef.value if (!canvas) return const context = canvas.getContext('2d') if (!context) return
const dpr = window.devicePixelRatio || 1 const width = canvas.clientWidth
if (canvas.width !== Math.round(width * dpr)) { canvas.style.height = `${CANVAS_HEIGHT}px` canvas.width = Math.round(width * dpr) canvas.height = Math.round(CANVAS_HEIGHT * dpr) }
context.setTransform(dpr, 0, 0, dpr, 0, 0) context.fillStyle = '#111827' context.fillRect(0, 0, width, CANVAS_HEIGHT)
const wrapThresholdX = width * 0.3 const wrapThresholdY = CANVAS_HEIGHT * 0.3
// 畫路徑 context.beginPath() let prevPx = -1 let prevPy = -1 for (let i = 0; i < TRAIL_LENGTH; i++) { const idx = (head + i) % TRAIL_LENGTH const px = historyX[idx]! * width const py = historyY[idx]! * CANVAS_HEIGHT
if (prevPx < 0 || Math.abs(px - prevPx) > wrapThresholdX || Math.abs(py - prevPy) > wrapThresholdY) { context.moveTo(px, py) } else { context.lineTo(px, py) } prevPx = px prevPy = py } context.strokeStyle = 'rgba(96, 165, 250, 0.5)' context.lineWidth = 2 context.stroke()
// 畫法線向量 for (let i = DIRECTION_STEP; i < TRAIL_LENGTH - DIRECTION_STEP; i += NORMAL_INTERVAL) { const idx = (head + i) % TRAIL_LENGTH const idxPrev = (head + i - DIRECTION_STEP + TRAIL_LENGTH) % TRAIL_LENGTH const idxNext = (head + i + DIRECTION_STEP) % TRAIL_LENGTH
// 跳過環繞跳躍點 if (Math.abs(historyX[idxNext]! - historyX[idxPrev]!) > 0.3 || Math.abs(historyY[idxNext]! - historyY[idxPrev]!) > 0.3) { continue }
// 在像素空間計算方向,避免寬高比例不同導致角度偏差 const dirPxX = (historyX[idxNext]! - historyX[idxPrev]!) * width const dirPxY = (historyY[idxNext]! - historyY[idxPrev]!) * CANVAS_HEIGHT const len = Math.sqrt(dirPxX * dirPxX + dirPxY * dirPxY) + 0.0001
// 垂直向量:(dx, dy) → (-dy, dx) const perpPxX = -dirPxY / len const perpPxY = dirPxX / len
const px = historyX[idx]! * width const py = historyY[idx]! * CANVAS_HEIGHT const normalPixelLength = NORMAL_LENGTH * CANVAS_HEIGHT const nx1 = px + perpPxX * normalPixelLength const ny1 = py + perpPxY * normalPixelLength const nx2 = px - perpPxX * normalPixelLength const ny2 = py - perpPxY * normalPixelLength
// 法線 context.beginPath() context.moveTo(nx1, ny1) context.lineTo(nx2, ny2) context.strokeStyle = 'rgba(251, 191, 36, 0.5)' context.lineWidth = 1 context.stroke()
// 中心點 context.beginPath() context.arc(px, py, 2, 0, Math.PI * 2) context.fillStyle = 'rgba(251, 191, 36, 0.7)' context.fill() }}
function animate() { const time = performance.now() * 0.001 updateTrail(time) draw() animationFrameId = requestAnimationFrame(animate)}
onMounted(() => { animationFrameId = requestAnimationFrame(animate)})
onBeforeUnmount(() => { cancelAnimationFrame(animationFrameId)})</script>
<template> <div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6 dark:border-gray-700"> <canvas ref="canvasRef" class="w-full rounded-lg" /> </div></template>// 計算路徑方向(看 30 步的差距)const dirX = trail.historyX[histIdx]! - trail.historyX[histIdx2]!const dirY = trail.historyY[histIdx]! - trail.historyY[histIdx2]!const invLen = 1 / (Math.sqrt(dirX * dirX + dirY * dirY) + 0.001)
// 法線向量(把方向轉 90°)const perpX = -dirY * invLenconst perpY = dirX * invLen
// 用噪聲決定偏移量const perpOffset = noise2d(i * 0.37, time * 0.15) * spread
const targetX = trail.historyX[histIdx]! + perpX * perpOffsetconst targetY = trail.historyY[histIdx]! + perpY * perpOffset法線向量是把方向向量旋轉 90°:(dx, dy) → (-dy, dx)。
乘以噪聲偏移量,魚就會在路徑兩側自然散開。
Step 6:散佈集中度 — spreadCurve 的魔法
Section titled “Step 6:散佈集中度 — spreadCurve 的魔法”上一步的散佈是均勻分布,但自然界的魚群通常大多數集中在路徑中心,只有少數離群者游在外圍。
spreadCurve 這個參數用冪函數來控制分佈的集中程度。
<script setup lang="ts">import { ref, onMounted, onBeforeUnmount } from 'vue'
const CANVAS_HEIGHT = 180const DOT_COUNT = 300
const canvasRef = ref<HTMLCanvasElement | null>(null)const spreadCurve = ref(2)
let animationFrameId = 0
// 預先產生隨機種子const seedList = Array.from({ length: DOT_COUNT }, () => Math.random() * 2 - 1)
function draw() { const canvas = canvasRef.value if (!canvas) return const context = canvas.getContext('2d') if (!context) return
const dpr = window.devicePixelRatio || 1 const width = canvas.clientWidth
if (canvas.width !== Math.round(width * dpr)) { canvas.style.height = `${CANVAS_HEIGHT}px` canvas.width = Math.round(width * dpr) canvas.height = Math.round(CANVAS_HEIGHT * dpr) }
context.setTransform(dpr, 0, 0, dpr, 0, 0) context.fillStyle = '#111827' context.fillRect(0, 0, width, CANVAS_HEIGHT)
const centerY = CANVAS_HEIGHT / 2 const maxSpread = CANVAS_HEIGHT * 0.4 const curve = spreadCurve.value
// 中心路線 context.beginPath() context.moveTo(0, centerY) context.lineTo(width, centerY) context.strokeStyle = 'rgba(96, 165, 250, 0.3)' context.lineWidth = 1 context.setLineDash([4, 4]) context.stroke() context.setLineDash([])
// 繪製魚點 for (let i = 0; i < DOT_COUNT; i++) { const raw = seedList[i]! const curved = (raw > 0 ? 1 : -1) * (Math.abs(raw) ** curve) const offset = curved * maxSpread
const x = (i / DOT_COUNT) * width const y = centerY + offset
context.beginPath() context.arc(x, y, 2, 0, Math.PI * 2) context.fillStyle = 'rgba(255, 214, 160, 0.7)' context.fill() }
// 標籤 context.fillStyle = '#9ca3af' context.font = '12px sans-serif' context.textAlign = 'left' context.fillText(`spreadCurve = ${curve.toFixed(1)}`, 12, 20)}
function animate() { draw() animationFrameId = requestAnimationFrame(animate)}
onMounted(() => { animationFrameId = requestAnimationFrame(animate)})
onBeforeUnmount(() => { cancelAnimationFrame(animationFrameId)})</script>
<template> <div class="flex flex-col gap-3 border border-gray-200 rounded-xl p-6 dark:border-gray-700"> <canvas ref="canvasRef" class="w-full rounded-lg" /> <div class="flex items-center gap-3"> <label class="text-sm text-gray-400">spreadCurve</label> <input v-model.number="spreadCurve" type="range" min="0.5" max="5" step="0.1" class="flex-1" > <span class="w-10 text-right text-sm font-mono text-gray-300">{{ spreadCurve.toFixed(1) }}</span> </div> <span class="text-xs text-gray-400"> curve=1 為均勻分佈,越大越集中在路徑中心,偶爾有離群魚 </span> </div></template>const rawPerp = noise2d(i * 0.37, time * 0.15) // -1 ~ 1
// 保留正負號,對絕對值做冪運算const curved = (rawPerp > 0 ? 1 : -1) * (Math.abs(rawPerp) ** spreadCurve)const perpOffset = curved * spread| spreadCurve | 效果 |
|---|---|
| 1.0 | 均勻分佈,看起來像一片扁平色帶 |
| 2.0 | 大多數集中中心,偶有離群 |
| 3.0+ | 幾乎全部靠中心,極少數在外圍 |
| 0.5 | 反過來,兩側更多、中心較少 |
原理很簡單:對一個 0~1 的值取平方,0.5 變成 0.25、0.9 變成 0.81,小的值被壓得更小,大的值相對影響較少。curve 越大,壓縮越嚴重,大部分的魚就被擠到中心。
拉拉看上方的滑桿,感受不同 spreadCurve 的效果。ヾ(◍'౪`◍)ノ゙
Step 7:旋轉與翻轉 — 讓魚活起來
Section titled “Step 7:旋轉與翻轉 — 讓魚活起來”到目前為止魚只是一堆飄動的光點。真正的魚會根據游動方向轉頭,左轉時翻個身,上升時仰頭。
按下按鈕關閉旋轉看看差異。沒有旋轉的魚永遠朝同一方向,像是被黏在滑軌上的道具。
<script setup lang="ts">import { createNoise2D } from 'simplex-noise'import { onBeforeUnmount, onMounted, ref } from 'vue'
const CANVAS_HEIGHT = 300const TRAIL_LENGTH = 500const TRAIL_COUNT = 2const FISH_COUNT = 60const FISH_SIZE = 8const RISE_SPEED = 0.0005const MEANDER_STRENGTH = 0.003const TURN_RATE = 0.04
const canvasRef = ref<HTMLCanvasElement | null>(null)const enableRotation = ref(true)const noise2d = createNoise2D()
interface Trail { historyX: Float32Array; historyY: Float32Array; head: number; phaseX: number; phaseY: number;}
interface Fish { x: number; y: number; prevX: number; prevY: number; angle: number; scaleX: number; trailIdx: number; delay: number;}
const trailList: Trail[] = []const fishList: Fish[] = []
for (let i = 0; i < TRAIL_COUNT; i++) { const historyX = new Float32Array(TRAIL_LENGTH) const historyY = new Float32Array(TRAIL_LENGTH) const startX = 0.2 + Math.random() * 0.6 historyX.fill(startX) historyY.fill(1.05 + Math.random() * 0.15) trailList.push({ historyX, historyY, head: 0, phaseX: Math.random() * 100, phaseY: Math.random() * 100, })}
function updateTrailList(time: number) { for (const trail of trailList) { const noiseX = noise2d(trail.phaseX, time * TURN_RATE) * MEANDER_STRENGTH const noiseY = noise2d(time * TURN_RATE, trail.phaseY) * MEANDER_STRENGTH * 0.75 const prevHead = (trail.head - 1 + TRAIL_LENGTH) % TRAIL_LENGTH let newX = trail.historyX[prevHead]! + noiseX let newY = trail.historyY[prevHead]! + noiseY - RISE_SPEED if (newX < -0.15) newX += 1.3 if (newX > 1.15) newX -= 1.3 if (newY < -0.15) { newY = 1.05 + Math.random() * 0.15 newX = 0.1 + Math.random() * 0.8 } trail.historyX[trail.head] = newX trail.historyY[trail.head] = newY trail.head = (trail.head + 1) % TRAIL_LENGTH }}
const fakeTime = Math.random() * 50for (let step = 0; step < TRAIL_LENGTH; step++) { updateTrailList(fakeTime + step * 0.016)}
for (let i = 0; i < FISH_COUNT; i++) { const trailIdx = i % TRAIL_COUNT const trail = trailList[trailIdx]! const delay = Math.floor(i / TRAIL_COUNT) / Math.ceil(FISH_COUNT / TRAIL_COUNT) * 0.95 const stepsBack = (delay * (TRAIL_LENGTH - 1)) | 0 const histIdx = (trail.head - 1 - stepsBack + TRAIL_LENGTH * 2) % TRAIL_LENGTH fishList.push({ x: trail.historyX[histIdx]!, y: trail.historyY[histIdx]!, prevX: trail.historyX[histIdx]!, prevY: trail.historyY[histIdx]!, angle: 0, scaleX: Math.random() > 0.5 ? 1 : -1, trailIdx, delay, })}
let animationFrameId = 0const maxPitch = Math.PI / 2.2
function drawFish( context: CanvasRenderingContext2D, x: number, y: number, angle: number, scaleX: number, size: number,) { context.save() context.translate(x, y) if (enableRotation.value) { context.rotate(angle) } context.scale(scaleX, 1)
// 身體橢圓 context.beginPath() context.ellipse(size * 0.1, 0, size * 0.5, size * 0.35, 0, 0, Math.PI * 2) context.fillStyle = 'rgba(255, 214, 160, 0.85)' context.fill()
// 尾巴 context.beginPath() context.moveTo(-size * 0.35, 0) context.lineTo(-size * 0.65, -size * 0.25) context.lineTo(-size * 0.65, size * 0.25) context.closePath() context.fillStyle = 'rgba(255, 200, 140, 0.7)' context.fill()
// 眼睛 context.beginPath() context.arc(size * 0.25, -size * 0.05, size * 0.06, 0, Math.PI * 2) context.fillStyle = '#333' context.fill()
context.restore()}
function animate() { const canvas = canvasRef.value if (!canvas) return const context = canvas.getContext('2d') if (!context) return
const time = performance.now() * 0.001 updateTrailList(time)
const dpr = window.devicePixelRatio || 1 const width = canvas.clientWidth
if (canvas.width !== Math.round(width * dpr)) { canvas.style.height = `${CANVAS_HEIGHT}px` canvas.width = Math.round(width * dpr) canvas.height = Math.round(CANVAS_HEIGHT * dpr) }
context.setTransform(dpr, 0, 0, dpr, 0, 0) context.fillStyle = '#111827' context.fillRect(0, 0, width, CANVAS_HEIGHT)
for (const fish of fishList) { const trail = trailList[fish.trailIdx]! const stepsBack = (fish.delay * (TRAIL_LENGTH - 1)) | 0 const histIdx = (trail.head - 1 - stepsBack + TRAIL_LENGTH * 2) % TRAIL_LENGTH const histIdx2 = (histIdx - 30 + TRAIL_LENGTH) % TRAIL_LENGTH const dirX = trail.historyX[histIdx]! - trail.historyX[histIdx2]! const dirY = trail.historyY[histIdx]! - trail.historyY[histIdx2]! const invLen = 1 / (Math.sqrt(dirX * dirX + dirY * dirY) + 0.001) const perpX = -dirY * invLen const perpY = dirX * invLen const idx = fishList.indexOf(fish) const rawPerp = noise2d(idx * 0.37, time * 0.15) const perpOffset = rawPerp * 0.12
const targetX = trail.historyX[histIdx]! + perpX * perpOffset const targetY = trail.historyY[histIdx]! + perpY * perpOffset
fish.prevX = fish.x fish.prevY = fish.y
const jumpX = targetX - fish.x const jumpY = targetY - fish.y if (jumpX * jumpX + jumpY * jumpY > 0.25) { fish.x = targetX fish.y = targetY } else { fish.x += jumpX * 0.03 fish.y += jumpY * 0.03 }
const deltaX = fish.x - fish.prevX const deltaY = fish.y - fish.prevY const speed = deltaX * deltaX + deltaY * deltaY
if (speed > 1e-10) { if (Math.abs(deltaX) > 0.00002) { fish.scaleX += ((deltaX > 0 ? 1 : -1) - fish.scaleX) * 0.08 } const facingSign = fish.scaleX >= 0 ? 1 : -1 const rawPitch = Math.atan2(deltaY, Math.abs(deltaX) + 0.0001) const targetAngle = Math.max(-maxPitch, Math.min(maxPitch, rawPitch)) * facingSign let angleDiff = targetAngle - fish.angle if (angleDiff > Math.PI) angleDiff -= Math.PI * 2 else if (angleDiff < -Math.PI) angleDiff += Math.PI * 2 fish.angle += angleDiff * 0.08 } else { fish.angle *= 0.95 }
drawFish(context, fish.x * width, fish.y * CANVAS_HEIGHT, fish.angle, fish.scaleX, FISH_SIZE) }
context.fillStyle = '#9ca3af' context.font = '12px sans-serif' context.textAlign = 'left' context.fillText(`旋轉與翻轉:${enableRotation.value ? '開啟' : '關閉'}`, 12, 20)
animationFrameId = requestAnimationFrame(animate)}
onMounted(() => { animationFrameId = requestAnimationFrame(animate)})
onBeforeUnmount(() => { cancelAnimationFrame(animationFrameId)})</script>
<template> <div class="flex flex-col gap-3 border border-gray-200 rounded-xl p-6 dark:border-gray-700"> <canvas ref="canvasRef" class="w-full rounded-lg" /> <div class="flex gap-3"> <button class="rounded-lg px-4 py-1.5 text-sm text-white transition-colors" :class="enableRotation ? 'bg-blue-600 hover:bg-blue-500' : 'bg-gray-600 hover:bg-gray-500'" @click="enableRotation = !enableRotation" > 旋轉:{{ enableRotation ? 'ON' : 'OFF' }} </button> </div> </div></template>從速度算角度
Section titled “從速度算角度”const deltaX = fish.x - fish.prevXconst deltaY = fish.y - fish.prevY
// atan2 根據 XY 位移算出角度const rawPitch = Math.atan2(deltaY, Math.abs(deltaX) + 0.0001)Math.atan2(y, x) 回傳 -π ~ π 的弧度值。Canvas 的正角度是順時針,直接傳入 deltaY 就能讓往上游(deltaY < 0)對應逆時針旋轉(魚頭朝上)。用 Math.abs(deltaX) 是因為翻轉(scaleX)已經處理了左右方向,角度只需要管俯仰。
const maxPitch = Math.PI / 2.2 // 約 ±81°
const targetAngle = Math.max(-maxPitch, Math.min(maxPitch, rawPitch))不限制的話,魚急轉彎時可能翻到 180°,看起來像翻肚子。限制在 ±81° 讓魚最多斜著游,不會完全垂直。
let angleDiff = targetAngle - fish.angleif (angleDiff > Math.PI) angleDiff -= Math.PI * 2else if (angleDiff < -Math.PI) angleDiff += Math.PI * 2
fish.angle += angleDiff * 0.08 // 每幀只轉 8%角度也用 lerp 插值,避免瞬間轉頭。0.08 的速度讓轉彎很滑順,大約 30 幀(半秒)轉到目標角度。
處理 -π 到 π 的跨界是角度插值的經典坑。
角度值在 ±180°(±π)的地方會「斷開」:179° 的下一步是 -179°,數值差了 358°,但實際上兩者只差 2°。如果不修正,lerp 會讓指針往 358° 的方向繞一大圈,而不是走 2° 的最短路徑。
點擊下方圓上任意位置,然後切換「跨界修正」按鈕觀察差異。試著讓目前角度和目標角度分別在 ±180° 線的兩側,效果最明顯。
<script setup lang="ts">import { onBeforeUnmount, onMounted, ref } from 'vue'
const CANVAS_SIZE = 260const RADIUS = 90const LERP_SPEED = 0.03
const canvasRef = ref<HTMLCanvasElement | null>(null)const enableWrapFix = ref(true)
/** 目前角度(弧度) */let currentAngle = Math.PI * 0.75/** 目標角度(弧度) */let targetAngle = -Math.PI * 0.75
let animationFrameId = 0
function draw() { const canvas = canvasRef.value if (!canvas) return const context = canvas.getContext('2d') if (!context) return
const dpr = window.devicePixelRatio || 1 const size = CANVAS_SIZE
if (canvas.width !== Math.round(size * dpr)) { canvas.style.width = `${size}px` canvas.style.height = `${size}px` canvas.width = Math.round(size * dpr) canvas.height = Math.round(size * dpr) }
context.setTransform(dpr, 0, 0, dpr, 0, 0)
const cx = size / 2 const cy = size / 2
// 背景 context.fillStyle = '#111827' context.fillRect(0, 0, size, size)
// 角度圓 context.beginPath() context.arc(cx, cy, RADIUS, 0, Math.PI * 2) context.strokeStyle = 'rgba(255, 255, 255, 0.12)' context.lineWidth = 1 context.stroke()
// 刻度標記 const markAngleList = [ { angle: 0, label: '0°' }, { angle: Math.PI / 2, label: '90°' }, { angle: Math.PI, label: '±180°' }, { angle: -Math.PI / 2, label: '-90°' }, ] context.font = '11px sans-serif' context.textAlign = 'center' context.textBaseline = 'middle' for (const mark of markAngleList) { const mx = cx + Math.cos(mark.angle) * RADIUS const my = cy + Math.sin(mark.angle) * RADIUS
context.beginPath() context.arc(mx, my, 2, 0, Math.PI * 2) context.fillStyle = 'rgba(255, 255, 255, 0.3)' context.fill()
const labelRadius = RADIUS + 16 const lx = cx + Math.cos(mark.angle) * labelRadius const ly = cy + Math.sin(mark.angle) * labelRadius context.fillStyle = '#6b7280' context.fillText(mark.label, lx, ly) }
// 計算 diff let diff = targetAngle - currentAngle const rawDiff = diff
if (enableWrapFix.value) { if (diff > Math.PI) diff -= Math.PI * 2 else if (diff < -Math.PI) diff += Math.PI * 2 }
// lerp currentAngle += diff * LERP_SPEED
// 保持在 -π ~ π if (currentAngle > Math.PI) currentAngle -= Math.PI * 2 else if (currentAngle < -Math.PI) currentAngle += Math.PI * 2
// 畫插值路徑弧線(實際走的方向) const arcStart = currentAngle const arcEnd = currentAngle + diff context.beginPath() if (diff >= 0) { context.arc(cx, cy, RADIUS - 12, arcStart, arcEnd, false) } else { context.arc(cx, cy, RADIUS - 12, arcStart, arcEnd, true) } context.strokeStyle = enableWrapFix.value ? 'rgba(96, 165, 250, 0.35)' : 'rgba(248, 113, 113, 0.35)' context.lineWidth = 3 context.stroke()
// 目標角度指針 const tx = cx + Math.cos(targetAngle) * RADIUS const ty = cy + Math.sin(targetAngle) * RADIUS context.beginPath() context.moveTo(cx, cy) context.lineTo(tx, ty) context.strokeStyle = 'rgba(251, 191, 36, 0.5)' context.lineWidth = 2 context.setLineDash([4, 4]) context.stroke() context.setLineDash([])
context.beginPath() context.arc(tx, ty, 5, 0, Math.PI * 2) context.fillStyle = '#fbbf24' context.fill()
// 目前角度指針 const px = cx + Math.cos(currentAngle) * RADIUS const py = cy + Math.sin(currentAngle) * RADIUS context.beginPath() context.moveTo(cx, cy) context.lineTo(px, py) context.strokeStyle = enableWrapFix.value ? 'rgba(96, 165, 250, 0.8)' : 'rgba(248, 113, 113, 0.8)' context.lineWidth = 2 context.stroke()
context.beginPath() context.arc(px, py, 6, 0, Math.PI * 2) context.fillStyle = enableWrapFix.value ? '#60a5fa' : '#f87171' context.fill()
// 中心點 context.beginPath() context.arc(cx, cy, 3, 0, Math.PI * 2) context.fillStyle = '#9ca3af' context.fill()
// 角度數值 const toDeg = (r: number) => `${Math.round(r * 180 / Math.PI)}°` context.font = '12px sans-serif' context.textAlign = 'left' context.textBaseline = 'top'
context.fillStyle = enableWrapFix.value ? '#60a5fa' : '#f87171' context.fillText(`目前:${toDeg(currentAngle)}`, 8, 8)
context.fillStyle = '#fbbf24' context.fillText(`目標:${toDeg(targetAngle)}`, 8, 26)
context.fillStyle = '#9ca3af' const diffLabel = enableWrapFix.value ? toDeg(diff) : toDeg(rawDiff) context.fillText(`差值:${diffLabel}`, 8, 44)
animationFrameId = requestAnimationFrame(draw)}
function handleClick(event: MouseEvent) { const canvas = canvasRef.value if (!canvas) return
const rect = canvas.getBoundingClientRect() const cx = CANVAS_SIZE / 2 const cy = CANVAS_SIZE / 2 const mx = event.clientX - rect.left - cx const my = event.clientY - rect.top - cy
targetAngle = Math.atan2(my, mx)}
onMounted(() => { animationFrameId = requestAnimationFrame(draw)})
onBeforeUnmount(() => { cancelAnimationFrame(animationFrameId)})</script>
<template> <div class="flex flex-col items-center gap-3 border border-gray-200 rounded-xl p-6 dark:border-gray-700"> <canvas ref="canvasRef" class="cursor-crosshair rounded-lg" @click="handleClick" /> <span class="text-xs text-gray-400"> 點擊圓上任意位置設定目標角度 </span> <button class="rounded-lg px-4 py-1.5 text-sm text-white transition-colors" :class="enableWrapFix ? 'bg-blue-600 hover:bg-blue-500' : 'bg-red-600 hover:bg-red-500'" @click="enableWrapFix = !enableWrapFix" > 跨界修正:{{ enableWrapFix ? 'ON(走最短路徑)' : 'OFF(繞遠路)' }} </button> </div></template>修正方式很直覺:如果算出來的差值絕對值超過 180°,代表繞遠了,往反方向修正一圈(360°)就是最短路徑。
let angleDiff = targetAngle - currentAngle// 差值 > 180° → 順時針繞太遠,減一圈改走逆時針if (angleDiff > Math.PI) angleDiff -= Math.PI * 2// 差值 < -180° → 逆時針繞太遠,加一圈改走順時針else if (angleDiff < -Math.PI) angleDiff += Math.PI * 2以 170° → -170° 為例:
| 差值 | 方向 | |
|---|---|---|
| 修正前 | -170 - 170 = -340° | 逆時針轉 340°(幾乎整圈) |
| 修正後 | -340 + 360 = +20° | 順時針轉 20°(最短路徑) |
水平翻轉(scaleX)
Section titled “水平翻轉(scaleX)”if (Math.abs(deltaX) > 0.00002) { fish.scaleX += ((deltaX > 0 ? 1 : -1) - fish.scaleX) * 0.08}scaleX 在 -1 到 1 之間平滑過渡,正值面向右、負值面向左。也是用 lerp,所以翻轉不會瞬間完成,看起來比較自然。
Step 8:Float32Array — 管理萬條魚的秘訣
Section titled “Step 8:Float32Array — 管理萬條魚的秘訣”到目前為止我們用 JavaScript 物件陣列存魚的狀態。100 條魚沒問題,但一萬條就不一樣了。
| 魚數量 | Object (ms) | Float32Array (ms) | 速度倍率 |
|---|
<script setup lang="ts">import { onMounted, ref } from 'vue'
const FISH_COUNT_LIST = [1000, 5000, 10000, 50000]const FRAMES = 120
/** GPU instance buffer 每條魚需要的 float 數:x, y, r, g, b, size */const GPU_FLOATS = 6
const resultList = ref<Array<{ count: number; objectTime: number; typedTime: number; ratio: number;}>>([])
const running = ref(false)
/** * Object 版:每幀更新物件屬性,再逐一打包成 Float32Array 給 GPU */function benchmarkObject(count: number): number { const fishList = Array.from({ length: count }, () => ({ x: Math.random(), y: Math.random(), r: Math.random(), g: Math.random(), b: Math.random(), size: 0.5 + Math.random() * 0.5, }))
// 模擬 GPU instance buffer const gpuBuffer = new Float32Array(count * GPU_FLOATS)
const start = performance.now()
for (let frame = 0; frame < FRAMES; frame++) { // 更新物件屬性 for (const fish of fishList) { fish.x += (Math.random() - 0.5) * 0.001 fish.y -= 0.0001 }
// 打包給 GPU:逐一從物件讀取屬性,寫入 Float32Array for (let i = 0; i < count; i++) { const fish = fishList[i]! const offset = i * GPU_FLOATS gpuBuffer[offset] = fish.x gpuBuffer[offset + 1] = fish.y gpuBuffer[offset + 2] = fish.r gpuBuffer[offset + 3] = fish.g gpuBuffer[offset + 4] = fish.b gpuBuffer[offset + 5] = fish.size } }
// 防止被優化掉 let sum = 0 for (let i = 0; i < GPU_FLOATS; i++) sum += gpuBuffer[i]! if (sum === -999) console.log(sum)
return performance.now() - start}
/** * TypedArray 版:資料直接存在 Float32Array 中,更新完就是 GPU 可用的格式 */function benchmarkTypedArray(count: number): number { // 資料本身就是 GPU instance buffer const gpuBuffer = new Float32Array(count * GPU_FLOATS)
for (let i = 0; i < count; i++) { const offset = i * GPU_FLOATS gpuBuffer[offset] = Math.random() // x gpuBuffer[offset + 1] = Math.random() // y gpuBuffer[offset + 2] = Math.random() // r gpuBuffer[offset + 3] = Math.random() // g gpuBuffer[offset + 4] = Math.random() // b gpuBuffer[offset + 5] = 0.5 + Math.random() * 0.5 // size }
const start = performance.now()
for (let frame = 0; frame < FRAMES; frame++) { // 更新:直接改 buffer,不需要額外打包 for (let i = 0; i < count; i++) { const offset = i * GPU_FLOATS gpuBuffer[offset]! += (Math.random() - 0.5) * 0.001 gpuBuffer[offset + 1]! -= 0.0001 } // GPU 上傳:gpuBuffer 已經是正確格式 // gl.bufferSubData(gl.ARRAY_BUFFER, 0, gpuBuffer) }
// 防止被優化掉 let sum = 0 for (let i = 0; i < GPU_FLOATS; i++) sum += gpuBuffer[i]! if (sum === -999) console.log(sum)
return performance.now() - start}
async function runBenchmark() { running.value = true resultList.value = []
for (const count of FISH_COUNT_LIST) { await new Promise(resolve => setTimeout(resolve, 50))
const objectTime = benchmarkObject(count) const typedTime = benchmarkTypedArray(count)
resultList.value.push({ count, objectTime: Math.round(objectTime * 10) / 10, typedTime: Math.round(typedTime * 10) / 10, ratio: Math.round(objectTime / typedTime * 10) / 10, }) }
running.value = false}
onMounted(() => { runBenchmark()})</script>
<template> <div class="flex flex-col gap-3 border border-gray-200 rounded-xl p-6 dark:border-gray-700"> <div class="flex items-center gap-3"> <span class="text-sm font-medium text-gray-300">JS Object vs Float32Array 效能測試</span> <button class="rounded-lg bg-blue-600 px-3 py-1 text-xs text-white hover:bg-blue-500 disabled:opacity-50" :disabled="running" @click="runBenchmark" > {{ running ? '測試中...' : '重新測試' }} </button> </div>
<div class="overflow-x-auto"> <table class="w-full text-sm"> <thead> <tr class="text-gray-400"> <th class="px-3 py-2 text-left"> 魚數量 </th> <th class="px-3 py-2 text-right"> Object (ms) </th> <th class="px-3 py-2 text-right"> Float32Array (ms) </th> <th class="px-3 py-2 text-right"> 速度倍率 </th> </tr> </thead> <tbody> <tr v-for="result in resultList" :key="result.count" class="border-t border-gray-700" > <td class="px-3 py-2 font-mono text-gray-300"> {{ result.count.toLocaleString() }} </td> <td class="px-3 py-2 text-right font-mono text-red-400"> {{ result.objectTime }} </td> <td class="px-3 py-2 text-right font-mono text-green-400"> {{ result.typedTime }} </td> <td class="px-3 py-2 text-right font-mono text-yellow-400"> {{ result.ratio }}x </td> </tr> </tbody> </table> </div>
<span class="text-xs text-gray-400"> 模擬 {{ FRAMES }} 幀的更新與 GPU 打包。Object 版每幀需要額外把屬性逐一寫入 Float32Array;TypedArray 版資料本身就是 GPU 可用的格式,省去打包步驟 </span> </div></template>JS Object 的問題
Section titled “JS Object 的問題”一萬個 { x, y, angle, scaleX, r, g, b, trailIdx, delay } 物件代表什麼?
- 一萬次 GC 壓力:每個物件都是獨立的堆記憶體分配,垃圾回收器要追蹤一萬個小物件
- 快取不友善:物件分散在記憶體各處,CPU 讀完一條魚的
x,下一條魚的x可能離很遠
Float32Array 的解法
Section titled “Float32Array 的解法”把所有魚的資料塞進一條連續的 Float32Array:
const FISH_FLOATS = 9 // 每條魚 9 個 float
// 索引常數const F_X = 0const F_Y = 1const F_ANGLE = 2const F_SCALE_X = 3const F_R = 4const F_G = 5const F_B = 6const F_TRAIL_IDX = 7const F_TRAIL_DELAY = 8
// 一次分配所有記憶體const fishState = new Float32Array(10000 * FISH_FLOATS)
// 讀寫第 i 條魚const offset = i * FISH_FLOATSfishState[offset + F_X] = 0.5fishState[offset + F_Y] = 0.3為什麼快這麼多?
Section titled “為什麼快這麼多?”| 面向 | JS Object 陣列 | Float32Array |
|---|---|---|
| 記憶體排列 | 分散在 heap 各處 | 連續一整塊 |
| CPU 快取命中 | 差,cache miss 頻繁 | 好,循序讀取超快 |
| GC 壓力 | 一萬個物件要追蹤 | 只有一個 TypedArray |
| 可直接傳給 GPU | 不行,要先轉換 | 可以直接 bufferSubData |
最後一點特別重要:稍後要把資料傳給 WebGL 時,Float32Array 可以零拷貝直接上傳,JavaScript 物件陣列則必須先逐一轉換。
Step 9:更自然的顏色
Section titled “Step 9:更自然的顏色”現在來幫小魚上色。( ´ ▽ ` )ノ
上方範例左右對照:左邊同色的點完全一模一樣,色塊感明顯;右邊每個點加上 ±7% 的隨機偏移後,整體看起來自然許多。
查看範例原始碼
<script setup lang="ts">import { onBeforeUnmount, onMounted, ref } from 'vue'
const CANVAS_HEIGHT = 340const DOT_RADIUS = 6const DOTS_PER_COLOR = 12
const canvasRef = ref<HTMLCanvasElement | null>(null)
const palette: Array<[number, number, number, string]> = [ [1.00, 0.84, 0.63, '琥珀金'], [1.00, 0.69, 0.63, '珊瑚紅'], [1.00, 0.76, 0.55, '焦橙'], [1.00, 0.89, 0.66, '暖金'], [1.00, 0.66, 0.70, '玫瑰赤'], [1.00, 0.91, 0.72, '蜜黃'], [1.00, 0.81, 0.69, '杏桃'], [0.63, 0.87, 1.00, '天空藍'], [0.59, 0.78, 1.00, '鈷藍'], [0.66, 0.96, 0.91, '薄荷綠'], [0.63, 0.91, 0.78, '翡翠綠'], [0.78, 0.70, 1.00, '薰衣草紫'],]
interface ColorDot { baseIdx: number; /** 在群組內的隨機偏移位置 */ offsetX: number; offsetY: number; jitterR: number; jitterG: number; jitterB: number;}
const dotList: ColorDot[] = []
for (let ci = 0; ci < palette.length; ci++) { for (let di = 0; di < DOTS_PER_COLOR; di++) { dotList.push({ baseIdx: ci, offsetX: (Math.random() - 0.5) * 0.8, offsetY: (Math.random() - 0.5) * 0.8, jitterR: (Math.random() - 0.5) * 0.14, jitterG: (Math.random() - 0.5) * 0.14, jitterB: (Math.random() - 0.5) * 0.14, }) }}
let animationFrameId = 0
function toRgb(r: number, g: number, b: number): string { return `rgb(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)})`}
function clamp01(value: number): number { return Math.min(1, Math.max(0, value))}
function draw() { const canvas = canvasRef.value if (!canvas) return const context = canvas.getContext('2d') if (!context) return
const dpr = window.devicePixelRatio || 1 const width = canvas.clientWidth
if (canvas.width !== Math.round(width * dpr)) { canvas.style.height = `${CANVAS_HEIGHT}px` canvas.width = Math.round(width * dpr) canvas.height = Math.round(CANVAS_HEIGHT * dpr) }
context.setTransform(dpr, 0, 0, dpr, 0, 0) context.fillStyle = '#111827' context.fillRect(0, 0, width, CANVAS_HEIGHT)
const halfWidth = width / 2 const dividerX = halfWidth
// 分隔線 context.beginPath() context.moveTo(dividerX, 0) context.lineTo(dividerX, CANVAS_HEIGHT) context.strokeStyle = 'rgba(255, 255, 255, 0.1)' context.lineWidth = 1 context.stroke()
// 標題 context.font = '12px sans-serif' context.textAlign = 'center' context.fillStyle = '#9ca3af' context.fillText('無抖動', halfWidth / 2, 20) context.fillText('有抖動(±7%)', halfWidth + halfWidth / 2, 20)
// 配置每個色票群組的位置 const cols = 4 const rows = Math.ceil(palette.length / cols) const groupWidth = (halfWidth - 40) / cols const groupHeight = (CANVAS_HEIGHT - 50) / rows const startY = 36
for (let ci = 0; ci < palette.length; ci++) { const col = ci % cols const row = Math.floor(ci / cols) const [baseR, baseG, baseB] = palette[ci]!
const groupCenterXLeft = 20 + col * groupWidth + groupWidth / 2 const groupCenterXRight = halfWidth + 20 + col * groupWidth + groupWidth / 2 const groupCenterY = startY + row * groupHeight + groupHeight / 2
// 畫出該色票群組的所有點 for (let di = 0; di < DOTS_PER_COLOR; di++) { const dot = dotList[ci * DOTS_PER_COLOR + di]! const dx = dot.offsetX * groupWidth * 0.4 const dy = dot.offsetY * groupHeight * 0.4
// 左側:無抖動(所有點顏色完全一樣) context.beginPath() context.arc(groupCenterXLeft + dx, groupCenterY + dy, DOT_RADIUS, 0, Math.PI * 2) context.fillStyle = toRgb(baseR, baseG, baseB) context.fill()
// 右側:有抖動(每個點微微不同) context.beginPath() context.arc(groupCenterXRight + dx, groupCenterY + dy, DOT_RADIUS, 0, Math.PI * 2) context.fillStyle = toRgb( clamp01(baseR + dot.jitterR), clamp01(baseG + dot.jitterG), clamp01(baseB + dot.jitterB), ) context.fill() } }}
function animate() { draw() animationFrameId = requestAnimationFrame(animate)}
onMounted(() => { animationFrameId = requestAnimationFrame(animate)})
onBeforeUnmount(() => { cancelAnimationFrame(animationFrameId)})</script>
<template> <div class="flex flex-col gap-3 border border-gray-200 rounded-xl p-6 dark:border-gray-700"> <canvas ref="canvasRef" class="w-full rounded-lg" /> <span class="text-xs text-gray-400"> 左:同色的點完全一樣,色塊感明顯。右:每個點 RGB 各 ±7% 隨機偏移,整體更自然 </span> </div></template>12 色調色盤
Section titled “12 色調色盤”const palette: Array<[number, number, number]> = [ [1.00, 0.84, 0.63], // 琥珀金 [1.00, 0.69, 0.63], // 珊瑚紅 [1.00, 0.76, 0.55], // 焦橙 [1.00, 0.89, 0.66], // 暖金 [1.00, 0.66, 0.70], // 玫瑰赤 [1.00, 0.91, 0.72], // 蜜黃 [1.00, 0.81, 0.69], // 杏桃 [0.63, 0.87, 1.00], // 天空藍 [0.59, 0.78, 1.00], // 鈷藍 [0.66, 0.96, 0.91], // 薄荷綠 [0.63, 0.91, 0.78], // 翡翠綠 [0.78, 0.70, 1.00], // 薰衣草紫]前 7 色是暖色系(金、橙、紅),後 5 色是冷色系(藍、綠、紫)。暖色佔多數讓整體畫面偏暖,冷色點綴增加層次。
色彩抖動(Color Jitter)
Section titled “色彩抖動(Color Jitter)”如果每條魚精準使用調色盤的顏色,大量魚群會看到明顯的色帶分界。
解法是加入微小的隨機偏移:
const base = palette[Math.floor(Math.random() * palette.length)]!const jitter = 0.06
const r = Math.min(1, Math.max(0, base[0] + (Math.random() - 0.5) * jitter))const g = Math.min(1, Math.max(0, base[1] + (Math.random() - 0.5) * jitter))const b = Math.min(1, Math.max(0, base[2] + (Math.random() - 0.5) * jitter))每個 channel 在 ±3%(jitter * 0.5 = 0.03)的範圍內隨機偏移。人眼幾乎分辨不出單獨一條魚的差異,但整體看起來色彩更豐富自然。
Step 10:Instanced Rendering — 一次繪製萬條魚
Section titled “Step 10:Instanced Rendering — 一次繪製萬條魚”到目前為止,為了方便理解,我們都使用 Canvas 2D 示範。
不過要畫一萬條魚,Canvas 2D 的 fillRect 或 arc 逐一呼叫實在太慢。
WebGL2 的 Instanced Rendering 可以用一次 draw call 畫出所有魚。
<script setup lang="ts">import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
const CANVAS_HEIGHT = 300const MAX_FISH = 10000const INSTANCE_FLOATS = 6
const canvasRef = ref<HTMLCanvasElement | null>(null)const fishCount = ref(5000)const fpsDisplay = ref(0)
// 頂點著色器const VERT_SRC = /* glsl */ `#version 300 eslayout(location = 0) in vec2 aQuadPos;layout(location = 1) in vec2 aPosition;layout(location = 2) in vec3 aColor;layout(location = 3) in float aSize;
uniform vec2 uResolution;
out vec3 vColor;
void main() { vColor = aColor; vec2 pixelSize = aSize / uResolution; vec2 clipPos = (aPosition * 2.0 - 1.0) + aQuadPos * pixelSize; gl_Position = vec4(clipPos, 0.0, 1.0);}`
// 片段著色器const FRAG_SRC = /* glsl */ `#version 300 esprecision mediump float;in vec3 vColor;out vec4 fragColor;void main() { fragColor = vec4(vColor, 1.0);}`
let gl: WebGL2RenderingContext | null = nulllet program: WebGLProgram | null = nulllet vao: WebGLVertexArrayObject | null = nulllet instanceBuffer: WebGLBuffer | null = nulllet uResolution: WebGLUniformLocation | null = nulllet animationFrameId = 0
// 以 MAX_FISH 上限一次分配,slider 改變時不需重新分配const instanceData = new Float32Array(MAX_FISH * INSTANCE_FLOATS)
interface FishData { x: number; y: number; velocityX: number; velocityY: number; r: number; g: number; b: number; size: number;}
let fishDataList: FishData[] = []
const palette: Array<[number, number, number]> = [ [1.00, 0.84, 0.63], [1.00, 0.69, 0.63], [1.00, 0.76, 0.55], [0.63, 0.87, 1.00], [0.59, 0.78, 1.00], [0.66, 0.96, 0.91], [0.78, 0.70, 1.00],]
function createFish(): FishData { const base = palette[Math.floor(Math.random() * palette.length)]! return { x: Math.random(), y: Math.random(), velocityX: (Math.random() - 0.5) * 0.001, velocityY: (Math.random() - 0.5) * 0.001 - 0.0003, r: base[0] + (Math.random() - 0.5) * 0.06, g: base[1] + (Math.random() - 0.5) * 0.06, b: base[2] + (Math.random() - 0.5) * 0.06, size: 3 + Math.random() * 5, }}
// slider 改變時,補齊或截斷魚群資料watch(fishCount, (newCount) => { while (fishDataList.length < newCount) { fishDataList.push(createFish()) }})
function compileShader(glCtx: WebGL2RenderingContext, type: number, source: string) { const shader = glCtx.createShader(type)! glCtx.shaderSource(shader, source) glCtx.compileShader(shader) if (!glCtx.getShaderParameter(shader, glCtx.COMPILE_STATUS)) { const info = glCtx.getShaderInfoLog(shader) glCtx.deleteShader(shader) throw new Error(`Shader error: ${info}`) } return shader}
function init() { const canvas = canvasRef.value if (!canvas) return
gl = canvas.getContext('webgl2', { alpha: false, antialias: false }) if (!gl) return
const vertShader = compileShader(gl, gl.VERTEX_SHADER, VERT_SRC) const fragShader = compileShader(gl, gl.FRAGMENT_SHADER, FRAG_SRC)
program = gl.createProgram()! gl.attachShader(program, vertShader) gl.attachShader(program, fragShader) gl.linkProgram(program) gl.deleteShader(vertShader) gl.deleteShader(fragShader)
uResolution = gl.getUniformLocation(program, 'uResolution')
// 四邊形頂點 const quadVertexList = new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]) const quadBuffer = gl.createBuffer()! gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer) gl.bufferData(gl.ARRAY_BUFFER, quadVertexList, gl.STATIC_DRAW)
// Instance buffer:以 MAX_FISH 上限分配,避免 slider 改變時重建 instanceBuffer = gl.createBuffer()! gl.bindBuffer(gl.ARRAY_BUFFER, instanceBuffer) gl.bufferData(gl.ARRAY_BUFFER, instanceData.byteLength, gl.DYNAMIC_DRAW)
// VAO vao = gl.createVertexArray()! gl.bindVertexArray(vao)
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer) gl.enableVertexAttribArray(0) gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0)
gl.bindBuffer(gl.ARRAY_BUFFER, instanceBuffer) const stride = INSTANCE_FLOATS * 4
gl.enableVertexAttribArray(1) gl.vertexAttribPointer(1, 2, gl.FLOAT, false, stride, 0) gl.vertexAttribDivisor(1, 1)
gl.enableVertexAttribArray(2) gl.vertexAttribPointer(2, 3, gl.FLOAT, false, stride, 8) gl.vertexAttribDivisor(2, 1)
gl.enableVertexAttribArray(3) gl.vertexAttribPointer(3, 1, gl.FLOAT, false, stride, 20) gl.vertexAttribDivisor(3, 1)
gl.bindVertexArray(null)
// 初始魚群 fishDataList = Array.from({ length: fishCount.value }, () => createFish())}
let lastFpsTime = 0let frameCounter = 0
function animate() { if (!gl || !program || !vao || !instanceBuffer) return
const canvas = canvasRef.value if (!canvas) return
const now = performance.now() frameCounter++ if (now - lastFpsTime > 1000) { fpsDisplay.value = frameCounter frameCounter = 0 lastFpsTime = now }
const dpr = Math.min(window.devicePixelRatio || 1, 2) const width = canvas.clientWidth const drawWidth = Math.round(width * dpr) const drawHeight = Math.round(CANVAS_HEIGHT * dpr)
if (canvas.width !== drawWidth || canvas.height !== drawHeight) { canvas.style.height = `${CANVAS_HEIGHT}px` canvas.width = drawWidth canvas.height = drawHeight }
const count = Math.min(fishDataList.length, fishCount.value) for (let i = 0; i < count; i++) { const fish = fishDataList[i]! fish.x += fish.velocityX fish.y += fish.velocityY
if (fish.x < -0.05) fish.x = 1.05 if (fish.x > 1.05) fish.x = -0.05 if (fish.y < -0.05) { fish.y = 1.05 fish.x = Math.random() }
const o = i * INSTANCE_FLOATS instanceData[o] = fish.x instanceData[o + 1] = 1 - fish.y instanceData[o + 2] = fish.r instanceData[o + 3] = fish.g instanceData[o + 4] = fish.b instanceData[o + 5] = fish.size * dpr }
gl.viewport(0, 0, drawWidth, drawHeight) gl.clearColor(0.067, 0.094, 0.153, 1) gl.clear(gl.COLOR_BUFFER_BIT)
gl.bindBuffer(gl.ARRAY_BUFFER, instanceBuffer) gl.bufferSubData(gl.ARRAY_BUFFER, 0, instanceData, 0, count * INSTANCE_FLOATS)
gl.useProgram(program) gl.uniform2f(uResolution, drawWidth, drawHeight)
gl.enable(gl.BLEND) gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA)
gl.bindVertexArray(vao) gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, count) gl.bindVertexArray(null) gl.disable(gl.BLEND)
animationFrameId = requestAnimationFrame(animate)}
function cleanup() { cancelAnimationFrame(animationFrameId) if (gl) { if (program) gl.deleteProgram(program) if (vao) gl.deleteVertexArray(vao) if (instanceBuffer) gl.deleteBuffer(instanceBuffer) const ext = gl.getExtension('WEBGL_lose_context') if (ext) ext.loseContext() gl = null program = null vao = null instanceBuffer = null }}
onMounted(() => { init() animationFrameId = requestAnimationFrame(animate)})
onBeforeUnmount(cleanup)</script>
<template> <div class="flex flex-col gap-3 border border-gray-200 rounded-xl p-6 dark:border-gray-700"> <div class="flex items-center gap-4 text-sm text-gray-400"> <span>繪製數量:{{ fishCount.toLocaleString() }}</span> <span>FPS:{{ fpsDisplay }}</span> <span class="text-xs text-green-400">僅 1 次 draw call</span> </div> <canvas ref="canvasRef" class="w-full rounded-lg" /> <div class="flex items-center gap-3"> <label class="text-sm text-gray-400">數量</label> <input v-model.number="fishCount" type="range" min="100" :max="MAX_FISH" step="100" class="flex-1" > <span class="w-16 text-right text-sm font-mono text-gray-300">{{ fishCount.toLocaleString() }}</span> </div> </div></template>什麼是 Instanced Rendering?
Section titled “什麼是 Instanced Rendering?”普通的繪圖是每個物件一次 draw call:
drawArrays(triangle, fish_0) // 第 1 次呼叫drawArrays(triangle, fish_1) // 第 2 次呼叫drawArrays(triangle, fish_2) // 第 3 次呼叫... // 10000 次呼叫 😱Instancing 則是把所有差異資料(位置、顏色、大小)打包成一個 buffer,一次畫完:
drawArraysInstanced(triangle, 6_vertices, 10000_instances) // 就這 1 次 👍GPU 會自動對每個 instance 執行一次 vertex shader,並從 instance buffer 中讀取對應的屬性。
設定 Instance Attributes
Section titled “設定 Instance Attributes”// 每個 instance 的資料格式:x, y, r, g, b, size = 6 floatsconst INSTANCE_FLOATS = 6const stride = INSTANCE_FLOATS * 4 // 每個 float 4 bytes
// 位置 (location 1)gl.vertexAttribPointer(1, 2, gl.FLOAT, false, stride, 0)gl.vertexAttribDivisor(1, 1) // 👈 關鍵!每 1 個 instance 換一次
// 顏色 (location 2)gl.vertexAttribPointer(2, 3, gl.FLOAT, false, stride, 8)gl.vertexAttribDivisor(2, 1)
// 大小 (location 3)gl.vertexAttribPointer(3, 1, gl.FLOAT, false, stride, 20)gl.vertexAttribDivisor(3, 1)vertexAttribDivisor(location, 1) 是告訴 GPU:「這個屬性每畫完一個 instance 才換下一筆資料」。沒設 divisor 的屬性(如四邊形頂點)則是每個頂點都換。
每幀更新 Instance Buffer
Section titled “每幀更新 Instance Buffer”// 更新所有魚的資料到 Float32Arrayfor (let i = 0; i < fishCount; i++) { const o = i * INSTANCE_FLOATS instanceData[o] = fish.x instanceData[o + 1] = 1 - fish.y // Y 軸翻轉 instanceData[o + 2] = fish.r instanceData[o + 3] = fish.g instanceData[o + 4] = fish.b instanceData[o + 5] = fish.size}
// 上傳到 GPUgl.bufferSubData(gl.ARRAY_BUFFER, 0, instanceData)
// 一次畫完gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, fishCount)Float32Array 直接 bufferSubData 上傳,不用複製轉換。這就是 Step 8 選用 Typed Array 的另一個重要理由。
拉拉看上方的數量滑桿,觀察 FPS 的變化。因為只有一次 draw call,數量從 100 到 10000 的效能差距比你想像的小很多。
Step 11:SDF 魚形 — 在 Shader 中畫魚
Section titled “Step 11:SDF 魚形 — 在 Shader 中畫魚”Instanced Rendering 現在畫的是方塊。每個 instance 共用同一組四邊形頂點,我們沒辦法為每條魚傳入不同的幾何形狀。
那用魚形貼圖呢?可以,但貼圖放大會模糊、縮小會浪費,解析度很難兩全。
更靈活的做法是在 Fragment Shader 中用數學「算」出形狀,這就是 SDF(Signed Distance Field) 的思路。SDF 不依賴解析度,而且可以輕鬆組合多個基本形狀、自帶抗鋸齒。
<script setup lang="ts">import { ref, onMounted, onBeforeUnmount } from 'vue'
const CANVAS_HEIGHT = 300const canvasRef = ref<HTMLCanvasElement | null>(null)const useSdfShape = ref(true)
const VERT_SRC = /* glsl */ `#version 300 eslayout(location = 0) in vec2 aQuadPos;layout(location = 1) in vec2 aPosition;layout(location = 2) in float aAngle;layout(location = 3) in float aScaleX;layout(location = 4) in vec3 aColor;
uniform vec2 uResolution;uniform float uFishSize;
out vec2 vUv;out float vScaleX;out vec3 vColor;
void main() { vUv = aQuadPos * 0.5 + 0.5; vScaleX = aScaleX; vColor = aColor;
float cosA = cos(aAngle); float sinA = sin(aAngle); vec2 scaled = vec2(aQuadPos.x * aScaleX, aQuadPos.y); vec2 rotated = vec2( scaled.x * cosA - scaled.y * sinA, scaled.x * sinA + scaled.y * cosA );
vec2 fishSize = uFishSize / uResolution; vec2 clipPos = (aPosition * 2.0 - 1.0) + rotated * fishSize; gl_Position = vec4(clipPos, 0.0, 1.0);}`
// SDF 魚形片段著色器const FRAG_SDF = /* glsl */ `#version 300 esprecision mediump float;in vec2 vUv;in float vScaleX;in vec3 vColor;out vec4 fragColor;
float sdTriangle(vec2 p, float size) { p.x = abs(p.x); float d = max( dot(p, vec2(0.866, 0.5)) - size * 0.5, -p.y - size * 0.5 ); return d;}
void main() { vec2 p = vUv * 2.0 - 1.0;
// 身體:橢圓 float body = length((p - vec2(0.15, 0.0)) / vec2(0.58, 0.42)) - 1.0;
// 尾巴:圓角三角形 vec2 tailPos = p - vec2(-0.6, 0.0); vec2 rotTail = vec2(-tailPos.y, tailPos.x); float tail = sdTriangle(rotTail, 0.3) - 0.06;
float shape = min(body, tail); float alpha = 1.0 - smoothstep(-0.02, 0.02, shape);
if (alpha < 0.01) discard;
// 光影 float lighting = 0.8 + 0.2 * abs(vScaleX); vec3 bodyColor = vColor * lighting;
// 眼睛 float eyeDist = length(p - vec2(0.32, 0.06)); float eye = 1.0 - smoothstep(0.06, 0.08, eyeDist); eye *= smoothstep(0.15, 0.4, abs(vScaleX)); bodyColor = mix(bodyColor, vec3(0.15), eye);
fragColor = vec4(bodyColor * alpha, alpha);}`
// 圓形片段著色器(對比用)const FRAG_CIRCLE = /* glsl */ `#version 300 esprecision mediump float;in vec2 vUv;in float vScaleX;in vec3 vColor;out vec4 fragColor;
void main() { vec2 p = vUv * 2.0 - 1.0; float dist = length(p); float alpha = 1.0 - smoothstep(0.7, 0.75, dist); if (alpha < 0.01) discard; fragColor = vec4(vColor * alpha, alpha);}`
interface GlState { gl: WebGL2RenderingContext; sdfProgram: WebGLProgram; circleProgram: WebGLProgram; vao: WebGLVertexArrayObject; instanceBuffer: WebGLBuffer; instanceData: Float32Array; uResolutionSdf: WebGLUniformLocation | null; uFishSizeSdf: WebGLUniformLocation | null; uResolutionCircle: WebGLUniformLocation | null; uFishSizeCircle: WebGLUniformLocation | null;}
let state: GlState | null = nulllet animationFrameId = 0
const FISH_COUNT = 60const INSTANCE_FLOATS = 7 // x, y, angle, scaleX, r, g, b
// 簡易魚群資料interface FishData { x: number; y: number; angle: number; scaleX: number; speed: number;}
const palette: Array<[number, number, number]> = [ [1.00, 0.84, 0.63], [1.00, 0.69, 0.63], [1.00, 0.76, 0.55], [0.63, 0.87, 1.00], [0.66, 0.96, 0.91], [0.78, 0.70, 1.00],]
let fishList: FishData[] = []let colorList: Array<[number, number, number]> = []
function compileShader(gl: WebGL2RenderingContext, type: number, source: string) { const shader = gl.createShader(type)! gl.shaderSource(shader, source) gl.compileShader(shader) if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { throw new Error(gl.getShaderInfoLog(shader) || 'Shader error') } return shader}
function createProgram(gl: WebGL2RenderingContext, vertSrc: string, fragSrc: string) { const vert = compileShader(gl, gl.VERTEX_SHADER, vertSrc) const frag = compileShader(gl, gl.FRAGMENT_SHADER, fragSrc) const prog = gl.createProgram()! gl.attachShader(prog, vert) gl.attachShader(prog, frag) gl.linkProgram(prog) gl.deleteShader(vert) gl.deleteShader(frag) return prog}
function init() { const canvas = canvasRef.value if (!canvas) return
const gl = canvas.getContext('webgl2', { alpha: true, premultipliedAlpha: true, antialias: false }) if (!gl) return
const sdfProgram = createProgram(gl, VERT_SRC, FRAG_SDF) const circleProgram = createProgram(gl, VERT_SRC, FRAG_CIRCLE)
const quadVertexList = new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]) const quadBuffer = gl.createBuffer()! gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer) gl.bufferData(gl.ARRAY_BUFFER, quadVertexList, gl.STATIC_DRAW)
const instanceData = new Float32Array(FISH_COUNT * INSTANCE_FLOATS) const instanceBuffer = gl.createBuffer()! gl.bindBuffer(gl.ARRAY_BUFFER, instanceBuffer) gl.bufferData(gl.ARRAY_BUFFER, instanceData.byteLength, gl.DYNAMIC_DRAW)
const vao = gl.createVertexArray()! gl.bindVertexArray(vao)
gl.bindBuffer(gl.ARRAY_BUFFER, quadBuffer) gl.enableVertexAttribArray(0) gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0)
gl.bindBuffer(gl.ARRAY_BUFFER, instanceBuffer) const stride = INSTANCE_FLOATS * 4 gl.enableVertexAttribArray(1) gl.vertexAttribPointer(1, 2, gl.FLOAT, false, stride, 0) gl.vertexAttribDivisor(1, 1) gl.enableVertexAttribArray(2) gl.vertexAttribPointer(2, 1, gl.FLOAT, false, stride, 8) gl.vertexAttribDivisor(2, 1) gl.enableVertexAttribArray(3) gl.vertexAttribPointer(3, 1, gl.FLOAT, false, stride, 12) gl.vertexAttribDivisor(3, 1) gl.enableVertexAttribArray(4) gl.vertexAttribPointer(4, 3, gl.FLOAT, false, stride, 16) gl.vertexAttribDivisor(4, 1)
gl.bindVertexArray(null)
state = { gl, sdfProgram, circleProgram, vao, instanceBuffer, instanceData, uResolutionSdf: gl.getUniformLocation(sdfProgram, 'uResolution'), uFishSizeSdf: gl.getUniformLocation(sdfProgram, 'uFishSize'), uResolutionCircle: gl.getUniformLocation(circleProgram, 'uResolution'), uFishSizeCircle: gl.getUniformLocation(circleProgram, 'uFishSize'), }
fishList = Array.from({ length: FISH_COUNT }, () => ({ x: Math.random(), y: Math.random(), angle: (Math.random() - 0.5) * 1.2, scaleX: Math.random() > 0.5 ? 1 : -1, speed: 0.0005 + Math.random() * 0.001, }))
colorList = Array.from({ length: FISH_COUNT }, () => { const base = palette[Math.floor(Math.random() * palette.length)]! return [ base[0] + (Math.random() - 0.5) * 0.06, base[1] + (Math.random() - 0.5) * 0.06, base[2] + (Math.random() - 0.5) * 0.06, ] as [number, number, number] })}
function animate() { if (!state) return const { gl, sdfProgram, circleProgram, vao, instanceBuffer, instanceData } = state const canvas = canvasRef.value if (!canvas) return
const dpr = Math.min(window.devicePixelRatio || 1, 2) const width = canvas.clientWidth const drawWidth = Math.round(width * dpr) const drawHeight = Math.round(CANVAS_HEIGHT * dpr)
if (canvas.width !== drawWidth || canvas.height !== drawHeight) { canvas.style.height = `${CANVAS_HEIGHT}px` canvas.width = drawWidth canvas.height = drawHeight }
const time = performance.now() * 0.001
for (let i = 0; i < FISH_COUNT; i++) { const fish = fishList[i]! const color = colorList[i]!
fish.x += Math.cos(fish.angle) * fish.speed fish.y -= Math.sin(fish.angle) * fish.speed fish.angle += Math.sin(time * 0.5 + i) * 0.01
// scaleX 由移動方向決定 const dx = Math.cos(fish.angle) if (Math.abs(dx) > 0.001) fish.scaleX += ((dx > 0 ? 1 : -1) - fish.scaleX) * 0.1
if (fish.x < -0.05) fish.x = 1.05 if (fish.x > 1.05) fish.x = -0.05 if (fish.y < -0.05) fish.y = 1.05 if (fish.y > 1.05) fish.y = -0.05
const o = i * INSTANCE_FLOATS instanceData[o] = fish.x instanceData[o + 1] = 1 - fish.y // 面朝左時加 π 補償 scaleX 翻轉 instanceData[o + 2] = fish.scaleX >= 0 ? fish.angle : fish.angle + Math.PI instanceData[o + 3] = fish.scaleX instanceData[o + 4] = color[0] instanceData[o + 5] = color[1] instanceData[o + 6] = color[2] }
gl.viewport(0, 0, drawWidth, drawHeight) gl.clearColor(0.067, 0.094, 0.153, 1) gl.clear(gl.COLOR_BUFFER_BIT)
gl.bindBuffer(gl.ARRAY_BUFFER, instanceBuffer) gl.bufferSubData(gl.ARRAY_BUFFER, 0, instanceData)
const prog = useSdfShape.value ? sdfProgram : circleProgram const uRes = useSdfShape.value ? state.uResolutionSdf : state.uResolutionCircle const uSize = useSdfShape.value ? state.uFishSizeSdf : state.uFishSizeCircle
gl.useProgram(prog) gl.uniform2f(uRes, drawWidth, drawHeight) gl.uniform1f(uSize, 18 * dpr)
gl.enable(gl.BLEND) gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA)
gl.bindVertexArray(vao) gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, FISH_COUNT) gl.bindVertexArray(null) gl.disable(gl.BLEND)
animationFrameId = requestAnimationFrame(animate)}
onMounted(() => { init() animationFrameId = requestAnimationFrame(animate)})
onBeforeUnmount(() => { cancelAnimationFrame(animationFrameId) if (state) { const { gl, sdfProgram, circleProgram, vao, instanceBuffer } = state gl.deleteProgram(sdfProgram) gl.deleteProgram(circleProgram) gl.deleteVertexArray(vao) gl.deleteBuffer(instanceBuffer) gl.getExtension('WEBGL_lose_context')?.loseContext() state = null }})</script>
<template> <div class="flex flex-col gap-3 border border-gray-200 rounded-xl p-6 dark:border-gray-700"> <canvas ref="canvasRef" class="w-full rounded-lg" /> <div class="flex gap-3"> <button class="rounded-lg px-4 py-1.5 text-sm text-white transition-colors" :class="useSdfShape ? 'bg-blue-600 hover:bg-blue-500' : 'bg-gray-600 hover:bg-gray-500'" @click="useSdfShape = !useSdfShape" > {{ useSdfShape ? 'SDF 魚形' : '圓形' }} </button> </div> <span class="text-xs text-gray-400"> 切換觀察:圓形只是一個 distance 判斷,SDF 魚形由橢圓身體 + 三角形尾巴組成 </span> </div></template>Shader 是什麼?
Section titled “Shader 是什麼?”上一步的 Instanced Rendering 把四邊形整個填滿顏色,所以魚看起來是方塊。我們要的是只有魚形有顏色、其他部分透明。
要做到這件事,得先理解 GPU 畫圖的流程。GPU 畫一個四邊形分成兩個階段,每個階段各有一段我們寫的小程式,這些小程式就叫 Shader:
- Vertex Shader(頂點著色器):決定四邊形的四個角要放在螢幕的哪裡。上一步已經用過了。
- Fragment Shader(片段著色器):四邊形擺好位置後,GPU 會對裡面的每一個像素各執行一次 Fragment Shader,問它:「這個像素要填什麼顏色?」
Fragment Shader 每次執行時,會收到一個座標 p,代表「這個像素在四邊形上的位置」(範圍 -1 到 1,中心是 0)。
如果我們能在 Fragment Shader 裡,用 p 算出「這個像素在不在魚的形狀內」,形狀內就填色、形狀外就透明,方塊就變成魚形了。
用 SDF 判斷形狀
Section titled “用 SDF 判斷形狀”最直覺的方式:算每個像素離形狀邊界的距離。以圓為例,離圓心的距離小於半徑就在圓內。
SDF(Signed Distance Field) 把這個想法寫成一行:
// Fragment Shader 裡的程式碼// 每個像素各執行一次,p 是該像素的座標(-1 ~ 1)float d = length(p) - 0.5;
// d < 0 → 在圓內(填色)// d > 0 → 在圓外(透明)if (d < 0.0) { fragColor = vec4(1.0, 0.84, 0.63, 1.0); // 填色} else { discard; // 不畫這個像素}length(p) 是像素離圓心的距離,減去半徑 0.5:距離比半徑小就是負數(在圓內),比半徑大就是正數(在圓外)。
下面的圖把四邊形裡每個像素的 d 值畫成顏色,讓你看到 SDF 的「距離場」長什麼樣子:
一步步畫出小魚
Section titled “一步步畫出小魚”點擊下方按鈕切換每個階段,觀察 SDF 指令如何逐步組合出完整的魚形。
float shape = length(p) - 0.5;<script setup lang="ts">import { onBeforeUnmount, onMounted, ref } from 'vue'
const CANVAS_SIZE = 280
const canvasRef = ref<HTMLCanvasElement | null>(null)const currentStep = ref(0)
const stepList = [ { label: '正圓', code: 'float shape = length(p) - 0.5;', }, { label: '壓成橢圓', code: 'float shape = length(p / vec2(0.58, 0.42)) - 1.0;', }, { label: '偏移中心', code: 'float body = length((p - vec2(0.15, 0.0)) / vec2(0.58, 0.42)) - 1.0;', }, { label: '加尾巴', code: `float tail = sdTriangle(rotTail, 0.3) - 0.06;float shape = min(body, tail); // 聯集`, }, { label: '抗鋸齒', code: 'float alpha = 1.0 - smoothstep(-0.02, 0.02, shape);', }, { label: '加眼睛', code: `float eyeDist = length(p - vec2(0.32, 0.06));float eye = 1.0 - smoothstep(0.06, 0.08, eyeDist);`, },]
const VERT_SRC = /* glsl */ `#version 300 eslayout(location = 0) in vec2 aPos;out vec2 vUv;
void main() { vUv = aPos * 0.5 + 0.5; gl_Position = vec4(aPos, 0.0, 1.0);}`
const FRAG_SRC = /* glsl */ `#version 300 esprecision mediump float;in vec2 vUv;out vec4 fragColor;
uniform int uStep;uniform vec3 uColor;
float sdTriangle(vec2 p, float size) { p.x = abs(p.x); float d = max( dot(p, vec2(0.866, 0.5)) - size * 0.5, -p.y - size * 0.5 ); return d;}
void main() { vec2 p = vUv * 2.0 - 1.0;
// 0: 正圓 float shape = length(p) - 0.5;
// 1: 壓成橢圓 if (uStep >= 1) { shape = length(p / vec2(0.58, 0.42)) - 1.0; }
// 2: 偏移中心 if (uStep >= 2) { shape = length((p - vec2(0.15, 0.0)) / vec2(0.58, 0.42)) - 1.0; }
// 3: 加尾巴 float body = shape; if (uStep >= 3) { body = length((p - vec2(0.15, 0.0)) / vec2(0.58, 0.42)) - 1.0; vec2 tailPos = p - vec2(-0.6, 0.0); vec2 rotTail = vec2(-tailPos.y, tailPos.x); float tail = sdTriangle(rotTail, 0.3) - 0.06; shape = min(body, tail); }
// 0-3: 硬邊;4+: smoothstep 抗鋸齒 float alpha; if (uStep < 4) { alpha = shape < 0.0 ? 1.0 : 0.0; } else { alpha = 1.0 - smoothstep(-0.02, 0.02, shape); }
if (alpha < 0.01) discard;
vec3 color = uColor;
// 5: 眼睛 if (uStep >= 5) { float eyeDist = length(p - vec2(0.32, 0.06)); float eye = 1.0 - smoothstep(0.06, 0.08, eyeDist); color = mix(color, vec3(0.15), eye); }
fragColor = vec4(color * alpha, alpha);}`
interface GlState { gl: WebGL2RenderingContext; program: WebGLProgram; vao: WebGLVertexArrayObject; buffer: WebGLBuffer; uStep: WebGLUniformLocation | null; uColor: WebGLUniformLocation | null;}
let state: GlState | null = nulllet animationFrameId = 0
function compileShader(gl: WebGL2RenderingContext, type: number, source: string) { const shader = gl.createShader(type)! gl.shaderSource(shader, source) gl.compileShader(shader) if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { console.error(gl.getShaderInfoLog(shader)) gl.deleteShader(shader) return null } return shader}
function init() { const canvas = canvasRef.value if (!canvas) return
const gl = canvas.getContext('webgl2', { alpha: true, premultipliedAlpha: true, antialias: false }) if (!gl) return
const vert = compileShader(gl, gl.VERTEX_SHADER, VERT_SRC) const frag = compileShader(gl, gl.FRAGMENT_SHADER, FRAG_SRC) if (!vert || !frag) return
const program = gl.createProgram()! gl.attachShader(program, vert) gl.attachShader(program, frag) gl.linkProgram(program) gl.deleteShader(vert) gl.deleteShader(frag)
const quadVertexList = new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]) const buffer = gl.createBuffer()! gl.bindBuffer(gl.ARRAY_BUFFER, buffer) gl.bufferData(gl.ARRAY_BUFFER, quadVertexList, gl.STATIC_DRAW)
const vao = gl.createVertexArray()! gl.bindVertexArray(vao) gl.enableVertexAttribArray(0) gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0) gl.bindVertexArray(null)
state = { gl, program, vao, buffer, uStep: gl.getUniformLocation(program, 'uStep'), uColor: gl.getUniformLocation(program, 'uColor'), }}
function draw() { if (!state) return
const { gl, program, vao } = state const canvas = canvasRef.value if (!canvas) return
const dpr = window.devicePixelRatio || 1 const size = CANVAS_SIZE
if (canvas.width !== Math.round(size * dpr)) { canvas.style.width = `${size}px` canvas.style.height = `${size}px` canvas.width = Math.round(size * dpr) canvas.height = Math.round(size * dpr) }
gl.viewport(0, 0, canvas.width, canvas.height) gl.clearColor(0.067, 0.094, 0.153, 1) gl.clear(gl.COLOR_BUFFER_BIT)
gl.enable(gl.BLEND) gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA)
gl.useProgram(program) gl.uniform1i(state.uStep, currentStep.value) gl.uniform3f(state.uColor, 1.0, 0.84, 0.63)
gl.bindVertexArray(vao) gl.drawArrays(gl.TRIANGLES, 0, 6) gl.bindVertexArray(null)
gl.disable(gl.BLEND)
animationFrameId = requestAnimationFrame(draw)}
onMounted(() => { init() animationFrameId = requestAnimationFrame(draw)})
onBeforeUnmount(() => { cancelAnimationFrame(animationFrameId) if (state) { const { gl, program, vao, buffer } = state gl.deleteProgram(program) gl.deleteVertexArray(vao) gl.deleteBuffer(buffer) gl.getExtension('WEBGL_lose_context')?.loseContext() state = null }})</script>
<template> <div class="flex flex-col items-center gap-3 border border-gray-200 rounded-xl p-6 dark:border-gray-700"> <div class="flex flex-wrap justify-center gap-2"> <button v-for="(step, index) in stepList" :key="index" class="rounded-lg px-3 py-1 text-xs text-white transition-colors" :class="currentStep === index ? 'bg-blue-600' : 'bg-gray-600 hover:bg-gray-500'" @click="currentStep = index" > {{ step.label }} </button> </div> <canvas ref="canvasRef" class="rounded-lg" /> <pre class="w-full overflow-x-auto rounded-lg bg-gray-800 px-4 py-3 text-xs leading-relaxed text-green-300"><code>{{ stepList[currentStep]?.code }}</code></pre> </div></template>最基本的 SDF:算出每個像素離圓心的距離,減去半徑。
float d = length(p) - 0.5;p 是當前像素在四邊形上的座標(-1 到 1)。下面的圖把每個像素的 SDF 值畫成顏色:藍色代表 d < 0(在圓內),紅色代表 d > 0(在圓外),白線是 d = 0(邊界)。
- 圓心
p = (0, 0):length(p) = 0,所以d = 0 - 0.5 = -0.5(深藍,離邊界最遠) - 邊界上
p = (0.5, 0):length(p) = 0.5,所以d = 0.5 - 0.5 = 0(白線) - 外部
p = (0.75, 0):length(p) = 0.75,所以d = 0.75 - 0.5 = 0.25(紅色)
有了 d 值,shader 只要判斷 d < 0 就填色、d > 0 就透明,就能畫出圓形。前幾步先用這種硬邊顯示,可以清楚看到鋸齒。
座標除以不同的 XY 比例,等於把空間壓扁,圓就變成橢圓。
float shape = length(p / vec2(0.58, 0.42)) - 1.0;0.58 > 0.42,所以 X 方向更寬、Y 方向更窄,形成橫向的橢圓。
把橢圓中心往右移 0.15,頭部靠右、左側留出空間放尾巴。
float body = length((p - vec2(0.15, 0.0)) / vec2(0.58, 0.42)) - 1.0;用 sdTriangle 在左側畫一個圓角三角形,減去 0.06 讓尖角變圓潤。
vec2 tailPos = p - vec2(-0.6, 0.0);vec2 rotTail = vec2(-tailPos.y, tailPos.x); // 旋轉 90°float tail = sdTriangle(rotTail, 0.3) - 0.06;SDF 的超酷特性:兩個形狀取 min 就是聯集。身體和尾巴用 min 就自然融合了。
float shape = min(body, tail);max 則是交集,-shape 是反轉(挖洞)。這三個操作就能組合出各種複雜形狀。
前面幾步的硬邊鋸齒感很重。smoothstep 在 SDF 值 -0.02 到 0.02 之間做平滑過渡,邊緣就變得柔和了。
float alpha = 1.0 - smoothstep(-0.02, 0.02, shape);眼睛也是一個小圓的 SDF,位置在身體右上方。
float eyeDist = length(p - vec2(0.32, 0.06));float eye = 1.0 - smoothstep(0.06, 0.08, eyeDist);實際元件中還會根據 scaleX 控制眼睛可見度,翻轉到一半時淡出避免穿模。
按下上方 step11-sdf-fish 範例的按鈕切換圓形和 SDF 魚形來比較。同樣的顏色和運動,形狀的差異讓感受完全不同。◝( •ω• )◟
Step 12:光暈效果 — 自帶發光的小魚
Section titled “Step 12:光暈效果 — 自帶發光的小魚”光暈(Glow)讓魚看起來像是自己在發光,整個畫面也變得柔和溫暖。
<script setup lang="ts">import { ref, onMounted, onBeforeUnmount } from 'vue'
const CANVAS_HEIGHT = 300const canvasRef = ref<HTMLCanvasElement | null>(null)const enableGlow = ref(true)
const VERT_SRC = /* glsl */ `#version 300 eslayout(location = 0) in vec2 aQuadPos;layout(location = 1) in vec2 aPosition;layout(location = 2) in float aAngle;layout(location = 3) in float aScaleX;layout(location = 4) in vec3 aColor;
uniform vec2 uResolution;uniform float uFishSize;uniform float uGlowExpand;
out vec2 vUv;out float vScaleX;out vec3 vColor;
void main() { vUv = (aQuadPos * uGlowExpand) * 0.5 + 0.5; vScaleX = aScaleX; vColor = aColor;
float cosA = cos(aAngle); float sinA = sin(aAngle); vec2 scaled = vec2(aQuadPos.x * aScaleX, aQuadPos.y) * uGlowExpand; vec2 rotated = vec2( scaled.x * cosA - scaled.y * sinA, scaled.x * sinA + scaled.y * cosA );
vec2 fishSize = uFishSize / uResolution; vec2 clipPos = (aPosition * 2.0 - 1.0) + rotated * fishSize; gl_Position = vec4(clipPos, 0.0, 1.0);}`
const FRAG_SRC = /* glsl */ `#version 300 esprecision mediump float;in vec2 vUv;in float vScaleX;in vec3 vColor;out vec4 fragColor;
uniform bool uEnableGlow;
float sdTriangle(vec2 p, float size) { p.x = abs(p.x); return max(dot(p, vec2(0.866, 0.5)) - size * 0.5, -p.y - size * 0.5);}
void main() { vec2 p = vUv * 2.0 - 1.0;
float body = length((p - vec2(0.15, 0.0)) / vec2(0.58, 0.42)) - 1.0; vec2 tailPos = p - vec2(-0.6, 0.0); float tail = sdTriangle(vec2(-tailPos.y, tailPos.x), 0.3) - 0.06; float shape = min(body, tail);
float bodyAlpha = 1.0 - smoothstep(-0.02, 0.02, shape);
float glow = 0.0; if (uEnableGlow) { float glowShape = length((p - vec2(-0.05, 0.0)) / vec2(0.82, 0.48)) - 1.0; float glowDist = max(glowShape, 0.0); glow = exp(-glowDist * 3.5) * 0.4; glow *= (1.0 - bodyAlpha); }
float totalAlpha = bodyAlpha + glow; if (totalAlpha < 0.001) discard;
float lighting = 0.8 + 0.2 * abs(vScaleX); vec3 bodyColor = vColor * lighting;
float eyeDist = length(p - vec2(0.32, 0.06)); float eye = 1.0 - smoothstep(0.06, 0.08, eyeDist); eye *= smoothstep(0.15, 0.4, abs(vScaleX)); bodyColor = mix(bodyColor, vec3(0.15), eye);
vec3 finalColor = mix(vColor, bodyColor, bodyAlpha / max(totalAlpha, 0.001));
fragColor = vec4(finalColor * totalAlpha, totalAlpha);}`
const FISH_COUNT = 40const INSTANCE_FLOATS = 7
interface GlState { gl: WebGL2RenderingContext; program: WebGLProgram; vao: WebGLVertexArrayObject; instanceBuffer: WebGLBuffer; instanceData: Float32Array; uResolution: WebGLUniformLocation | null; uFishSize: WebGLUniformLocation | null; uGlowExpand: WebGLUniformLocation | null; uEnableGlow: WebGLUniformLocation | null;}
let state: GlState | null = nulllet animationFrameId = 0
const palette: Array<[number, number, number]> = [ [1.00, 0.84, 0.63], [1.00, 0.69, 0.63], [1.00, 0.76, 0.55], [0.63, 0.87, 1.00], [0.66, 0.96, 0.91], [0.78, 0.70, 1.00],]
interface FishData { x: number; y: number; angle: number; scaleX: number; speed: number }let fishList: FishData[] = []let colorList: Array<[number, number, number]> = []
function compileShader(gl: WebGL2RenderingContext, type: number, source: string) { const shader = gl.createShader(type)! gl.shaderSource(shader, source) gl.compileShader(shader) if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(shader) || '') return shader}
function init() { const canvas = canvasRef.value if (!canvas) return const gl = canvas.getContext('webgl2', { alpha: true, premultipliedAlpha: true, antialias: false }) if (!gl) return
const vert = compileShader(gl, gl.VERTEX_SHADER, VERT_SRC) const frag = compileShader(gl, gl.FRAGMENT_SHADER, FRAG_SRC) const program = gl.createProgram()! gl.attachShader(program, vert) gl.attachShader(program, frag) gl.linkProgram(program) gl.deleteShader(vert) gl.deleteShader(frag)
const quadVerts = new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]) const qb = gl.createBuffer()! gl.bindBuffer(gl.ARRAY_BUFFER, qb) gl.bufferData(gl.ARRAY_BUFFER, quadVerts, gl.STATIC_DRAW)
const instanceData = new Float32Array(FISH_COUNT * INSTANCE_FLOATS) const instanceBuffer = gl.createBuffer()! gl.bindBuffer(gl.ARRAY_BUFFER, instanceBuffer) gl.bufferData(gl.ARRAY_BUFFER, instanceData.byteLength, gl.DYNAMIC_DRAW)
const vao = gl.createVertexArray()! gl.bindVertexArray(vao)
gl.bindBuffer(gl.ARRAY_BUFFER, qb) gl.enableVertexAttribArray(0) gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0)
gl.bindBuffer(gl.ARRAY_BUFFER, instanceBuffer) const stride = INSTANCE_FLOATS * 4 gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1, 2, gl.FLOAT, false, stride, 0); gl.vertexAttribDivisor(1, 1) gl.enableVertexAttribArray(2); gl.vertexAttribPointer(2, 1, gl.FLOAT, false, stride, 8); gl.vertexAttribDivisor(2, 1) gl.enableVertexAttribArray(3); gl.vertexAttribPointer(3, 1, gl.FLOAT, false, stride, 12); gl.vertexAttribDivisor(3, 1) gl.enableVertexAttribArray(4); gl.vertexAttribPointer(4, 3, gl.FLOAT, false, stride, 16); gl.vertexAttribDivisor(4, 1) gl.bindVertexArray(null)
state = { gl, program, vao, instanceBuffer, instanceData, uResolution: gl.getUniformLocation(program, 'uResolution'), uFishSize: gl.getUniformLocation(program, 'uFishSize'), uGlowExpand: gl.getUniformLocation(program, 'uGlowExpand'), uEnableGlow: gl.getUniformLocation(program, 'uEnableGlow'), }
fishList = Array.from({ length: FISH_COUNT }, () => ({ x: Math.random(), y: Math.random(), angle: (Math.random() - 0.5) * 1.2, scaleX: Math.random() > 0.5 ? 1 : -1, speed: 0.0005 + Math.random() * 0.001, }))
colorList = fishList.map(() => { const b = palette[Math.floor(Math.random() * palette.length)]! return [b[0] + (Math.random() - 0.5) * 0.06, b[1] + (Math.random() - 0.5) * 0.06, b[2] + (Math.random() - 0.5) * 0.06] as [number, number, number] })}
function animate() { if (!state) return const { gl, program, vao, instanceBuffer, instanceData } = state const canvas = canvasRef.value if (!canvas) return
const dpr = Math.min(window.devicePixelRatio || 1, 2) const width = canvas.clientWidth const dw = Math.round(width * dpr) const dh = Math.round(CANVAS_HEIGHT * dpr) if (canvas.width !== dw || canvas.height !== dh) { canvas.style.height = `${CANVAS_HEIGHT}px` canvas.width = dw; canvas.height = dh }
const time = performance.now() * 0.001 for (let i = 0; i < FISH_COUNT; i++) { const f = fishList[i]!; const c = colorList[i]! f.x += Math.cos(f.angle) * f.speed f.y -= Math.sin(f.angle) * f.speed f.angle += Math.sin(time * 0.5 + i) * 0.01
const dx = Math.cos(f.angle) if (Math.abs(dx) > 0.001) f.scaleX += ((dx > 0 ? 1 : -1) - f.scaleX) * 0.1
if (f.x < -0.05) f.x = 1.05; if (f.x > 1.05) f.x = -0.05 if (f.y < -0.05) f.y = 1.05; if (f.y > 1.05) f.y = -0.05
const o = i * INSTANCE_FLOATS instanceData[o] = f.x; instanceData[o + 1] = 1 - f.y instanceData[o + 2] = f.scaleX >= 0 ? f.angle : f.angle + Math.PI instanceData[o + 3] = f.scaleX instanceData[o + 4] = c[0]; instanceData[o + 5] = c[1]; instanceData[o + 6] = c[2] }
gl.viewport(0, 0, dw, dh) gl.clearColor(0.067, 0.094, 0.153, 1) gl.clear(gl.COLOR_BUFFER_BIT)
gl.bindBuffer(gl.ARRAY_BUFFER, instanceBuffer) gl.bufferSubData(gl.ARRAY_BUFFER, 0, instanceData)
const glowOn = enableGlow.value gl.useProgram(program) gl.uniform2f(state.uResolution, dw, dh) gl.uniform1f(state.uFishSize, 22 * dpr) gl.uniform1f(state.uGlowExpand, glowOn ? 1.5 : 1.0) gl.uniform1i(state.uEnableGlow, glowOn ? 1 : 0)
gl.enable(gl.BLEND) gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA) gl.bindVertexArray(vao) gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, FISH_COUNT) gl.bindVertexArray(null) gl.disable(gl.BLEND)
animationFrameId = requestAnimationFrame(animate)}
onMounted(() => { init(); animationFrameId = requestAnimationFrame(animate) })onBeforeUnmount(() => { cancelAnimationFrame(animationFrameId) if (state) { const { gl, program, vao, instanceBuffer } = state gl.deleteProgram(program) gl.deleteVertexArray(vao) gl.deleteBuffer(instanceBuffer) gl.getExtension('WEBGL_lose_context')?.loseContext() state = null }})</script>
<template> <div class="flex flex-col gap-3 border border-gray-200 rounded-xl p-6 dark:border-gray-700"> <canvas ref="canvasRef" class="w-full rounded-lg" /> <div class="flex gap-3"> <button class="rounded-lg px-4 py-1.5 text-sm text-white transition-colors" :class="enableGlow ? 'bg-blue-600 hover:bg-blue-500' : 'bg-gray-600 hover:bg-gray-500'" @click="enableGlow = !enableGlow" > 光暈:{{ enableGlow ? 'ON' : 'OFF' }} </button> </div> <span class="text-xs text-gray-400"> 光暈使用指數衰減函式 exp(-d × 3.5) × 0.4,僅在身體外部渲染,讓魚看起來自帶發光效果 </span> </div></template>// 用比身體大的橢圓計算距離float glowShape = length((point - vec2(-0.05, 0.0)) / vec2(0.82, 0.48)) - 1.0;
// 只取外部距離(內部為 0)float glowDist = max(glowShape, 0.0);
// 指數衰減float glow = exp(-glowDist * 3.5) * 0.4;
// 光暈只在身體外部glow *= (1.0 - bodyAlpha);幾個設計重點:
- 光暈橢圓比身體大:
vec2(0.82, 0.48)vs 身體的vec2(0.58, 0.42),讓光暈包覆整隻魚 - 指數衰減
exp(-d * 3.5):離身體越遠光暈越弱,衰減速度由 3.5 控制 - 乘以 0.4:最大亮度只有 40%,避免太亮搶戲
- 身體外部限定:
glow *= (1.0 - bodyAlpha)確保光暈不會疊加在身體上
為什麼要放大四邊形?
Section titled “為什麼要放大四邊形?”光暈超出魚的身體範圍,如果四邊形剛好是魚的大小,光暈就會被裁切。
const float GLOW_EXPAND = 1.5;
// 頂點著色器中放大 UV 和四邊形vUv = (aQuadPos * GLOW_EXPAND) * 0.5 + 0.5;vec2 scaled = vec2(aQuadPos.x * aScaleX, aQuadPos.y) * GLOW_EXPAND;四邊形放大 1.5 倍,UV 座標也相應調整。這樣光暈就有足夠的空間渲染了。
float totalAlpha = bodyAlpha + glow;vec3 finalColor = mix(vColor, bodyColor, bodyAlpha / totalAlpha);身體部分用帶光影的顏色(bodyColor),光暈部分用原始魚色(vColor)。mix 根據各自的 alpha 佔比混合。
切換光暈按鈕觀察差異。沒有光暈的魚邊緣銳利生硬,有光暈後整體柔和許多,像是水中的光影效果。ヾ(◍'౪`◍)ノ゙
Step 13:深度霧化與排序 — 遠近分明的水世界
Section titled “Step 13:深度霧化與排序 — 遠近分明的水世界”最後一步讓魚群有前後深度感。近處的魚大而清晰,遠處的魚小而模糊,融入背景。
<script setup lang="ts">import { ref, onMounted, onBeforeUnmount } from 'vue'
const CANVAS_HEIGHT = 300const canvasRef = ref<HTMLCanvasElement | null>(null)const enableFog = ref(true)const enableSort = ref(true)
const VERT_SRC = /* glsl */ `#version 300 eslayout(location = 0) in vec2 aQuadPos;layout(location = 1) in vec2 aPosition;layout(location = 2) in float aAngle;layout(location = 3) in float aScaleX;layout(location = 4) in vec3 aColor;layout(location = 5) in float aDepth;
uniform vec2 uResolution;uniform float uFishSize;
out vec2 vUv;out float vScaleX;out vec3 vColor;out float vDepth;
void main() { float expand = 1.5; vUv = (aQuadPos * expand) * 0.5 + 0.5; vScaleX = aScaleX; vColor = aColor; vDepth = aDepth;
float cosA = cos(aAngle); float sinA = sin(aAngle); float depthScale = mix(0.05, 1.0, aDepth); vec2 scaled = vec2(aQuadPos.x * aScaleX, aQuadPos.y) * depthScale * expand; vec2 rotated = vec2( scaled.x * cosA - scaled.y * sinA, scaled.x * sinA + scaled.y * cosA );
vec2 fishSize = uFishSize / uResolution; vec2 clipPos = (aPosition * 2.0 - 1.0) + rotated * fishSize; gl_Position = vec4(clipPos, 0.0, 1.0);}`
const FRAG_SRC = /* glsl */ `#version 300 esprecision mediump float;in vec2 vUv;in float vScaleX;in vec3 vColor;in float vDepth;
uniform vec3 uFogColor;uniform bool uEnableFog;
out vec4 fragColor;
float sdTriangle(vec2 p, float size) { p.x = abs(p.x); return max(dot(p, vec2(0.866, 0.5)) - size * 0.5, -p.y - size * 0.5);}
void main() { vec2 p = vUv * 2.0 - 1.0;
float body = length((p - vec2(0.15, 0.0)) / vec2(0.58, 0.42)) - 1.0; vec2 tp = p - vec2(-0.6, 0.0); float tail = sdTriangle(vec2(-tp.y, tp.x), 0.3) - 0.06; float shape = min(body, tail); float bodyAlpha = 1.0 - smoothstep(-0.02, 0.02, shape);
float glowShape = length((p - vec2(-0.05, 0.0)) / vec2(0.82, 0.48)) - 1.0; float glow = exp(-max(glowShape, 0.0) * 3.5) * 0.4 * (1.0 - bodyAlpha); float totalAlpha = bodyAlpha + glow; if (totalAlpha < 0.001) discard;
float lighting = 0.8 + 0.2 * abs(vScaleX); vec3 bodyColor = vColor * lighting;
float eye = (1.0 - smoothstep(0.06, 0.08, length(p - vec2(0.32, 0.06)))) * smoothstep(0.15, 0.4, abs(vScaleX)); bodyColor = mix(bodyColor, vec3(0.15), eye);
vec3 finalColor = mix(vColor, bodyColor, bodyAlpha / max(totalAlpha, 0.001));
if (uEnableFog) { float fogAmount = clamp((1.0 - vDepth) * 2.0, 0.0, 1.0); finalColor = mix(finalColor, uFogColor, fogAmount * fogAmount * 0.85); }
fragColor = vec4(finalColor * totalAlpha, totalAlpha);}`
const FISH_COUNT = 80const INSTANCE_FLOATS = 8 // x, y, angle, scaleX, r, g, b, depth
interface GlState { gl: WebGL2RenderingContext; program: WebGLProgram; vao: WebGLVertexArrayObject; instanceBuffer: WebGLBuffer; instanceData: Float32Array; uResolution: WebGLUniformLocation | null; uFishSize: WebGLUniformLocation | null; uFogColor: WebGLUniformLocation | null; uEnableFog: WebGLUniformLocation | null;}
let glState: GlState | null = nulllet animationFrameId = 0
const palette: Array<[number, number, number]> = [ [1.00, 0.84, 0.63], [1.00, 0.69, 0.63], [1.00, 0.76, 0.55], [0.63, 0.87, 1.00], [0.66, 0.96, 0.91], [0.78, 0.70, 1.00],]
interface FishData { x: number; y: number; angle: number; scaleX: number; speed: number; depth: number; r: number; g: number; b: number;}
let fishList: FishData[] = []let sortIndexList: number[] = []let sortedData: Float32Array | null = null
function compileShader(gl: WebGL2RenderingContext, type: number, src: string) { const s = gl.createShader(type)! gl.shaderSource(s, src); gl.compileShader(s) if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s) || '') return s}
function init() { const canvas = canvasRef.value if (!canvas) return const gl = canvas.getContext('webgl2', { alpha: true, premultipliedAlpha: true, antialias: false }) if (!gl) return
const vs = compileShader(gl, gl.VERTEX_SHADER, VERT_SRC) const fs = compileShader(gl, gl.FRAGMENT_SHADER, FRAG_SRC) const program = gl.createProgram()! gl.attachShader(program, vs); gl.attachShader(program, fs) gl.linkProgram(program); gl.deleteShader(vs); gl.deleteShader(fs)
const qv = new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]) const qb = gl.createBuffer()! gl.bindBuffer(gl.ARRAY_BUFFER, qb); gl.bufferData(gl.ARRAY_BUFFER, qv, gl.STATIC_DRAW)
const instanceData = new Float32Array(FISH_COUNT * INSTANCE_FLOATS) const instanceBuffer = gl.createBuffer()! gl.bindBuffer(gl.ARRAY_BUFFER, instanceBuffer) gl.bufferData(gl.ARRAY_BUFFER, instanceData.byteLength, gl.DYNAMIC_DRAW)
const vao = gl.createVertexArray()! gl.bindVertexArray(vao)
gl.bindBuffer(gl.ARRAY_BUFFER, qb) gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0)
gl.bindBuffer(gl.ARRAY_BUFFER, instanceBuffer) const stride = INSTANCE_FLOATS * 4 gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1, 2, gl.FLOAT, false, stride, 0); gl.vertexAttribDivisor(1, 1) gl.enableVertexAttribArray(2); gl.vertexAttribPointer(2, 1, gl.FLOAT, false, stride, 8); gl.vertexAttribDivisor(2, 1) gl.enableVertexAttribArray(3); gl.vertexAttribPointer(3, 1, gl.FLOAT, false, stride, 12); gl.vertexAttribDivisor(3, 1) gl.enableVertexAttribArray(4); gl.vertexAttribPointer(4, 3, gl.FLOAT, false, stride, 16); gl.vertexAttribDivisor(4, 1) gl.enableVertexAttribArray(5); gl.vertexAttribPointer(5, 1, gl.FLOAT, false, stride, 28); gl.vertexAttribDivisor(5, 1) gl.bindVertexArray(null)
glState = { gl, program, vao, instanceBuffer, instanceData, uResolution: gl.getUniformLocation(program, 'uResolution'), uFishSize: gl.getUniformLocation(program, 'uFishSize'), uFogColor: gl.getUniformLocation(program, 'uFogColor'), uEnableFog: gl.getUniformLocation(program, 'uEnableFog'), }
fishList = Array.from({ length: FISH_COUNT }, () => { const base = palette[Math.floor(Math.random() * palette.length)]! return { x: Math.random(), y: Math.random(), angle: (Math.random() - 0.5) * 1.2, scaleX: Math.random() > 0.5 ? 1 : -1, speed: 0.0003 + Math.random() * 0.001, depth: 0.1 + Math.random() * 0.9, r: base[0] + (Math.random() - 0.5) * 0.06, g: base[1] + (Math.random() - 0.5) * 0.06, b: base[2] + (Math.random() - 0.5) * 0.06, } })
sortIndexList = Array.from({ length: FISH_COUNT }, (_, i) => i) sortedData = new Float32Array(FISH_COUNT * INSTANCE_FLOATS)}
function animate() { if (!glState || !sortedData) return const { gl, program, vao, instanceBuffer, instanceData } = glState const canvas = canvasRef.value if (!canvas) return
const dpr = Math.min(window.devicePixelRatio || 1, 2) const width = canvas.clientWidth const dw = Math.round(width * dpr) const dh = Math.round(CANVAS_HEIGHT * dpr) if (canvas.width !== dw || canvas.height !== dh) { canvas.style.height = `${CANVAS_HEIGHT}px` canvas.width = dw; canvas.height = dh }
const time = performance.now() * 0.001 for (let i = 0; i < FISH_COUNT; i++) { const f = fishList[i]! const depthSpeed = f.speed * f.depth f.x += Math.cos(f.angle) * depthSpeed f.y -= Math.sin(f.angle) * depthSpeed f.angle += Math.sin(time * 0.5 + i) * 0.008
const dx = Math.cos(f.angle) if (Math.abs(dx) > 0.001) f.scaleX += ((dx > 0 ? 1 : -1) - f.scaleX) * 0.1
if (f.x < -0.05) f.x = 1.05; if (f.x > 1.05) f.x = -0.05 if (f.y < -0.05) f.y = 1.05; if (f.y > 1.05) f.y = -0.05
const o = i * INSTANCE_FLOATS instanceData[o] = f.x; instanceData[o + 1] = 1 - f.y instanceData[o + 2] = f.scaleX >= 0 ? f.angle : f.angle + Math.PI instanceData[o + 3] = f.scaleX instanceData[o + 4] = f.r; instanceData[o + 5] = f.g; instanceData[o + 6] = f.b instanceData[o + 7] = f.depth }
// 深度排序 let uploadData = instanceData if (enableSort.value) { sortIndexList.sort((a, b) => fishList[a]!.depth - fishList[b]!.depth) for (let si = 0; si < FISH_COUNT; si++) { const src = sortIndexList[si]! * INSTANCE_FLOATS const dst = si * INSTANCE_FLOATS for (let j = 0; j < INSTANCE_FLOATS; j++) { sortedData[dst + j] = instanceData[src + j]! } } uploadData = sortedData }
gl.viewport(0, 0, dw, dh) gl.clearColor(0.067, 0.094, 0.153, 1) gl.clear(gl.COLOR_BUFFER_BIT)
gl.bindBuffer(gl.ARRAY_BUFFER, instanceBuffer) gl.bufferSubData(gl.ARRAY_BUFFER, 0, uploadData, 0, FISH_COUNT * INSTANCE_FLOATS)
gl.useProgram(program) gl.uniform2f(glState.uResolution, dw, dh) gl.uniform1f(glState.uFishSize, 22 * dpr) gl.uniform3f(glState.uFogColor, 0.067, 0.094, 0.153) gl.uniform1i(glState.uEnableFog, enableFog.value ? 1 : 0)
gl.enable(gl.BLEND) gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA) gl.bindVertexArray(vao) gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, FISH_COUNT) gl.bindVertexArray(null) gl.disable(gl.BLEND)
animationFrameId = requestAnimationFrame(animate)}
onMounted(() => { init(); animationFrameId = requestAnimationFrame(animate) })onBeforeUnmount(() => { cancelAnimationFrame(animationFrameId) if (glState) { const { gl, program, vao, instanceBuffer } = glState gl.deleteProgram(program) gl.deleteVertexArray(vao) gl.deleteBuffer(instanceBuffer) gl.getExtension('WEBGL_lose_context')?.loseContext() glState = null }})</script>
<template> <div class="flex flex-col gap-3 border border-gray-200 rounded-xl p-6 dark:border-gray-700"> <canvas ref="canvasRef" class="w-full rounded-lg" /> <div class="flex gap-3"> <button class="rounded-lg px-4 py-1.5 text-sm text-white transition-colors" :class="enableFog ? 'bg-blue-600 hover:bg-blue-500' : 'bg-gray-600 hover:bg-gray-500'" @click="enableFog = !enableFog" > 霧化:{{ enableFog ? 'ON' : 'OFF' }} </button> <button class="rounded-lg px-4 py-1.5 text-sm text-white transition-colors" :class="enableSort ? 'bg-blue-600 hover:bg-blue-500' : 'bg-gray-600 hover:bg-gray-500'" @click="enableSort = !enableSort" > 深度排序:{{ enableSort ? 'ON' : 'OFF' }} </button> </div> <span class="text-xs text-gray-400"> 霧化讓遠處的魚融入背景色,深度排序確保遠的先畫、近的後畫,避免遮擋錯誤 </span> </div></template>深度值的來源
Section titled “深度值的來源”每條路徑帶有 historyZ 深度歷史,同樣用 Simplex Noise 驅動:
const noiseZ = noise2d(trail.phaseZ, time * turnRate * 0.4)const depthVal = 0.78 + noiseZ * 0.18深度在 0.6 ~ 0.96 之間緩慢變化。乘以 0.4 讓深度的變化頻率比 XY 蜿蜒低,避免魚群忽近忽遠閃爍。
Vertex Shader 的深度縮放
Section titled “Vertex Shader 的深度縮放”float depthScale = mix(0.05, 1.0, aDepth);vec2 scaled = vec2(aQuadPos.x * aScaleX, aQuadPos.y) * depthScale * GLOW_EXPAND;depth = 1.0 的魚是原始大小,depth = 0.0 的魚縮到 5%(幾乎看不見)。這產生了近大遠小的透視效果。
Fragment Shader 的霧化
Section titled “Fragment Shader 的霧化”// fogAmount 在 depth 接近 0 時趨近 1(全霧)float fogAmount = clamp((1.0 - vDepth) * 2.0, 0.0, 1.0);
// 顏色混合向背景色vec3 foggedColor = mix(finalColor, uFogColor, fogAmount * fogAmount * 0.85);| depth | fogAmount | 效果 |
|---|---|---|
| 1.0 | 0.0 | 完全清晰 |
| 0.75 | 0.5 | 稍微朦朧 |
| 0.5 | 1.0 | 幾乎融入背景 |
| 0.0 | 1.0 | 完全看不到 |
fogAmount * fogAmount 讓衰減曲線更平滑。乘以 0.85 保留一絲存在感,不會完全消失。
深度排序(Painter's Algorithm)
Section titled “深度排序(Painter's Algorithm)”2D 沒有 Z-buffer,所以必須手動排序。原則很單純:先畫遠的,後畫近的。
// 依深度值排序 indexsortIndexList.sort((a, b) => fishList[a]!.depth - fishList[b]!.depth)
// 按排序後的順序重排 instance datafor (let si = 0; si < count; si++) { const src = sortIndexList[si]! * INSTANCE_FLOATS const dst = si * INSTANCE_FLOATS // 複製 8 個 float for (let j = 0; j < INSTANCE_FLOATS; j++) { sortedInstanceData[dst + j] = instanceData[src + j]! }}不排序的話,遠處的魚可能畫在近處的魚上面,因為 GPU 是按照 buffer 順序處理的。
按下按鈕分別切換霧化和深度排序看看效果。
- 關閉霧化:所有魚一樣清晰,沒有前後層次
- 關閉排序:偶爾遠處的半透明魚會浮在近處魚上面
兩者同時開啟才能呈現完整的水中透視效果。
霧化的目標色不能寫死,不然切換暗色模式就穿幫了。元件會自動偵測背景色:
function detectFogColor() { let el: HTMLElement | null = containerRef.value while (el) { const bg = getComputedStyle(el).backgroundColor // 解析 RGBA 值 const match = bg.match(/[\d.]+/g) if (match && match.length >= 3) { const alpha = match.length >= 4 ? +match[3]! : 1 if (alpha > 0.1) { fogColor = [+match[0]! / 255, +match[1]! / 255, +match[2]! / 255] return } } el = el.parentElement // 往上找 }
// fallback:根據系統偏好 fogColor = window.matchMedia('(prefers-color-scheme: dark)').matches ? [0.1, 0.1, 0.12] : [1, 1, 1]}從元件容器開始往上走 DOM 樹,找到第一個有不透明背景色的祖先。找不到就看系統偏好是暗色還是亮色。
還會偵測 prefers-color-scheme 變化和 <html> 的 class 變化(手動切主題),隨時更新霧色。
Step 14:全部整合 — 500 條魚的完成品
Section titled “Step 14:全部整合 — 500 條魚的完成品”前面 13 步各自拆開講,現在把所有概念接在一起,看看效果。◝( •ω• )◟
<script setup lang="ts">import { createNoise2D } from 'simplex-noise'import { onBeforeUnmount, onMounted, ref } from 'vue'
const CANVAS_HEIGHT = 400const canvasRef = ref<HTMLCanvasElement | null>(null)
// ---- 參數 ----const FISH_COUNT = 500const TRAIL_COUNT = 6const TRAIL_LENGTH = 2000const FISH_SIZE = 16const SPREAD = 0.18const RISE_SPEED = 0.0005const MEANDER_STRENGTH = 0.003const FOLLOW_SPEED = 0.03const TURN_RATE = 0.04const SPREAD_CURVE = 2
// ---- Noise ----const noise2d = createNoise2D()
// ---- 調色盤 ----const palette: Array<[number, number, number]> = [ [1.00, 0.84, 0.63], [1.00, 0.69, 0.63], [1.00, 0.76, 0.55], [1.00, 0.89, 0.66], [1.00, 0.66, 0.70], [1.00, 0.81, 0.69], [0.63, 0.87, 1.00], [0.59, 0.78, 1.00], [0.66, 0.96, 0.91], [0.63, 0.91, 0.78], [0.78, 0.70, 1.00],]
// ---- Trail 系統(Step 2-4)----interface Trail { historyX: Float32Array; historyY: Float32Array; historyZ: Float32Array; head: number; phaseX: number; phaseY: number; phaseZ: number;}
let trailList: Trail[] = []
function createTrailList(): Trail[] { const list: Trail[] = [] for (let i = 0; i < TRAIL_COUNT; i++) { const startX = 0.1 + Math.random() * 0.8 const startY = 1.05 + Math.random() * 0.15 const historyX = new Float32Array(TRAIL_LENGTH).fill(startX) const historyY = new Float32Array(TRAIL_LENGTH).fill(startY) const historyZ = new Float32Array(TRAIL_LENGTH).fill(0.6) list.push({ historyX, historyY, historyZ, head: 0, phaseX: Math.random() * 100, phaseY: Math.random() * 100, phaseZ: Math.random() * 100, }) }
// 預模擬讓路徑已蜿蜒穿過畫面 const fakeTime = Math.random() * 50 for (let step = 0; step < TRAIL_LENGTH; step++) { updateTrailList(list, fakeTime + step * 0.016) } return list}
function updateTrailList(list: Trail[], time: number) { for (const trail of list) { const noiseZ = noise2d(trail.phaseZ, time * TURN_RATE * 0.4) const depthVal = 0.78 + noiseZ * 0.18
const noiseX = noise2d(trail.phaseX, time * TURN_RATE) * MEANDER_STRENGTH * depthVal const noiseY = noise2d(time * TURN_RATE, trail.phaseY) * MEANDER_STRENGTH * 0.75 * depthVal
const prevHead = (trail.head - 1 + TRAIL_LENGTH) % TRAIL_LENGTH let newX = trail.historyX[prevHead]! + noiseX let newY = trail.historyY[prevHead]! + noiseY - RISE_SPEED * depthVal
if (newX < -0.15) newX += 1.3 if (newX > 1.15) newX -= 1.3 if (newY < -0.15) { newY = 1.05 + Math.random() * 0.15 newX = 0.1 + Math.random() * 0.8 }
trail.historyX[trail.head] = newX trail.historyY[trail.head] = newY trail.historyZ[trail.head] = depthVal trail.head = (trail.head + 1) % TRAIL_LENGTH }}
// ---- 魚群狀態(Step 8:Float32Array)----const FISH_FLOATS = 9const F_X = 0, F_Y = 1, F_ANGLE = 2, F_SCALE_X = 3const F_R = 4, F_G = 5, F_B = 6const F_TRAIL_IDX = 7, F_TRAIL_DELAY = 8
let fishState: Float32Array | null = null
function createFishState(): Float32Array { const state = new Float32Array(FISH_COUNT * FISH_FLOATS) for (let i = 0; i < FISH_COUNT; i++) { const base = palette[Math.floor(Math.random() * palette.length)]! const o = i * FISH_FLOATS const trailIdx = i % TRAIL_COUNT const trail = trailList[trailIdx]! const delay = Math.floor(i / TRAIL_COUNT) / Math.ceil(FISH_COUNT / TRAIL_COUNT) * 0.95 const stepsBack = (delay * (TRAIL_LENGTH - 1)) | 0 const histIdx = (trail.head - 1 - stepsBack + TRAIL_LENGTH * 2) % TRAIL_LENGTH
state[o + F_X] = trail.historyX[histIdx]! state[o + F_Y] = trail.historyY[histIdx]! state[o + F_ANGLE] = 0 state[o + F_SCALE_X] = Math.random() > 0.5 ? 1 : -1 state[o + F_R] = Math.min(1, Math.max(0, base[0] + (Math.random() - 0.5) * 0.06)) state[o + F_G] = Math.min(1, Math.max(0, base[1] + (Math.random() - 0.5) * 0.06)) state[o + F_B] = Math.min(1, Math.max(0, base[2] + (Math.random() - 0.5) * 0.06)) state[o + F_TRAIL_IDX] = trailIdx state[o + F_TRAIL_DELAY] = delay } return state}
// ---- Shader(Step 10-12:Instanced + SDF + Glow + Fog)----const INSTANCE_FLOATS = 8
const VERT_SRC = /* glsl */ `#version 300 eslayout(location = 0) in vec2 aQuadPos;layout(location = 1) in vec2 aPosition;layout(location = 2) in float aAngle;layout(location = 3) in float aScaleX;layout(location = 4) in vec3 aColor;layout(location = 5) in float aDepth;
uniform vec2 uResolution;uniform float uFishSize;
out vec2 vUv;out float vScaleX;out vec3 vColor;out float vDepth;
const float GLOW_EXPAND = 1.5;
void main() { vUv = (aQuadPos * GLOW_EXPAND) * 0.5 + 0.5; vScaleX = aScaleX; vColor = aColor; vDepth = aDepth;
float cosA = cos(aAngle); float sinA = sin(aAngle); float depthScale = mix(0.05, 1.0, aDepth); vec2 scaled = vec2(aQuadPos.x * aScaleX, aQuadPos.y) * depthScale * GLOW_EXPAND; vec2 rotated = vec2( scaled.x * cosA - scaled.y * sinA, scaled.x * sinA + scaled.y * cosA );
vec2 fishSize = uFishSize / uResolution; vec2 clipPos = (aPosition * 2.0 - 1.0) + rotated * fishSize; gl_Position = vec4(clipPos, 0.0, 1.0);}`
const FRAG_SRC = /* glsl */ `#version 300 esprecision mediump float;in vec2 vUv;in float vScaleX;in vec3 vColor;in float vDepth;
uniform vec3 uFogColor;
out vec4 fragColor;
float sdTriangle(vec2 p, float size) { p.x = abs(p.x); return max(dot(p, vec2(0.866, 0.5)) - size * 0.5, -p.y - size * 0.5);}
void main() { vec2 p = vUv * 2.0 - 1.0;
float body = length((p - vec2(0.15, 0.0)) / vec2(0.58, 0.42)) - 1.0; vec2 tp = p - vec2(-0.6, 0.0); float tail = sdTriangle(vec2(-tp.y, tp.x), 0.3) - 0.06; float shape = min(body, tail); float bodyAlpha = 1.0 - smoothstep(-0.02, 0.02, shape);
float glowShape = length((p - vec2(-0.05, 0.0)) / vec2(0.82, 0.48)) - 1.0; float glow = exp(-max(glowShape, 0.0) * 3.5) * 0.4 * (1.0 - bodyAlpha); float totalAlpha = bodyAlpha + glow; if (totalAlpha < 0.001) discard;
float lighting = 0.8 + 0.2 * abs(vScaleX); vec3 bodyColor = vColor * lighting;
float eye = (1.0 - smoothstep(0.06, 0.08, length(p - vec2(0.32, 0.06)))) * smoothstep(0.15, 0.4, abs(vScaleX)); bodyColor = mix(bodyColor, vec3(0.15), eye);
vec3 finalColor = mix(vColor, bodyColor, bodyAlpha / max(totalAlpha, 0.001));
float fogAmount = clamp((1.0 - vDepth) * 2.0, 0.0, 1.0); finalColor = mix(finalColor, uFogColor, fogAmount * fogAmount * 0.85);
fragColor = vec4(finalColor * totalAlpha, totalAlpha);}`
// ---- WebGL 狀態 ----interface GlState { gl: WebGL2RenderingContext; program: WebGLProgram; vao: WebGLVertexArrayObject; instanceBuffer: WebGLBuffer; instanceData: Float32Array; uResolution: WebGLUniformLocation | null; uFishSize: WebGLUniformLocation | null; uFogColor: WebGLUniformLocation | null;}
let glState: GlState | null = nulllet animationFrameId = 0
let sortIndexList: number[] = []let sortedData: Float32Array | null = null
function compileShader(gl: WebGL2RenderingContext, type: number, src: string) { const s = gl.createShader(type)! gl.shaderSource(s, src) gl.compileShader(s) if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(s) || '') return s}
function init() { const canvas = canvasRef.value if (!canvas) return const gl = canvas.getContext('webgl2', { alpha: true, premultipliedAlpha: true, antialias: false }) if (!gl) return
// 建立路徑與魚群 trailList = createTrailList() fishState = createFishState()
// 編譯 Shader const vs = compileShader(gl, gl.VERTEX_SHADER, VERT_SRC) const fs = compileShader(gl, gl.FRAGMENT_SHADER, FRAG_SRC) const program = gl.createProgram()! gl.attachShader(program, vs) gl.attachShader(program, fs) gl.linkProgram(program) gl.deleteShader(vs) gl.deleteShader(fs)
// 四邊形頂點 const qb = gl.createBuffer()! gl.bindBuffer(gl.ARRAY_BUFFER, qb) gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]), gl.STATIC_DRAW)
// Instance buffer const instanceData = new Float32Array(FISH_COUNT * INSTANCE_FLOATS) const instanceBuffer = gl.createBuffer()! gl.bindBuffer(gl.ARRAY_BUFFER, instanceBuffer) gl.bufferData(gl.ARRAY_BUFFER, instanceData.byteLength, gl.DYNAMIC_DRAW)
// VAO const vao = gl.createVertexArray()! gl.bindVertexArray(vao)
gl.bindBuffer(gl.ARRAY_BUFFER, qb) gl.enableVertexAttribArray(0) gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0)
gl.bindBuffer(gl.ARRAY_BUFFER, instanceBuffer) const stride = INSTANCE_FLOATS * 4 gl.enableVertexAttribArray(1); gl.vertexAttribPointer(1, 2, gl.FLOAT, false, stride, 0); gl.vertexAttribDivisor(1, 1) gl.enableVertexAttribArray(2); gl.vertexAttribPointer(2, 1, gl.FLOAT, false, stride, 8); gl.vertexAttribDivisor(2, 1) gl.enableVertexAttribArray(3); gl.vertexAttribPointer(3, 1, gl.FLOAT, false, stride, 12); gl.vertexAttribDivisor(3, 1) gl.enableVertexAttribArray(4); gl.vertexAttribPointer(4, 3, gl.FLOAT, false, stride, 16); gl.vertexAttribDivisor(4, 1) gl.enableVertexAttribArray(5); gl.vertexAttribPointer(5, 1, gl.FLOAT, false, stride, 28); gl.vertexAttribDivisor(5, 1) gl.bindVertexArray(null)
glState = { gl, program, vao, instanceBuffer, instanceData, uResolution: gl.getUniformLocation(program, 'uResolution'), uFishSize: gl.getUniformLocation(program, 'uFishSize'), uFogColor: gl.getUniformLocation(program, 'uFogColor'), }
sortIndexList = Array.from({ length: FISH_COUNT }, (_, i) => i) sortedData = new Float32Array(FISH_COUNT * INSTANCE_FLOATS)}
function animate() { if (!glState || !fishState || !sortedData) return const { gl, program, vao, instanceBuffer, instanceData } = glState const canvas = canvasRef.value if (!canvas) return
const dpr = Math.min(window.devicePixelRatio || 1, 2) const width = canvas.clientWidth const dw = Math.round(width * dpr) const dh = Math.round(CANVAS_HEIGHT * dpr) if (canvas.width !== dw || canvas.height !== dh) { canvas.style.height = `${CANVAS_HEIGHT}px` canvas.width = dw canvas.height = dh }
const time = performance.now() * 0.001 const maxPitch = Math.PI / 2.2
// 更新路徑 updateTrailList(trailList, time)
// 更新每條魚(Step 5-7:跟隨、散佈、旋轉) for (let i = 0; i < FISH_COUNT; i++) { const fo = i * FISH_FLOATS const io = i * INSTANCE_FLOATS
const trail = trailList[fishState[fo + F_TRAIL_IDX]!]! const stepsBack = (fishState[fo + F_TRAIL_DELAY]! * (TRAIL_LENGTH - 1)) | 0 const histIdx = (trail.head - 1 - stepsBack + TRAIL_LENGTH * 2) % TRAIL_LENGTH const depth = trail.historyZ[histIdx]!
// 垂直散佈(Step 5) const histIdx2 = (histIdx - 30 + TRAIL_LENGTH) % TRAIL_LENGTH const trailDirX = trail.historyX[histIdx]! - trail.historyX[histIdx2]! const trailDirY = trail.historyY[histIdx]! - trail.historyY[histIdx2]! const invLen = 1.0 / (Math.sqrt(trailDirX * trailDirX + trailDirY * trailDirY) + 0.001) const perpX = -trailDirY * invLen const perpY = trailDirX * invLen
// 散佈集中度(Step 6) const depthSpread = SPREAD * depth const rawPerp = noise2d(i * 0.37, time * 0.15) const perpOffset = (rawPerp > 0 ? 1 : -1) * (Math.abs(rawPerp) ** SPREAD_CURVE) * depthSpread const rawAlong = noise2d(time * 0.15, i * 0.37) const alongOffset = (rawAlong > 0 ? 1 : -1) * (Math.abs(rawAlong) ** SPREAD_CURVE) * depthSpread * 0.3 const targetX = trail.historyX[histIdx]! + perpX * perpOffset + trailDirX * invLen * alongOffset const targetY = trail.historyY[histIdx]! + perpY * perpOffset + trailDirY * invLen * alongOffset
// 平滑跟隨 const prevX = fishState[fo + F_X]! const prevY = fishState[fo + F_Y]! const jumpX = targetX - prevX const jumpY = targetY - prevY
let curX: number, curY: number if (jumpX * jumpX + jumpY * jumpY > 0.25) { curX = targetX; curY = targetY } else { curX = prevX + jumpX * FOLLOW_SPEED curY = prevY + jumpY * FOLLOW_SPEED } fishState[fo + F_X] = curX fishState[fo + F_Y] = curY
// 旋轉與翻轉(Step 7) const deltaX = curX - prevX const deltaY = curY - prevY const speed = deltaX * deltaX + deltaY * deltaY
let angle = fishState[fo + F_ANGLE]! let scaleX = fishState[fo + F_SCALE_X]!
if (speed > 0.0000000025) { if (deltaX > 0.00002 || deltaX < -0.00002) scaleX += ((deltaX > 0 ? 1 : -1) - scaleX) * 0.08
const facingSign = scaleX >= 0 ? 1 : -1 const rawPitch = Math.atan2(-deltaY, (deltaX > 0 ? deltaX : -deltaX) + 0.0001) const targetAngle = Math.max(-maxPitch, Math.min(maxPitch, rawPitch)) * facingSign
let angleDiff = targetAngle - angle if (angleDiff > Math.PI) angleDiff -= Math.PI * 2 else if (angleDiff < -Math.PI) angleDiff += Math.PI * 2 angle += angleDiff * 0.08 } else { angle *= 0.95 } fishState[fo + F_ANGLE] = angle fishState[fo + F_SCALE_X] = scaleX
// 寫入 instance data instanceData[io] = curX instanceData[io + 1] = 1 - curY instanceData[io + 2] = angle instanceData[io + 3] = scaleX instanceData[io + 4] = fishState[fo + F_R]! instanceData[io + 5] = fishState[fo + F_G]! instanceData[io + 6] = fishState[fo + F_B]! instanceData[io + 7] = depth }
// 深度排序(Step 13) sortIndexList.sort((a, b) => instanceData[a * INSTANCE_FLOATS + 7]! - instanceData[b * INSTANCE_FLOATS + 7]!, ) for (let si = 0; si < FISH_COUNT; si++) { const src = sortIndexList[si]! * INSTANCE_FLOATS const dst = si * INSTANCE_FLOATS for (let j = 0; j < INSTANCE_FLOATS; j++) sortedData[dst + j] = instanceData[src + j]! }
// 繪製 gl.viewport(0, 0, dw, dh) gl.clearColor(0.067, 0.094, 0.153, 1) gl.clear(gl.COLOR_BUFFER_BIT)
gl.bindBuffer(gl.ARRAY_BUFFER, instanceBuffer) gl.bufferSubData(gl.ARRAY_BUFFER, 0, sortedData, 0, FISH_COUNT * INSTANCE_FLOATS)
gl.useProgram(program) gl.uniform2f(glState.uResolution, dw, dh) gl.uniform1f(glState.uFishSize, FISH_SIZE * dpr) gl.uniform3f(glState.uFogColor, 0.067, 0.094, 0.153)
gl.enable(gl.BLEND) gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA) gl.bindVertexArray(vao) gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, FISH_COUNT) gl.bindVertexArray(null) gl.disable(gl.BLEND)
animationFrameId = requestAnimationFrame(animate)}
onMounted(() => { init() animationFrameId = requestAnimationFrame(animate)})
onBeforeUnmount(() => { cancelAnimationFrame(animationFrameId) if (glState) { const { gl, program, vao, instanceBuffer } = glState gl.deleteProgram(program) gl.deleteVertexArray(vao) gl.deleteBuffer(instanceBuffer) gl.getExtension('WEBGL_lose_context')?.loseContext() glState = null }})</script>
<template> <div class="flex flex-col gap-3 border border-gray-200 rounded-xl p-6 dark:border-gray-700"> <canvas ref="canvasRef" class="w-full rounded-lg" /> <span class="text-xs text-gray-400"> 500 條魚 × 6 條路徑,整合 Step 1-13 所有概念 </span> </div></template>每一幀的更新順序:
Noise 驅動路徑(Step 1, 3) → Ring Buffer 記錄歷史(Step 2) → 每條魚讀取對應路徑位置(Step 5) → spreadCurve 控制散佈(Step 6) → 平滑跟隨 + 旋轉翻轉(Step 7) → Float32Array 寫入 instance data(Step 8) → 深度排序(Step 13) → GPU:Instanced Draw(Step 10) → SDF 魚形 + 光暈 + 霧化(Step 11-13)不過說到底,每個環節做的事都很單純,一步只解一個問題。把單純的東西串起來,就是完整的效果。
從 500 到 10000
Section titled “從 500 到 10000”這個範例用了 500 條魚方便在文章中展示,實際元件預設是 10000 條。差在哪?
其實架構完全一樣,改一個常數就好。Instanced Rendering 的好處就在這裡:不管畫 500 條還是 10000 條,都只有一次 draw call,GPU 負擔差異不大。
真正的瓶頸在 JavaScript 端的物理更新和深度排序。10000 條魚每幀都要算位置、排序 index,不過因為用了 Float32Array 連續記憶體,快取命中率高,現代瀏覽器跑起來還是很流暢。
完整元件還多了幾個細節:
- 背景色偵測:自動偵測父元素背景色作為霧化目標,亮暗模式都能用
- 亮暗模式監聯:偵測
prefers-color-scheme與 DOM class 變化 - Props 響應式:魚的大小、散佈寬度等參數都能動態調整
- 容器尺寸追蹤:用
useElementSize自動適應容器大小
想玩完整版可以到 Starry Sea 元件頁。
讓我們回顧一下從零到一萬條魚的旅程:
| Step | 概念 | 核心技術 |
|---|---|---|
| 1 | Simplex Noise | 連續平滑的噪聲函式產生有機運動 |
| 2 | 環狀緩衝區 | 固定記憶體、O(1) 寫入的路徑歷史 |
| 3 | 噪聲路徑 | Noise + Ring Buffer = 蜿蜒軌跡 |
| 4 | 多條路徑 | 獨立 phase 讓路徑不同步 |
| 5 | 跟隨散佈 | 延遲讀取 + 垂直散佈 = 魚群隊形 |
| 6 | 集中度 | 冪函數控制分佈形狀 |
| 7 | 旋轉翻轉 | atan2 + lerp 角度插值 |
| 8 | Typed Array | Float32Array 連續記憶體高效管理 |
| 9 | 色彩設計 | 暖冷調色盤 + 色彩抖動 |
| 10 | Instanced Rendering | 一次 draw call 繪製萬個實例 |
| 11 | SDF 魚形 | 橢圓 + 三角形聯集 + smoothstep |
| 12 | 光暈效果 | 指數衰減 + 四邊形放大 |
| 13 | 深度霧化 | 霧化混合 + 畫家演算法排序 |
| 14 | 全部整合 | 串接所有環節成完整效果 |
感謝您讀到這裡,如果您覺得有收穫,歡迎分享出去。有錯誤還請多多指教 🐟