Starry Sea background
從零開始,一步步打造萬條魚群悠游上升的 Starry Sea 效果。◝( •ω• )◟
完成品請見 Starry Sea元件。
前言
超時空輝耀姬中 Starry Sea 的演出背景真的好美,不過要在瀏覽器上畫出一萬條小魚並保持流暢,是個不小的挑戰。(´・ω・`)
先從最基本的噪聲概念開始,用 Canvas 2D 建立路徑和魚群物理,再一步步搬到 WebGL2 Shader 上,每一步只加一個新概念。
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 = 200
const DOT_RADIUS = 6
const 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 state
let randomDot = { x: 0.5, y: 0.5 }
const randomTrailList: Point[] = []
// Noise dot state
let 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 函式?
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
| 特性 | Math.random() | Simplex Noise |
|---|---|---|
| 輸出範圍 | 0 ~ 1 | -1 ~ 1 |
| 連續性 | 完全不連續,每次獨立 | 平滑連續,相鄰值相近 |
| 可重現 | 不行(除非設定 seed) | 同輸入同輸出 |
| 適合場景 | 初始化、機率判斷 | 動畫、地形、自然運動 |
為什麼選 Simplex 而不是 Perlin?
其實兩者效果很接近,甚至老爸都是同一個人 ( ´ ▽ ` )ノ
不過 Simplex Noise 在高維度的效能更好,而且沒有 Perlin Noise 在軸對齊方向的格子感(axis-aligned artifact)。
感謝偉大的 npm,我們可以直接使用 simplex-noise 套件,一行就搞定了。ヾ(◍'౪`◍)ノ゙
用 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:游動路徑 - 環狀緩衝區
現在我們有了連續且自然的路徑了,接下來要讓魚群跟著路徑游。
領頭的魚沿著路徑前進,第二條魚跟著「路徑 0.5 秒前經過的位置」,第三條跟著「1 秒前的位置」以此類推。
每條魚讀取不同時間點的歷史位置,自然就排成了一列隊伍。
所以我們要把路徑記下來,不過現在我們有很多魚,而且不能無限記下去,不然可能跑個幾秒鐘,記憶體就爆炸了。ヽ(́◕◞౪◟◕‵)ノ
所以需要一個固定大小、寫滿就自動覆蓋最舊資料的結構,這就是 Ring Buffer(環狀緩衝區)。
查看範例原始碼
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
const BUFFER_SIZE = 24
const CANVAS_HEIGHT = 220
const canvasRef = ref<HTMLCanvasElement | null>(null)
// 環狀緩衝區
const buffer = new Float32Array(BUFFER_SIZE)
let head = 0
let writeCount = 0
let animationFrameId = 0
let 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 概念
想像一個固定長度的陣列,尾端接回頭部形成一個環。寫入時只需移動 head 指標:
const TRAIL_LENGTH = 2000
const 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?
| 操作 | 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 也沒什麼問題就是了。乁( ◔ ௰◔)「
讀取歷史位置
從 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:噪聲路徑
把 Simplex Noise 和 Ring Buffer 結合,就能畫出一條不斷蜿蜒上升的路徑。
查看範例原始碼
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { createNoise2D } from 'simplex-noise'
const CANVAS_HEIGHT = 300
const TRAIL_LENGTH = 500
const RISE_SPEED = 0.0005
const MEANDER_STRENGTH = 0.003
const 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 = 0
const phaseX = Math.random() * 100
const phaseY = Math.random() * 100
// 初始位置在畫面下方
const startX = 0.3 + Math.random() * 0.4
historyX.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>路徑更新邏輯
每一幀做一件事:在「上一步的位置」加上噪聲偏移,得到新位置,寫入 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.3
if (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() * 50
for (let step = 0; step < TRAIL_LENGTH; step++) {
updateTrail(fakeTimeOffset + step * 0.016)
}這樣一開場路徑就已經蜿蜒穿過整個畫面了。(≖ᴗ≖✿)
Step 4:多條路徑與長龍隊形
一條路徑太孤單了,真實的魚群會分成好幾條「長龍」,各自蜿蜒穿梭。
查看範例原始碼
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
import { createNoise2D } from 'simplex-noise'
const CANVAS_HEIGHT = 300
const TRAIL_LENGTH = 500
const TRAIL_COUNT = 6
const RISE_SPEED = 0.0005
const MEANDER_STRENGTH = 0.003
const 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() * 50
for (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 的值差距夠大,噪聲就不會撞在一起。每條路徑獨立更新,彼此不干擾。
路徑數量的選擇
| trailCount | 效果 |
|---|---|
| 1-2 | 單一方向感很強,像一條河流 |
| 4-6 | 自然交錯,有層次感 |
| 10+ | 路徑太密集,魚群散佈反而沒重點 |
預設 6 條是個不錯的平衡點,讓畫面有足夠的路徑交錯但又不至於太亂。
Step 5:跟隨路徑 — 魚群的隊形散佈
路徑畫好了,接下來要讓魚群跟著路徑走。
每條魚被分配到一條路徑(trailIdx),並根據自己的延遲值(delay)去讀取路徑歷史中對應的位置。delay 越大的魚讀越舊的歷史,自然就排在隊伍越後面。
查看範例原始碼
<script setup lang="ts">
import { createNoise2D } from 'simplex-noise'
import { onBeforeUnmount, onMounted, ref } from 'vue'
const CANVAS_HEIGHT = 300
const TRAIL_LENGTH = 500
const TRAIL_COUNT = 3
const FISH_COUNT = 200
const RISE_SPEED = 0.0005
const MEANDER_STRENGTH = 0.003
const 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() * 50
for (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 % trailCount
const delay = Math.floor(i / trailCount) / Math.ceil(fishCount / trailCount) * 0.95
// 讀取路徑歷史中的目標位置
const stepsBack = (delay * (trailLength - 1)) | 0
const histIdx = (trail.head - 1 - stepsBack + trailLength * 2) % trailLength
const targetX = trail.historyX[histIdx]!
const targetY = trail.historyY[histIdx]!delay 的值在 0 ~ 0.95 之間均勻分佈。乘以路徑歷史長度就是「往回看幾步」。
| 0 是 JavaScript 的取整技巧,效果等同 Math.floor 但更快。
Lerp 跟隨
魚不會瞬間跳到目標位置,而是用 lerp 平滑移動過去:
const followSpeed = 0.03
fish.x += (targetX - fish.x) * followSpeed
fish.y += (targetY - fish.y) * followSpeed每幀只移動「到目標距離」的 3%。離得遠就移動得多,離得近就移動得少,天生帶有減速效果。
垂直散佈
如果所有魚都精準走在路徑線上,看起來比較像螞蟻隊伍,不是魚群,應該要讓魚沿路徑的垂直方向散開才對
下面的範例在路徑上標出每個點的法線向量(黃色短線),魚會沿著黃線方向偏移。
查看範例原始碼
<script setup lang="ts">
import { createNoise2D } from 'simplex-noise'
import { onBeforeUnmount, onMounted, ref } from 'vue'
const CANVAS_HEIGHT = 300
const TRAIL_LENGTH = 500
const RISE_SPEED = 0.0005
const MEANDER_STRENGTH = 0.003
const 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 = 0
const phaseX = Math.random() * 100
const phaseY = Math.random() * 100
// 初始位置在畫面下方
const startX = 0.3 + Math.random() * 0.4
historyX.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 * invLen
const perpY = dirX * invLen
// 用噪聲決定偏移量
const perpOffset = noise2d(i * 0.37, time * 0.15) * spread
const targetX = trail.historyX[histIdx]! + perpX * perpOffset
const targetY = trail.historyY[histIdx]! + perpY * perpOffset法線向量是把方向向量旋轉 90°:(dx, dy) → (-dy, dx)。
乘以噪聲偏移量,魚就會在路徑兩側自然散開。
Step 6:散佈集中度 — spreadCurve 的魔法
上一步的散佈是均勻分布,但自然界的魚群通常大多數集中在路徑中心,只有少數離群者游在外圍。
spreadCurve 這個參數用冪函數來控制分佈的集中程度。
查看範例原始碼
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
const CANVAS_HEIGHT = 180
const 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:旋轉與翻轉 — 讓魚活起來
到目前為止魚只是一堆飄動的光點。真正的魚會根據游動方向轉頭,左轉時翻個身,上升時仰頭。
按下按鈕關閉旋轉看看差異。沒有旋轉的魚永遠朝同一方向,像是被黏在滑軌上的道具。
查看範例原始碼
<script setup lang="ts">
import { createNoise2D } from 'simplex-noise'
import { onBeforeUnmount, onMounted, ref } from 'vue'
const CANVAS_HEIGHT = 300
const TRAIL_LENGTH = 500
const TRAIL_COUNT = 2
const FISH_COUNT = 60
const FISH_SIZE = 8
const RISE_SPEED = 0.0005
const MEANDER_STRENGTH = 0.003
const 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() * 50
for (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 = 0
const 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>從速度算角度
const deltaX = fish.x - fish.prevX
const 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.angle
if (angleDiff > Math.PI)
angleDiff -= Math.PI * 2
else 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 = 260
const RADIUS = 90
const 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)
if (Math.abs(deltaX) > 0.00002) {
fish.scaleX += ((deltaX > 0 ? 1 : -1) - fish.scaleX) * 0.08
}scaleX 在 -1 到 1 之間平滑過渡,正值面向右、負值面向左。也是用 lerp,所以翻轉不會瞬間完成,看起來比較自然。
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 的問題
一萬個 { x, y, angle, scaleX, r, g, b, trailIdx, delay } 物件代表什麼?
- 一萬次 GC 壓力:每個物件都是獨立的堆記憶體分配,垃圾回收器要追蹤一萬個小物件
- 快取不友善:物件分散在記憶體各處,CPU 讀完一條魚的
x,下一條魚的x可能離很遠
Float32Array 的解法
把所有魚的資料塞進一條連續的 Float32Array:
const FISH_FLOATS = 9 // 每條魚 9 個 float
// 索引常數
const F_X = 0
const F_Y = 1
const F_ANGLE = 2
const F_SCALE_X = 3
const F_R = 4
const F_G = 5
const F_B = 6
const F_TRAIL_IDX = 7
const F_TRAIL_DELAY = 8
// 一次分配所有記憶體
const fishState = new Float32Array(10000 * FISH_FLOATS)
// 讀寫第 i 條魚
const offset = i * FISH_FLOATS
fishState[offset + F_X] = 0.5
fishState[offset + F_Y] = 0.3為什麼快這麼多?
| 面向 | JS Object 陣列 | Float32Array |
|---|---|---|
| 記憶體排列 | 分散在 heap 各處 | 連續一整塊 |
| CPU 快取命中 | 差,cache miss 頻繁 | 好,循序讀取超快 |
| GC 壓力 | 一萬個物件要追蹤 | 只有一個 TypedArray |
| 可直接傳給 GPU | 不行,要先轉換 | 可以直接 bufferSubData |
最後一點特別重要:稍後要把資料傳給 WebGL 時,Float32Array 可以零拷貝直接上傳,JavaScript 物件陣列則必須先逐一轉換。
Step 9:更自然的顏色
現在來幫小魚上色。( ´ ▽ ` )ノ
上方範例左右對照:左邊同色的點完全一模一樣,色塊感明顯;右邊每個點加上 ±7% 的隨機偏移後,整體看起來自然許多。
查看範例原始碼
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
const CANVAS_HEIGHT = 340
const DOT_RADIUS = 6
const 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 色調色盤
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)
如果每條魚精準使用調色盤的顏色,大量魚群會看到明顯的色帶分界。
解法是加入微小的隨機偏移:
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 — 一次繪製萬條魚
到目前為止,為了方便理解,我們都使用 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 = 300
const MAX_FISH = 10000
const INSTANCE_FLOATS = 6
const canvasRef = ref<HTMLCanvasElement | null>(null)
const fishCount = ref(5000)
const fpsDisplay = ref(0)
// 頂點著色器
const VERT_SRC = /* glsl */ `#version 300 es
layout(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 es
precision mediump float;
in vec3 vColor;
out vec4 fragColor;
void main() {
fragColor = vec4(vColor, 1.0);
}
`
let gl: WebGL2RenderingContext | null = null
let program: WebGLProgram | null = null
let vao: WebGLVertexArrayObject | null = null
let instanceBuffer: WebGLBuffer | null = null
let uResolution: WebGLUniformLocation | null = null
let 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 = 0
let 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?
普通的繪圖是每個物件一次 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
// 每個 instance 的資料格式:x, y, r, g, b, size = 6 floats
const INSTANCE_FLOATS = 6
const 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
// 更新所有魚的資料到 Float32Array
for (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
}
// 上傳到 GPU
gl.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 中畫魚
Instanced Rendering 現在畫的是方塊。每個 instance 共用同一組四邊形頂點,我們沒辦法為每條魚傳入不同的幾何形狀。
那用魚形貼圖呢?可以,但貼圖放大會模糊、縮小會浪費,解析度很難兩全。
更靈活的做法是在 Fragment Shader 中用數學「算」出形狀,這就是 SDF(Signed Distance Field) 的思路。SDF 不依賴解析度,而且可以輕鬆組合多個基本形狀、自帶抗鋸齒。
查看範例原始碼
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
const CANVAS_HEIGHT = 300
const canvasRef = ref<HTMLCanvasElement | null>(null)
const useSdfShape = ref(true)
const VERT_SRC = /* glsl */ `#version 300 es
layout(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 es
precision 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 es
precision 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 = null
let animationFrameId = 0
const FISH_COUNT = 60
const 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 是什麼?
上一步的 Instanced Rendering 把四邊形整個填滿顏色,所以魚看起來是方塊。我們要的是只有魚形有顏色、其他部分透明。
要做到這件事,得先理解 GPU 畫圖的流程。GPU 畫一個四邊形分成兩個階段,每個階段各有一段我們寫的小程式,這些小程式就叫 Shader:
- Vertex Shader(頂點著色器):決定四邊形的四個角要放在螢幕的哪裡。上一步已經用過了。
- Fragment Shader(片段著色器):四邊形擺好位置後,GPU 會對裡面的每一個像素各執行一次 Fragment Shader,問它:「這個像素要填什麼顏色?」
Fragment Shader 每次執行時,會收到一個座標 p,代表「這個像素在四邊形上的位置」(範圍 -1 到 1,中心是 0)。
如果我們能在 Fragment Shader 裡,用 p 算出「這個像素在不在魚的形狀內」,形狀內就填色、形狀外就透明,方塊就變成魚形了。
用 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 的「距離場」長什麼樣子:
一步步畫出小魚
點擊下方按鈕切換每個階段,觀察 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 es
layout(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 es
precision 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 = null
let 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:光暈效果 — 自帶發光的小魚
光暈(Glow)讓魚看起來像是自己在發光,整個畫面也變得柔和溫暖。
查看範例原始碼
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
const CANVAS_HEIGHT = 300
const canvasRef = ref<HTMLCanvasElement | null>(null)
const enableGlow = ref(true)
const VERT_SRC = /* glsl */ `#version 300 es
layout(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 es
precision 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 = 40
const 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 = null
let 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)確保光暈不會疊加在身體上
為什麼要放大四邊形?
光暈超出魚的身體範圍,如果四邊形剛好是魚的大小,光暈就會被裁切。
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:深度霧化與排序 — 遠近分明的水世界
最後一步讓魚群有前後深度感。近處的魚大而清晰,遠處的魚小而模糊,融入背景。
查看範例原始碼
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
const CANVAS_HEIGHT = 300
const canvasRef = ref<HTMLCanvasElement | null>(null)
const enableFog = ref(true)
const enableSort = ref(true)
const VERT_SRC = /* glsl */ `#version 300 es
layout(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 es
precision 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 = 80
const 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 = null
let 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>深度值的來源
每條路徑帶有 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 的深度縮放
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 的霧化
// 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)
2D 沒有 Z-buffer,所以必須手動排序。原則很單純:先畫遠的,後畫近的。
// 依深度值排序 index
sortIndexList.sort((a, b) => fishList[a]!.depth - fishList[b]!.depth)
// 按排序後的順序重排 instance data
for (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 條魚的完成品
前面 13 步各自拆開講,現在把所有概念接在一起,看看效果。◝( •ω• )◟
查看範例原始碼
<script setup lang="ts">
import { createNoise2D } from 'simplex-noise'
import { onBeforeUnmount, onMounted, ref } from 'vue'
const CANVAS_HEIGHT = 400
const canvasRef = ref<HTMLCanvasElement | null>(null)
// ---- 參數 ----
const FISH_COUNT = 500
const TRAIL_COUNT = 6
const TRAIL_LENGTH = 2000
const FISH_SIZE = 16
const SPREAD = 0.18
const RISE_SPEED = 0.0005
const MEANDER_STRENGTH = 0.003
const FOLLOW_SPEED = 0.03
const TURN_RATE = 0.04
const 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 = 9
const F_X = 0, F_Y = 1, F_ANGLE = 2, F_SCALE_X = 3
const F_R = 4, F_G = 5, F_B = 6
const 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 es
layout(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 es
precision 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 = null
let 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
這個範例用了 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 | 全部整合 | 串接所有環節成完整效果 |
感謝您讀到這裡,如果您覺得有收穫,歡迎分享出去。有錯誤還請多多指教 🐟