Skip to content
歡迎來票選你最喜歡的元件! 也可以告訴我任何你想說的話喔!(*´∀`)~♥

Starry Sea background

從零開始,一步步打造萬條魚群悠游上升的 Starry Sea 效果。◝( •ω• )◟

完成品請見 Starry Sea元件

前言

超時空輝耀姬中 Starry Sea 的演出背景真的好美,不過要在瀏覽器上畫出一萬條小魚並保持流暢,是個不小的挑戰。(´・ω・`)

先從最基本的噪聲概念開始,用 Canvas 2D 建立路徑和魚群物理,再一步步搬到 WebGL2 Shader 上,每一步只加一個新概念。

Step 1:Simplex Noise — 有機的隨機

做生物相關的動畫,第一步通常需要「隨機」讓東西動起來。

Math.random() 產生的亂數,每次呼叫的結果跟前一次完全無關。

物體看起來會像中邪一樣抖個不停。

Simplex Noise 不一樣,它產生的值是連續且平滑的,相鄰的輸入會得到相近的輸出,形成自然的起伏波動。


路人:「我覺得 random 很自然啊,和你平時一樣 ( ˙꒳​˙)」

鱈魚:「不要瞎掰好嘛 ლ(╹ε╹ლ)」


查看範例原始碼
vue
<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),回傳一個 -11 之間的浮點數。重點在於輸入越接近,輸出越相似。

ts
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 製造動畫

關鍵在於把時間當作噪聲的輸入軸:

ts
const time = performance.now() * 0.001 // 秒

// X 方向的蜿蜒
const offsetX = noise2d(phaseX, time * turnRate) * meanderStrength

// Y 方向的蜿蜒
const offsetY = noise2d(time * turnRate, phaseY) * meanderStrength

phaseXphaseY 是每條路徑的隨機偏移,讓不同路徑不會同步擺動。

turnRate 控制轉彎頻率,越小曲線越平緩。

Step 2:游動路徑 - 環狀緩衝區

現在我們有了連續且自然的路徑了,接下來要讓魚群跟著路徑游。

領頭的魚沿著路徑前進,第二條魚跟著「路徑 0.5 秒前經過的位置」,第三條跟著「1 秒前的位置」以此類推。

每條魚讀取不同時間點的歷史位置,自然就排成了一列隊伍。

所以我們要把路徑記下來,不過現在我們有很多魚,而且不能無限記下去,不然可能跑個幾秒鐘,記憶體就爆炸了。ヽ(́◕◞౪◟◕‵)ノ

所以需要一個固定大小、寫滿就自動覆蓋最舊資料的結構,這就是 Ring Buffer(環狀緩衝區)

查看範例原始碼
vue
<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 指標:

ts
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 Bufferpush + shift
寫入新資料O(1),改 headO(1),push
移除最舊自動覆蓋,O(1)O(n),shift 要搬全部
記憶體固定,不會成長會頻繁 GC
讀取第 N 舊的資料O(1),算 indexO(1)

Array.shift() 要把後面所有元素往前搬一格,2000 個元素每幀做一次,CPU 會不開心。

不過如果資料量真的很少,其實用 Array 也沒什麼問題就是了。乁( ◔ ௰◔)「

讀取歷史位置

從 ring buffer 讀「N 步之前」的資料:

ts
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 結合,就能畫出一條不斷蜿蜒上升的路徑。

查看範例原始碼
vue
<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。

ts
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:水平蜿蜒比垂直稍大,看起來比較像在水中游
  • phaseXphaseY:每條路徑獨立的噪聲種子

邊界處理

路徑游到畫面外要怎麼辦?

ts
// 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 軸則是在底部重新出發,因為魚群是往上游的。

預模擬

直接啟動的話,所有路徑都擠在底部慢慢爬上來,要好幾秒才能填滿畫面。

解決方法很直接:初始化時先跑一輪完整的歷史模擬。

ts
const fakeTimeOffset = Math.random() * 50
for (let step = 0; step < TRAIL_LENGTH; step++) {
  updateTrail(fakeTimeOffset + step * 0.016)
}

這樣一開場路徑就已經蜿蜒穿過整個畫面了。(≖ᴗ≖✿)

Step 4:多條路徑與長龍隊形

一條路徑太孤單了,真實的魚群會分成好幾條「長龍」,各自蜿蜒穿梭。

查看範例原始碼
vue
<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:

ts
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,
  })
}

phaseXphaseY 的值差距夠大,噪聲就不會撞在一起。每條路徑獨立更新,彼此不干擾。

路徑數量的選擇

trailCount效果
1-2單一方向感很強,像一條河流
4-6自然交錯,有層次感
10+路徑太密集,魚群散佈反而沒重點

預設 6 條是個不錯的平衡點,讓畫面有足夠的路徑交錯但又不至於太亂。

Step 5:跟隨路徑 — 魚群的隊形散佈

路徑畫好了,接下來要讓魚群跟著路徑走。

每條魚被分配到一條路徑(trailIdx),並根據自己的延遲值(delay)去讀取路徑歷史中對應的位置。delay 越大的魚讀越舊的歷史,自然就排在隊伍越後面。

查看範例原始碼
vue
<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>

延遲跟隨

ts
// 分配路徑與延遲
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 平滑移動過去:

ts
const followSpeed = 0.03

fish.x += (targetX - fish.x) * followSpeed
fish.y += (targetY - fish.y) * followSpeed

每幀只移動「到目標距離」的 3%。離得遠就移動得多,離得近就移動得少,天生帶有減速效果。

垂直散佈

如果所有魚都精準走在路徑線上,看起來比較像螞蟻隊伍,不是魚群,應該要讓魚沿路徑的垂直方向散開才對

下面的範例在路徑上標出每個點的法線向量(黃色短線),魚會沿著黃線方向偏移。

查看範例原始碼
vue
<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>
ts
// 計算路徑方向(看 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 這個參數用冪函數來控制分佈的集中程度。

2.0
curve=1 為均勻分佈,越大越集中在路徑中心,偶爾有離群魚
查看範例原始碼
vue
<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>

冪函數分佈

ts
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:旋轉與翻轉 — 讓魚活起來

到目前為止魚只是一堆飄動的光點。真正的魚會根據游動方向轉頭,左轉時翻個身,上升時仰頭。

按下按鈕關閉旋轉看看差異。沒有旋轉的魚永遠朝同一方向,像是被黏在滑軌上的道具。

查看範例原始碼
vue
<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>

從速度算角度

ts
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)已經處理了左右方向,角度只需要管俯仰。

角度限制

ts
const maxPitch = Math.PI / 2.2 // 約 ±81°

const targetAngle = Math.max(-maxPitch, Math.min(maxPitch, rawPitch))

不限制的話,魚急轉彎時可能翻到 180°,看起來像翻肚子。限制在 ±81° 讓魚最多斜著游,不會完全垂直。

平滑插值

ts
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° 線的兩側,效果最明顯。

點擊圓上任意位置設定目標角度
查看範例原始碼
vue
<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°)就是最短路徑。

ts
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)

ts
if (Math.abs(deltaX) > 0.00002) {
  fish.scaleX += ((deltaX > 0 ? 1 : -1) - fish.scaleX) * 0.08
}

scaleX-11 之間平滑過渡,正值面向右、負值面向左。也是用 lerp,所以翻轉不會瞬間完成,看起來比較自然。

Step 8:Float32Array — 管理萬條魚的秘訣

到目前為止我們用 JavaScript 物件陣列存魚的狀態。100 條魚沒問題,但一萬條就不一樣了。

JS Object vs Float32Array 效能測試
魚數量 Object (ms) Float32Array (ms) 速度倍率
模擬 120 幀的更新與 GPU 打包。Object 版每幀需要額外把屬性逐一寫入 Float32Array;TypedArray 版資料本身就是 GPU 可用的格式,省去打包步驟
查看範例原始碼
vue
<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

ts
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:更自然的顏色

現在來幫小魚上色。( ´ ▽ ` )ノ

左:同色的點完全一樣,色塊感明顯。右:每個點 RGB 各 ±7% 隨機偏移,整體更自然

上方範例左右對照:左邊同色的點完全一模一樣,色塊感明顯;右邊每個點加上 ±7% 的隨機偏移後,整體看起來自然許多。

查看範例原始碼
vue
<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 色調色盤

ts
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)

如果每條魚精準使用調色盤的顏色,大量魚群會看到明顯的色帶分界。

解法是加入微小的隨機偏移:

ts
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 的 fillRectarc 逐一呼叫實在太慢。

WebGL2 的 Instanced Rendering 可以用一次 draw call 畫出所有魚。

繪製數量:5,000FPS:0僅 1 次 draw call
5,000
查看範例原始碼
vue
<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

ts
// 每個 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

ts
// 更新所有魚的資料到 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 不依賴解析度,而且可以輕鬆組合多個基本形狀、自帶抗鋸齒。

切換觀察:圓形只是一個 distance 判斷,SDF 魚形由橢圓身體 + 三角形尾巴組成
查看範例原始碼
vue
<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

  1. Vertex Shader(頂點著色器):決定四邊形的四個角要放在螢幕的哪裡。上一步已經用過了。
  2. Fragment Shader(片段著色器):四邊形擺好位置後,GPU 會對裡面的每一個像素各執行一次 Fragment Shader,問它:「這個像素要填什麼顏色?」

Fragment Shader 每次執行時,會收到一個座標 p,代表「這個像素在四邊形上的位置」(範圍 -1 到 1,中心是 0)。

如果我們能在 Fragment Shader 裡,用 p 算出「這個像素在不在魚的形狀內」,形狀內就填色、形狀外就透明,方塊就變成魚形了。

用 SDF 判斷形狀

最直覺的方式:算每個像素離形狀邊界的距離。以圓為例,離圓心的距離小於半徑就在圓內。

SDF(Signed Distance Field) 把這個想法寫成一行:

glsl
// 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;
查看範例原始碼
vue
<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:算出每個像素離圓心的距離,減去半徑。

glsl
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 比例,等於把空間壓扁,圓就變成橢圓。

glsl
float shape = length(p / vec2(0.58, 0.42)) - 1.0;

0.58 > 0.42,所以 X 方向更寬、Y 方向更窄,形成橫向的橢圓。

偏移中心

把橢圓中心往右移 0.15,頭部靠右、左側留出空間放尾巴。

glsl
float body = length((p - vec2(0.15, 0.0)) / vec2(0.58, 0.42)) - 1.0;

加尾巴

sdTriangle 在左側畫一個圓角三角形,減去 0.06 讓尖角變圓潤。

glsl
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 就自然融合了。

glsl
float shape = min(body, tail);

max 則是交集,-shape 是反轉(挖洞)。這三個操作就能組合出各種複雜形狀。

抗鋸齒

前面幾步的硬邊鋸齒感很重。smoothstep 在 SDF 值 -0.02 到 0.02 之間做平滑過渡,邊緣就變得柔和了。

glsl
float alpha = 1.0 - smoothstep(-0.02, 0.02, shape);

加眼睛

眼睛也是一個小圓的 SDF,位置在身體右上方。

glsl
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)讓魚看起來像是自己在發光,整個畫面也變得柔和溫暖。

光暈使用指數衰減函式 exp(-d × 3.5) × 0.4,僅在身體外部渲染,讓魚看起來自帶發光效果
查看範例原始碼
vue
<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>

光暈計算

glsl
// 用比身體大的橢圓計算距離
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);

幾個設計重點:

  1. 光暈橢圓比身體大vec2(0.82, 0.48) vs 身體的 vec2(0.58, 0.42),讓光暈包覆整隻魚
  2. 指數衰減 exp(-d * 3.5):離身體越遠光暈越弱,衰減速度由 3.5 控制
  3. 乘以 0.4:最大亮度只有 40%,避免太亮搶戲
  4. 身體外部限定glow *= (1.0 - bodyAlpha) 確保光暈不會疊加在身體上

為什麼要放大四邊形?

光暈超出魚的身體範圍,如果四邊形剛好是魚的大小,光暈就會被裁切。

glsl
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 座標也相應調整。這樣光暈就有足夠的空間渲染了。

合成顏色

glsl
float totalAlpha = bodyAlpha + glow;
vec3 finalColor = mix(vColor, bodyColor, bodyAlpha / totalAlpha);

身體部分用帶光影的顏色(bodyColor),光暈部分用原始魚色(vColor)。mix 根據各自的 alpha 佔比混合。

切換光暈按鈕觀察差異。沒有光暈的魚邊緣銳利生硬,有光暈後整體柔和許多,像是水中的光影效果。ヾ(◍'౪`◍)ノ゙

Step 13:深度霧化與排序 — 遠近分明的水世界

最後一步讓魚群有前後深度感。近處的魚大而清晰,遠處的魚小而模糊,融入背景。

霧化讓遠處的魚融入背景色,深度排序確保遠的先畫、近的後畫,避免遮擋錯誤
查看範例原始碼
vue
<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 驅動:

ts
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 的深度縮放

glsl
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 的霧化

glsl
// 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);
depthfogAmount效果
1.00.0完全清晰
0.750.5稍微朦朧
0.51.0幾乎融入背景
0.01.0完全看不到

fogAmount * fogAmount 讓衰減曲線更平滑。乘以 0.85 保留一絲存在感,不會完全消失。

深度排序(Painter's Algorithm)

2D 沒有 Z-buffer,所以必須手動排序。原則很單純:先畫遠的,後畫近的

ts
// 依深度值排序 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 順序處理的。

按下按鈕分別切換霧化和深度排序看看效果。

  • 關閉霧化:所有魚一樣清晰,沒有前後層次
  • 關閉排序:偶爾遠處的半透明魚會浮在近處魚上面

兩者同時開啟才能呈現完整的水中透視效果。

背景色偵測

霧化的目標色不能寫死,不然切換暗色模式就穿幫了。元件會自動偵測背景色:

ts
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 步各自拆開講,現在把所有概念接在一起,看看效果。◝( •ω• )◟

500 條魚 × 6 條路徑,整合 Step 1-13 所有概念
查看範例原始碼
vue
<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 條魚 &times; 6 條路徑,整合 Step 1-13 所有概念
    </span>
  </div>
</template>

整合的流程

每一幀的更新順序:

txt
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概念核心技術
1Simplex Noise連續平滑的噪聲函式產生有機運動
2環狀緩衝區固定記憶體、O(1) 寫入的路徑歷史
3噪聲路徑Noise + Ring Buffer = 蜿蜒軌跡
4多條路徑獨立 phase 讓路徑不同步
5跟隨散佈延遲讀取 + 垂直散佈 = 魚群隊形
6集中度冪函數控制分佈形狀
7旋轉翻轉atan2 + lerp 角度插值
8Typed ArrayFloat32Array 連續記憶體高效管理
9色彩設計暖冷調色盤 + 色彩抖動
10Instanced Rendering一次 draw call 繪製萬個實例
11SDF 魚形橢圓 + 三角形聯集 + smoothstep
12光暈效果指數衰減 + 四邊形放大
13深度霧化霧化混合 + 畫家演算法排序
14全部整合串接所有環節成完整效果

感謝您讀到這裡,如果您覺得有收穫,歡迎分享出去。有錯誤還請多多指教 🐟

v0.62.0