Skip to content

蟲群文字 step-by-step

完成品請見 蟲群文字元件

Shader 教學

本篇涉及 Shader 相關概念,可以參考更詳細的 Shader 教學

文字化為蟲群,滑鼠靠近時會散開,離開後重新聚集。


路人:「數大便是美的概念?(ゝ∀・)b」

鱈魚:「哈哈大便!ᕕ( ゚ ∀。)ᕗ 」

路人:「你小學生喔?...(。-`ω´-)」


讓我們從最基本的 Canvas 2D 開始,先在 CPU 把整個效果做出來,然後再一步步搬到 WebGL2 Shader 上。

每一步只加入一個新概念,讓我們一步一腳印地理解。

Step 1:在 Canvas 上畫文字

萬事起頭難,先從一塊空白 Canvas 和一行文字開始吧。( ´ ▽ ` )ノ

查看範例原始碼
vue
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'

const canvasRef = ref<HTMLCanvasElement | null>(null)

const CANVAS_HEIGHT = 200
const TEXT_CONTENT = 'SWARM'

function draw() {
  const canvas = canvasRef.value
  if (!canvas) return

  const devicePixelRatio = window.devicePixelRatio || 1
  const width = canvas.clientWidth

  canvas.style.height = `${CANVAS_HEIGHT}px`
  canvas.width = width * devicePixelRatio
  canvas.height = CANVAS_HEIGHT * devicePixelRatio

  const context = canvas.getContext('2d')
  if (!context) return

  context.scale(devicePixelRatio, devicePixelRatio)

  context.fillStyle = '#111827'
  context.fillRect(0, 0, width, CANVAS_HEIGHT)

  context.fillStyle = '#e5e7eb'
  context.font = 'bold 80px sans-serif'
  context.textAlign = 'center'
  context.textBaseline = 'middle'
  context.fillText(TEXT_CONTENT, width / 2, CANVAS_HEIGHT / 2)
}

function handleResize() {
  draw()
}

onMounted(() => {
  draw()
  window.addEventListener('resize', handleResize)
})

onBeforeUnmount(() => {
  window.removeEventListener('resize', handleResize)
})
</script>

<template>
  <div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6">
    <canvas ref="canvasRef" class="w-full" />
  </div>
</template>

Canvas 2D Context

Canvas 是 HTML5 提供的畫布元素,可以用 JavaScript 在上面畫任何東西。第一步是取得 2D 繪圖上下文:

ts
const canvas = document.querySelector('canvas')
const ctx = canvas.getContext('2d')

ctx 就是我們的畫布,所有繪圖操作都在它身上進行。

fillText 繪製文字

ts
ctx.font = '80px sans-serif'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillStyle = '#000'
ctx.fillText('SWARM', canvas.width / 2, canvas.height / 2)
  • textAlign: 'center' + textBaseline: 'middle' 讓文字以中心點定位
  • 座標 (canvas.width / 2, canvas.height / 2) 就是畫布正中央

其實到這步為止,畫面上就只是一行靜態文字,跟 HTML 沒什麼兩樣。

不過 Canvas 的重點不在「顯示」,而在於我們能拿到每個像素的資料。( •̀ ω •́ )✧

DPR 匹配

在高解析度螢幕(如 Retina)上,Canvas 預設會模糊。

解法是把實際像素放大:

ts
const dpr = window.devicePixelRatio || 1
canvas.width = logicalWidth * dpr
canvas.height = logicalHeight * dpr
canvas.style.width = `${logicalWidth}px`
canvas.style.height = `${logicalHeight}px`

canvas.width 是實際像素大小,canvas.style.width 是 CSS 顯示大小。兩者不同就能在高 DPR 螢幕上保持清晰。

左邊沒有匹配 DPR,右邊有,若 DPR 更高差異會更明顯:

模擬 DPR = 2 的效果
❌ 未匹配 DPR
canvas: 0×0
✅ 已匹配 DPR
canvas: 0×0

Step 2:像素取樣,從文字生出粒子

上一步畫了文字,但我們的目標不是顯示文字,而是把文字「打碎」成一堆粒子。

這一步的核心概念很簡單,掃描整個 canvas 每個像素,有顏色的地方就生成一個粒子。

粒子數量:0

查看範例原始碼
vue
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'

interface Particle {
  x: number
  y: number
}

const canvasRef = ref<HTMLCanvasElement | null>(null)
const particleCount = ref(0)

const CANVAS_HEIGHT = 200
const TEXT_CONTENT = 'SWARM'
const SAMPLE_GAP = 3

function sampleTextParticleList(
  width: number,
  height: number
): Particle[] {
  const offscreenCanvas = document.createElement('canvas')
  offscreenCanvas.width = width
  offscreenCanvas.height = height

  const offscreenContext = offscreenCanvas.getContext('2d')
  if (!offscreenContext) return []

  offscreenContext.fillStyle = '#ffffff'
  offscreenContext.font = 'bold 80px sans-serif'
  offscreenContext.textAlign = 'center'
  offscreenContext.textBaseline = 'middle'
  offscreenContext.fillText(TEXT_CONTENT, width / 2, height / 2)

  const imageData = offscreenContext.getImageData(0, 0, width, height)
  const pixelDataList = imageData.data

  const result: Particle[] = []

  for (let y = 0; y < height; y += SAMPLE_GAP) {
    for (let x = 0; x < width; x += SAMPLE_GAP) {
      const index = (y * width + x) * 4
      const alpha = pixelDataList[index + 3]
      if (alpha !== undefined && alpha > 128) {
        result.push({ x, y })
      }
    }
  }

  return result
}

function draw() {
  const canvas = canvasRef.value
  if (!canvas) return

  const devicePixelRatio = window.devicePixelRatio || 1
  const width = canvas.clientWidth

  canvas.style.height = `${CANVAS_HEIGHT}px`
  canvas.width = width * devicePixelRatio
  canvas.height = CANVAS_HEIGHT * devicePixelRatio

  const context = canvas.getContext('2d')
  if (!context) return

  context.scale(devicePixelRatio, devicePixelRatio)

  const particleList = sampleTextParticleList(width, CANVAS_HEIGHT)
  particleCount.value = particleList.length

  context.fillStyle = '#111827'
  context.fillRect(0, 0, width, CANVAS_HEIGHT)

  context.fillStyle = '#9ca3af'
  for (const particle of particleList) {
    context.beginPath()
    context.arc(particle.x, particle.y, 1, 0, Math.PI * 2)
    context.fill()
  }
}

function handleResize() {
  draw()
}

onMounted(() => {
  draw()
  window.addEventListener('resize', handleResize)
})

onBeforeUnmount(() => {
  window.removeEventListener('resize', handleResize)
})
</script>

<template>
  <div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6">
    <p class="text-sm text-gray-500">
      粒子數量:{{ particleCount }}
    </p>
    <canvas ref="canvasRef" class="w-full" />
  </div>
</template>

getImageData:拿到所有像素

ts
ctx.fillText('SWARM', width / 2, height / 2)
const imageData = ctx.getImageData(0, 0, width, height)
const { data } = imageData

data 是一個超長的一維陣列,每 4 個值代表一個像素的 R、G、B、A。例如 data[0]data[3] 是左上角第一個像素的 RGBA。

取樣邏輯

ts
const gap = 3
const particleList = []

for (let y = 0; y < height; y += gap) {
  for (let x = 0; x < width; x += gap) {
    const index = (y * width + x) * 4
    const alpha = data[index + 3]

    if (alpha > 128) {
      particleList.push({ x, y })
    }
  }
}

重點是 alpha > 128 這個判斷。文字區域的像素有顏色(alpha 值高),背景區域是透明的(alpha 為 0)。所以只要檢查 alpha 通道就能區分文字和背景。

gap 的取捨

gap 決定了粒子密度。gap 越小,粒子越多,效果越細緻,但效能壓力也越大。

gap粒子數量(概估)效果
1超多幾乎還原原始文字,可能超卡
3中等看得出文字形狀,效能 OK
6較少有點稀疏但跑起來很順

Step 3:Canvas 2D 粒子渲染

有了粒子座標,接下來要把它們畫出來。

這裡用 requestAnimationFrame 建立動畫循環,每幀重新繪製所有粒子。

粒子數量:0影格:0
查看範例原始碼
vue
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'

interface Particle {
  x: number
  y: number
}

const canvasRef = ref<HTMLCanvasElement | null>(null)
const particleCount = ref(0)
const frameCount = ref(0)

const CANVAS_HEIGHT = 200
const TEXT_CONTENT = 'SWARM'
const SAMPLE_GAP = 3

let particleList: Particle[] = []
let animationFrameId = 0

function sampleTextParticleList(
  width: number,
  height: number
): Particle[] {
  const offscreenCanvas = document.createElement('canvas')
  offscreenCanvas.width = width
  offscreenCanvas.height = height

  const offscreenContext = offscreenCanvas.getContext('2d')
  if (!offscreenContext) return []

  offscreenContext.fillStyle = '#ffffff'
  offscreenContext.font = 'bold 80px sans-serif'
  offscreenContext.textAlign = 'center'
  offscreenContext.textBaseline = 'middle'
  offscreenContext.fillText(TEXT_CONTENT, width / 2, height / 2)

  const imageData = offscreenContext.getImageData(0, 0, width, height)
  const pixelDataList = imageData.data

  const result: Particle[] = []

  for (let y = 0; y < height; y += SAMPLE_GAP) {
    for (let x = 0; x < width; x += SAMPLE_GAP) {
      const index = (y * width + x) * 4
      const alpha = pixelDataList[index + 3]
      if (alpha !== undefined && alpha > 128) {
        result.push({ x, y })
      }
    }
  }

  return result
}

function initCanvas() {
  const canvas = canvasRef.value
  if (!canvas) return

  const devicePixelRatio = window.devicePixelRatio || 1
  const width = canvas.clientWidth

  canvas.style.height = `${CANVAS_HEIGHT}px`
  canvas.width = width * devicePixelRatio
  canvas.height = CANVAS_HEIGHT * devicePixelRatio

  particleList = sampleTextParticleList(width, CANVAS_HEIGHT)
  particleCount.value = particleList.length
}

function render() {
  const canvas = canvasRef.value
  if (!canvas) return

  const devicePixelRatio = window.devicePixelRatio || 1
  const width = canvas.clientWidth

  const context = canvas.getContext('2d')
  if (!context) return

  context.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0)

  context.fillStyle = '#111827'
  context.fillRect(0, 0, width, CANVAS_HEIGHT)

  context.fillStyle = '#9ca3af'
  for (const particle of particleList) {
    context.beginPath()
    context.arc(particle.x, particle.y, 1, 0, Math.PI * 2)
    context.fill()
  }

  frameCount.value += 1
  animationFrameId = requestAnimationFrame(render)
}

function handleResize() {
  initCanvas()
}

onMounted(() => {
  initCanvas()
  animationFrameId = requestAnimationFrame(render)
  window.addEventListener('resize', handleResize)
})

onBeforeUnmount(() => {
  cancelAnimationFrame(animationFrameId)
  window.removeEventListener('resize', handleResize)
})
</script>

<template>
  <div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6">
    <div class="flex items-center gap-4 text-sm text-gray-500">
      <span>粒子數量:{{ particleCount }}</span>
      <span>影格:{{ frameCount }}</span>
    </div>
    <canvas ref="canvasRef" class="w-full" />
  </div>
</template>

requestAnimationFrame 動畫循環

ts
function animate() {
  ctx.clearRect(0, 0, canvas.width, canvas.height)

  for (const particle of particleList) {
    ctx.fillRect(particle.x, particle.y, 2, 2)
  }

  requestAnimationFrame(animate)
}

requestAnimationFrame(animate)

requestAnimationFrame 會在瀏覽器下一次重繪前呼叫 animate,通常是 60fps。每一幀的流程很單純:

  1. clearRect 清空畫布
  2. 遍歷所有粒子,畫出小方塊
  3. 預約下一幀

為什麼用 requestAnimationFrame 而不是 setInterval?

requestAnimationFramesetInterval(fn, 16)
觸發時機瀏覽器準備重繪時固定間隔,不管瀏覽器忙不忙
頁面切到背景自動暫停,省 CPU繼續跑,白白浪費資源
螢幕刷新率適配自動配合(60Hz、120Hz、144Hz)永遠 16ms,高刷螢幕浪費、低刷螢幕卡頓
動畫流暢度跟 vsync 同步,不會撕裂可能跟重繪錯開,造成畫面抖動

requestAnimationFrame 會跟瀏覽器的重繪週期同步。

setInterval 不管瀏覽器有沒有準備好就硬塞,可能重繪前算了兩次(浪費),也可能兩次重繪之間都沒算到(掉幀)。

實際開發動畫使用 setInterval 幾乎沒有好處。乁( ◔ ௰◔)「

fillRect vs arc

畫粒子有兩種常見做法:

  • fillRect(x, y, size, size):畫方形,效能好
  • arc(x, y, radius, 0, Math.PI * 2) + fill():畫圓形,好看但每次都要 beginPath

粒子很小的時候其實方形和圓形看起來差不多,所以 Canvas 2D 版本用 fillRect 就夠了。◝( •ω• )◟


路人:「所以畫面怎麼不會動?」

鱈魚:「請付費解...ヽ(́◕◞౪◟◕‵)ノ」

路人:(抽出生魚片刀)

鱈魚:「因為還沒加入靈魂 ヽ(́⊙◞౪◟⊙‵)ノ」

Step 4:速度與摩擦力

接下來讓我們加入靈魂,讓每個粒子有「速度」的概念,再搭配「摩擦力」讓它不會永遠飛下去。

粒子數量:0影格:0
查看範例原始碼
vue
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'

interface Particle {
  x: number
  y: number
  originX: number
  originY: number
  velocityX: number
  velocityY: number
}

const canvasRef = ref<HTMLCanvasElement | null>(null)
const particleCount = ref(0)
const frameCount = ref(0)

const CANVAS_HEIGHT = 200
const TEXT_CONTENT = 'SWARM'
const SAMPLE_GAP = 3
const FRICTION = 0.92
const SCATTER_FORCE = 15

let particleList: Particle[] = []
let animationFrameId = 0

function sampleTextParticleList(
  width: number,
  height: number
): Particle[] {
  const offscreenCanvas = document.createElement('canvas')
  offscreenCanvas.width = width
  offscreenCanvas.height = height

  const offscreenContext = offscreenCanvas.getContext('2d')
  if (!offscreenContext) return []

  offscreenContext.fillStyle = '#ffffff'
  offscreenContext.font = 'bold 80px sans-serif'
  offscreenContext.textAlign = 'center'
  offscreenContext.textBaseline = 'middle'
  offscreenContext.fillText(TEXT_CONTENT, width / 2, height / 2)

  const imageData = offscreenContext.getImageData(0, 0, width, height)
  const pixelDataList = imageData.data

  const result: Particle[] = []

  for (let y = 0; y < height; y += SAMPLE_GAP) {
    for (let x = 0; x < width; x += SAMPLE_GAP) {
      const index = (y * width + x) * 4
      const alpha = pixelDataList[index + 3]
      if (alpha !== undefined && alpha > 128) {
        result.push({
          x,
          y,
          originX: x,
          originY: y,
          velocityX: 0,
          velocityY: 0,
        })
      }
    }
  }

  return result
}

function scatterParticleList() {
  for (const particle of particleList) {
    const angle = Math.random() * Math.PI * 2
    const force = Math.random() * SCATTER_FORCE
    particle.velocityX = Math.cos(angle) * force
    particle.velocityY = Math.sin(angle) * force
  }
}

function initCanvas() {
  const canvas = canvasRef.value
  if (!canvas) return

  const devicePixelRatio = window.devicePixelRatio || 1
  const width = canvas.clientWidth

  canvas.style.height = `${CANVAS_HEIGHT}px`
  canvas.width = width * devicePixelRatio
  canvas.height = CANVAS_HEIGHT * devicePixelRatio

  particleList = sampleTextParticleList(width, CANVAS_HEIGHT)
  particleCount.value = particleList.length

  scatterParticleList()
}

function updateParticleList() {
  for (const particle of particleList) {
    particle.velocityX *= FRICTION
    particle.velocityY *= FRICTION

    particle.x += particle.velocityX
    particle.y += particle.velocityY
  }
}

function render() {
  const canvas = canvasRef.value
  if (!canvas) return

  const devicePixelRatio = window.devicePixelRatio || 1
  const width = canvas.clientWidth

  const context = canvas.getContext('2d')
  if (!context) return

  updateParticleList()

  context.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0)

  context.fillStyle = '#111827'
  context.fillRect(0, 0, width, CANVAS_HEIGHT)

  context.fillStyle = '#9ca3af'
  for (const particle of particleList) {
    context.beginPath()
    context.arc(particle.x, particle.y, 1, 0, Math.PI * 2)
    context.fill()
  }

  frameCount.value += 1
  animationFrameId = requestAnimationFrame(render)
}

function handleResize() {
  cancelAnimationFrame(animationFrameId)
  initCanvas()
  animationFrameId = requestAnimationFrame(render)
}

onMounted(() => {
  initCanvas()
  animationFrameId = requestAnimationFrame(render)
  window.addEventListener('resize', handleResize)
})

onBeforeUnmount(() => {
  cancelAnimationFrame(animationFrameId)
  window.removeEventListener('resize', handleResize)
})
</script>

<template>
  <div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6">
    <div class="flex items-center gap-4 text-sm text-gray-500">
      <span>粒子數量:{{ particleCount }}</span>
      <span>影格:{{ frameCount }}</span>
    </div>

    <canvas ref="canvasRef" class="w-full" />

    <div>
      <button
        class="rounded-lg bg-gray-700 px-4 py-1.5 text-sm text-white transition-colors hover:bg-gray-600"
        @click="scatterParticleList"
      >
        重新散開
      </button>
    </div>
  </div>
</template>

粒子資料結構

ts
interface Particle {
  x: number;
  y: number;
  originX: number;
  originY: number;
  velocityX: number;
  velocityY: number;
}

每個粒子除了目前座標 xy,還多了兩組資料:

  • originXoriginY:記住自己的「家」在哪裡(文字像素位置)
  • velocityXvelocityY:X、Y 方向的速度

每幀更新

ts
function updateParticle(particle: Particle) {
  particle.velocityX *= friction
  particle.velocityY *= friction

  particle.x += particle.velocityX
  particle.y += particle.velocityY
}

就兩件事:

  1. 摩擦力:速度乘以一個小於 1 的數(例如 0.92),每幀吃掉 8% 的速度
  2. 移動:位置加上速度

摩擦力 0.92 實際衰減多快?

幀數剩餘速度
0100%
1043%
308%
600.6%

大概半秒(30 幀)速度就只剩不到 10%,視覺上已經感覺快停了。不過還保留一點殘餘速度,讓收尾有飄飄的感覺。

Step 5:回歸原位

粒子有了速度和摩擦力,但如果被推走之後沒有力量拉它回來,就永遠迷路了。(╥ω╥`)

這一步加入回歸力,讓粒子慢慢飄回自己的「家」。

粒子數量:0影格:0
查看範例原始碼
vue
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'

interface Particle {
  x: number
  y: number
  originX: number
  originY: number
  velocityX: number
  velocityY: number
}

const canvasRef = ref<HTMLCanvasElement | null>(null)
const particleCount = ref(0)
const frameCount = ref(0)

const CANVAS_HEIGHT = 200
const TEXT_CONTENT = 'SWARM'
const SAMPLE_GAP = 3
const FRICTION = 0.92
const SCATTER_FORCE = 15
const RETURN_SPEED = 0.1

let particleList: Particle[] = []
let animationFrameId = 0

function sampleTextParticleList(
  width: number,
  height: number
): Particle[] {
  const offscreenCanvas = document.createElement('canvas')
  offscreenCanvas.width = width
  offscreenCanvas.height = height

  const offscreenContext = offscreenCanvas.getContext('2d')
  if (!offscreenContext) return []

  offscreenContext.fillStyle = '#ffffff'
  offscreenContext.font = 'bold 80px sans-serif'
  offscreenContext.textAlign = 'center'
  offscreenContext.textBaseline = 'middle'
  offscreenContext.fillText(TEXT_CONTENT, width / 2, height / 2)

  const imageData = offscreenContext.getImageData(0, 0, width, height)
  const pixelDataList = imageData.data

  const result: Particle[] = []

  for (let y = 0; y < height; y += SAMPLE_GAP) {
    for (let x = 0; x < width; x += SAMPLE_GAP) {
      const index = (y * width + x) * 4
      const alpha = pixelDataList[index + 3]
      if (alpha !== undefined && alpha > 128) {
        result.push({
          x,
          y,
          originX: x,
          originY: y,
          velocityX: 0,
          velocityY: 0,
        })
      }
    }
  }

  return result
}

function scatterParticleList() {
  for (const particle of particleList) {
    const angle = Math.random() * Math.PI * 2
    const force = Math.random() * SCATTER_FORCE
    particle.velocityX = Math.cos(angle) * force
    particle.velocityY = Math.sin(angle) * force
  }
}

function initCanvas() {
  const canvas = canvasRef.value
  if (!canvas) return

  const devicePixelRatio = window.devicePixelRatio || 1
  const width = canvas.clientWidth

  canvas.style.height = `${CANVAS_HEIGHT}px`
  canvas.width = width * devicePixelRatio
  canvas.height = CANVAS_HEIGHT * devicePixelRatio

  particleList = sampleTextParticleList(width, CANVAS_HEIGHT)
  particleCount.value = particleList.length

  scatterParticleList()
}

function updateParticleList() {
  for (const particle of particleList) {
    const distanceX = particle.originX - particle.x
    const distanceY = particle.originY - particle.y

    particle.velocityX += distanceX * RETURN_SPEED
    particle.velocityY += distanceY * RETURN_SPEED

    particle.velocityX *= FRICTION
    particle.velocityY *= FRICTION

    particle.x += particle.velocityX
    particle.y += particle.velocityY
  }
}

function render() {
  const canvas = canvasRef.value
  if (!canvas) return

  const devicePixelRatio = window.devicePixelRatio || 1
  const width = canvas.clientWidth

  const context = canvas.getContext('2d')
  if (!context) return

  updateParticleList()

  context.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0)

  context.fillStyle = '#111827'
  context.fillRect(0, 0, width, CANVAS_HEIGHT)

  context.fillStyle = '#9ca3af'
  for (const particle of particleList) {
    context.beginPath()
    context.arc(particle.x, particle.y, 1, 0, Math.PI * 2)
    context.fill()
  }

  frameCount.value += 1
  animationFrameId = requestAnimationFrame(render)
}

function handleResize() {
  cancelAnimationFrame(animationFrameId)
  initCanvas()
  animationFrameId = requestAnimationFrame(render)
}

onMounted(() => {
  initCanvas()
  animationFrameId = requestAnimationFrame(render)
  window.addEventListener('resize', handleResize)
})

onBeforeUnmount(() => {
  cancelAnimationFrame(animationFrameId)
  window.removeEventListener('resize', handleResize)
})
</script>

<template>
  <div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6">
    <div class="flex items-center gap-4 text-sm text-gray-500">
      <span>粒子數量:{{ particleCount }}</span>
      <span>影格:{{ frameCount }}</span>
    </div>

    <canvas ref="canvasRef" class="w-full" />

    <div>
      <button
        class="rounded-lg bg-gray-700 px-4 py-1.5 text-sm text-white transition-colors hover:bg-gray-600"
        @click="scatterParticleList"
      >
        重新散開
      </button>
    </div>
  </div>
</template>

Lerp(線性內插)

ts
particle.x += (particle.originX - particle.x) * returnSpeed
particle.y += (particle.originY - particle.y) * returnSpeed

returnSpeed 設為 0.1,代表每幀移動「到家距離」的 10%。

舉個例子,假設粒子離家 100px:

幀數離家距離
0100px
190px(移動了 10%)
281px(移動了 9px)
1035px
304px

離家越遠回得越快,越接近家回得越慢,自然產生減速效果。這就是 lerp 的經典用法。

Step 6:追蹤滑鼠位置

粒子有了物理基礎,接下來要加入互動。首先得知道滑鼠在哪裡。

pointerX: 0, pointerY: 0

查看範例原始碼
vue
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'

interface Particle {
  x: number
  y: number
  originX: number
  originY: number
  velocityX: number
  velocityY: number
  randomSeed: number
}

const canvasRef = ref<HTMLCanvasElement | null>(null)

const CANVAS_HEIGHT = 200
const TEXT_CONTENT = 'SWARM'
const SAMPLE_GAP = 3
const FRICTION = 0.92
const RETURN_SPEED = 0.1
const SCATTER_RADIUS = 60

let particleList: Particle[] = []
let animationFrameId = 0
let canvasWidth = 0

const pointerX = ref(0)
const pointerY = ref(0)
let isPointerInside = false

function sampleTextParticleList(
  width: number,
  height: number
): Particle[] {
  const offscreenCanvas = document.createElement('canvas')
  offscreenCanvas.width = width
  offscreenCanvas.height = height

  const offscreenContext = offscreenCanvas.getContext('2d')
  if (!offscreenContext) return []

  offscreenContext.fillStyle = '#ffffff'
  offscreenContext.font = 'bold 80px sans-serif'
  offscreenContext.textAlign = 'center'
  offscreenContext.textBaseline = 'middle'
  offscreenContext.fillText(TEXT_CONTENT, width / 2, height / 2)

  const imageData = offscreenContext.getImageData(0, 0, width, height)
  const pixelDataList = imageData.data

  const result: Particle[] = []

  for (let y = 0; y < height; y += SAMPLE_GAP) {
    for (let x = 0; x < width; x += SAMPLE_GAP) {
      const index = (y * width + x) * 4
      const alpha = pixelDataList[index + 3]
      if (alpha !== undefined && alpha > 128) {
        result.push({
          x,
          y,
          originX: x,
          originY: y,
          velocityX: 0,
          velocityY: 0,
          randomSeed: Math.random() * Math.PI * 2,
        })
      }
    }
  }

  return result
}

function initParticleList() {
  const canvas = canvasRef.value
  if (!canvas) return

  const devicePixelRatio = window.devicePixelRatio || 1
  canvasWidth = canvas.clientWidth

  canvas.style.height = `${CANVAS_HEIGHT}px`
  canvas.width = canvasWidth * devicePixelRatio
  canvas.height = CANVAS_HEIGHT * devicePixelRatio

  particleList = sampleTextParticleList(canvasWidth, CANVAS_HEIGHT)
}

function updateParticleList() {
  for (const particle of particleList) {
    const distanceX = particle.originX - particle.x
    const distanceY = particle.originY - particle.y

    particle.velocityX += distanceX * RETURN_SPEED
    particle.velocityY += distanceY * RETURN_SPEED

    particle.velocityX *= FRICTION
    particle.velocityY *= FRICTION

    particle.x += particle.velocityX
    particle.y += particle.velocityY
  }
}

function render() {
  const canvas = canvasRef.value
  if (!canvas) return

  const devicePixelRatio = window.devicePixelRatio || 1
  const context = canvas.getContext('2d')
  if (!context) return

  context.save()
  context.scale(devicePixelRatio, devicePixelRatio)

  context.fillStyle = '#111827'
  context.fillRect(0, 0, canvasWidth, CANVAS_HEIGHT)

  context.fillStyle = '#9ca3af'
  for (const particle of particleList) {
    context.beginPath()
    context.arc(particle.x, particle.y, 1, 0, Math.PI * 2)
    context.fill()
  }

  if (isPointerInside) {
    // Draw scatter radius circle
    context.strokeStyle = 'rgba(250, 204, 21, 0.3)'
    context.lineWidth = 1
    context.beginPath()
    context.arc(pointerX.value, pointerY.value, SCATTER_RADIUS, 0, Math.PI * 2)
    context.stroke()

    // Draw crosshair circle
    context.strokeStyle = 'rgba(250, 204, 21, 0.8)'
    context.lineWidth = 1.5
    context.beginPath()
    context.arc(pointerX.value, pointerY.value, 6, 0, Math.PI * 2)
    context.stroke()

    // Draw crosshair lines
    context.beginPath()
    context.moveTo(pointerX.value - 10, pointerY.value)
    context.lineTo(pointerX.value + 10, pointerY.value)
    context.moveTo(pointerX.value, pointerY.value - 10)
    context.lineTo(pointerX.value, pointerY.value + 10)
    context.stroke()
  }

  context.restore()
}

function animationLoop() {
  updateParticleList()
  render()
  animationFrameId = requestAnimationFrame(animationLoop)
}

function handlePointerMove(event: PointerEvent) {
  const canvas = canvasRef.value
  if (!canvas) return

  const rect = canvas.getBoundingClientRect()
  pointerX.value = Math.round(event.clientX - rect.left)
  pointerY.value = Math.round(event.clientY - rect.top)
  isPointerInside = true
}

function handlePointerLeave() {
  isPointerInside = false
}

function handleResize() {
  initParticleList()
}

onMounted(() => {
  initParticleList()
  animationFrameId = requestAnimationFrame(animationLoop)
  window.addEventListener('resize', handleResize)
})

onBeforeUnmount(() => {
  cancelAnimationFrame(animationFrameId)
  window.removeEventListener('resize', handleResize)
})
</script>

<template>
  <div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6">
    <p class="text-sm text-gray-500">
      pointerX: {{ pointerX }}, pointerY: {{ pointerY }}
    </p>
    <canvas
      ref="canvasRef"
      class="w-full"
      @pointermove="handlePointerMove"
      @pointerleave="handlePointerLeave"
    />
  </div>
</template>

Pointer Events

ts
let pointerX = -9999
let pointerY = -9999

canvas.addEventListener('pointermove', (event) => {
  const rect = canvas.getBoundingClientRect()
  pointerX = event.clientX - rect.left
  pointerY = event.clientY - rect.top
})

canvas.addEventListener('pointerleave', () => {
  pointerX = -9999
  pointerY = -9999
})

pointermove 而不是 mousemove,因為 Pointer Events 同時支援滑鼠和觸控。

為什麼用 -9999 而不是 null?

離開時把座標設成 -9999 而不是 null,好處是後續計算不需要做 null check。-9999 離任何粒子都超過 scatterRadius,自然不會觸發任何推力。省一個 if 少一個分支。

座標轉換

ts
pointerX = event.clientX - rect.left
pointerY = event.clientY - rect.top

event.clientX 是滑鼠相對於瀏覽器視窗的座標,減掉 Canvas 左上角的偏移 rect.left,就得到滑鼠在 Canvas 內部的座標。

Step 7:徑向推力,吹散粒子

知道滑鼠在哪之後,就能對附近的粒子施加推力了。這一步是整個效果的靈魂。(ノ>ω<)ノ

pointerX: 0, pointerY: 0

查看範例原始碼
vue
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'

interface Particle {
  x: number
  y: number
  originX: number
  originY: number
  velocityX: number
  velocityY: number
  randomSeed: number
}

const canvasRef = ref<HTMLCanvasElement | null>(null)

const CANVAS_HEIGHT = 200
const TEXT_CONTENT = 'SWARM'
const SAMPLE_GAP = 3
const FRICTION = 0.92
const RETURN_SPEED = 0.1
const SCATTER_RADIUS = 60

let particleList: Particle[] = []
let animationFrameId = 0
let canvasWidth = 0

const pointerX = ref(0)
const pointerY = ref(0)
let isPointerInside = false

function sampleTextParticleList(
  width: number,
  height: number
): Particle[] {
  const offscreenCanvas = document.createElement('canvas')
  offscreenCanvas.width = width
  offscreenCanvas.height = height

  const offscreenContext = offscreenCanvas.getContext('2d')
  if (!offscreenContext) return []

  offscreenContext.fillStyle = '#ffffff'
  offscreenContext.font = 'bold 80px sans-serif'
  offscreenContext.textAlign = 'center'
  offscreenContext.textBaseline = 'middle'
  offscreenContext.fillText(TEXT_CONTENT, width / 2, height / 2)

  const imageData = offscreenContext.getImageData(0, 0, width, height)
  const pixelDataList = imageData.data

  const result: Particle[] = []

  for (let y = 0; y < height; y += SAMPLE_GAP) {
    for (let x = 0; x < width; x += SAMPLE_GAP) {
      const index = (y * width + x) * 4
      const alpha = pixelDataList[index + 3]
      if (alpha !== undefined && alpha > 128) {
        result.push({
          x,
          y,
          originX: x,
          originY: y,
          velocityX: 0,
          velocityY: 0,
          randomSeed: Math.random() * Math.PI * 2,
        })
      }
    }
  }

  return result
}

function initParticleList() {
  const canvas = canvasRef.value
  if (!canvas) return

  const devicePixelRatio = window.devicePixelRatio || 1
  canvasWidth = canvas.clientWidth

  canvas.style.height = `${CANVAS_HEIGHT}px`
  canvas.width = canvasWidth * devicePixelRatio
  canvas.height = CANVAS_HEIGHT * devicePixelRatio

  particleList = sampleTextParticleList(canvasWidth, CANVAS_HEIGHT)
}

function updateParticleList() {
  for (const particle of particleList) {
    // Radial push force
    if (isPointerInside) {
      const deltaX = particle.x - pointerX.value
      const deltaY = particle.y - pointerY.value
      const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY)

      if (distance < SCATTER_RADIUS && distance > 0) {
        const influence = 1 - distance / SCATTER_RADIUS
        const directionX = deltaX / distance
        const directionY = deltaY / distance
        const force = influence * 2

        particle.velocityX += directionX * force
        particle.velocityY += directionY * force
      }
    }

    // Return to origin
    const distanceX = particle.originX - particle.x
    const distanceY = particle.originY - particle.y

    particle.velocityX += distanceX * RETURN_SPEED
    particle.velocityY += distanceY * RETURN_SPEED

    particle.velocityX *= FRICTION
    particle.velocityY *= FRICTION

    particle.x += particle.velocityX
    particle.y += particle.velocityY
  }
}

function render() {
  const canvas = canvasRef.value
  if (!canvas) return

  const devicePixelRatio = window.devicePixelRatio || 1
  const context = canvas.getContext('2d')
  if (!context) return

  context.save()
  context.scale(devicePixelRatio, devicePixelRatio)

  context.fillStyle = '#111827'
  context.fillRect(0, 0, canvasWidth, CANVAS_HEIGHT)

  context.fillStyle = '#9ca3af'
  for (const particle of particleList) {
    context.beginPath()
    context.arc(particle.x, particle.y, 1, 0, Math.PI * 2)
    context.fill()
  }

  context.restore()
}

function animationLoop() {
  updateParticleList()
  render()
  animationFrameId = requestAnimationFrame(animationLoop)
}

function handlePointerMove(event: PointerEvent) {
  const canvas = canvasRef.value
  if (!canvas) return

  const rect = canvas.getBoundingClientRect()
  pointerX.value = Math.round(event.clientX - rect.left)
  pointerY.value = Math.round(event.clientY - rect.top)
  isPointerInside = true
}

function handlePointerLeave() {
  isPointerInside = false
}

function handleResize() {
  initParticleList()
}

onMounted(() => {
  initParticleList()
  animationFrameId = requestAnimationFrame(animationLoop)
  window.addEventListener('resize', handleResize)
})

onBeforeUnmount(() => {
  cancelAnimationFrame(animationFrameId)
  window.removeEventListener('resize', handleResize)
})
</script>

<template>
  <div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6">
    <p class="text-sm text-gray-500">
      pointerX: {{ pointerX }}, pointerY: {{ pointerY }}
    </p>
    <canvas
      ref="canvasRef"
      class="w-full"
      @pointermove="handlePointerMove"
      @pointerleave="handlePointerLeave"
    />
  </div>
</template>

推力計算

ts
const dx = particle.x - pointerX
const dy = particle.y - pointerY
const distance = Math.sqrt(dx * dx + dy * dy)

if (distance < scatterRadius) {
  const influence = 1 - distance / scatterRadius
  const force = scatterForce * influence

  particle.velocityX += (dx / distance) * force
  particle.velocityY += (dy / distance) * force
}

原理是這樣的:

  1. 算出粒子到滑鼠的方向向量 (dx, dy)
  2. 算出距離 distance
  3. 如果距離小於 scatterRadius,就施加推力
  4. dx / distance 是單位方向向量,乘以力道就是推力

影響力衰減

influence = 1 - distance / scatterRadius 是線性衰減。正中央影響力是 1(最強),邊緣是 0(沒影響)。

用折線圖來看衰減曲線:

1.000radiusinfluence

Step 8:風力,讓推力有方向感

上一步的推力是「從滑鼠中心向外推」,像炸彈一樣。

但用手撥動的感覺不是這樣,還要順著手的移動方向帶走才對。◝( •ω• )◟

快速水平劃過兩邊的文字,比較差異
只有徑向推力(Step 7)
徑向 + 風力(Step 8)
查看範例原始碼
vue
<template>
  <div class="flex flex-col gap-3 border border-gray-200 rounded-xl p-6">
    <div class="text-sm text-gray-500">
      快速水平劃過兩邊的文字,比較差異
    </div>
    <div class="grid grid-cols-2 gap-3">
      <div class="flex flex-col gap-1">
        <div class="text-xs text-gray-400 font-bold">
          只有徑向推力(Step 7)
        </div>
        <canvas
          ref="canvasLeftRef"
          class="w-full rounded"
          @pointermove="handlePointerMoveLeft"
          @pointerleave="handlePointerLeaveLeft"
        />
      </div>
      <div class="flex flex-col gap-1">
        <div class="text-xs text-gray-400 font-bold">
          徑向 + 風力(Step 8)
        </div>
        <canvas
          ref="canvasRightRef"
          class="w-full rounded"
          @pointermove="handlePointerMoveRight"
          @pointerleave="handlePointerLeaveRight"
        />
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'

interface Particle {
  x: number;
  y: number;
  originX: number;
  originY: number;
  velocityX: number;
  velocityY: number;
}

const canvasLeftRef = ref<HTMLCanvasElement | null>(null)
const canvasRightRef = ref<HTMLCanvasElement | null>(null)

const CANVAS_HEIGHT = 150
const TEXT_CONTENT = 'SWARM'
const SAMPLE_GAP = 3
const FRICTION = 0.92
const RETURN_SPEED = 0.1
const SCATTER_RADIUS = 50
const SCATTER_FORCE = 2

let leftParticleList: Particle[] = []
let rightParticleList: Particle[] = []
let animationFrameId = 0
let canvasWidth = 0

// 左邊(只有徑向)
let leftPointerX = -9999
let leftPointerY = -9999

// 右邊(徑向 + 風力)
let rightPointerX = -9999
let rightPointerY = -9999
let rightPrevPointerX = -9999
let rightPrevPointerY = -9999
let rightPointerVelocityX = 0
let rightPointerVelocityY = 0

function sampleParticleList(
  width: number,
  height: number,
): Particle[] {
  const offscreen = document.createElement('canvas')
  offscreen.width = width
  offscreen.height = height
  const ctx = offscreen.getContext('2d')
  if (!ctx)
    return []

  ctx.fillStyle = '#fff'
  ctx.font = 'bold 60px sans-serif'
  ctx.textAlign = 'center'
  ctx.textBaseline = 'middle'
  ctx.fillText(TEXT_CONTENT, width / 2, height / 2)

  const imageData = ctx.getImageData(0, 0, width, height)
  const pixelDataList = imageData.data
  const result: Particle[] = []

  for (let y = 0; y < height; y += SAMPLE_GAP) {
    for (let x = 0; x < width; x += SAMPLE_GAP) {
      const index = (y * width + x) * 4
      const alpha = pixelDataList[index + 3]
      if (alpha !== undefined && alpha > 128) {
        result.push({
          x,
          y,
          originX: x,
          originY: y,
          velocityX: 0,
          velocityY: 0,
        })
      }
    }
  }
  return result
}

function applyRadialForce(
  particleList: Particle[],
  pointerX: number,
  pointerY: number,
) {
  for (const particle of particleList) {
    const dx = particle.x - pointerX
    const dy = particle.y - pointerY
    const distance = Math.sqrt(dx * dx + dy * dy)

    if (distance < SCATTER_RADIUS && distance > 0.1) {
      const influence = 1 - distance / SCATTER_RADIUS
      const force = SCATTER_FORCE * influence
      particle.velocityX += (dx / distance) * force
      particle.velocityY += (dy / distance) * force
    }

    particle.velocityX += (particle.originX - particle.x) * RETURN_SPEED
    particle.velocityY += (particle.originY - particle.y) * RETURN_SPEED
    particle.velocityX *= FRICTION
    particle.velocityY *= FRICTION
    particle.x += particle.velocityX
    particle.y += particle.velocityY
  }
}

function applyRadialAndWindForce(
  particleList: Particle[],
  pointerX: number,
  pointerY: number,
  velX: number,
  velY: number,
) {
  const mouseSpeed = Math.sqrt(velX * velX + velY * velY)

  for (const particle of particleList) {
    const dx = particle.x - pointerX
    const dy = particle.y - pointerY
    const distance = Math.sqrt(dx * dx + dy * dy)

    if (distance < SCATTER_RADIUS && distance > 0.1) {
      const influence = 1 - distance / SCATTER_RADIUS

      // 徑向推力
      const force = SCATTER_FORCE * influence
      particle.velocityX += (dx / distance) * force
      particle.velocityY += (dy / distance) * force

      // 風力
      if (mouseSpeed > 0.5) {
        const windDirX = velX / mouseSpeed
        const windDirY = velY / mouseSpeed
        particle.velocityX += windDirX * mouseSpeed * influence * 5
        particle.velocityY += windDirY * mouseSpeed * influence * 5
      }
    }

    particle.velocityX += (particle.originX - particle.x) * RETURN_SPEED
    particle.velocityY += (particle.originY - particle.y) * RETURN_SPEED
    particle.velocityX *= FRICTION
    particle.velocityY *= FRICTION
    particle.x += particle.velocityX
    particle.y += particle.velocityY
  }
}

function renderCanvas(
  canvas: HTMLCanvasElement,
  particleList: Particle[],
) {
  const dpr = window.devicePixelRatio || 1
  const ctx = canvas.getContext('2d')
  if (!ctx)
    return

  ctx.save()
  ctx.scale(dpr, dpr)
  ctx.fillStyle = '#111827'
  ctx.fillRect(0, 0, canvasWidth, CANVAS_HEIGHT)
  ctx.fillStyle = '#9ca3af'
  for (const particle of particleList) {
    ctx.beginPath()
    ctx.arc(particle.x, particle.y, 1, 0, Math.PI * 2)
    ctx.fill()
  }
  ctx.restore()
}

function animationLoop() {
  applyRadialForce(leftParticleList, leftPointerX, leftPointerY)
  applyRadialAndWindForce(
    rightParticleList,
    rightPointerX,
    rightPointerY,
    rightPointerVelocityX,
    rightPointerVelocityY,
  )

  rightPointerVelocityX *= 0.92
  rightPointerVelocityY *= 0.92

  const leftCanvas = canvasLeftRef.value
  const rightCanvas = canvasRightRef.value
  if (leftCanvas)
    renderCanvas(leftCanvas, leftParticleList)
  if (rightCanvas)
    renderCanvas(rightCanvas, rightParticleList)

  animationFrameId = requestAnimationFrame(animationLoop)
}

function initCanvas(canvas: HTMLCanvasElement) {
  const dpr = window.devicePixelRatio || 1
  canvasWidth = canvas.clientWidth
  canvas.width = canvasWidth * dpr
  canvas.height = CANVAS_HEIGHT * dpr
  canvas.style.height = `${CANVAS_HEIGHT}px`
}

function handlePointerMoveLeft(event: PointerEvent) {
  const canvas = canvasLeftRef.value
  if (!canvas)
    return
  const rect = canvas.getBoundingClientRect()
  leftPointerX = event.clientX - rect.left
  leftPointerY = event.clientY - rect.top
}

function handlePointerLeaveLeft() {
  leftPointerX = -9999
  leftPointerY = -9999
}

function handlePointerMoveRight(event: PointerEvent) {
  const canvas = canvasRightRef.value
  if (!canvas)
    return
  const rect = canvas.getBoundingClientRect()
  const newX = event.clientX - rect.left
  const newY = event.clientY - rect.top

  if (rightPrevPointerX > -9000) {
    rightPointerVelocityX = newX - rightPrevPointerX
    rightPointerVelocityY = newY - rightPrevPointerY
  }

  rightPrevPointerX = newX
  rightPrevPointerY = newY
  rightPointerX = newX
  rightPointerY = newY
}

function handlePointerLeaveRight() {
  rightPointerX = -9999
  rightPointerY = -9999
  rightPrevPointerX = -9999
  rightPrevPointerY = -9999
}

onMounted(() => {
  const leftCanvas = canvasLeftRef.value
  const rightCanvas = canvasRightRef.value
  if (!leftCanvas || !rightCanvas)
    return

  initCanvas(leftCanvas)
  initCanvas(rightCanvas)

  leftParticleList = sampleParticleList(canvasWidth, CANVAS_HEIGHT)
  rightParticleList = sampleParticleList(canvasWidth, CANVAS_HEIGHT)

  animationFrameId = requestAnimationFrame(animationLoop)
})

onBeforeUnmount(() => {
  cancelAnimationFrame(animationFrameId)
})
</script>

快速水平劃過兩邊的文字,左邊粒子只會四散,右邊粒子會順著滑鼠方向飛走。

滑鼠速度追蹤

ts
let previousPointerX = -9999
let previousPointerY = -9999
let pointerVelocityX = 0
let pointerVelocityY = 0

function onPointerMove(event: PointerEvent) {
  const newX = event.clientX - rect.left
  const newY = event.clientY - rect.top

  if (previousPointerX > -9000) {
    pointerVelocityX = newX - previousPointerX
    pointerVelocityY = newY - previousPointerY
  }

  previousPointerX = newX
  previousPointerY = newY
}

每次 pointermove 時,算出跟上一幀的位移差,就是滑鼠的速度向量。

風力公式

ts
const mouseSpeed = Math.sqrt(
  pointerVelocityX ** 2 + pointerVelocityY ** 2
)

if (mouseSpeed > 0.5) {
  const windDirX = pointerVelocityX / mouseSpeed
  const windDirY = pointerVelocityY / mouseSpeed

  particle.velocityX += windDirX * mouseSpeed * influence * 0.025
  particle.velocityY += windDirY * mouseSpeed * influence * 0.025
}
  • mouseSpeed > 0.5:太慢的滑鼠移動不算風力,避免靜止時的微小抖動
  • windDirX/Y:滑鼠移動方向的單位向量
  • 力道跟 mouseSpeed 成正比:滑越快風越大

風力與徑向推力的差別

方向效果
徑向推力從滑鼠向外粒子四散
風力順著滑鼠移動方向粒子被「吹」走

兩者加在一起,滑鼠慢慢靠近時粒子往外推開,快速劃過時粒子順著風向飛走。=͟͟͞͞( •̀д•́)

速度衰減

ts
pointerVelocityX *= 0.92
pointerVelocityY *= 0.92

滑鼠速度也要加摩擦力。不加的話,滑鼠停下來後速度會殘留,粒子還在被吹動。

Step 9:飄盪回歸

粒子被吹散後會直直回到原位,路線太死板。

真正的蟲蟲應該要有點飄忽不定的感覺。(「・ω・)「

按下「飄散」後觀察回歸路徑:左邊直線回家,右邊會繞來繞去才飄回去。

無飄盪(直線回歸)
有飄盪(Step 9)
查看範例原始碼
vue
<template>
  <div class="flex flex-col gap-3 border border-gray-200 rounded-xl p-6">
    <div class="flex items-center gap-3">
      <button
        class="rounded bg-gray-700 px-3 py-1 text-sm text-white hover:bg-gray-600"
        @click="scatter"
      >
        飄散
      </button>
    </div>
    <div class="grid grid-cols-2 gap-3">
      <div class="flex flex-col gap-1">
        <div class="text-xs text-gray-400 font-bold">
          無飄盪(直線回歸)
        </div>
        <canvas
          ref="canvasLeftRef"
          class="w-full rounded"
        />
      </div>
      <div class="flex flex-col gap-1">
        <div class="text-xs text-gray-400 font-bold">
          有飄盪(Step 9)
        </div>
        <canvas
          ref="canvasRightRef"
          class="w-full rounded"
        />
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'

interface Particle {
  x: number;
  y: number;
  originX: number;
  originY: number;
  velocityX: number;
  velocityY: number;
  randomSeed: number;
}

const canvasLeftRef = ref<HTMLCanvasElement | null>(null)
const canvasRightRef = ref<HTMLCanvasElement | null>(null)

const CANVAS_HEIGHT = 150
const TEXT_CONTENT = 'SWARM'
const SAMPLE_GAP = 3
const FRICTION = 0.92
const RETURN_SPEED = 0.1

let leftParticleList: Particle[] = []
let rightParticleList: Particle[] = []
let animationFrameId = 0
let canvasWidth = 0

function sampleParticleList(
  width: number,
  height: number,
): Particle[] {
  const offscreen = document.createElement('canvas')
  offscreen.width = width
  offscreen.height = height
  const ctx = offscreen.getContext('2d')
  if (!ctx)
    return []

  ctx.fillStyle = '#fff'
  ctx.font = 'bold 60px sans-serif'
  ctx.textAlign = 'center'
  ctx.textBaseline = 'middle'
  ctx.fillText(TEXT_CONTENT, width / 2, height / 2)

  const imageData = ctx.getImageData(0, 0, width, height)
  const pixelDataList = imageData.data
  const result: Particle[] = []

  for (let y = 0; y < height; y += SAMPLE_GAP) {
    for (let x = 0; x < width; x += SAMPLE_GAP) {
      const index = (y * width + x) * 4
      const alpha = pixelDataList[index + 3]
      if (alpha !== undefined && alpha > 128) {
        result.push({
          x,
          y,
          originX: x,
          originY: y,
          velocityX: 0,
          velocityY: 0,
          randomSeed: Math.random() * Math.PI * 2,
        })
      }
    }
  }
  return result
}

function scatter() {
  for (const particle of leftParticleList) {
    particle.velocityX = 0
    particle.velocityY = -8
  }
  for (const particle of rightParticleList) {
    particle.velocityX = 0
    particle.velocityY = -8
  }
}

function updateParticleList(
  particleList: Particle[],
  enableDrift: boolean,
  time: number,
) {
  for (const particle of particleList) {
    // 飄盪(只有右邊開啟)
    if (enableDrift) {
      const dispX = particle.x - particle.originX
      const dispY = particle.y - particle.originY
      const displacement = Math.sqrt(dispX * dispX + dispY * dispY)

      if (displacement > 1) {
        const driftScale = Math.min(displacement / 40, 1)
        const driftAngle = Math.sin(
          time * 0.001 + particle.randomSeed,
        ) * Math.PI
        particle.velocityX += Math.cos(driftAngle) * driftScale * 1.2
        particle.velocityY += Math.sin(driftAngle) * driftScale * 1.2
      }
    }

    // 回歸(兩邊完全相同)
    particle.velocityX += (particle.originX - particle.x) * RETURN_SPEED
    particle.velocityY += (particle.originY - particle.y) * RETURN_SPEED
    particle.velocityX *= FRICTION
    particle.velocityY *= FRICTION
    particle.x += particle.velocityX
    particle.y += particle.velocityY
  }
}

function renderCanvas(
  canvas: HTMLCanvasElement,
  particleList: Particle[],
) {
  const dpr = window.devicePixelRatio || 1
  const ctx = canvas.getContext('2d')
  if (!ctx)
    return

  ctx.save()
  ctx.scale(dpr, dpr)
  ctx.fillStyle = '#111827'
  ctx.fillRect(0, 0, canvasWidth, CANVAS_HEIGHT)
  ctx.fillStyle = '#9ca3af'
  for (const particle of particleList) {
    ctx.beginPath()
    ctx.arc(particle.x, particle.y, 1, 0, Math.PI * 2)
    ctx.fill()
  }
  ctx.restore()
}

function animationLoop() {
  const time = performance.now()

  updateParticleList(leftParticleList, false, time)
  updateParticleList(rightParticleList, true, time)

  const leftCanvas = canvasLeftRef.value
  const rightCanvas = canvasRightRef.value
  if (leftCanvas)
    renderCanvas(leftCanvas, leftParticleList)
  if (rightCanvas)
    renderCanvas(rightCanvas, rightParticleList)

  animationFrameId = requestAnimationFrame(animationLoop)
}

function initCanvas(canvas: HTMLCanvasElement) {
  const dpr = window.devicePixelRatio || 1
  canvasWidth = canvas.clientWidth
  canvas.width = canvasWidth * dpr
  canvas.height = CANVAS_HEIGHT * dpr
  canvas.style.height = `${CANVAS_HEIGHT}px`
}

onMounted(() => {
  const leftCanvas = canvasLeftRef.value
  const rightCanvas = canvasRightRef.value
  if (!leftCanvas || !rightCanvas)
    return

  initCanvas(leftCanvas)
  initCanvas(rightCanvas)

  leftParticleList = sampleParticleList(canvasWidth, CANVAS_HEIGHT)
  rightParticleList = sampleParticleList(canvasWidth, CANVAS_HEIGHT)

  animationFrameId = requestAnimationFrame(animationLoop)
})

onBeforeUnmount(() => {
  cancelAnimationFrame(animationFrameId)
})
</script>
ts
const driftAngle = Math.sin(
  time * 0.001 + particle.randomSeed
) * Math.PI
const driftForce = 0.3

particle.velocityX += Math.cos(driftAngle) * driftForce
particle.velocityY += Math.sin(driftAngle) * driftForce

每個粒子都有獨立的 randomSeed(初始化時隨機生成),讓它們的飄移方向不同步。time 讓方向隨時間變化,sin 確保方向是平滑旋轉而不是亂跳。

離家近的粒子不該飄

ts
const distFromOrigin = Math.sqrt(
  (particle.x - particle.originX) ** 2
  + (particle.y - particle.originY) ** 2,
)

const displacement = Math.min(distFromOrigin / 40, 1)
const actualDriftForce = 0.3 * displacement

displacement 是 0~1 之間的值。靠近原位時趨近 0,飄移力幾乎為零,粒子乖乖回家。被吹散的粒子趨近 1,飄移力才完全發揮。

這個細節很重要。不加這個判斷的話,粒子在原位也會不停抖動,文字永遠看不清楚。

每個粒子的個性

最終元件裡,每個粒子的摩擦力和回歸速度都有些微差異:

ts
const particleFriction = friction - Math.random() * 0.04
const returnRate = returnSpeed * (0.6 + Math.random() * 0.8)

這讓粒子群不會整齊劃一地動作,看起來更像一群各有性格的小蟲子,而不是同步的機器人。ヾ(◍'౪`◍)ノ゙

Step 10:CPU 的效能瓶頸

到這一步,Canvas 2D 版本已經完成基本功能了。

不過大家可能已經發現一個問題,就是粒子數量一多,畫面會卡到不行 (›´ω`‹ )

粒子數量:0|FPS:0

查看範例原始碼
vue
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'

interface Particle {
  x: number;
  y: number;
  originX: number;
  originY: number;
  velocityX: number;
  velocityY: number;
  randomSeed: number;
}

const canvasRef = ref<HTMLCanvasElement | null>(null)

const CANVAS_HEIGHT = 200
const TEXT_CONTENT = 'SWARM'
const FRICTION = 0.92
const RETURN_SPEED = 0.1
const SCATTER_RADIUS = 60

const sampleGap = ref(3)
const particleCount = ref(0)
const currentFps = ref(0)

let particleList: Particle[] = []
let animationFrameId = 0
let canvasWidth = 0

let pointerX = 0
let pointerY = 0
let isPointerInside = false

let previousPointerX = 0
let previousPointerY = 0
let pointerVelocityX = 0
let pointerVelocityY = 0

let frameCount = 0
let lastFpsTime = performance.now()

function sampleTextParticleList(
  width: number,
  height: number,
  gap: number,
): Particle[] {
  const offscreenCanvas = document.createElement('canvas')
  offscreenCanvas.width = width
  offscreenCanvas.height = height

  const offscreenContext = offscreenCanvas.getContext('2d')
  if (!offscreenContext)
    return []

  offscreenContext.fillStyle = '#ffffff'
  offscreenContext.font = 'bold 80px sans-serif'
  offscreenContext.textAlign = 'center'
  offscreenContext.textBaseline = 'middle'
  offscreenContext.fillText(TEXT_CONTENT, width / 2, height / 2)

  const imageData = offscreenContext.getImageData(0, 0, width, height)
  const pixelDataList = imageData.data

  const result: Particle[] = []

  for (let y = 0; y < height; y += gap) {
    for (let x = 0; x < width; x += gap) {
      const pixelX = Math.floor(x)
      const pixelY = Math.floor(y)
      const index = (pixelY * width + pixelX) * 4
      const alpha = pixelDataList[index + 3]
      if (alpha !== undefined && alpha > 128) {
        result.push({
          x,
          y,
          originX: x,
          originY: y,
          velocityX: 0,
          velocityY: 0,
          randomSeed: Math.random() * Math.PI * 2,
        })
      }
    }
  }

  return result
}

function initParticleList() {
  const canvas = canvasRef.value
  if (!canvas)
    return

  const devicePixelRatio = window.devicePixelRatio || 1
  canvasWidth = canvas.clientWidth

  canvas.style.height = `${CANVAS_HEIGHT}px`
  canvas.width = canvasWidth * devicePixelRatio
  canvas.height = CANVAS_HEIGHT * devicePixelRatio

  particleList = sampleTextParticleList(
    canvasWidth,
    CANVAS_HEIGHT,
    sampleGap.value,
  )
  particleCount.value = particleList.length
}

function updateParticleList() {
  const time = performance.now()

  // Decay pointer velocity each frame
  pointerVelocityX *= 0.92
  pointerVelocityY *= 0.92

  const currentMouseSpeed = Math.sqrt(
    pointerVelocityX * pointerVelocityX
    + pointerVelocityY * pointerVelocityY,
  )

  for (const particle of particleList) {
    // 每粒子隨機力道係數(參考 GPU shader 的 forceScale)
    const forceScale = 0.5 + (Math.sin(particle.randomSeed * 100) * 0.5 + 0.5)

    if (isPointerInside) {
      const deltaX = particle.x - pointerX
      const deltaY = particle.y - pointerY
      const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY)

      if (distance < SCATTER_RADIUS && distance > 0) {
        // smoothstep 風格衰減(參考 GPU shader)
        const ratio = distance / SCATTER_RADIUS
        const influence = 1 - ratio * ratio * (3 - 2 * ratio)

        const directionX = deltaX / distance
        const directionY = deltaY / distance

        // 徑向推力(參考 GPU: influence² × scatterForce × 0.5)
        const radialForce = influence * influence * 8 * forceScale
        particle.velocityX += directionX * radialForce
        particle.velocityY += directionY * radialForce

        // 風力(參考 GPU: mouseSpeed × influence × scatterForce × 0.025)
        if (currentMouseSpeed > 0.5) {
          particle.velocityX += pointerVelocityX * influence * 0.8 * forceScale
          particle.velocityY += pointerVelocityY * influence * 0.8 * forceScale
        }
      }
    }

    // 飄盪效果(參考 step 9)
    const displacementX = particle.x - particle.originX
    const displacementY = particle.y - particle.originY
    const displacement = Math.sqrt(
      displacementX * displacementX + displacementY * displacementY,
    )

    if (displacement > 1) {
      const driftScale = Math.min(displacement / 40, 1)
      const driftAngle = Math.sin(
        time * 0.001 + particle.randomSeed,
      ) * Math.PI
      particle.velocityX += Math.cos(driftAngle) * driftScale * 1.2 * forceScale
      particle.velocityY += Math.sin(driftAngle) * driftScale * 1.2 * forceScale
    }

    // 回歸原位
    const distanceX = particle.originX - particle.x
    const distanceY = particle.originY - particle.y

    particle.velocityX += distanceX * RETURN_SPEED
    particle.velocityY += distanceY * RETURN_SPEED

    particle.velocityX *= FRICTION
    particle.velocityY *= FRICTION

    particle.x += particle.velocityX
    particle.y += particle.velocityY
  }
}

function render() {
  const canvas = canvasRef.value
  if (!canvas)
    return

  const devicePixelRatio = window.devicePixelRatio || 1
  const context = canvas.getContext('2d')
  if (!context)
    return

  context.save()
  context.scale(devicePixelRatio, devicePixelRatio)

  context.fillStyle = '#111827'
  context.fillRect(0, 0, canvasWidth, CANVAS_HEIGHT)

  context.fillStyle = '#9ca3af'
  for (const particle of particleList) {
    context.beginPath()
    context.arc(particle.x, particle.y, 1, 0, Math.PI * 2)
    context.fill()
  }

  context.restore()
}

function animationLoop() {
  updateParticleList()
  render()

  // FPS calculation
  frameCount++
  const now = performance.now()
  const elapsed = now - lastFpsTime

  if (elapsed >= 1000) {
    currentFps.value = Math.round((frameCount * 1000) / elapsed)
    frameCount = 0
    lastFpsTime = now
  }

  animationFrameId = requestAnimationFrame(animationLoop)
}

function handlePointerMove(event: PointerEvent) {
  const canvas = canvasRef.value
  if (!canvas)
    return

  const rect = canvas.getBoundingClientRect()
  const currentX = event.clientX - rect.left
  const currentY = event.clientY - rect.top

  if (isPointerInside) {
    pointerVelocityX = currentX - previousPointerX
    pointerVelocityY = currentY - previousPointerY
  }

  previousPointerX = currentX
  previousPointerY = currentY
  pointerX = currentX
  pointerY = currentY
  isPointerInside = true
}

function handlePointerLeave() {
  isPointerInside = false
}

function handleResize() {
  initParticleList()
}

watch(sampleGap, () => {
  initParticleList()
})

onMounted(() => {
  initParticleList()
  animationFrameId = requestAnimationFrame(animationLoop)
  window.addEventListener('resize', handleResize)
})

onBeforeUnmount(() => {
  cancelAnimationFrame(animationFrameId)
  window.removeEventListener('resize', handleResize)
})
</script>

<template>
  <div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6">
    <div class="flex items-center gap-4 text-sm text-gray-500">
      <label class="flex items-center gap-2">
        粒子間距 (gap)
        <input
          v-model.number="sampleGap"
          type="range"
          min="0.5"
          max="8"
          step="0.5"
          class="w-32"
        >
        <span class="w-4 text-center">{{ sampleGap }}</span>
      </label>
    </div>

    <p class="text-sm text-gray-500">
      粒子數量:{{ particleCount }}|FPS:{{ currentFps }}
    </p>

    <canvas
      ref="canvasRef"
      class="w-full"
      @pointermove="handlePointerMove"
      @pointerleave="handlePointerLeave"
    />
  </div>
</template>

每幀 CPU 要做多少事?

假設有 5000 個粒子,每幀的工作量:

txt
for 5000 個粒子:
  1. 計算滑鼠距離 → 2 次減法 + 1 次開根號
  2. 風力計算     → 若干乘法
  3. 摩擦力       → 2 次乘法
  4. 回歸力       → 4 次加減乘
  5. 更新位置     → 2 次加法
  6. fillRect     → 1 次 Canvas API 呼叫

每個粒子約 15~20 個數學運算 + 1 次繪圖呼叫。

5000 個粒子就是 10 萬次運算 + 5000 次 fillRect

60fps 代表每幀只有 16.6ms 的時間預算。Canvas 2D 的 fillRect 開銷不小(每次都要走一趟渲染管線),5000 次呼叫可能就吃掉大半時間。

瓶頸在哪裡?

其實數學運算不是問題,CPU 做浮點運算很快,十萬次也只要幾毫秒。

真正的瓶頸是 Canvas 2D API 的繪圖呼叫

每次 fillRect 都是一次獨立的繪圖指令,沒辦法批次處理。

粒子越多,繪圖呼叫越多,瀏覽器越吃力。

接下來讓我們會把物理計算和渲染全部搬到 GPU 上。

CPU 只負責傳滑鼠座標等少量資料,其他全交給 Shader。

來體驗一下 GPU 平行運算的威力。( •̀ ω •́ )✧

Step 11:WebGL2 基礎,GPU 思維

開始寫 Shader 之前,我們需要先了解 GPU 的思考方式。不用怕,其實核心概念不複雜。

下面這個範例讓你親手比較 Canvas 2D(CPU)和 WebGL2(GPU)的渲染耗時。

選好粒子數量後按下測試,感受一下差異:

Canvas 2D(CPU)
WebGL2(GPU)
CPU
- ms
GPU
- ms
GPU 快了約

點擊開始後,兩邊各連續渲染 120 幀相同粒子,CPU 在 Worker 中執行不阻塞頁面。

查看範例原始碼
vue
<template>
  <div class="benchmark-root">
    <!-- 控制列 -->
    <div class="benchmark-controls">
      <label class="benchmark-slider-group">
        <span class="benchmark-label">粒子數量</span>
        <input
          v-model.number="particleCountLevel"
          type="range"
          :min="0"
          :max="PARTICLE_COUNT_LIST.length - 1"
          step="1"
          class="benchmark-slider"
          :disabled="phase !== 'idle'"
        >
        <span class="benchmark-count">
          {{ particleCountLabel }}
        </span>
      </label>

      <button
        class="benchmark-button"
        :class="{ 'is-running': phase !== 'idle' }"
        :disabled="phase !== 'idle'"
        @click="runBenchmark"
      >
        <template v-if="phase === 'idle'">
          ▶ 開始測試
        </template>
        <template v-else-if="phase === 'cpu'">
          CPU 測試中⋯
        </template>
        <template v-else>
          GPU 測試中⋯
        </template>
      </button>
    </div>

    <!-- Canvas 區 -->
    <div class="benchmark-canvas-row">
      <div class="benchmark-canvas-box">
        <div class="benchmark-canvas-label">
          <span class="benchmark-dot dot-cpu" />
          Canvas 2D(CPU)
        </div>
        <canvas ref="cpuCanvasRef" class="benchmark-canvas" />
      </div>
      <div class="benchmark-canvas-box">
        <div class="benchmark-canvas-label">
          <span class="benchmark-dot dot-gpu" />
          WebGL2(GPU)
        </div>
        <canvas ref="gpuCanvasRef" class="benchmark-canvas" />
      </div>
    </div>

    <!-- 結果 -->
    <div class="benchmark-result">
      <div class="benchmark-bars">
        <div class="benchmark-bar-row">
          <span class="benchmark-bar-label">CPU</span>
          <div class="benchmark-bar-track">
            <div
              class="benchmark-bar-fill bar-cpu"
              :style="{ width: cpuBarWidth }"
            />
          </div>
          <span class="benchmark-bar-value">
            {{ displayResult.cpuAverage }} ms
          </span>
        </div>
        <div class="benchmark-bar-row">
          <span class="benchmark-bar-label">GPU</span>
          <div class="benchmark-bar-track">
            <div
              class="benchmark-bar-fill bar-gpu"
              :style="{ width: gpuBarWidth }"
            />
          </div>
          <span class="benchmark-bar-value">
            {{ displayResult.gpuAverage }} ms
          </span>
        </div>
      </div>

      <div class="benchmark-speedup">
        GPU 快了約
        <strong>{{ displayResult.speedup }}×</strong>
      </div>
    </div>

    <p class="benchmark-hint">
      點擊開始後,兩邊各連續渲染 {{ BENCHMARK_FRAME_COUNT }} 幀相同粒子,CPU 在 Worker 中執行不阻塞頁面。
    </p>
  </div>
</template>

<script setup lang="ts">
import {
  computed,
  onBeforeUnmount,
  onMounted,
  ref,
  shallowRef,
} from 'vue'

// ====== 常數 ======
const CANVAS_HEIGHT = 150
const PARTICLE_COUNT_LIST = [100, 1_000, 20_000]
const BENCHMARK_FRAME_COUNT = 120

// ====== Refs ======
const cpuCanvasRef = ref<HTMLCanvasElement | null>(null)
const gpuCanvasRef = ref<HTMLCanvasElement | null>(null)

const particleCountLevel = ref(1)
const particleCountLabel = computed(
  () => `${PARTICLE_COUNT_LIST[particleCountLevel.value]?.toLocaleString() ?? 0} 個`,
)

type BenchmarkPhase = 'idle' | 'cpu' | 'gpu'
const phase = ref<BenchmarkPhase>('idle')

interface BenchmarkResult {
  cpuAverage: string;
  gpuAverage: string;
  speedup: string;
  /** 0~1,CPU 佔比 */
  cpuRatio: number;
  /** 0~1,GPU 佔比 */
  gpuRatio: number;
}
const benchmarkResult = shallowRef<BenchmarkResult | null>(null)

const DEFAULT_RESULT: BenchmarkResult = {
  cpuAverage: '-',
  gpuAverage: '-',
  speedup: '-',
  cpuRatio: 0,
  gpuRatio: 0,
}

const displayResult = computed(
  () => benchmarkResult.value ?? DEFAULT_RESULT,
)

const cpuBarWidth = computed(
  () => `${(displayResult.value.cpuRatio * 100).toFixed(1)}%`,
)
const gpuBarWidth = computed(
  () => `${(displayResult.value.gpuRatio * 100).toFixed(1)}%`,
)

// ====== 內部狀態 ======
let canvasWidth = 0
let aborted = false

// GPU 資源
let gl: WebGL2RenderingContext | null = null
let gpuProgram: WebGLProgram | null = null
let gpuPositionBuffer: WebGLBuffer | null = null
let gpuResolutionLoc: WebGLUniformLocation | null = null
let gpuPositionLoc = -1

// Worker
let cpuWorker: Worker | null = null

// ====== Worker 原始碼 ======
const WORKER_SOURCE = /* js */ `
let canvas = null
let ctx = null

self.onmessage = function (event) {
  const msg = event.data

  if (msg.type === 'init') {
    canvas = msg.canvas
    canvas.width = msg.width
    canvas.height = msg.height
    ctx = canvas.getContext('2d')
    return
  }

  if (msg.type === 'resize') {
    if (!canvas) return
    canvas.width = msg.width
    canvas.height = msg.height
    ctx = canvas.getContext('2d')
    return
  }

  if (msg.type === 'benchmark') {
    if (!ctx || !canvas) {
      self.postMessage({ type: 'result', totalTime: -1 })
      return
    }

    const positionList = new Float32Array(msg.positionBuffer)
    const count = msg.count
    const frameCount = msg.frameCount
    const width = canvas.width
    const height = canvas.height

    // 暖機 2 幀
    for (let w = 0; w < 2; w++) {
      ctx.fillStyle = '#111827'
      ctx.fillRect(0, 0, width, height)
      ctx.fillStyle = '#9ca3af'
      for (let i = 0; i < count; i++) {
        ctx.fillRect(positionList[i * 2], positionList[i * 2 + 1], 2, 2)
      }
    }

    // 正式測試
    const start = performance.now()
    for (let f = 0; f < frameCount; f++) {
      ctx.fillStyle = '#111827'
      ctx.fillRect(0, 0, width, height)
      ctx.fillStyle = '#9ca3af'
      for (let i = 0; i < count; i++) {
        ctx.fillRect(positionList[i * 2], positionList[i * 2 + 1], 2, 2)
      }
    }
    const totalTime = performance.now() - start

    self.postMessage({ type: 'result', totalTime })
  }
}
`

// ====== Shader ======
const VERT_SOURCE = `#version 300 es
in vec2 aPosition;
uniform vec2 uResolution;

void main() {
  vec2 clip = (aPosition / uResolution) * 2.0 - 1.0;
  clip.y = -clip.y;
  gl_Position = vec4(clip, 0.0, 1.0);
  gl_PointSize = 2.0;
}
`

const FRAG_SOURCE = `#version 300 es
precision highp float;
out vec4 fragColor;

void main() {
  fragColor = vec4(0.61, 0.64, 0.69, 1.0);
}
`

// ====== 工具函式 ======
function compileShader(
  glCtx: WebGL2RenderingContext,
  type: number,
  source: string,
): WebGLShader | null {
  const shader = glCtx.createShader(type)
  if (!shader)
    return null
  glCtx.shaderSource(shader, source)
  glCtx.compileShader(shader)
  if (!glCtx.getShaderParameter(shader, glCtx.COMPILE_STATUS)) {
    console.error(glCtx.getShaderInfoLog(shader))
    glCtx.deleteShader(shader)
    return null
  }
  return shader
}

function generatePositionList(count: number): Float32Array {
  const list = new Float32Array(count * 2)
  for (let i = 0; i < count; i++) {
    list[i * 2] = Math.random() * canvasWidth
    list[i * 2 + 1] = Math.random() * CANVAS_HEIGHT
  }
  return list
}

// ====== GPU 渲染 ======
function renderGpuFrame(count: number) {
  if (!gl || !gpuProgram || !gpuPositionBuffer)
    return
  const canvas = gpuCanvasRef.value
  if (!canvas)
    return

  gl.viewport(0, 0, canvas.width, canvas.height)
  gl.clearColor(0.067, 0.094, 0.153, 1.0)
  gl.clear(gl.COLOR_BUFFER_BIT)

  gl.useProgram(gpuProgram)
  gl.uniform2f(gpuResolutionLoc, canvasWidth, CANVAS_HEIGHT)

  gl.bindBuffer(gl.ARRAY_BUFFER, gpuPositionBuffer)
  gl.enableVertexAttribArray(gpuPositionLoc)
  gl.vertexAttribPointer(gpuPositionLoc, 2, gl.FLOAT, false, 0, 0)

  gl.drawArrays(gl.POINTS, 0, count)
  gl.finish()
}

// ====== 初始化 ======
function initGpu() {
  const canvas = gpuCanvasRef.value
  if (!canvas)
    return

  gl = canvas.getContext('webgl2', { alpha: false })
  if (!gl)
    return

  const vert = compileShader(gl, gl.VERTEX_SHADER, VERT_SOURCE)
  const frag = compileShader(gl, gl.FRAGMENT_SHADER, FRAG_SOURCE)
  if (!vert || !frag)
    return

  gpuProgram = gl.createProgram()
  if (!gpuProgram)
    return

  gl.attachShader(gpuProgram, vert)
  gl.attachShader(gpuProgram, frag)
  gl.linkProgram(gpuProgram)
  gl.deleteShader(vert)
  gl.deleteShader(frag)

  gpuPositionBuffer = gl.createBuffer()
  gl.useProgram(gpuProgram)
  gpuResolutionLoc = gl.getUniformLocation(gpuProgram, 'uResolution')
  gpuPositionLoc = gl.getAttribLocation(gpuProgram, 'aPosition')
}

function initWorker() {
  const cpuCanvas = cpuCanvasRef.value
  if (!cpuCanvas)
    return

  const blob = new Blob([WORKER_SOURCE], { type: 'application/javascript' })
  const url = URL.createObjectURL(blob)
  cpuWorker = new Worker(url)
  URL.revokeObjectURL(url)

  const offscreen = cpuCanvas.transferControlToOffscreen()
  cpuWorker.postMessage(
    { type: 'init', canvas: offscreen, width: canvasWidth, height: CANVAS_HEIGHT },
    [offscreen],
  )
}

function setupCanvas(canvas: HTMLCanvasElement) {
  canvasWidth = canvas.clientWidth
  canvas.width = canvasWidth
  canvas.height = CANVAS_HEIGHT
  canvas.style.height = `${CANVAS_HEIGHT}px`
}

// ====== CPU 基準測試(透過 Worker) ======
function runCpuBenchmark(
  positionList: Float32Array,
  count: number,
): Promise<number> {
  return new Promise((resolve) => {
    if (!cpuWorker) {
      resolve(-1)
      return
    }

    const handler = (event: MessageEvent) => {
      if (event.data.type === 'result') {
        cpuWorker?.removeEventListener('message', handler)
        resolve(event.data.totalTime as number)
      }
    }
    cpuWorker.addEventListener('message', handler)

    const buffer = positionList.buffer.slice(0)
    cpuWorker.postMessage(
      {
        type: 'benchmark',
        positionBuffer: buffer,
        count,
        frameCount: BENCHMARK_FRAME_COUNT,
      },
      [buffer],
    )
  })
}

// ====== GPU 基準測試(每幀透過 rAF 分散,不阻塞主執行緒) ======
function runGpuBenchmark(count: number): Promise<number> {
  return new Promise((resolve) => {
    if (!gl) {
      resolve(-1)
      return
    }

    // 暖機 2 幀
    renderGpuFrame(count)
    renderGpuFrame(count)

    let frameIndex = 0
    let totalTime = 0

    function tick() {
      if (aborted) {
        resolve(-1)
        return
      }

      const start = performance.now()
      renderGpuFrame(count)
      totalTime += performance.now() - start

      frameIndex++
      if (frameIndex < BENCHMARK_FRAME_COUNT) {
        requestAnimationFrame(tick)
      }
      else {
        resolve(totalTime)
      }
    }

    requestAnimationFrame(tick)
  })
}

// ====== 執行測試 ======
async function runBenchmark() {
  if (!gl || !cpuWorker)
    return

  phase.value = 'cpu'
  benchmarkResult.value = null
  aborted = false

  const count = PARTICLE_COUNT_LIST[particleCountLevel.value] ?? 5_000
  const positionList = generatePositionList(count)

  // 上傳到 GPU buffer
  gl.bindBuffer(gl.ARRAY_BUFFER, gpuPositionBuffer)
  gl.bufferData(gl.ARRAY_BUFFER, positionList, gl.STATIC_DRAW)

  // 等一幀讓 UI 更新
  await new Promise((resolve) => requestAnimationFrame(resolve))
  if (aborted)
    return

  // --- CPU(Worker) ---
  const cpuTotal = await runCpuBenchmark(positionList, count)
  if (aborted)
    return

  // --- GPU ---
  phase.value = 'gpu'
  await new Promise((resolve) => requestAnimationFrame(resolve))
  if (aborted)
    return

  const gpuTotal = await runGpuBenchmark(count)
  if (aborted || gpuTotal < 0)
    return

  // --- 結果 ---
  const cpuAverage = cpuTotal / BENCHMARK_FRAME_COUNT
  const gpuAverage = gpuTotal / BENCHMARK_FRAME_COUNT
  const maxTime = Math.max(cpuAverage, gpuAverage)
  const speedup = gpuAverage > 0 ? cpuAverage / gpuAverage : 0

  benchmarkResult.value = {
    cpuAverage: cpuAverage.toFixed(2),
    gpuAverage: gpuAverage.toFixed(2),
    speedup: speedup.toFixed(1),
    cpuRatio: maxTime > 0 ? cpuAverage / maxTime : 0,
    gpuRatio: maxTime > 0 ? gpuAverage / maxTime : 0,
  }

  phase.value = 'idle'
}

// ====== 生命週期 ======
onMounted(() => {
  const cpuCanvas = cpuCanvasRef.value
  const gpuCanvas = gpuCanvasRef.value
  if (!cpuCanvas || !gpuCanvas)
    return

  // GPU canvas 先 setup(取得 canvasWidth)
  setupCanvas(gpuCanvas)
  // CPU canvas 只設 style,實際 size 由 Worker 控制
  cpuCanvas.style.height = `${CANVAS_HEIGHT}px`

  initGpu()
  initWorker()
})

onBeforeUnmount(() => {
  aborted = true
  cpuWorker?.terminate()

  if (gl) {
    if (gpuProgram)
      gl.deleteProgram(gpuProgram)
    if (gpuPositionBuffer)
      gl.deleteBuffer(gpuPositionBuffer)
  }
})
</script>

<style scoped>
.benchmark-root {
  --bar-cpu: #f59e0b;
  --bar-gpu: #22d3ee;
  --surface: #f8fafc;
  --surface-dark: #111827;
  --border: #e2e8f0;
  --text-1: #1e293b;
  --text-2: #64748b;
  --text-3: #94a3b8;

  display: flex;
  flex-direction: column;
  gap: 16px;
  border: 1px solid var(--border);
  border-radius: 14px;
  padding: 24px;
  font-family: 'PingFang TC', 'Microsoft JhengHei', system-ui, sans-serif;
}

/* ---- 控制列 ---- */
.benchmark-controls {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 12px;
}

.benchmark-slider-group {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 13px;
  color: var(--text-2);
}

.benchmark-label {
  white-space: nowrap;
}

.benchmark-slider {
  width: 96px;
  accent-color: var(--text-2);
}

.benchmark-count {
  min-width: 72px;
  font-variant-numeric: tabular-nums;
  text-align: right;
}

.benchmark-button {
  padding: 6px 16px;
  border: none;
  border-radius: 8px;
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
  background: var(--surface-dark);
  color: #fff;
  transition: background 0.15s, opacity 0.15s;
  line-height: 1.5;
}
.benchmark-button:hover:not(:disabled) {
  background: #374151;
}
.benchmark-button.is-running {
  background: var(--text-3);
  cursor: wait;
  animation: benchmark-pulse 1.6s ease-in-out infinite;
}

@keyframes benchmark-pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.6; }
}

/* ---- Canvas ---- */
.benchmark-canvas-row {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 12px;
}

.benchmark-canvas-box {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.benchmark-canvas-label {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 12px;
  font-weight: 600;
  color: var(--text-3);
  text-transform: uppercase;
  letter-spacing: 0.03em;
}

.benchmark-dot {
  display: inline-block;
  width: 8px;
  height: 8px;
  border-radius: 50%;
}
.dot-cpu { background: var(--bar-cpu); }
.dot-gpu { background: var(--bar-gpu); }

.benchmark-canvas {
  width: 100%;
  border-radius: 8px;
  background: var(--surface-dark);
}

/* ---- 結果 ---- */
.benchmark-result {
  display: flex;
  flex-direction: column;
  gap: 12px;
  padding: 16px;
  background: var(--surface);
  border-radius: 10px;
}

.benchmark-bars {
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.benchmark-bar-row {
  display: grid;
  grid-template-columns: 36px 1fr auto;
  align-items: center;
  gap: 10px;
}

.benchmark-bar-label {
  font-size: 12px;
  font-weight: 700;
  color: var(--text-2);
  text-align: right;
}

.benchmark-bar-track {
  height: 22px;
  border-radius: 6px;
  background: #e2e8f0;
  overflow: hidden;
}

.benchmark-bar-fill {
  height: 100%;
  border-radius: 6px;
  transition: width 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}

.bar-cpu {
  background: linear-gradient(90deg, #f59e0b, #fbbf24);
}
.bar-gpu {
  background: linear-gradient(90deg, #06b6d4, #22d3ee);
}

.benchmark-bar-value {
  min-width: 80px;
  text-align: right;
  font-size: 13px;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  color: var(--text-1);
}

.benchmark-speedup {
  text-align: center;
  font-size: 13px;
  color: var(--text-2);
}
.benchmark-speedup strong {
  font-size: 18px;
  color: #0891b2;
}

/* ---- 提示 ---- */
.benchmark-hint {
  margin: 0;
  font-size: 12px;
  color: var(--text-3);
}

/* ---- Dark mode(VitePress) ---- */
.dark .benchmark-root {
  --surface: #1e293b;
  --border: #334155;
  --text-1: #f1f5f9;
  --text-2: #94a3b8;
  --text-3: #64748b;
}
.dark .benchmark-bar-track {
  background: #334155;
}
.dark .benchmark-speedup strong {
  color: #22d3ee;
}
</style>

CPU vs GPU

CPU逐一處理
GPU全部同時

CPU 就像一個很強的資優生,運算能力很強,但是只能一個一個慢慢來。

GPU 就像一群幫手小精靈,單體能力不強,不過大家一起上,效率超高。(*´ω`)人(´ω`*)

什麼是 Shader?

Shader 是跑在 GPU 上的小程式,用 GLSL 寫。WebGL2 用的是 GLSL ES 3.0(#version 300 es)。

WebGL2 只有兩種 Shader:

類型用途執行頻率
Vertex Shader決定每個頂點的位置每個頂點跑一次
Fragment Shader決定每個像素的顏色每個像素跑一次

WebGPU / OpenGL 4.x+ / Vulkan / DirectX 還有其他 Shader 類型:

類型用途
Geometry Shader可以新增或刪除頂點,例如把一個點變成一個三角形
Tessellation Shader把粗糙的網格細分成更多三角形,用於地形、曲面等
Compute Shader通用 GPU 運算,不一定和圖形渲染有關(物理模擬、粒子系統等)
Mesh Shader較新的概念,取代 Vertex + Geometry 的組合,效能更好

蟲群文字用 GL_POINTS,每個粒子 = 一個頂點。gl_PointSize 決定每個頂點渲染成多大的方塊。所以:

  • Vertex Shader 跑「粒子數量」次(決定每個粒子的位置)
  • Fragment Shader 跑「粒子數量 × pointSize²」次(決定每個像素的顏色)

下面的圖解用 9 個粒子示範這個過程:

頂點資料
Vertex Shader
光柵化
Fragment Shader
畫面
Vertex Shader
(3,0) (2,1) (3,1) (4,1) (1,2) (3,2) (5,2) (3,3) (3,4)
「這個粒子放在哪?」
9 個粒子 → 跑 9 次
Fragment Shader
「這個像素什麼顏色?」
每個粒子 4×4 = 16 像素,共 144 次
gl_PointSize = 4.0 代表每個頂點渲染成 4×4 像素的方塊。 Vertex Shader 決定方塊的位置,Fragment Shader 決定方塊內每個像素的顏色

取得 WebGL2 Context

ts
const gl = canvas.getContext('webgl2', {
  premultipliedAlpha: false,
  alpha: true,
})

getContext('2d') 相同概念,不過多了一些設定:

  • premultipliedAlpha: false 避免 alpha 預乘導致的混色問題
  • alpha: true 讓 canvas 支援透明背景。

Shader 與 Canvas 2D 最大的不同在於不能迴圈遍歷所有粒子,只能控制「自己這一個」

沒有 for 迴圈跑全部粒子。每個粒子的 Shader 同時執行,各自只知道自己的資料,粒子之間沒辦法直接溝通。

那怎麼知道滑鼠在哪?用 Uniform 讓所有粒子共用同一份資料。(●'◡'●)

Step 12:Shader 編譯與 Uniform

WebGL 的 Shader 是純文字的 GLSL 程式碼,必須在執行時動態編譯。

先來看最簡單的例子:

畫一個圓,拖動滑桿可以改變半徑,半徑透過 Uniform 從 CPU 傳給 GPU。

查看範例原始碼
vue
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'

const canvasRef = ref<HTMLCanvasElement | null>(null)
const radius = ref(30)

let glContext: WebGL2RenderingContext | null = null
let program: WebGLProgram | null = null
let vertexBuffer: WebGLBuffer | null = null
let animationFrameId = 0

/** Vertex Shader:把全螢幕三角形的頂點直接傳到裁剪空間 */
const vertexShaderSource = `#version 300 es
in vec2 aPosition;

void main() {
  gl_Position = vec4(aPosition, 0.0, 1.0);
}
`

/**
 * Fragment Shader:每個像素各自執行一次
 * 用 uResolution 把像素座標換算成 0~1 的 UV,再判斷是否在圓內
 */
const fragmentShaderSource = `#version 300 es
precision highp float;

uniform vec2 uResolution;
uniform float uRadius;

out vec4 fragColor;

void main() {
  // 將像素座標正規化為 0~1
  vec2 uv = gl_FragCoord.xy / uResolution;

  // 圓心在畫面正中央
  vec2 center = vec2(0.5, 0.5);

  // 計算此像素到圓心的距離
  float dist = length(uv - center);

  // 半徑從百分比轉為 0~1 範圍
  float r = uRadius / 100.0;

  // smoothstep 讓邊緣柔和,避免鋸齒
  float circle = smoothstep(r + 0.01, r - 0.01, dist);

  // 圓的顏色
  vec3 color = vec3(0.26, 0.52, 0.96) * circle;

  fragColor = vec4(color, 1.0);
}
`

function compileShader(
  glContext: WebGL2RenderingContext,
  type: number,
  source: string
): WebGLShader | null {
  const shader = glContext.createShader(type)
  if (!shader) return null
  glContext.shaderSource(shader, source)
  glContext.compileShader(shader)
  if (!glContext.getShaderParameter(shader, glContext.COMPILE_STATUS)) {
    console.error(glContext.getShaderInfoLog(shader))
    glContext.deleteShader(shader)
    return null
  }
  return shader
}

function linkProgram(
  glContext: WebGL2RenderingContext,
  vertSource: string,
  fragSource: string
): WebGLProgram | null {
  const vert = compileShader(glContext, glContext.VERTEX_SHADER, vertSource)
  const frag = compileShader(glContext, glContext.FRAGMENT_SHADER, fragSource)
  if (!vert || !frag) return null
  const program = glContext.createProgram()
  if (!program) return null
  glContext.attachShader(program, vert)
  glContext.attachShader(program, frag)
  glContext.linkProgram(program)
  if (!glContext.getProgramParameter(program, glContext.LINK_STATUS)) {
    console.error(glContext.getProgramInfoLog(program))
    glContext.deleteProgram(program)
    return null
  }
  glContext.deleteShader(vert)
  glContext.deleteShader(frag)
  return program
}

function initWebGL(): void {
  const canvas = canvasRef.value
  if (!canvas) return

  canvas.width = canvas.clientWidth
  canvas.height = canvas.clientHeight

  glContext = canvas.getContext('webgl2')
  if (!glContext) {
    console.error('WebGL2 not supported')
    return
  }

  program = linkProgram(glContext, vertexShaderSource, fragmentShaderSource)
  if (!program) return

  // 全螢幕四邊形(兩個三角形)
  const positionList = new Float32Array([
    -1, -1,
     1, -1,
    -1,  1,
    -1,  1,
     1, -1,
     1,  1,
  ])

  vertexBuffer = glContext.createBuffer()
  glContext.bindBuffer(glContext.ARRAY_BUFFER, vertexBuffer)
  glContext.bufferData(glContext.ARRAY_BUFFER, positionList, glContext.STATIC_DRAW)

  const positionLocation = glContext.getAttribLocation(program, 'aPosition')
  glContext.enableVertexAttribArray(positionLocation)
  glContext.vertexAttribPointer(positionLocation, 2, glContext.FLOAT, false, 0, 0)
}

function render(): void {
  if (!glContext || !program) return

  const canvas = canvasRef.value
  if (!canvas) return

  canvas.width = canvas.clientWidth
  canvas.height = canvas.clientHeight
  glContext.viewport(0, 0, canvas.width, canvas.height)

  glContext.useProgram(program)

  const resolutionLocation = glContext.getUniformLocation(program, 'uResolution')
  const radiusLocation = glContext.getUniformLocation(program, 'uRadius')

  glContext.uniform2f(resolutionLocation, canvas.width, canvas.height)
  glContext.uniform1f(radiusLocation, radius.value)

  glContext.drawArrays(glContext.TRIANGLES, 0, 6)

  animationFrameId = requestAnimationFrame(render)
}

onMounted(() => {
  initWebGL()
  animationFrameId = requestAnimationFrame(render)
})

onBeforeUnmount(() => {
  cancelAnimationFrame(animationFrameId)

  if (glContext) {
    if (program)
      glContext.deleteProgram(program)
    if (vertexBuffer)
      glContext.deleteBuffer(vertexBuffer)
    glContext.getExtension('WEBGL_lose_context')?.loseContext()
  }
})
</script>

<template>
  <div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6">
    <label class="flex items-center gap-2 text-sm text-gray-700">
      半徑:{{ radius }}%
      <input
        v-model.number="radius"
        type="range"
        min="5"
        max="50"
        step="1"
        class="flex-1"
      >
    </label>

    <canvas
      ref="canvasRef"
      class="w-full rounded-lg"
      style="height: 320px;"
    />
  </div>
</template>

上一步提到 Vertex Shader 決定「頂點在哪」,Fragment Shader 決定「像素什麼顏色」。現在來看這兩段程式碼實際長什麼樣子。

不過先小小提醒一下,Step 11 說的是「每個粒子 = 一個頂點」,用 GL_POINTS 畫點。但這個範例的目的不是畫粒子,而是先學會 GLSL 語法

這裡用頂點拼出一個覆蓋畫面的四邊形,讓 Fragment Shader 能在每個像素上執行,用數學畫出一個圓。

等到後面真正做粒子系統時,才會回到「一個頂點 = 一個粒子」的做法。

Vertex Shader:決定頂點位置

glsl
#version 300 es
in vec2 aPosition;

void main() {
  gl_Position = vec4(aPosition, 0.0, 1.0);
}

aPosition 是從 JavaScript 傳進來的頂點座標(Attribute),每個頂點跑一次。

同樣的頂點資料,drawArrays 的第一個參數決定 GPU 怎麼連接這些頂點。

WebGL 提供 7 種繪圖模式:

gl.POINTS:每個頂點畫一個點,大小由 gl_PointSize 決定。

1234

gl.LINES:每 2 個頂點連一條線,多餘的丟掉。

1234

gl.LINE_STRIP:依序連成一條折線。

1234

gl.LINE_LOOP:跟 LINE_STRIP 一樣,但首尾會相連。

1234

三角形

gl.TRIANGLES:每 3 個頂點組一個獨立三角形,最通用。需要 6 個頂點才能拼出四邊形。

123456

gl.TRIANGLE_STRIP:每個新頂點和前兩個組成三角形,頂點共用。4 個頂點就能拼出四邊形。

123456

gl.TRIANGLE_FAN:所有三角形共用第一個頂點,像扇子展開。適合畫扇形、圓形。

12345

這個範例用 gl.TRIANGLES,所以要拼出一個覆蓋整個畫面的四邊形,得拆成兩個三角形,共 6 個頂點:

(-1,1)(1,1)(-1,-1)(1,-1)△1△2

對應到 JavaScript 就是:

ts
const positionList = new Float32Array([
  -1,
  -1,
  1,
  -1,
  -1,
  1,
  -1,
  1, // 三角形 1(左下)
  -1,
  1,
  1,
  -1,
  1,
  1, // 三角形 2(右上)
])

有了這個全螢幕四邊形,Fragment Shader 就能在畫面上的每個像素執行,用數學算出圓形。

gl_Position 是 GLSL 的內建變數,用來告訴 GPU「這個頂點放在哪」。vec4 的四個值分別是 x、y、z、w,2D 的情況下 z 固定 0.0,w 固定 1.0 就好。

Fragment Shader:決定像素顏色

Vertex Shader 決定了頂點圍出的區域後,GPU 會自動把那個區域內的每個像素都交給 Fragment Shader 處理。

不同的繪圖模式,Fragment Shader 執行的範圍也不同:

  • POINTS:每個頂點周圍 gl_PointSize × gl_PointSize 的正方形區域
  • LINES:每條線段涵蓋的像素(通常只有 1px 寬)
  • TRIANGLES:三角形內部的所有像素

這個範例用兩個三角形拼成全螢幕四邊形,所以 Fragment Shader 會在畫面上的每一個像素都跑一次。

每個像素執行時,可以透過 gl_FragCoord.xy 知道「我是畫面上哪一個像素」。

這是 GLSL 的內建變數,單位是像素座標(例如 320.5, 240.5)。

有了位置,就能用數學決定這個像素該是什麼顏色。

WebGL 必須手動宣告的浮點精度,桌面 OpenGL 預設就是高精度,但 WebGL 繼承自行動端的 OpenGL ES,Fragment Shader 不會預設精度,不寫就會編譯錯誤。

有三種可以選:

精度用途
lowp省電,但容易出現色階斷層
mediump夠用於大部分 2D 效果
highp精準計算距離、座標時必須用

畫圓需要精準的距離計算,所以這裡用 highp

這個 Shader 有兩個 uniform,都是 JavaScript 在每幀傳進來的:

  • uResolution:畫布的寬高(像素),用來把像素座標正規化成 0~1
  • uRadius:圓的半徑(百分比),來自範例中的滑桿數值
glsl
#version 300 es
precision highp float;

uniform vec2 uResolution;
uniform float uRadius;

out vec4 fragColor;

void main() {
  // 將像素座標正規化為 0~1
  vec2 uv = gl_FragCoord.xy / uResolution;

  // 圓心在畫面正中央
  vec2 center = vec2(0.5, 0.5);

  // 計算此像素到圓心的距離
  float dist = length(uv - center);

  // 半徑從百分比轉為 0~1 範圍
  float r = uRadius / 100.0;

  // smoothstep:圓內 → 1.0,圓外 → 0.0,邊緣柔和過渡
  float circle = smoothstep(r + 0.01, r - 0.01, dist);

  vec3 color = vec3(0.26, 0.52, 0.96) * circle;
  fragColor = vec4(color, 1.0);
}

核心邏輯只有三步:

  1. 取得位置gl_FragCoord.xy 除以 uResolution 正規化成 0~1 的 UV
  2. 算距離length(uv - center) 算出此像素到圓心有多遠
  3. 判斷是否在圓內smoothstep 讓邊緣柔和過渡,避免鋸齒

最後把 circle 乘上顏色,圓內是藍色,圓外是黑色。fragColor 就是這個像素最終的顏色輸出。

編譯流程

ts
// 1. 建立 shader 物件
const shader = gl.createShader(gl.FRAGMENT_SHADER)

// 2. 設定原始碼
gl.shaderSource(shader, sourceCode)

// 3. 編譯
gl.compileShader(shader)

// 4. 檢查有沒有錯
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
  console.error(gl.getShaderInfoLog(shader))
}

連結成 Program

Vertex Shader 和 Fragment Shader 必須配成一對,連結成 Program 才能使用。

ts
const program = gl.createProgram()
gl.attachShader(program, vertexShader)
gl.attachShader(program, fragmentShader)
gl.linkProgram(program)

就像寫 TypeScript 一樣,原始碼要先「編譯」才能執行。一個 Program 就是一組完整的 GPU 小程式。

Uniform:CPU 傳資料給 GPU

上面範例中的 uRadius 就是一個 Uniform。JavaScript 每幀把滑桿的值傳給 GPU,所有像素共用同一個半徑值。

ts
const radiusLocation = gl.getUniformLocation(program, 'uRadius')
gl.uniform1f(radiusLocation, radius)

Uniform 是「每幀全部像素共用」的常數。

滑鼠位置、散開半徑、摩擦力這些值對所有粒子都一樣,所以用 Uniform 傳入。

那每個粒子各自的位置和速度都不一樣怎麼辦呢?塞進 Texture 吧。( ´ ▽ ` )ノ

Step 13:用浮點紋理儲存粒子

GPU 世界裡,大量的個別資料要塞進 Texture(紋理)。

查看範例原始碼
vue
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'

interface ParticleState {
  x: number;
  y: number;
  velocityX: number;
  velocityY: number;
}

const canvasRef = ref<HTMLCanvasElement | null>(null)
const infoText = ref('')

const GRID_SIZE = 8
const TOTAL_PARTICLES = GRID_SIZE * GRID_SIZE

let glContext: WebGL2RenderingContext | null = null
let floatTexture: WebGLTexture | null = null
let canvasContext: CanvasRenderingContext2D | null = null
let textureData = new Float32Array(GRID_SIZE * GRID_SIZE * 4)

function compileShader(
  glContext: WebGL2RenderingContext,
  type: number,
  source: string
): WebGLShader | null {
  const shader = glContext.createShader(type)
  if (!shader) return null
  glContext.shaderSource(shader, source)
  glContext.compileShader(shader)
  if (!glContext.getShaderParameter(shader, glContext.COMPILE_STATUS)) {
    console.error(glContext.getShaderInfoLog(shader))
    glContext.deleteShader(shader)
    return null
  }
  return shader
}

function linkProgram(
  glContext: WebGL2RenderingContext,
  vertSource: string,
  fragSource: string
): WebGLProgram | null {
  const vert = compileShader(glContext, glContext.VERTEX_SHADER, vertSource)
  const frag = compileShader(glContext, glContext.FRAGMENT_SHADER, fragSource)
  if (!vert || !frag) return null
  const program = glContext.createProgram()
  if (!program) return null
  glContext.attachShader(program, vert)
  glContext.attachShader(program, frag)
  glContext.linkProgram(program)
  if (!glContext.getProgramParameter(program, glContext.LINK_STATUS)) {
    console.error(glContext.getProgramInfoLog(program))
    glContext.deleteProgram(program)
    return null
  }
  glContext.deleteShader(vert)
  glContext.deleteShader(frag)
  return program
}

function generateRandomData(): Float32Array {
  const data = new Float32Array(GRID_SIZE * GRID_SIZE * 4)

  for (let index = 0; index < GRID_SIZE * GRID_SIZE; index++) {
    const offset = index * 4
    data[offset + 0] = Math.random() * 400
    data[offset + 1] = Math.random() * 300
    data[offset + 2] = (Math.random() - 0.5) * 4
    data[offset + 3] = (Math.random() - 0.5) * 4
  }

  return data
}

function initWebGL(): void {
  const offscreenCanvas = document.createElement('canvas')
  offscreenCanvas.width = GRID_SIZE
  offscreenCanvas.height = GRID_SIZE

  glContext = offscreenCanvas.getContext('webgl2')
  if (!glContext) {
    console.error('WebGL2 not supported')
    return
  }

  const extensionColorBufferFloat = glContext.getExtension('EXT_color_buffer_float')
  if (!extensionColorBufferFloat) {
    console.error('EXT_color_buffer_float not supported')
    return
  }

  floatTexture = glContext.createTexture()
  glContext.bindTexture(glContext.TEXTURE_2D, floatTexture)
  glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MIN_FILTER, glContext.NEAREST)
  glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MAG_FILTER, glContext.NEAREST)
  glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_S, glContext.CLAMP_TO_EDGE)
  glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_T, glContext.CLAMP_TO_EDGE)

  textureData = generateRandomData()
  uploadTextureData(textureData)

  infoText.value = `Texture 大小:${GRID_SIZE}x${GRID_SIZE}(共 ${TOTAL_PARTICLES} 個粒子)`
}

function uploadTextureData(data: Float32Array): void {
  if (!glContext || !floatTexture) return

  glContext.bindTexture(glContext.TEXTURE_2D, floatTexture)
  glContext.texImage2D(
    glContext.TEXTURE_2D,
    0,
    glContext.RGBA32F,
    GRID_SIZE,
    GRID_SIZE,
    0,
    glContext.RGBA,
    glContext.FLOAT,
    data
  )
}

function readParticleState(index: number): ParticleState {
  const offset = index * 4
  return {
    x: textureData[offset + 0],
    y: textureData[offset + 1],
    velocityX: textureData[offset + 2],
    velocityY: textureData[offset + 3],
  }
}

function drawVisualization(): void {
  const canvas = canvasRef.value
  if (!canvas) return

  if (!canvasContext) {
    canvasContext = canvas.getContext('2d')
  }
  if (!canvasContext) return

  canvas.width = canvas.clientWidth
  canvas.height = canvas.clientHeight

  const context = canvasContext
  context.clearRect(0, 0, canvas.width, canvas.height)

  const cellWidth = canvas.width / GRID_SIZE
  const cellHeight = (canvas.height - 40) / GRID_SIZE
  const offsetY = 40

  context.fillStyle = '#111'
  context.font = '12px monospace'
  context.fillText('每個格子 = 1 個像素 = 1 個粒子的狀態 (x, y, vx, vy)', 10, 20)

  for (let row = 0; row < GRID_SIZE; row++) {
    for (let column = 0; column < GRID_SIZE; column++) {
      const index = row * GRID_SIZE + column
      const particle = readParticleState(index)

      const normalizedX = Math.min(1, Math.max(0, particle.x / 400))
      const normalizedY = Math.min(1, Math.max(0, particle.y / 300))
      const speed = Math.sqrt(
        particle.velocityX * particle.velocityX +
        particle.velocityY * particle.velocityY
      )
      const normalizedSpeed = Math.min(1, speed / 4)

      const red = Math.floor(normalizedX * 255)
      const green = Math.floor(normalizedY * 255)
      const blue = Math.floor(normalizedSpeed * 255)

      const drawX = column * cellWidth
      const drawY = row * cellHeight + offsetY

      context.fillStyle = `rgb(${red}, ${green}, ${blue})`
      context.fillRect(drawX + 1, drawY + 1, cellWidth - 2, cellHeight - 2)

      context.strokeStyle = '#333'
      context.strokeRect(drawX, drawY, cellWidth, cellHeight)

      if (cellWidth > 50) {
        context.fillStyle = '#fff'
        context.font = '9px monospace'
        context.fillText(
          `${particle.x.toFixed(0)},${particle.y.toFixed(0)}`,
          drawX + 3,
          drawY + 14
        )
        context.fillText(
          `v:${particle.velocityX.toFixed(1)},${particle.velocityY.toFixed(1)}`,
          drawX + 3,
          drawY + 26
        )
      }
    }
  }
}

function handleRandomize(): void {
  textureData = generateRandomData()
  uploadTextureData(textureData)
  drawVisualization()
}

onMounted(() => {
  initWebGL()
  drawVisualization()
})

onBeforeUnmount(() => {
  if (glContext && floatTexture) {
    glContext.deleteTexture(floatTexture)
  }
})
</script>

<template>
  <div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6">
    <div class="flex items-center justify-between">
      <p class="text-sm text-gray-600">
        {{ infoText }}
      </p>

      <button
        class="rounded-lg bg-blue-500 px-4 py-1.5 text-sm text-white hover:bg-blue-600"
        @click="handleRandomize"
      >
        隨機化
      </button>
    </div>

    <canvas
      ref="canvasRef"
      class="w-full rounded-lg bg-gray-50"
      style="height: 360px;"
    />
  </div>
</template>

紋理不只是圖片

假設有 10000 個粒子。我們建立一張 100×100 的紋理(ceil(sqrt(10000))),每個像素代表一個粒子:

txt
像素 RGBA = (positionX, positionY, velocityX, velocityY)

一個像素 4 個通道剛好存下位置和速度。

為什麼需要 RGBA32F?

預設紋理每通道只有 8 bit(0~255),用來存圖片顏色沒問題,但粒子座標可能是 372.58,8 bit 根本存不下。

所以我們需要浮點紋理 RGBA32F,每通道 32 bit float,精度跟 JavaScript 的 Number 一樣。

ts
gl.getExtension('EXT_color_buffer_float')

gl.texImage2D(
  gl.TEXTURE_2D,
  0,
  gl.RGBA32F,
  textureSize,
  textureSize,
  0,
  gl.RGBA,
  gl.FLOAT,
  initialData
)

EXT_color_buffer_float 擴展讓 GPU 支援讀寫浮點紋理,幾乎所有現代裝置都支援。

兩張紋理:狀態與原始位置

蟲群文字用了兩種紋理:

紋理內容會變嗎?
狀態紋理(x, y, velocityX, velocityY)每幀更新
原始位置紋理(originX, originY, size, opacity)永遠不變

原始位置紋理只建一次,之後 Shader 用它來算回歸力(知道「家」在哪)。

texelFetch:讀取紋理

glsl
ivec2 coord = ivec2(gl_FragCoord.xy);
vec4 state = texelFetch(uState, coord, 0);

texelFetch 用整數座標讀取紋理,不做任何插值。每個粒子的 Shader 用自己的座標去讀自己的資料,互不干擾。ლ(´∀`ლ)

Step 14:Ping-Pong 與 Framebuffer

知道怎麼「讀」紋理後,接下來要能「寫」紋理,才能每幀更新粒子狀態。

GPU 的限制:不能同時讀寫

GPU 有個重要限制:不能同時讀取和寫入同一張紋理

這很合理。想像有 10,000 個粒子同時在跑,如果大家同時讀寫同一塊記憶體,結果會完全不可預測。

Ping-Pong:準備兩張紋理

解法是準備兩張狀態紋理 A 和 B,交替使用:

txt
第 1 幀:讀 A → 算物理 → 寫到 B
第 2 幀:讀 B → 算物理 → 寫到 A
第 3 幀:讀 A → 算物理 → 寫到 B
...

這就叫 Ping-Pong,在 GPU 粒子系統中相當常見的技巧。

Framebuffer:讓 Shader 寫入紋理

正常情況下,Fragment Shader 的輸出會畫到螢幕上。

但我們想讓它寫入紋理,所以需要 Framebuffer:

ts
const framebuffer = gl.createFramebuffer()
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer)
gl.framebufferTexture2D(
  gl.FRAMEBUFFER,
  gl.COLOR_ATTACHMENT0,
  gl.TEXTURE_2D,
  stateTexture,
  0
)

綁定 Framebuffer 後,Fragment Shader 的 out 就會寫入對應的紋理而不是螢幕。

完整 Ping-Pong 流程

currentStateIndex 記錄目前要「讀」的是哪張紋理,初始值為 0(代表紋理 A):

ts
const currentStateIndex = 0

每幀的 Ping-Pong 邏輯:

ts
const readIndex = currentStateIndex
const writeIndex = 1 - currentStateIndex

// 綁定「讀」的紋理
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, stateTextureList[readIndex])

// 綁定「寫」的 Framebuffer
gl.bindFramebuffer(
  gl.FRAMEBUFFER,
  framebufferList[writeIndex]
)

// 全螢幕四邊形,觸發每個像素的 Fragment Shader
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)

// 交換索引
currentStateIndex = writeIndex

逐幀展開來看:

txt
第 1 幀:currentStateIndex = 0
  readIndex = 0, writeIndex = 1
  讀紋理 A → Shader 計算 → 寫入紋理 B
  currentStateIndex = 1

第 2 幀:currentStateIndex = 1
  readIndex = 1, writeIndex = 0
  讀紋理 B → Shader 計算 → 寫入紋理 A
  currentStateIndex = 0

第 3 幀:又回到讀 A → 寫 B,不斷交替…

每一幀結束時 currentStateIndex = writeIndex,把「剛寫完的」變成「下一幀要讀的」。兩張紋理像乒乓球一樣來回,就永遠不會同時讀寫同一張。

Step 15:GPU 物理 Shader

準備工作做完了,現在來寫真正算物理的 Fragment Shader。

完整的 Physics Shader

glsl
#version 300 es
precision highp float;

uniform sampler2D uState;
uniform sampler2D uOrigin;
uniform vec2 uMouse;
uniform vec2 uMouseVelocity;
uniform int uMouseInside;
uniform float uScatterRadius;
uniform float uScatterForce;
uniform float uReturnSpeed;
uniform float uFriction;

out vec4 outState;

void main() {
  ivec2 coord = ivec2(gl_FragCoord.xy);
  vec4 state = texelFetch(uState, coord, 0);
  vec4 origin = texelFetch(uOrigin, coord, 0);

  vec2 pos = state.xy;
  vec2 vel = state.zw;
  vec2 originPos = origin.xy;

  // 滑鼠推力
  if (uMouseInside != 0) {
    vec2 toParticle = pos - uMouse;
    float dist = length(toParticle);

    if (dist < uScatterRadius) {
      float influence = smoothstep(
        uScatterRadius, 0.0, dist
      );

      // 風力
      float mouseSpeed = length(uMouseVelocity);
      if (mouseSpeed > 0.5) {
        vec2 windDir = uMouseVelocity / mouseSpeed;
        vel += windDir * mouseSpeed
             * influence * uScatterForce * 0.025;
      }

      // 徑向推力
      vel += normalize(toParticle)
           * influence * influence
           * uScatterForce * 0.5;
    }
  }

  // 摩擦力
  vel *= uFriction;

  // 更新位置
  pos += vel;

  // 回歸
  pos = mix(pos, originPos, uReturnSpeed);

  outState = vec4(pos, vel);
}

ivec2 是什麼?

glsl
ivec2 coord = ivec2(gl_FragCoord.xy);

ivec2 是整數版的二維向量(integer vector 2)。gl_FragCoord.xy 本身是 float,但 texelFetch 需要整數座標才能精確取到紋理中的某一格,所以先轉成 ivec2

類似的型別還有:

型別內容例子
vec22 個 float位置、速度
vec44 個 float顏色 RGBA、狀態封裝
ivec22 個 int紋理座標、像素索引

Uniform 怎麼來的?

Shader 裡這些 uniform 變數不是憑空出現的,每一個都是 JavaScript 在每幀透過 gl.uniform* 傳進來的:

ts
// 紋理:把粒子狀態和原始位置以紋理方式綁定
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, stateTexture) // → uState
gl.activeTexture(gl.TEXTURE1)
gl.bindTexture(gl.TEXTURE_2D, originTexture) // → uOrigin

// 滑鼠相關
gl.uniform2f(loc.uMouse, mouseX, mouseY) // 滑鼠座標
gl.uniform2f(loc.uMouseVelocity, velX, velY) // 滑鼠移動速度
gl.uniform1i(loc.uMouseInside, isInside ? 1 : 0) // 滑鼠是否在畫布內

// 物理參數(來自元件 props)
gl.uniform1f(loc.uScatterRadius, props.scatterRadius) // 散開半徑
gl.uniform1f(loc.uScatterForce, props.scatterForce) // 推力強度
gl.uniform1f(loc.uReturnSpeed, props.returnSpeed) // 回歸速度
gl.uniform1f(loc.uFriction, props.friction) // 摩擦力

對照表:

Uniform型別來源用途
uStatesampler2D上一幀的狀態紋理讀取每個粒子的位置 xy 和速度 zw
uOriginsampler2D初始位置紋理粒子的「家」,回歸用
uMousevec2useMouseInElement滑鼠在畫布上的座標
uMouseVelocityvec2每幀座標差值計算風力方向和強度
uMouseInsideintisOutside 取反滑鼠不在畫布內就不算推力
uScatterRadiusfloatprops推力的影響範圍(像素)
uScatterForcefloatprops推力的強度倍率
uReturnSpeedfloatpropsmix 的插值比例,越大回越快
uFrictionfloatprops每幀速度乘上這個值,< 1 就會減速

如 Step 12 提到的概念,Uniform 是「每幀全部像素共用」的常數。

滑鼠位置、物理參數對所有粒子都一樣,所以用 Uniform;而每個粒子各自不同的位置和速度,則存在紋理裡用 texelFetch 讀取。ԅ(´∀` ԅ)

跟 CPU 版的對比

注意到了嗎?邏輯跟 Step 4~8 幾乎一模一樣:

概念CPU 版GPU 版
摩擦力velocity *= frictionvel *= uFriction
回歸x += (origin - x) * speedpos = mix(pos, originPos, speed)
距離Math.sqrt(dx*dx + dy*dy)length(toParticle)
衰減1 - dist / radiussmoothstep(radius, 0, dist)

GLSL 的 mix 就是 lerp,length 就是向量長度,smoothstep 是更平滑的衰減曲線。差別只在語法和「誰來算」。

smoothstep vs 線性衰減

CPU 版用 1 - distance / radius 做線性衰減,GPU 版改用 smoothstep。差異在於 smoothstep 的邊緣是平滑的 S 曲線,視覺上更自然。

txt
smoothstep(edge0, edge1, x):
  t = clamp((x - edge0) / (edge1 - edge0), 0, 1)
  return t * t * (3 - 2 * t)

influence * influence

注意徑向推力用了 influence * influence(平方),不是線性。平方衰減讓邊緣更柔,中心更強,推出來的「空洞」更有層次。

Step 16:gl.POINTS 與 Vertex Shader

物理算完了,粒子位置存在紋理裡,最後只剩畫到螢幕上了!ヾ(◍'౪`◍)ノ゙

不過在動手之前,先退一步看看整體流程。

到目前為止我們寫了 Step 15 的 Physics Shader,接下來還要寫渲染用的 Vertex Shader 和 Fragment Shader,Shader 越來越多,容易搞混誰是誰。

整個粒子系統的 Shader 可以分成兩大類:

  • 模擬用 Shader:負責算物理,更新粒子的位置和速度
  • 渲染用 Shader:負責把粒子畫出來,決定位置、大小、形狀和顏色

這兩類 Shader 各司其職,資料透過紋理傳遞。

兩類 Shader 的關係

模擬階段Physics Fragment Shader(Step 15)寫入新狀態狀態紋理pos + vel讀取位置渲染階段Render Vertex Shader(Step 16)varying 傳遞Render Fragment Shader(Step 17)輸出像素螢幕

模擬階段的 Fragment Shader 把計算結果寫入紋理,渲染階段的 Vertex Shader 再從同一張紋理讀取位置。

兩個階段透過紋理「接力」,完全不需要 CPU 介入搬資料。( ‧ω‧)ノ╰(‧ω‧ )

每個像素對應一個粒子,Fragment Shader 就變成了平行計算引擎。(≖‿ゝ≖)✧

這也是為什麼模擬用的 Shader 需要全螢幕四邊形(gl.TRIANGLE_STRIP)觸發,而渲染用的 Shader 用 gl.POINTS 觸發。

觸發方式不同,但都是 GPU 在跑,不會經過 CPU,性能好棒棒。ヾ(◍'౪`◍)ノ゙

改良頂點著色器:每個粒子有獨立的大小、透明度、位移量與速度
查看範例原始碼
vue
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'

const canvasRef = ref<HTMLCanvasElement | null>(null)

let glContext: WebGL2RenderingContext | null = null
let physicsProgram: WebGLProgram | null = null
let renderProgram: WebGLProgram | null = null
let animationFrameId = 0

const stateTextureList: [WebGLTexture | null, WebGLTexture | null] = [null, null]
const framebufferList: [WebGLFramebuffer | null, WebGLFramebuffer | null] = [null, null]
let originTexture: WebGLTexture | null = null
let quadVertexBuffer: WebGLBuffer | null = null
let quadVertexArray: WebGLVertexArrayObject | null = null

let particleCount = 0
let textureSize = 0
let pingPongIndex = 0

let mouseX = 0
let mouseY = 0
let isMouseInside = false

const DISPLAY_TEXT = 'SWARM'

// ----- Physics pass shaders -----
const physicsVertexSource = `#version 300 es
in vec2 aPosition;
void main() {
  gl_Position = vec4(aPosition, 0.0, 1.0);
}
`

const physicsFragmentSource = `#version 300 es
precision highp float;

uniform sampler2D uState;
uniform sampler2D uOrigin;
uniform vec2 uMouse;
uniform int uMouseInside;
uniform float uScatterRadius;
uniform float uScatterForce;
uniform float uReturnSpeed;
uniform float uFriction;

out vec4 fragColor;

void main() {
  ivec2 coord = ivec2(gl_FragCoord.xy);
  vec4 state = texelFetch(uState, coord, 0);
  vec4 origin = texelFetch(uOrigin, coord, 0);

  float x = state.x;
  float y = state.y;
  float velocityX = state.z;
  float velocityY = state.w;
  float originX = origin.x;
  float originY = origin.y;

  if (originX < -9999.0) {
    fragColor = state;
    return;
  }

  // Mouse radial push
  if (uMouseInside == 1) {
    vec2 delta = vec2(x, y) - uMouse;
    float distance = length(delta);
    float influence = smoothstep(uScatterRadius, 0.0, distance);

    if (distance > 0.001) {
      vec2 direction = delta / distance;
      velocityX += direction.x * influence * uScatterForce;
      velocityY += direction.y * influence * uScatterForce;
    }
  }

  // Return to origin
  velocityX += (originX - x) * uReturnSpeed;
  velocityY += (originY - y) * uReturnSpeed;

  // Friction
  velocityX *= uFriction;
  velocityY *= uFriction;

  // Update position
  x += velocityX;
  y += velocityY;

  fragColor = vec4(x, y, velocityX, velocityY);
}
`

// ----- Render pass shaders -----
const renderVertexSource = `#version 300 es
precision highp float;

uniform sampler2D uState;
uniform sampler2D uOrigin;
uniform int uTextureSize;
uniform vec2 uResolution;

out float vOpacity;
out float vDisplacement;
out float vSpeed;

void main() {
  int texelX = gl_VertexID % uTextureSize;
  int texelY = gl_VertexID / uTextureSize;
  vec4 state = texelFetch(uState, ivec2(texelX, texelY), 0);
  vec4 origin = texelFetch(uOrigin, ivec2(texelX, texelY), 0);

  float x = state.x;
  float y = state.y;
  float velocityX = state.z;
  float velocityY = state.w;
  float originX = origin.x;
  float originY = origin.y;
  float size = origin.z;
  float opacity = origin.w;

  if (originX < -9999.0) {
    gl_Position = vec4(-9999.0, -9999.0, 0.0, 1.0);
    gl_PointSize = 0.0;
    vOpacity = 0.0;
    vDisplacement = 0.0;
    vSpeed = 0.0;
    return;
  }

  vec2 clipPosition = (vec2(x, y) / uResolution) * 2.0 - 1.0;
  clipPosition.y *= -1.0;

  gl_Position = vec4(clipPosition, 0.0, 1.0);
  gl_PointSize = size;

  vOpacity = opacity;

  float distanceFromOrigin = length(vec2(x, y) - vec2(originX, originY));
  vDisplacement = smoothstep(0.0, 40.0, distanceFromOrigin);

  float speed = length(vec2(velocityX, velocityY));
  vSpeed = smoothstep(0.0, 8.0, speed);
}
`

const renderFragmentSource = `#version 300 es
precision highp float;

in float vOpacity;
in float vDisplacement;
in float vSpeed;

out vec4 fragColor;

void main() {
  fragColor = vec4(0.53, 0.53, 0.53, vOpacity);
}
`

// ----- Helper functions -----
function compileShader(
  gl: WebGL2RenderingContext,
  type: number,
  source: string,
): WebGLShader | null {
  const shader = gl.createShader(type)
  if (!shader)
    return null
  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 linkProgram(
  gl: WebGL2RenderingContext,
  vertSource: string,
  fragSource: string,
): WebGLProgram | null {
  const vert = compileShader(gl, gl.VERTEX_SHADER, vertSource)
  const frag = compileShader(gl, gl.FRAGMENT_SHADER, fragSource)
  if (!vert || !frag)
    return null
  const program = gl.createProgram()
  if (!program)
    return null
  gl.attachShader(program, vert)
  gl.attachShader(program, frag)
  gl.linkProgram(program)
  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    console.error(gl.getProgramInfoLog(program))
    gl.deleteProgram(program)
    return null
  }
  gl.deleteShader(vert)
  gl.deleteShader(frag)
  return program
}

function createFloatTexture(
  gl: WebGL2RenderingContext,
  size: number,
  data: Float32Array,
): WebGLTexture | null {
  const texture = gl.createTexture()
  if (!texture)
    return null
  gl.bindTexture(gl.TEXTURE_2D, texture)
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, size, size, 0, gl.RGBA, gl.FLOAT, data)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
  return texture
}

// ----- Particle sampling from text -----
interface SampledParticle {
  x: number;
  y: number;
  size: number;
  opacity: number;
}

function sampleParticleListFromText(
  text: string,
  canvasWidth: number,
  canvasHeight: number,
): SampledParticle[] {
  const offscreen = document.createElement('canvas')
  offscreen.width = canvasWidth
  offscreen.height = canvasHeight
  const context = offscreen.getContext('2d')
  if (!context)
    return []

  const fontSize = Math.min(canvasWidth / (text.length * 0.7), canvasHeight * 0.6)
  context.fillStyle = 'white'
  context.font = `bold ${fontSize}px Arial, sans-serif`
  context.textAlign = 'center'
  context.textBaseline = 'middle'
  context.fillText(text, canvasWidth / 2, canvasHeight / 2)

  const imageData = context.getImageData(0, 0, canvasWidth, canvasHeight)
  const pixelDataList = imageData.data
  const gap = 1
  const resultList: SampledParticle[] = []

  for (let y = 0; y < canvasHeight; y += gap) {
    for (let x = 0; x < canvasWidth; x += gap) {
      const index = (y * canvasWidth + x) * 4
      const alpha = pixelDataList[index + 3]
      if (alpha > 128) {
        resultList.push({
          x,
          y,
          size: 2.0,
          opacity: alpha / 255,
        })
      }
    }
  }

  return resultList
}

// ----- Setup textures and framebuffers -----
function setupParticleTextures(
  gl: WebGL2RenderingContext,
  sampledParticleList: SampledParticle[],
): void {
  particleCount = sampledParticleList.length
  textureSize = Math.ceil(Math.sqrt(particleCount))
  const totalPixelCount = textureSize * textureSize

  const stateData = new Float32Array(totalPixelCount * 4)
  const originData = new Float32Array(totalPixelCount * 4)

  for (let i = 0; i < totalPixelCount; i++) {
    if (i < particleCount) {
      const particle = sampledParticleList[i]
      stateData[i * 4 + 0] = particle.x
      stateData[i * 4 + 1] = particle.y
      stateData[i * 4 + 2] = 0
      stateData[i * 4 + 3] = 0
      originData[i * 4 + 0] = particle.x
      originData[i * 4 + 1] = particle.y
      originData[i * 4 + 2] = particle.size
      originData[i * 4 + 3] = particle.opacity
    }
    else {
      stateData[i * 4 + 0] = -99999
      stateData[i * 4 + 1] = -99999
      stateData[i * 4 + 2] = 0
      stateData[i * 4 + 3] = 0
      originData[i * 4 + 0] = -99999
      originData[i * 4 + 1] = -99999
      originData[i * 4 + 2] = 0
      originData[i * 4 + 3] = 0
    }
  }

  stateTextureList[0] = createFloatTexture(gl, textureSize, stateData)
  stateTextureList[1] = createFloatTexture(gl, textureSize, new Float32Array(stateData))
  originTexture = createFloatTexture(gl, textureSize, originData)

  for (let i = 0; i < 2; i++) {
    framebufferList[i] = gl.createFramebuffer()
    gl.bindFramebuffer(gl.FRAMEBUFFER, framebufferList[i])
    gl.framebufferTexture2D(
      gl.FRAMEBUFFER,
      gl.COLOR_ATTACHMENT0,
      gl.TEXTURE_2D,
      stateTextureList[i],
      0,
    )
  }
  gl.bindFramebuffer(gl.FRAMEBUFFER, null)
}

function setupQuadBuffer(gl: WebGL2RenderingContext): void {
  const positionList = new Float32Array([
    -1,
    -1,
    1,
    -1,
    -1,
    1,
    -1,
    1,
    1,
    -1,
    1,
    1,
  ])

  quadVertexArray = gl.createVertexArray()
  gl.bindVertexArray(quadVertexArray)

  quadVertexBuffer = gl.createBuffer()
  gl.bindBuffer(gl.ARRAY_BUFFER, quadVertexBuffer)
  gl.bufferData(gl.ARRAY_BUFFER, positionList, gl.STATIC_DRAW)

  gl.enableVertexAttribArray(0)
  gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0)

  gl.bindVertexArray(null)
}

// ----- Pointer events -----
function handlePointerMove(event: PointerEvent): void {
  const canvas = canvasRef.value
  if (!canvas)
    return
  const rect = canvas.getBoundingClientRect()
  const devicePixelRatio = window.devicePixelRatio || 1
  mouseX = (event.clientX - rect.left) * devicePixelRatio
  mouseY = (event.clientY - rect.top) * devicePixelRatio
  isMouseInside = true
}

function handlePointerLeave(): void {
  isMouseInside = false
}

// ----- Init and render -----
function initWebGL(): void {
  const canvas = canvasRef.value
  if (!canvas)
    return

  const devicePixelRatio = window.devicePixelRatio || 1
  const displayWidth = canvas.clientWidth
  const displayHeight = canvas.clientHeight
  canvas.width = displayWidth * devicePixelRatio
  canvas.height = displayHeight * devicePixelRatio

  glContext = canvas.getContext('webgl2')
  if (!glContext) {
    console.error('WebGL2 not supported')
    return
  }

  const extColorFloat = glContext.getExtension('EXT_color_buffer_float')
  if (!extColorFloat) {
    console.error('EXT_color_buffer_float not supported')
    return
  }

  physicsProgram = linkProgram(glContext, physicsVertexSource, physicsFragmentSource)
  renderProgram = linkProgram(glContext, renderVertexSource, renderFragmentSource)
  if (!physicsProgram || !renderProgram)
    return

  setupQuadBuffer(glContext)

  const sampledParticleList = sampleParticleListFromText(
    DISPLAY_TEXT,
    canvas.width,
    canvas.height,
  )
  setupParticleTextures(glContext, sampledParticleList)

  canvas.addEventListener('pointermove', handlePointerMove)
  canvas.addEventListener('pointerleave', handlePointerLeave)
}

function render(): void {
  if (!glContext || !physicsProgram || !renderProgram)
    return

  const canvas = canvasRef.value
  if (!canvas)
    return

  const gl = glContext
  const readIndex = pingPongIndex
  const writeIndex = 1 - pingPongIndex

  // ----- Physics pass -----
  gl.bindFramebuffer(gl.FRAMEBUFFER, framebufferList[writeIndex])
  gl.viewport(0, 0, textureSize, textureSize)

  gl.useProgram(physicsProgram)

  gl.activeTexture(gl.TEXTURE0)
  gl.bindTexture(gl.TEXTURE_2D, stateTextureList[readIndex])
  gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uState'), 0)

  gl.activeTexture(gl.TEXTURE1)
  gl.bindTexture(gl.TEXTURE_2D, originTexture)
  gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uOrigin'), 1)

  gl.uniform2f(gl.getUniformLocation(physicsProgram, 'uMouse'), mouseX, mouseY)
  gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uMouseInside'), isMouseInside ? 1 : 0)
  gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uScatterRadius'), 60.0)
  gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uScatterForce'), 40.0)
  gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uReturnSpeed'), 0.1)
  gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uFriction'), 0.92)

  gl.bindVertexArray(quadVertexArray)
  gl.drawArrays(gl.TRIANGLES, 0, 6)
  gl.bindVertexArray(null)

  pingPongIndex = writeIndex

  // ----- Render pass -----
  gl.bindFramebuffer(gl.FRAMEBUFFER, null)
  gl.viewport(0, 0, canvas.width, canvas.height)
  gl.clearColor(0, 0, 0, 1)
  gl.clear(gl.COLOR_BUFFER_BIT)

  gl.useProgram(renderProgram)

  gl.activeTexture(gl.TEXTURE0)
  gl.bindTexture(gl.TEXTURE_2D, stateTextureList[writeIndex])
  gl.uniform1i(gl.getUniformLocation(renderProgram, 'uState'), 0)

  gl.activeTexture(gl.TEXTURE1)
  gl.bindTexture(gl.TEXTURE_2D, originTexture)
  gl.uniform1i(gl.getUniformLocation(renderProgram, 'uOrigin'), 1)

  gl.uniform1i(gl.getUniformLocation(renderProgram, 'uTextureSize'), textureSize)
  gl.uniform2f(
    gl.getUniformLocation(renderProgram, 'uResolution'),
    canvas.width,
    canvas.height,
  )

  gl.drawArrays(gl.POINTS, 0, textureSize * textureSize)

  animationFrameId = requestAnimationFrame(render)
}

onMounted(() => {
  initWebGL()
  animationFrameId = requestAnimationFrame(render)
})

onBeforeUnmount(() => {
  cancelAnimationFrame(animationFrameId)

  const canvas = canvasRef.value
  if (canvas) {
    canvas.removeEventListener('pointermove', handlePointerMove)
    canvas.removeEventListener('pointerleave', handlePointerLeave)
  }

  if (glContext) {
    if (physicsProgram)
      glContext.deleteProgram(physicsProgram)
    if (renderProgram)
      glContext.deleteProgram(renderProgram)
    for (const texture of stateTextureList) {
      if (texture)
        glContext.deleteTexture(texture)
    }
    if (originTexture)
      glContext.deleteTexture(originTexture)
    for (const framebuffer of framebufferList) {
      if (framebuffer)
        glContext.deleteFramebuffer(framebuffer)
    }
    if (quadVertexBuffer)
      glContext.deleteBuffer(quadVertexBuffer)
    if (quadVertexArray)
      glContext.deleteVertexArray(quadVertexArray)
  }
})
</script>

<template>
  <div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6">
    <span class="text-sm text-gray-600">
      改良頂點著色器:每個粒子有獨立的大小、透明度、位移量與速度
    </span>

    <canvas
      ref="canvasRef"
      class="w-full rounded-lg"
      style="height: 320px;"
    />
  </div>
</template>

gl.POINTS

WebGL 有個很方便的繪製模式叫 gl.POINTS,每個頂點畫成一個正方形的「點」。

ts
gl.drawArrays(gl.POINTS, 0, particleCount)

一行就畫完所有粒子。大小由 Vertex Shader 裡的 gl_PointSize 控制。

Vertex Shader:從紋理讀取位置

除了前面見過的 uStateuOriginuResolution,這裡多了兩個 uniform:

  • uTextureSize:狀態紋理的邊長(例如 128),用來把一維的頂點 ID 轉換成二維的紋理座標
  • uPointScale:粒子大小的縮放倍率,來自元件 props,讓外部能控制粒子的視覺大小
glsl
#version 300 es
precision highp float;

uniform sampler2D uState;
uniform sampler2D uOrigin;
uniform vec2 uResolution;
uniform float uTextureSize;
uniform float uPointScale;

out float vOpacity;
out float vDisplacement;
out float vSpeed;

void main() {
  int index = gl_VertexID;
  ivec2 texCoord = ivec2(
    index % int(uTextureSize),
    index / int(uTextureSize)
  );

  vec4 state = texelFetch(uState, texCoord, 0);
  vec4 origin = texelFetch(uOrigin, texCoord, 0);

  vec2 pos = state.xy;
  vec2 vel = state.zw;
  vec2 originPos = origin.xy;
  float size = origin.z;
  float opacity = origin.w;

  // 位移量與速度,傳給 Fragment Shader
  float distFromOrigin = length(pos - originPos);
  vDisplacement = smoothstep(0.0, 40.0, distFromOrigin);
  vSpeed = smoothstep(0.0, 8.0, length(vel));

  // 轉換到 clip space(-1 ~ 1)
  vec2 clipPos = (pos / uResolution) * 2.0 - 1.0;
  clipPos.y = -clipPos.y;

  gl_Position = vec4(clipPos, 0.0, 1.0);
  gl_PointSize = size * 2.0 * uPointScale;
  vOpacity = opacity * (1.0 - vDisplacement * 0.3);
}

gl_VertexID 的妙用

gl_VertexID 是 WebGL2 的內建變數,代表「第幾個頂點」。我們不需要傳入任何頂點資料,只要知道 ID 就能從紋理中查到位置。

傳統做法需要建立頂點緩衝區(VBO)存座標,每幀從 CPU 上傳新資料。紋理查表的話,資料全程留在 GPU 上,零傳輸成本。ヽ(●`∀´●)ノ

Clip Space 座標轉換

Canvas 的座標是左上角 (0, 0),向右向下增加。WebGL 的 clip space 是 (-1, -1)(1, 1),中心是 (0, 0),Y 軸向上。

glsl
vec2 clipPos = (pos / uResolution) * 2.0 - 1.0;
clipPos.y = -clipPos.y;

先除以畫布大小正規化到 0~1,乘 2 減 1 變成 -1~1,最後翻轉 Y 軸。

Varying 傳遞

Vertex Shader 和 Fragment Shader 是兩個獨立的程式,不能共用變數。但 Fragment Shader 常常需要用到 Vertex Shader 算好的值,例如粒子離家多遠、跑多快。

Varying 就是這條橋樑:Vertex Shader 用 out 送出,Fragment Shader 用 in 接收,變數名稱必須完全相同

glsl
// Vertex Shader
out float vDisplacement;   // 送出去

// Fragment Shader
in float vDisplacement;    // 接收(名稱必須一致)

在三角形模式中,Varying 會自動對三角形內部的像素做重心插值(barycentric interpolation),讓顏色、光照能平滑過渡。

不過蟲群文字用的是 gl.POINTS,每個粒子就是一個點,不存在「三個頂點之間」的插值,所以 Fragment Shader 收到的就是 Vertex Shader 設定的原始值。

命名慣例上,v 字首代表 Varying,方便一眼辨認資料來源:

字首意思例子
aAttribute(來自 JavaScript 的頂點資料)aPosition
uUniform(來自 JavaScript 的全域常數)uMouse
vVarying(來自 Vertex Shader)vDisplacement

Step 17:圓形粒子與 Alpha Blending

Vertex Shader 決定了粒子的「位置」和「大小」,Fragment Shader 決定它「長什麼樣」。

原始正方形
discard 裁圓
柔邊 + Alpha Blending
查看範例原始碼
vue
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'

const canvasListRef = ref<HTMLCanvasElement[]>([])

interface GlResource {
  gl: WebGL2RenderingContext;
  program: WebGLProgram;
  vao: WebGLVertexArrayObject;
  buffer: WebGLBuffer;
}

const resourceList: GlResource[] = []

const PARTICLE_LIST = [
  { x: 0.3, y: 0.5, size: 80.0, opacity: 0.7 },
  { x: 0.5, y: 0.5, size: 80.0, opacity: 0.7 },
  { x: 0.7, y: 0.5, size: 80.0, opacity: 0.7 },
  { x: 0.4, y: 0.35, size: 60.0, opacity: 0.5 },
  { x: 0.6, y: 0.35, size: 60.0, opacity: 0.5 },
  { x: 0.5, y: 0.65, size: 60.0, opacity: 0.5 },
]

const VERTEX_SOURCE = `#version 300 es
precision highp float;
in vec2 aPosition;
in float aSize;
in float aOpacity;
out float vOpacity;

void main() {
  vec2 clip = aPosition * 2.0 - 1.0;
  clip.y *= -1.0;
  gl_Position = vec4(clip, 0.0, 1.0);
  gl_PointSize = aSize;
  vOpacity = aOpacity;
}
`

// 1) 原始正方形:不做任何處理
const FRAG_SQUARE = `#version 300 es
precision highp float;
in float vOpacity;
out vec4 fragColor;

void main() {
  fragColor = vec4(0.53, 0.53, 0.53, 1.0);
}
`

// 2) discard 裁圓,硬邊
const FRAG_CIRCLE_HARD = `#version 300 es
precision highp float;
in float vOpacity;
out vec4 fragColor;

void main() {
  float dist = length(gl_PointCoord - 0.5) * 2.0;
  if (dist > 1.0) discard;
  fragColor = vec4(0.53, 0.53, 0.53, 1.0);
}
`

// 3) smoothstep 柔邊 + opacity + alpha blending
const FRAG_SOFT_BLEND = `#version 300 es
precision highp float;
in float vOpacity;
out vec4 fragColor;

void main() {
  float dist = length(gl_PointCoord - 0.5) * 2.0;
  if (dist > 1.0) discard;
  float alpha = smoothstep(1.0, 0.4, dist);
  fragColor = vec4(0.53, 0.53, 0.53, vOpacity * alpha);
}
`

const FRAGMENT_SOURCE_LIST = [FRAG_SQUARE, FRAG_CIRCLE_HARD, FRAG_SOFT_BLEND]

function compileShader(
  gl: WebGL2RenderingContext,
  type: number,
  source: string,
): WebGLShader | null {
  const shader = gl.createShader(type)
  if (!shader)
    return null
  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 linkProgram(
  gl: WebGL2RenderingContext,
  vertSource: string,
  fragSource: string,
): WebGLProgram | null {
  const vert = compileShader(gl, gl.VERTEX_SHADER, vertSource)
  const frag = compileShader(gl, gl.FRAGMENT_SHADER, fragSource)
  if (!vert || !frag)
    return null
  const program = gl.createProgram()
  if (!program)
    return null
  gl.attachShader(program, vert)
  gl.attachShader(program, frag)
  gl.linkProgram(program)
  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    console.error(gl.getProgramInfoLog(program))
    gl.deleteProgram(program)
    return null
  }
  gl.deleteShader(vert)
  gl.deleteShader(frag)
  return program
}

function initCanvas(canvas: HTMLCanvasElement, fragSource: string, enableBlend: boolean): void {
  const dpr = window.devicePixelRatio || 1
  const logicalWidth = canvas.clientWidth
  const logicalHeight = canvas.clientHeight
  canvas.width = logicalWidth * dpr
  canvas.height = logicalHeight * dpr

  const gl = canvas.getContext('webgl2', {
    premultipliedAlpha: false,
    alpha: true,
  })
  if (!gl)
    return

  const program = linkProgram(gl, VERTEX_SOURCE, fragSource)
  if (!program)
    return

  const vertexData = new Float32Array(PARTICLE_LIST.length * 4)
  for (let i = 0; i < PARTICLE_LIST.length; i++) {
    const particle = PARTICLE_LIST[i]!
    vertexData[i * 4 + 0] = particle.x
    vertexData[i * 4 + 1] = particle.y
    vertexData[i * 4 + 2] = particle.size * dpr
    vertexData[i * 4 + 3] = particle.opacity
  }

  const vao = gl.createVertexArray()!
  gl.bindVertexArray(vao)

  const buffer = gl.createBuffer()!
  gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
  gl.bufferData(gl.ARRAY_BUFFER, vertexData, gl.STATIC_DRAW)

  const stride = 4 * 4
  const posLoc = gl.getAttribLocation(program, 'aPosition')
  gl.enableVertexAttribArray(posLoc)
  gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, stride, 0)

  const sizeLoc = gl.getAttribLocation(program, 'aSize')
  gl.enableVertexAttribArray(sizeLoc)
  gl.vertexAttribPointer(sizeLoc, 1, gl.FLOAT, false, stride, 8)

  const opacityLoc = gl.getAttribLocation(program, 'aOpacity')
  gl.enableVertexAttribArray(opacityLoc)
  gl.vertexAttribPointer(opacityLoc, 1, gl.FLOAT, false, stride, 12)

  gl.bindVertexArray(null)

  resourceList.push({ gl, program, vao, buffer })

  // Draw
  gl.viewport(0, 0, canvas.width, canvas.height)
  gl.clearColor(0.07, 0.07, 0.07, 1)
  gl.clear(gl.COLOR_BUFFER_BIT)

  if (enableBlend) {
    gl.enable(gl.BLEND)
    gl.blendFuncSeparate(
      gl.SRC_ALPHA,
      gl.ONE_MINUS_SRC_ALPHA,
      gl.ONE,
      gl.ONE_MINUS_SRC_ALPHA,
    )
  }

  gl.useProgram(program)
  gl.bindVertexArray(vao)
  gl.drawArrays(gl.POINTS, 0, PARTICLE_LIST.length)
  gl.bindVertexArray(null)
}

onMounted(() => {
  const canvasList = canvasListRef.value
  if (canvasList.length < 3)
    return

  initCanvas(canvasList[0]!, FRAGMENT_SOURCE_LIST[0]!, false)
  initCanvas(canvasList[1]!, FRAGMENT_SOURCE_LIST[1]!, false)
  initCanvas(canvasList[2]!, FRAGMENT_SOURCE_LIST[2]!, true)
})

onBeforeUnmount(() => {
  for (const resource of resourceList) {
    resource.gl.deleteProgram(resource.program)
    resource.gl.deleteBuffer(resource.buffer)
    resource.gl.deleteVertexArray(resource.vao)
  }
  resourceList.length = 0
})
</script>

<template>
  <div class="flex flex-col gap-3 border border-gray-200 rounded-xl p-6">
    <div class="grid grid-cols-3 gap-3">
      <div class="flex flex-col items-center gap-1">
        <canvas
          :ref="(el: any) => { if (el) canvasListRef[0] = el }"
          class="w-full rounded-lg"
          style="height: 180px;"
        />
        <span class="text-xs text-gray-500">
          原始正方形
        </span>
      </div>

      <div class="flex flex-col items-center gap-1">
        <canvas
          :ref="(el: any) => { if (el) canvasListRef[1] = el }"
          class="w-full rounded-lg"
          style="height: 180px;"
        />
        <span class="text-xs text-gray-500">
          discard 裁圓
        </span>
      </div>

      <div class="flex flex-col items-center gap-1">
        <canvas
          :ref="(el: any) => { if (el) canvasListRef[2] = el }"
          class="w-full rounded-lg"
          style="height: 180px;"
        />
        <span class="text-xs text-gray-500">
          柔邊 + Alpha Blending
        </span>
      </div>
    </div>
  </div>
</template>

正方形變圓形

gl.POINTS 畫出來的是正方形。gl_PointCoord 提供正方形內的 UV 座標(0~1)。

uColor 是粒子的顏色,由 JavaScript 將元件的 particleColor prop 轉成 vec3(RGB 0~1)後傳入。

glsl
void main() {
  float dist = length(gl_PointCoord - 0.5) * 2.0;
  if (dist > 1.0) discard;

  float alpha = smoothstep(1.0, 0.4, dist) * vOpacity;
  fragColor = vec4(uColor, alpha);
}

算出到中心的距離,超過 1 的用 discard 丟掉,正方形就變成圓形了。

discard 是什麼?

discard 是 GLSL 的關鍵字,意思是「這個像素不要了,什麼都不畫」。被 discard 的像素不會影響 Framebuffer,就像從來沒存在過一樣。

柔和邊緣

smoothstep(1.0, 0.4, dist) 讓粒子邊緣不是硬切的。距離從 0.4 到 1.0 之間會平滑過渡到透明,看起來像微微發光的小點,而不是銳利的圓形。

Alpha Blending

粒子互相重疊時需要混色:

ts
gl.enable(gl.BLEND)
gl.blendFuncSeparate(
  gl.SRC_ALPHA,
  gl.ONE_MINUS_SRC_ALPHA,
  gl.ONE,
  gl.ONE_MINUS_SRC_ALPHA
)

這是標準的 alpha 混合模式。多個半透明粒子疊在一起,顏色自然地混合,不會互相蓋掉。

模擬與繪製邏輯都完成了!✧⁑。٩(ˊᗜˋ*)و✧⁕。

最後讓我們加入 Curl Noise 和顏色變化,讓粒子更有層次,動作更像蟲群 ◝( •ω• )◟

Step 18:Curl Noise 擾動

目前粒子被推開後直線回來,少了自然界那種混沌又有秩序的美感。(´・ω・`)

Curl Noise 會讓粒子沿著漩渦走,看起來就像被風捲著跑。

回補原始版的渲染層次:多段位移、時間性微飄動、位移混色與完整指標慣性,讓回歸時更有渦流感。
查看範例原始碼
vue
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'

const canvasRef = ref<HTMLCanvasElement | null>(null)

let glContext: WebGL2RenderingContext | null = null
let physicsProgram: WebGLProgram | null = null
let renderProgram: WebGLProgram | null = null
let noiseProgram: WebGLProgram | null = null
let animationFrameId = 0

const stateTextureList: [WebGLTexture | null, WebGLTexture | null] = [null, null]
const framebufferList: [WebGLFramebuffer | null, WebGLFramebuffer | null] = [null, null]
let originTexture: WebGLTexture | null = null
let noiseTexture: WebGLTexture | null = null
let noiseFramebuffer: WebGLFramebuffer | null = null
let quadVertexBuffer: WebGLBuffer | null = null
let quadVertexArray: WebGLVertexArrayObject | null = null
let particleVertexArray: WebGLVertexArrayObject | null = null

let particleCount = 0
let textureSize = 0
let pingPongIndex = 0
let startTime = 0
let logicalWidth = 0
let logicalHeight = 0

let mouseX = 0
let mouseY = 0
let previousMouseX = -9999
let previousMouseY = -9999
let mouseVelocityX = 0
let mouseVelocityY = 0
let isMouseInside = false

const DISPLAY_TEXT = 'SWARM'
const NOISE_SIZE = 256

// ----- Noise generation shader -----
const noiseVertexSource = `#version 300 es
in vec2 aPosition;
void main() {
  gl_Position = vec4(aPosition, 0.0, 1.0);
}
`

const noiseFragmentSource = `#version 300 es
precision highp float;
out vec4 fragColor;

float hash(vec2 p) {
  vec3 p3 = fract(vec3(p.xyx) * 0.13);
  p3 += dot(p3, p3.yzx + 33.33);
  return fract((p3.x + p3.y) * p3.z);
}

float noise(vec2 x) {
  vec2 i = floor(x); vec2 f = fract(x);
  float a = hash(i); float b = hash(i + vec2(1.0, 0.0));
  float c = hash(i + vec2(0.0, 1.0)); float d = hash(i + vec2(1.0, 1.0));
  vec2 u = f * f * (3.0 - 2.0 * f);
  return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y;
}

float fbm(vec2 p) {
  float v = 0.0; float a = 0.5;
  for (int i = 0; i < 4; i++) { v += a * noise(p); p *= 2.0; a *= 0.5; }
  return v;
}

void main() {
  vec2 uv = gl_FragCoord.xy / 256.0;
  vec2 p = uv * 6.0;
  const float e = 0.1;
  float a = (fbm(p + vec2(0.0, e)) - fbm(p - vec2(0.0, e))) / (2.0 * e);
  float b = (fbm(p + vec2(e, 0.0)) - fbm(p - vec2(e, 0.0))) / (2.0 * e);
  fragColor = vec4(a, -b, 0.0, 1.0);
}
`

// ----- Physics pass shaders -----
const physicsVertexSource = `#version 300 es
in vec2 aPosition;
void main() {
  gl_Position = vec4(aPosition, 0.0, 1.0);
}
`

const physicsFragmentSource = `#version 300 es
precision highp float;

uniform sampler2D uState;
uniform sampler2D uOrigin;
uniform sampler2D uNoise;
uniform vec2 uMouse;
uniform vec2 uMouseVelocity;
uniform int uMouseInside;
uniform float uScatterRadius;
uniform float uScatterForce;
uniform float uReturnSpeed;
uniform float uFriction;
uniform float uTime;

out vec4 fragColor;

float hash(vec2 p) {
  vec3 p3 = fract(vec3(p.xyx) * 0.13);
  p3 += dot(p3, p3.yzx + 33.33);
  return fract((p3.x + p3.y) * p3.z);
}

void main() {
  ivec2 coord = ivec2(gl_FragCoord.xy);
  vec4 state = texelFetch(uState, coord, 0);
  vec4 origin = texelFetch(uOrigin, coord, 0);

  vec2 pos = state.xy;
  vec2 vel = state.zw;
  vec2 originPos = origin.xy;

  if (originPos.x < -9999.0) {
    fragColor = state;
    return;
  }

  float pRand = hash(vec2(float(coord.x), float(coord.y)));
  float forceScale = 0.5 + pRand * 1.0;

  if (uMouseInside == 1) {
    vec2 toParticle = pos - uMouse;
    float dist = length(toParticle);

    if (dist < uScatterRadius) {
      float influence = smoothstep(uScatterRadius, 0.0, dist);
      float mouseSpeed = length(uMouseVelocity);

      if (mouseSpeed > 0.5) {
        vec2 windDir = uMouseVelocity / mouseSpeed;
        vel += windDir * mouseSpeed * influence * uScatterForce * 0.025 * forceScale;
      }

      vec2 noiseUV = pos * 0.005 + uTime * 0.15 + pRand * 10.0;
      vec2 curl = texture(uNoise, noiseUV).xy;
      float turbStrength = (1.5 + mouseSpeed * 0.4) * influence * uScatterForce * 0.12 * forceScale;
      vel += curl * turbStrength;

      if (dist > 0.01) {
        vel += (toParticle / dist) * influence * influence * uScatterForce * 0.5 * forceScale;
      }
    }
  }

  vec2 toOrigin = originPos - pos;
  float distFromOrigin = length(toOrigin);

  if (distFromOrigin > 2.0) {
    float displacement = smoothstep(2.0, 40.0, distFromOrigin);
    vec2 driftUV = pos * 0.002 + uTime * 0.08 + pRand * 0.3;
    vec2 drift = texture(uNoise, driftUV).xy;
    vel += drift * displacement * 1.5 * forceScale;
  }

  float particleFriction = uFriction - pRand * 0.04;
  vel *= particleFriction;

  pos += vel;

  float returnRate = uReturnSpeed * (0.6 + pRand * 0.8);
  pos = mix(pos, originPos, returnRate);

  fragColor = vec4(pos, vel);
}
`

// ----- Render pass shaders -----
const renderVertexSource = `#version 300 es
precision highp float;

uniform sampler2D uState;
uniform sampler2D uOrigin;
uniform int uTextureSize;
uniform vec2 uResolution;
uniform float uPointScale;
uniform float uTime;

out float vOpacity;
out float vDisplacement;
out float vSpeed;

void main() {
  int index = gl_VertexID;
  int texelX = index % uTextureSize;
  int texelY = index / uTextureSize;

  vec4 state = texelFetch(uState, ivec2(texelX, texelY), 0);
  vec4 origin = texelFetch(uOrigin, ivec2(texelX, texelY), 0);

  vec2 pos = state.xy;
  vec2 vel = state.zw;
  vec2 originPos = origin.xy;
  float size = origin.z;
  float opacity = origin.w;

  if (originPos.x < -9999.0) {
    gl_Position = vec4(-2.0, -2.0, 0.0, 1.0);
    gl_PointSize = 0.0;
    vOpacity = 0.0;
    vDisplacement = 0.0;
    vSpeed = 0.0;
    return;
  }

  float distFromOrigin = length(pos - originPos);
  float speed = length(vel);

  float displacementNear = smoothstep(0.0, 8.0, distFromOrigin);
  float displacementMid = smoothstep(8.0, 30.0, distFromOrigin);
  float displacementFar = smoothstep(30.0, 80.0, distFromOrigin);
  float displacement = displacementNear * 0.3 + displacementMid * 0.4 + displacementFar * 0.3;

  float speedFactor = smoothstep(0.0, 8.0, speed);

  float phase = float(index) * 1.618033988;
  pos.x += sin(phase + uTime * 0.001) * 0.2;
  pos.y += cos(phase * 1.3 + uTime * 0.0008) * 0.2;

  vec2 clipPosition = (pos / uResolution) * 2.0 - 1.0;
  clipPosition.y *= -1.0;

  gl_Position = vec4(clipPosition, 0.0, 1.0);
  gl_PointSize = size * 2.0 * uPointScale;

  vOpacity = opacity;
  vDisplacement = displacement;
  vSpeed = speedFactor;
}
`

const renderFragmentSource = `#version 300 es
precision highp float;

in float vOpacity;
in float vDisplacement;
in float vSpeed;

out vec4 fragColor;

void main() {
  float dist = length(gl_PointCoord - 0.5) * 2.0;
  if (dist > 1.0) discard;

  float alpha = smoothstep(1.0, 0.4, dist) * vOpacity;

  fragColor = vec4(0.53, 0.53, 0.53, alpha);
}
`

// ----- Helper functions -----
function compileShader(
  gl: WebGL2RenderingContext,
  type: number,
  source: string,
): WebGLShader | null {
  const shader = gl.createShader(type)
  if (!shader)
    return null
  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 linkProgram(
  gl: WebGL2RenderingContext,
  vertSource: string,
  fragSource: string,
): WebGLProgram | null {
  const vert = compileShader(gl, gl.VERTEX_SHADER, vertSource)
  const frag = compileShader(gl, gl.FRAGMENT_SHADER, fragSource)
  if (!vert || !frag)
    return null
  const program = gl.createProgram()
  if (!program)
    return null
  gl.attachShader(program, vert)
  gl.attachShader(program, frag)
  gl.linkProgram(program)
  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    console.error(gl.getProgramInfoLog(program))
    gl.deleteProgram(program)
    return null
  }
  gl.deleteShader(vert)
  gl.deleteShader(frag)
  return program
}

function createFloatTexture(
  gl: WebGL2RenderingContext,
  size: number,
  data: Float32Array,
): WebGLTexture | null {
  const texture = gl.createTexture()
  if (!texture)
    return null
  gl.bindTexture(gl.TEXTURE_2D, texture)
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, size, size, 0, gl.RGBA, gl.FLOAT, data)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
  return texture
}

// ----- Particle sampling from text -----
interface SampledParticle {
  x: number;
  y: number;
  size: number;
  opacity: number;
}

function sampleParticleListFromText(
  text: string,
  canvasWidth: number,
  canvasHeight: number,
): SampledParticle[] {
  const offscreen = document.createElement('canvas')
  offscreen.width = canvasWidth
  offscreen.height = canvasHeight
  const context = offscreen.getContext('2d')
  if (!context)
    return []

  const fontSize = Math.min(canvasWidth / (text.length * 0.7), canvasHeight * 0.6)
  context.fillStyle = 'white'
  context.font = `bold ${fontSize}px Arial, sans-serif`
  context.textAlign = 'center'
  context.textBaseline = 'middle'
  context.fillText(text, canvasWidth / 2, canvasHeight / 2)

  const imageData = context.getImageData(0, 0, canvasWidth, canvasHeight)
  const pixelDataList = imageData.data
  const gap = 1
  const resultList: SampledParticle[] = []

  for (let y = 0; y < canvasHeight; y += gap) {
    for (let x = 0; x < canvasWidth; x += gap) {
      const pixelX = Math.floor(x)
      const pixelY = Math.floor(y)
      const index = (pixelY * canvasWidth + pixelX) * 4
      const alpha = (pixelDataList[index + 3] ?? 0) / 255
      if (alpha > 0.15) {
        resultList.push({
          x,
          y,
          size: 1.5 * (0.4 + alpha * 0.6),
          opacity: alpha * (0.8 + Math.random() * 0.2),
        })
      }
    }
  }

  return resultList
}

// ----- Setup textures and framebuffers -----
function setupParticleTextures(
  gl: WebGL2RenderingContext,
  sampledParticleList: SampledParticle[],
): void {
  particleCount = sampledParticleList.length
  textureSize = Math.ceil(Math.sqrt(particleCount))
  const totalPixelCount = textureSize * textureSize

  const stateData = new Float32Array(totalPixelCount * 4)
  const originData = new Float32Array(totalPixelCount * 4)

  for (let i = 0; i < totalPixelCount; i++) {
    if (i < particleCount) {
      const particle = sampledParticleList[i]
      stateData[i * 4 + 0] = particle?.x ?? 0
      stateData[i * 4 + 1] = particle?.y ?? 0
      stateData[i * 4 + 2] = 0
      stateData[i * 4 + 3] = 0
      originData[i * 4 + 0] = particle?.x ?? 0
      originData[i * 4 + 1] = particle?.y ?? 0
      originData[i * 4 + 2] = particle?.size ?? 0
      originData[i * 4 + 3] = particle?.opacity ?? 0
    }
    else {
      stateData[i * 4 + 0] = -99999
      stateData[i * 4 + 1] = -99999
      stateData[i * 4 + 2] = 0
      stateData[i * 4 + 3] = 0
      originData[i * 4 + 0] = -99999
      originData[i * 4 + 1] = -99999
      originData[i * 4 + 2] = 0
      originData[i * 4 + 3] = 0
    }
  }

  stateTextureList[0] = createFloatTexture(gl, textureSize, stateData)
  stateTextureList[1] = createFloatTexture(gl, textureSize, new Float32Array(stateData))
  originTexture = createFloatTexture(gl, textureSize, originData)

  for (let i = 0; i < 2; i++) {
    framebufferList[i] = gl.createFramebuffer()
    gl.bindFramebuffer(gl.FRAMEBUFFER, framebufferList[i])
    gl.framebufferTexture2D(
      gl.FRAMEBUFFER,
      gl.COLOR_ATTACHMENT0,
      gl.TEXTURE_2D,
      stateTextureList[i],
      0,
    )
  }
  gl.bindFramebuffer(gl.FRAMEBUFFER, null)
}

function generateNoiseTexture(gl: WebGL2RenderingContext): void {
  noiseProgram = linkProgram(gl, noiseVertexSource, noiseFragmentSource)
  if (!noiseProgram)
    return

  noiseTexture = gl.createTexture()
  gl.bindTexture(gl.TEXTURE_2D, noiseTexture)
  gl.texImage2D(
    gl.TEXTURE_2D,
    0,
    gl.RGBA32F,
    NOISE_SIZE,
    NOISE_SIZE,
    0,
    gl.RGBA,
    gl.FLOAT,
    null,
  )

  const hasFloatLinear = !!gl.getExtension('OES_texture_float_linear')
  const noiseFilter = hasFloatLinear ? gl.LINEAR : gl.NEAREST
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, noiseFilter)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, noiseFilter)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT)

  noiseFramebuffer = gl.createFramebuffer()
  gl.bindFramebuffer(gl.FRAMEBUFFER, noiseFramebuffer)
  gl.framebufferTexture2D(
    gl.FRAMEBUFFER,
    gl.COLOR_ATTACHMENT0,
    gl.TEXTURE_2D,
    noiseTexture,
    0,
  )

  gl.viewport(0, 0, NOISE_SIZE, NOISE_SIZE)
  gl.useProgram(noiseProgram)
  gl.bindVertexArray(quadVertexArray)
  gl.drawArrays(gl.TRIANGLES, 0, 6)
  gl.bindVertexArray(null)

  gl.bindFramebuffer(gl.FRAMEBUFFER, null)
  gl.deleteProgram(noiseProgram)
  noiseProgram = null
}

function setupBuffers(gl: WebGL2RenderingContext): void {
  const positionList = new Float32Array([
    -1,
    -1,
    1,
    -1,
    -1,
    1,
    -1,
    1,
    1,
    -1,
    1,
    1,
  ])

  quadVertexArray = gl.createVertexArray()
  gl.bindVertexArray(quadVertexArray)

  quadVertexBuffer = gl.createBuffer()
  gl.bindBuffer(gl.ARRAY_BUFFER, quadVertexBuffer)
  gl.bufferData(gl.ARRAY_BUFFER, positionList, gl.STATIC_DRAW)

  const positionLocation = 0
  gl.enableVertexAttribArray(positionLocation)
  gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0)
  gl.bindVertexArray(null)

  particleVertexArray = gl.createVertexArray()
}

// ----- Pointer events -----
function handlePointerDown(event: PointerEvent): void {
  const target = event.currentTarget
  if (target instanceof Element)
    target.setPointerCapture(event.pointerId)
  handlePointerMove(event)
}

function handlePointerMove(event: PointerEvent): void {
  const newX = event.offsetX
  const newY = event.offsetY

  if (previousMouseX > -9000 && previousMouseY > -9000) {
    mouseVelocityX = newX - previousMouseX
    mouseVelocityY = newY - previousMouseY
  }

  previousMouseX = newX
  previousMouseY = newY
  mouseX = newX
  mouseY = newY
  isMouseInside = true
}

function handlePointerLeave(): void {
  isMouseInside = false
  previousMouseX = -9999
  previousMouseY = -9999
}

// ----- Init and render -----
function initWebGL(): void {
  const canvas = canvasRef.value
  if (!canvas)
    return

  const dpr = window.devicePixelRatio || 1
  logicalWidth = canvas.clientWidth
  logicalHeight = canvas.clientHeight
  canvas.width = logicalWidth * dpr
  canvas.height = logicalHeight * dpr

  glContext = canvas.getContext('webgl2', {
    premultipliedAlpha: false,
    alpha: true,
  })
  if (!glContext) {
    console.error('WebGL2 not supported')
    return
  }

  const extColorFloat = glContext.getExtension('EXT_color_buffer_float')
  if (!extColorFloat) {
    console.error('EXT_color_buffer_float not supported')
    return
  }

  physicsProgram = linkProgram(glContext, physicsVertexSource, physicsFragmentSource)
  renderProgram = linkProgram(glContext, renderVertexSource, renderFragmentSource)
  if (!physicsProgram || !renderProgram)
    return

  setupBuffers(glContext)
  generateNoiseTexture(glContext)

  const sampledParticleList = sampleParticleListFromText(
    DISPLAY_TEXT,
    logicalWidth,
    logicalHeight,
  )
  setupParticleTextures(glContext, sampledParticleList)

  glContext.enable(glContext.BLEND)
  glContext.blendFuncSeparate(
    glContext.SRC_ALPHA,
    glContext.ONE_MINUS_SRC_ALPHA,
    glContext.ONE,
    glContext.ONE_MINUS_SRC_ALPHA,
  )

  previousMouseX = -9999
  previousMouseY = -9999
  mouseVelocityX = 0
  mouseVelocityY = 0
  startTime = performance.now()

  canvas.addEventListener('pointerdown', handlePointerDown)
  canvas.addEventListener('pointermove', handlePointerMove)
  canvas.addEventListener('pointerup', handlePointerLeave)
  canvas.addEventListener('pointerleave', handlePointerLeave)
}

function render(): void {
  if (!glContext || !physicsProgram || !renderProgram)
    return

  const canvas = canvasRef.value
  if (!canvas)
    return

  const gl = glContext
  const currentTime = (performance.now() - startTime) * 0.001

  mouseVelocityX *= 0.92
  mouseVelocityY *= 0.92

  const readIndex = pingPongIndex
  const writeIndex = 1 - pingPongIndex

  gl.disable(gl.BLEND)
  gl.bindFramebuffer(gl.FRAMEBUFFER, framebufferList[writeIndex])
  gl.viewport(0, 0, textureSize, textureSize)

  gl.useProgram(physicsProgram)

  gl.activeTexture(gl.TEXTURE0)
  gl.bindTexture(gl.TEXTURE_2D, stateTextureList[readIndex])
  gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uState'), 0)

  gl.activeTexture(gl.TEXTURE1)
  gl.bindTexture(gl.TEXTURE_2D, originTexture)
  gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uOrigin'), 1)

  gl.activeTexture(gl.TEXTURE2)
  gl.bindTexture(gl.TEXTURE_2D, noiseTexture)
  gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uNoise'), 2)

  gl.uniform2f(gl.getUniformLocation(physicsProgram, 'uMouse'), mouseX, mouseY)
  gl.uniform2f(gl.getUniformLocation(physicsProgram, 'uMouseVelocity'), mouseVelocityX, mouseVelocityY)
  gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uMouseInside'), isMouseInside ? 1 : 0)
  gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uScatterRadius'), 40.0)
  gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uScatterForce'), 40.0)
  gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uReturnSpeed'), 0.1)
  gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uFriction'), 0.92)
  gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uTime'), currentTime)

  gl.bindVertexArray(quadVertexArray)
  gl.drawArrays(gl.TRIANGLES, 0, 6)
  gl.bindVertexArray(null)

  pingPongIndex = writeIndex

  gl.bindFramebuffer(gl.FRAMEBUFFER, null)
  gl.viewport(0, 0, canvas.width, canvas.height)
  gl.clearColor(0, 0, 0, 0)
  gl.clear(gl.COLOR_BUFFER_BIT)

  gl.enable(gl.BLEND)
  gl.blendFuncSeparate(
    gl.SRC_ALPHA,
    gl.ONE_MINUS_SRC_ALPHA,
    gl.ONE,
    gl.ONE_MINUS_SRC_ALPHA,
  )

  gl.useProgram(renderProgram)

  gl.activeTexture(gl.TEXTURE0)
  gl.bindTexture(gl.TEXTURE_2D, stateTextureList[writeIndex])
  gl.uniform1i(gl.getUniformLocation(renderProgram, 'uState'), 0)

  gl.activeTexture(gl.TEXTURE1)
  gl.bindTexture(gl.TEXTURE_2D, originTexture)
  gl.uniform1i(gl.getUniformLocation(renderProgram, 'uOrigin'), 1)

  const dpr = window.devicePixelRatio || 1
  gl.uniform1i(gl.getUniformLocation(renderProgram, 'uTextureSize'), textureSize)
  gl.uniform2f(
    gl.getUniformLocation(renderProgram, 'uResolution'),
    logicalWidth,
    logicalHeight,
  )
  gl.uniform1f(gl.getUniformLocation(renderProgram, 'uPointScale'), dpr)
  gl.uniform1f(gl.getUniformLocation(renderProgram, 'uTime'), performance.now())

  gl.bindVertexArray(particleVertexArray)
  gl.drawArrays(gl.POINTS, 0, particleCount)
  gl.bindVertexArray(null)

  animationFrameId = requestAnimationFrame(render)
}

onMounted(() => {
  initWebGL()
  animationFrameId = requestAnimationFrame(render)
})

onBeforeUnmount(() => {
  cancelAnimationFrame(animationFrameId)

  const canvas = canvasRef.value
  if (canvas) {
    canvas.removeEventListener('pointerdown', handlePointerDown)
    canvas.removeEventListener('pointermove', handlePointerMove)
    canvas.removeEventListener('pointerup', handlePointerLeave)
    canvas.removeEventListener('pointerleave', handlePointerLeave)
  }

  if (glContext) {
    if (physicsProgram)
      glContext.deleteProgram(physicsProgram)
    if (renderProgram)
      glContext.deleteProgram(renderProgram)
    for (const texture of stateTextureList) {
      if (texture)
        glContext.deleteTexture(texture)
    }
    if (originTexture)
      glContext.deleteTexture(originTexture)
    if (noiseTexture)
      glContext.deleteTexture(noiseTexture)
    for (const framebuffer of framebufferList) {
      if (framebuffer)
        glContext.deleteFramebuffer(framebuffer)
    }
    if (noiseFramebuffer)
      glContext.deleteFramebuffer(noiseFramebuffer)
    if (quadVertexBuffer)
      glContext.deleteBuffer(quadVertexBuffer)
    if (quadVertexArray)
      glContext.deleteVertexArray(quadVertexArray)
    if (particleVertexArray)
      glContext.deleteVertexArray(particleVertexArray)
  }
})
</script>

<template>
  <div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6">
    <span class="text-sm text-gray-600">
      回補原始版的渲染層次:多段位移、時間性微飄動、位移混色與完整指標慣性,讓回歸時更有渦流感。
    </span>

    <canvas
      ref="canvasRef"
      class="w-full rounded-lg"
      style="height: 320px;"
    />
  </div>
</template>

普通亂數 vs Curl Noise

一般的 Math.random() 會讓粒子亂跳,像喝醉一樣毫無方向感。

Curl Noise 不一樣,它產生的是旋轉場。鄰近的點會有相似的方向,粒子沿著它走會形成漩渦般的軌跡。

Math.random()
Curl Noise
查看範例原始碼
vue
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'

const containerRef = ref<HTMLElement | null>(null)
const randomCanvasRef = ref<HTMLCanvasElement | null>(null)
const curlCanvasRef = ref<HTMLCanvasElement | null>(null)

const PARTICLE_COUNT = 600
const CANVAS_SIZE = 280
const TRAIL_ALPHA = 0.06

let animationFrameId = 0

interface Particle {
  x: number;
  y: number;
  velocityX: number;
  velocityY: number;
}

// ---- Curl Noise utilities ----
function hash(x: number, y: number): number {
  const p3x = ((x * 0.13) % 1 + 1) % 1
  const p3y = ((y * 0.13) % 1 + 1) % 1
  const p3z = ((x * 0.13) % 1 + 1) % 1
  const dot = p3x * (p3y + 33.33) + p3y * (p3z + 33.33) + p3z * (p3x + 33.33)
  return ((dot * dot) % 1 + 1) % 1
}

function smoothNoise(x: number, y: number): number {
  const ix = Math.floor(x)
  const iy = Math.floor(y)
  const fx = x - ix
  const fy = y - iy

  const a = hash(ix, iy)
  const b = hash(ix + 1, iy)
  const c = hash(ix, iy + 1)
  const d = hash(ix + 1, iy + 1)

  const ux = fx * fx * (3 - 2 * fx)
  const uy = fy * fy * (3 - 2 * fy)

  return a + (b - a) * ux + (c - a) * uy * (1 - ux) + (d - b) * ux * uy
}

function fbm(x: number, y: number): number {
  let value = 0
  let amplitude = 0.5
  let px = x
  let py = y

  for (let i = 0; i < 4; i++) {
    value += amplitude * smoothNoise(px, py)
    px *= 2
    py *= 2
    amplitude *= 0.5
  }

  return value
}

function curlNoise(x: number, y: number): { x: number; y: number } {
  const epsilon = 0.1
  const a = (fbm(x, y + epsilon) - fbm(x, y - epsilon)) / (2 * epsilon)
  const b = (fbm(x + epsilon, y) - fbm(x - epsilon, y)) / (2 * epsilon)
  return { x: a, y: -b }
}

// ---- Particle creation ----
function createParticleList(): Particle[] {
  const list: Particle[] = []
  for (let i = 0; i < PARTICLE_COUNT; i++) {
    list.push({
      x: CANVAS_SIZE / 2 + (Math.random() - 0.5) * 40,
      y: CANVAS_SIZE / 2 + (Math.random() - 0.5) * 40,
      velocityX: 0,
      velocityY: 0,
    })
  }
  return list
}

let randomParticleList: Particle[] = []
let curlParticleList: Particle[] = []
let time = 0

function updateRandomParticleList(): void {
  for (const particle of randomParticleList) {
    const angle = Math.random() * Math.PI * 2
    const strength = 1.2
    particle.velocityX += Math.cos(angle) * strength
    particle.velocityY += Math.sin(angle) * strength

    particle.velocityX *= 0.92
    particle.velocityY *= 0.92

    particle.x += particle.velocityX
    particle.y += particle.velocityY

    // 邊界回彈
    if (particle.x < 0 || particle.x > CANVAS_SIZE) {
      particle.velocityX *= -0.5
      particle.x = Math.max(0, Math.min(CANVAS_SIZE, particle.x))
    }
    if (particle.y < 0 || particle.y > CANVAS_SIZE) {
      particle.velocityY *= -0.5
      particle.y = Math.max(0, Math.min(CANVAS_SIZE, particle.y))
    }
  }
}

function updateCurlParticleList(): void {
  time += 0.01

  for (const particle of curlParticleList) {
    const scale = 0.012
    const curl = curlNoise(
      particle.x * scale + time * 0.8,
      particle.y * scale + time * 0.5,
    )
    const strength = 1.2
    particle.velocityX += curl.x * strength
    particle.velocityY += curl.y * strength

    particle.velocityX *= 0.92
    particle.velocityY *= 0.92

    particle.x += particle.velocityX
    particle.y += particle.velocityY

    // 邊界回彈
    if (particle.x < 0 || particle.x > CANVAS_SIZE) {
      particle.velocityX *= -0.5
      particle.x = Math.max(0, Math.min(CANVAS_SIZE, particle.x))
    }
    if (particle.y < 0 || particle.y > CANVAS_SIZE) {
      particle.velocityY *= -0.5
      particle.y = Math.max(0, Math.min(CANVAS_SIZE, particle.y))
    }
  }
}

function drawParticleList(
  context: CanvasRenderingContext2D,
  particleList: Particle[],
  color: string,
  dpr: number,
): void {
  // 半透明背景覆蓋產生拖尾
  context.fillStyle = `rgba(24, 24, 27, ${TRAIL_ALPHA})`
  context.fillRect(0, 0, CANVAS_SIZE * dpr, CANVAS_SIZE * dpr)

  context.fillStyle = color
  for (const particle of particleList) {
    const speed = Math.sqrt(
      particle.velocityX * particle.velocityX
      + particle.velocityY * particle.velocityY,
    )
    const radius = Math.min(1 + speed * 0.15, 2.5) * dpr

    context.beginPath()
    context.arc(particle.x * dpr, particle.y * dpr, radius, 0, Math.PI * 2)
    context.fill()
  }
}

function animate(): void {
  const randomCanvas = randomCanvasRef.value
  const curlCanvas = curlCanvasRef.value
  if (!randomCanvas || !curlCanvas)
    return

  const dpr = window.devicePixelRatio || 1
  const randomContext = randomCanvas.getContext('2d')
  const curlContext = curlCanvas.getContext('2d')
  if (!randomContext || !curlContext)
    return

  updateRandomParticleList()
  updateCurlParticleList()

  drawParticleList(randomContext, randomParticleList, 'rgba(160, 160, 170, 0.8)', dpr)
  drawParticleList(curlContext, curlParticleList, 'rgba(130, 180, 255, 0.8)', dpr)

  animationFrameId = requestAnimationFrame(animate)
}

function initCanvas(): void {
  const randomCanvas = randomCanvasRef.value
  const curlCanvas = curlCanvasRef.value
  if (!randomCanvas || !curlCanvas)
    return

  const dpr = window.devicePixelRatio || 1
  randomCanvas.width = CANVAS_SIZE * dpr
  randomCanvas.height = CANVAS_SIZE * dpr
  curlCanvas.width = CANVAS_SIZE * dpr
  curlCanvas.height = CANVAS_SIZE * dpr

  // 初始填充背景
  const randomContext = randomCanvas.getContext('2d')
  const curlContext = curlCanvas.getContext('2d')
  if (randomContext) {
    randomContext.fillStyle = 'rgb(24, 24, 27)'
    randomContext.fillRect(0, 0, CANVAS_SIZE * dpr, CANVAS_SIZE * dpr)
  }
  if (curlContext) {
    curlContext.fillStyle = 'rgb(24, 24, 27)'
    curlContext.fillRect(0, 0, CANVAS_SIZE * dpr, CANVAS_SIZE * dpr)
  }

  randomParticleList = createParticleList()
  curlParticleList = createParticleList()
  time = 0
}

onMounted(() => {
  initCanvas()
  animationFrameId = requestAnimationFrame(animate)
})

onBeforeUnmount(() => {
  cancelAnimationFrame(animationFrameId)
})
</script>

<template>
  <div
    ref="containerRef"
    class="flex flex-col gap-4 border border-gray-200 rounded-xl p-6"
  >
    <div class="grid grid-cols-2 gap-4">
      <div class="flex flex-col items-center gap-2">
        <span class="text-sm text-gray-500 font-bold">
          Math.random()
        </span>
        <canvas
          ref="randomCanvasRef"
          class="w-full rounded-lg"
          :style="{ aspectRatio: '1 / 1' }"
        />
      </div>

      <div class="flex flex-col items-center gap-2">
        <span class="text-sm text-blue-400 font-bold">
          Curl Noise
        </span>
        <canvas
          ref="curlCanvasRef"
          class="w-full rounded-lg"
          :style="{ aspectRatio: '1 / 1' }"
        />
      </div>
    </div>
  </div>
</template>

原理

對一個 2D 噪聲場取偏導數,然後旋轉 90 度。e 是微小的取樣間距(例如 0.01),用來做數值微分:

glsl
float e = 0.01;
float a = (fbm(p + vec2(0.0, e))
         - fbm(p - vec2(0.0, e))) / (2.0 * e);
float b = (fbm(p + vec2(e, 0.0))
         - fbm(p - vec2(e, 0.0))) / (2.0 * e);
curl = vec2(a, -b);

FBM(Fractal Brownian Motion)

fbm 是分形布朗運動,只要把多個頻率的噪聲疊在一起。裡面的 noise() 是自己實作的 2D 雜訊函式(例如 Simplex Noise 或 Value Noise),輸入一個座標,回傳 -1~1 的浮點數:

glsl
float fbm(vec2 p) {
  float value = 0.0;
  float amplitude = 0.5;
  for (int i = 0; i < 4; i++) {
    value += amplitude * noise(p);
    p *= 2.0;       // 頻率加倍(看得更細)
    amplitude *= 0.5; // 振幅減半(影響力變小)
  }
  return value;
}

低頻的大漩渦 + 高頻的小擾動,看起來就很像自然界的紊流。

FBM 層數:

1 層:只有大塊色斑,像馬賽克

查看範例原始碼
vue
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'

const canvasRef = ref<HTMLCanvasElement | null>(null)
const octaveCount = ref(1)

const CANVAS_WIDTH = 600
const CANVAS_HEIGHT = 200
const NOISE_SCALE = 0.02

// ---- 噪聲工具 ----
function hash(x: number, y: number): number {
  const p3x = ((x * 0.1031) % 1 + 1) % 1
  const p3y = ((y * 0.1030) % 1 + 1) % 1
  const p3z = ((x * 0.0973) % 1 + 1) % 1
  const dotValue = p3x * (p3y + 33.33) + p3y * (p3z + 33.33) + p3z * (p3x + 33.33)
  return ((dotValue * (dotValue + 23.45)) % 1 + 1) % 1
}

function smoothNoise(x: number, y: number): number {
  const ix = Math.floor(x)
  const iy = Math.floor(y)
  const fx = x - ix
  const fy = y - iy

  const a = hash(ix, iy)
  const b = hash(ix + 1, iy)
  const c = hash(ix, iy + 1)
  const d = hash(ix + 1, iy + 1)

  // smoothstep 插值
  const ux = fx * fx * (3 - 2 * fx)
  const uy = fy * fy * (3 - 2 * fy)

  return a + (b - a) * ux + (c - a) * uy * (1 - ux) + (d - b) * ux * uy
}

/** 指定層數的 FBM */
function fbm(x: number, y: number, octaveMax: number): number {
  let value = 0
  let amplitude = 0.5
  let px = x
  let py = y

  for (let i = 0; i < octaveMax; i++) {
    value += amplitude * smoothNoise(px, py)
    px *= 2
    py *= 2
    amplitude *= 0.5
  }

  return value
}

function drawNoise(): void {
  const canvas = canvasRef.value
  if (!canvas)
    return

  const dpr = window.devicePixelRatio || 1
  const logicalWidth = canvas.clientWidth
  const logicalHeight = canvas.clientHeight
  canvas.width = logicalWidth * dpr
  canvas.height = logicalHeight * dpr

  const context = canvas.getContext('2d')
  if (!context)
    return

  const imageData = context.createImageData(canvas.width, canvas.height)
  const pixelDataList = imageData.data

  const currentOctaveCount = octaveCount.value

  for (let pixelY = 0; pixelY < canvas.height; pixelY++) {
    for (let pixelX = 0; pixelX < canvas.width; pixelX++) {
      const noiseX = (pixelX / dpr) * NOISE_SCALE
      const noiseY = (pixelY / dpr) * NOISE_SCALE

      const noiseValue = fbm(noiseX, noiseY, currentOctaveCount)
      const brightness = Math.floor(noiseValue * 255)

      const index = (pixelY * canvas.width + pixelX) * 4
      pixelDataList[index] = brightness
      pixelDataList[index + 1] = brightness
      pixelDataList[index + 2] = brightness
      pixelDataList[index + 3] = 255
    }
  }

  context.putImageData(imageData, 0, 0)
}

const octaveLabelList = [
  '1 層:只有大塊色斑,像馬賽克',
  '2 層:加入中頻細節',
  '3 層:開始有自然紋理感',
  '4 層:接近真實紊流',
]

let resizeObserver: ResizeObserver | null = null

onMounted(() => {
  drawNoise()

  resizeObserver = new ResizeObserver(() => {
    drawNoise()
  })
  if (canvasRef.value) {
    resizeObserver.observe(canvasRef.value)
  }
})

onBeforeUnmount(() => {
  resizeObserver?.disconnect()
})

watch(octaveCount, () => {
  drawNoise()
})
</script>

<template>
  <div class="flex flex-col gap-3 border border-gray-200 rounded-xl p-6">
    <div class="flex flex-wrap items-center gap-2">
      <span class="text-sm text-gray-500 shrink-0">
        FBM 層數:
      </span>
      <div class="flex gap-1.5">
        <button
          v-for="n in 4"
          :key="n"
          class="min-w-9 rounded-lg px-3 py-1.5 text-sm transition-colors"
          :class="octaveCount === n
            ? 'bg-blue-500 text-white'
            : 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'"
          @click="octaveCount = n"
        >
          {{ n }}
        </button>
      </div>
    </div>

    <canvas
      ref="canvasRef"
      class="w-full rounded-lg"
      :style="{ height: '200px' }"
    />

    <p class="m-0 text-sm text-gray-400">
      {{ octaveLabelList[octaveCount - 1] }}
    </p>
  </div>
</template>

預計算噪聲紋理

每幀在 Shader 裡即時算 FBM 太耗時,(4 層巢狀噪聲)。

最簡單的方法是初始化時先將整張噪聲場算好,烘焙成 256×256 的紋理:

ts
gl.viewport(0, 0, 256, 256)
gl.useProgram(noiseProgram)
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)

之後物理 Shader 只要 texture(uNoise, uv) 查表就好,就是經典的用空間換時間策略。

紋理的 WRAP 設成 REPEAT,讓 UV 超出 0~1 時自動循環,等於無限大的噪聲場。

在物理 Shader 中加入擾動

這段程式碼用到三個新變數:

  • uNoise:上一節烘焙好的噪聲紋理,用 texture() 查表讀取(和 texelFetch 不同,texture 用 0~1 的浮點 UV 座標,且會自動插值)
  • uTime:JavaScript 每幀傳入的累計時間(秒),讓噪聲場隨時間漂移
  • pRand:每個粒子的隨機種子,在初始化時存進原始位置紋理的空閒通道,Shader 裡用 origin.zorigin.w 讀出來
glsl
vec2 noiseUV = pos * 0.005 + uTime * 0.15 + pRand * 10.0;
vec2 curl = texture(uNoise, noiseUV).xy;
float turbStrength = influence * uScatterForce * 0.12;
vel += curl * turbStrength;
  • pos * 0.005:用粒子位置當 UV,鄰近粒子查到相似的值(群集感)
  • uTime * 0.15:隨時間漂移,讓漩渦不斷流動
  • pRand * 10.0:每個粒子加一點偏移,避免完全同步

飄盪回歸也用噪聲

被吹散的粒子在回家路上也會查噪聲紋理,產生飄盪效果:

glsl
if (distFromOrigin > 2.0) {
  float displacement = smoothstep(2.0, 40.0, distFromOrigin);
  vec2 driftUV = pos * 0.002 + uTime * 0.08 + pRand * 0.3;
  vec2 drift = texture(uNoise, driftUV).xy;
  vel += drift * displacement * 1.5;
}

displacement 的作用跟 Step 9 一樣:離家近不飄,離家遠才飄。(ゝ∀・)b

Step 19:多層發光效果

粒子飄起來了!但看起來都是一樣亮度的白點,讓我們加入一點顏色變化提高視覺層次感。✧⁑。٩(ˊᗜˋ*)و✧⁕。


路人:「蟲蟲會變色?(́⊙◞౪◟⊙‵)」

鱈魚:「我說會就會ヽ(́◕◞౪◟◕‵)ノ」

多層發光效果:位移越遠顏色越亮、邊緣隨位移柔化
查看範例原始碼
vue
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'

const canvasRef = ref<HTMLCanvasElement | null>(null)

let glContext: WebGL2RenderingContext | null = null
let physicsProgram: WebGLProgram | null = null
let renderProgram: WebGLProgram | null = null
let noiseProgram: WebGLProgram | null = null
let animationFrameId = 0

const stateTextureList: [WebGLTexture | null, WebGLTexture | null] = [null, null]
const framebufferList: [WebGLFramebuffer | null, WebGLFramebuffer | null] = [null, null]
let originTexture: WebGLTexture | null = null
let noiseTexture: WebGLTexture | null = null
let noiseFramebuffer: WebGLFramebuffer | null = null
let quadVertexBuffer: WebGLBuffer | null = null
let quadVertexArray: WebGLVertexArrayObject | null = null
let particleVertexArray: WebGLVertexArrayObject | null = null

let particleCount = 0
let textureSize = 0
let pingPongIndex = 0
let startTime = 0
let logicalWidth = 0
let logicalHeight = 0

let mouseX = 0
let mouseY = 0
let previousMouseX = -9999
let previousMouseY = -9999
let mouseVelocityX = 0
let mouseVelocityY = 0
let isMouseInside = false

const DISPLAY_TEXT = 'SWARM'
const NOISE_SIZE = 256

// ----- Noise generation shader -----
const noiseVertexSource = `#version 300 es
in vec2 aPosition;
void main() {
  gl_Position = vec4(aPosition, 0.0, 1.0);
}
`

const noiseFragmentSource = `#version 300 es
precision highp float;
out vec4 fragColor;

float hash(vec2 p) {
  vec3 p3 = fract(vec3(p.xyx) * 0.13);
  p3 += dot(p3, p3.yzx + 33.33);
  return fract((p3.x + p3.y) * p3.z);
}

float noise(vec2 x) {
  vec2 i = floor(x); vec2 f = fract(x);
  float a = hash(i); float b = hash(i + vec2(1.0, 0.0));
  float c = hash(i + vec2(0.0, 1.0)); float d = hash(i + vec2(1.0, 1.0));
  vec2 u = f * f * (3.0 - 2.0 * f);
  return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y;
}

float fbm(vec2 p) {
  float v = 0.0; float a = 0.5;
  for (int i = 0; i < 4; i++) { v += a * noise(p); p *= 2.0; a *= 0.5; }
  return v;
}

void main() {
  vec2 uv = gl_FragCoord.xy / 256.0;
  vec2 p = uv * 6.0;
  const float e = 0.1;
  float a = (fbm(p + vec2(0.0, e)) - fbm(p - vec2(0.0, e))) / (2.0 * e);
  float b = (fbm(p + vec2(e, 0.0)) - fbm(p - vec2(e, 0.0))) / (2.0 * e);
  fragColor = vec4(a, -b, 0.0, 1.0);
}
`

// ----- Physics pass shaders -----
const physicsVertexSource = `#version 300 es
in vec2 aPosition;
void main() {
  gl_Position = vec4(aPosition, 0.0, 1.0);
}
`

const physicsFragmentSource = `#version 300 es
precision highp float;

uniform sampler2D uState;
uniform sampler2D uOrigin;
uniform sampler2D uNoise;
uniform vec2 uMouse;
uniform vec2 uMouseVelocity;
uniform int uMouseInside;
uniform float uScatterRadius;
uniform float uScatterForce;
uniform float uReturnSpeed;
uniform float uFriction;
uniform float uTime;

out vec4 fragColor;

float hash(vec2 p) {
  vec3 p3 = fract(vec3(p.xyx) * 0.13);
  p3 += dot(p3, p3.yzx + 33.33);
  return fract((p3.x + p3.y) * p3.z);
}

void main() {
  ivec2 coord = ivec2(gl_FragCoord.xy);
  vec4 state = texelFetch(uState, coord, 0);
  vec4 origin = texelFetch(uOrigin, coord, 0);

  vec2 pos = state.xy;
  vec2 vel = state.zw;
  vec2 originPos = origin.xy;

  if (originPos.x < -9999.0) {
    fragColor = state;
    return;
  }

  float pRand = hash(vec2(float(coord.x), float(coord.y)));
  float forceScale = 0.5 + pRand * 1.0;

  if (uMouseInside == 1) {
    vec2 toParticle = pos - uMouse;
    float dist = length(toParticle);

    if (dist < uScatterRadius) {
      float influence = smoothstep(uScatterRadius, 0.0, dist);
      float mouseSpeed = length(uMouseVelocity);

      if (mouseSpeed > 0.5) {
        vec2 windDir = uMouseVelocity / mouseSpeed;
        vel += windDir * mouseSpeed * influence * uScatterForce * 0.025 * forceScale;
      }

      vec2 noiseUV = pos * 0.005 + uTime * 0.15 + pRand * 10.0;
      vec2 curl = texture(uNoise, noiseUV).xy;
      float turbStrength = (1.5 + mouseSpeed * 0.4) * influence * uScatterForce * 0.12 * forceScale;
      vel += curl * turbStrength;

      if (dist > 0.01) {
        vel += (toParticle / dist) * influence * influence * uScatterForce * 0.5 * forceScale;
      }
    }
  }

  vec2 toOrigin = originPos - pos;
  float distFromOrigin = length(toOrigin);

  if (distFromOrigin > 2.0) {
    float displacement = smoothstep(2.0, 40.0, distFromOrigin);
    vec2 driftUV = pos * 0.002 + uTime * 0.08 + pRand * 0.3;
    vec2 drift = texture(uNoise, driftUV).xy;
    vel += drift * displacement * 1.5 * forceScale;
  }

  float particleFriction = uFriction - pRand * 0.04;
  vel *= particleFriction;

  pos += vel;

  float returnRate = uReturnSpeed * (0.6 + pRand * 0.8);
  pos = mix(pos, originPos, returnRate);

  fragColor = vec4(pos, vel);
}
`

// ----- Render pass shaders -----
const renderVertexSource = `#version 300 es
precision highp float;

uniform sampler2D uState;
uniform sampler2D uOrigin;
uniform int uTextureSize;
uniform vec2 uResolution;
uniform float uPointScale;
uniform float uTime;

out float vOpacity;
out float vDisplacement;
out float vSpeed;

void main() {
  int index = gl_VertexID;
  int texelX = index % uTextureSize;
  int texelY = index / uTextureSize;

  vec4 state = texelFetch(uState, ivec2(texelX, texelY), 0);
  vec4 origin = texelFetch(uOrigin, ivec2(texelX, texelY), 0);

  vec2 pos = state.xy;
  vec2 vel = state.zw;
  vec2 originPos = origin.xy;
  float size = origin.z;
  float opacity = origin.w;

  if (originPos.x < -9999.0) {
    gl_Position = vec4(-2.0, -2.0, 0.0, 1.0);
    gl_PointSize = 0.0;
    vOpacity = 0.0;
    vDisplacement = 0.0;
    vSpeed = 0.0;
    return;
  }

  float distFromOrigin = length(pos - originPos);
  float speed = length(vel);

  float displacementNear = smoothstep(0.0, 8.0, distFromOrigin);
  float displacementMid = smoothstep(8.0, 30.0, distFromOrigin);
  float displacementFar = smoothstep(30.0, 80.0, distFromOrigin);
  float displacement = displacementNear * 0.3 + displacementMid * 0.4 + displacementFar * 0.3;

  float speedFactor = smoothstep(0.0, 8.0, speed);

  float phase = float(index) * 1.618033988;
  pos.x += sin(phase + uTime * 0.001) * 0.2;
  pos.y += cos(phase * 1.3 + uTime * 0.0008) * 0.2;

  vec2 clipPosition = (pos / uResolution) * 2.0 - 1.0;
  clipPosition.y *= -1.0;

  gl_Position = vec4(clipPosition, 0.0, 1.0);
  gl_PointSize = size * 2.0 * uPointScale;

  vOpacity = opacity;
  vDisplacement = displacement;
  vSpeed = speedFactor;
}
`

const renderFragmentSource = `#version 300 es
precision highp float;

uniform vec3 uColor;

in float vOpacity;
in float vDisplacement;
in float vSpeed;

out vec4 fragColor;

void main() {
  float dist = length(gl_PointCoord - 0.5) * 2.0;
  if (dist > 1.0) discard;

  // 邊緣柔化隨 displacement 變化
  float coreEdge = mix(0.4, 0.25, vDisplacement);
  float alpha = smoothstep(1.0, coreEdge, dist) * vOpacity;

  // 暖白漸變:位移越遠越亮
  vec3 warmHighlight = mix(
    uColor,
    uColor + vec3(0.15, 0.1, 0.05),
    vDisplacement
  );
  vec3 color = mix(
    warmHighlight,
    vec3(1.0),
    vDisplacement * 0.5 + vSpeed * 0.3
  );

  // 高速白芯
  float coreBright = (1.0 - smoothstep(0.0, 0.5, dist))
                   * vSpeed * 0.4;
  color += vec3(coreBright);

  fragColor = vec4(color, alpha);
}
`

// ----- Helper functions -----
function compileShader(
  gl: WebGL2RenderingContext,
  type: number,
  source: string,
): WebGLShader | null {
  const shader = gl.createShader(type)
  if (!shader)
    return null
  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 linkProgram(
  gl: WebGL2RenderingContext,
  vertSource: string,
  fragSource: string,
): WebGLProgram | null {
  const vert = compileShader(gl, gl.VERTEX_SHADER, vertSource)
  const frag = compileShader(gl, gl.FRAGMENT_SHADER, fragSource)
  if (!vert || !frag)
    return null
  const program = gl.createProgram()
  if (!program)
    return null
  gl.attachShader(program, vert)
  gl.attachShader(program, frag)
  gl.linkProgram(program)
  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
    console.error(gl.getProgramInfoLog(program))
    gl.deleteProgram(program)
    return null
  }
  gl.deleteShader(vert)
  gl.deleteShader(frag)
  return program
}

function createFloatTexture(
  gl: WebGL2RenderingContext,
  size: number,
  data: Float32Array,
): WebGLTexture | null {
  const texture = gl.createTexture()
  if (!texture)
    return null
  gl.bindTexture(gl.TEXTURE_2D, texture)
  gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, size, size, 0, gl.RGBA, gl.FLOAT, data)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
  return texture
}

// ----- Particle sampling from text -----
interface SampledParticle {
  x: number;
  y: number;
  size: number;
  opacity: number;
}

function sampleParticleListFromText(
  text: string,
  canvasWidth: number,
  canvasHeight: number,
): SampledParticle[] {
  const offscreen = document.createElement('canvas')
  offscreen.width = canvasWidth
  offscreen.height = canvasHeight
  const context = offscreen.getContext('2d')
  if (!context)
    return []

  const fontSize = Math.min(canvasWidth / (text.length * 0.7), canvasHeight * 0.6)
  context.fillStyle = 'white'
  context.font = `bold ${fontSize}px Arial, sans-serif`
  context.textAlign = 'center'
  context.textBaseline = 'middle'
  context.fillText(text, canvasWidth / 2, canvasHeight / 2)

  const imageData = context.getImageData(0, 0, canvasWidth, canvasHeight)
  const pixelDataList = imageData.data
  const gap = 1
  const resultList: SampledParticle[] = []

  for (let y = 0; y < canvasHeight; y += gap) {
    for (let x = 0; x < canvasWidth; x += gap) {
      const pixelX = Math.floor(x)
      const pixelY = Math.floor(y)
      const index = (pixelY * canvasWidth + pixelX) * 4
      const alpha = (pixelDataList[index + 3] ?? 0) / 255
      if (alpha > 0.15) {
        resultList.push({
          x,
          y,
          size: 1.5 * (0.4 + alpha * 0.6),
          opacity: alpha * (0.8 + Math.random() * 0.2),
        })
      }
    }
  }

  return resultList
}

// ----- Setup textures and framebuffers -----
function setupParticleTextures(
  gl: WebGL2RenderingContext,
  sampledParticleList: SampledParticle[],
): void {
  particleCount = sampledParticleList.length
  textureSize = Math.ceil(Math.sqrt(particleCount))
  const totalPixelCount = textureSize * textureSize

  const stateData = new Float32Array(totalPixelCount * 4)
  const originData = new Float32Array(totalPixelCount * 4)

  for (let i = 0; i < totalPixelCount; i++) {
    if (i < particleCount) {
      const particle = sampledParticleList[i]
      stateData[i * 4 + 0] = particle?.x ?? 0
      stateData[i * 4 + 1] = particle?.y ?? 0
      stateData[i * 4 + 2] = 0
      stateData[i * 4 + 3] = 0
      originData[i * 4 + 0] = particle?.x ?? 0
      originData[i * 4 + 1] = particle?.y ?? 0
      originData[i * 4 + 2] = particle?.size ?? 0
      originData[i * 4 + 3] = particle?.opacity ?? 0
    }
    else {
      stateData[i * 4 + 0] = -99999
      stateData[i * 4 + 1] = -99999
      stateData[i * 4 + 2] = 0
      stateData[i * 4 + 3] = 0
      originData[i * 4 + 0] = -99999
      originData[i * 4 + 1] = -99999
      originData[i * 4 + 2] = 0
      originData[i * 4 + 3] = 0
    }
  }

  stateTextureList[0] = createFloatTexture(gl, textureSize, stateData)
  stateTextureList[1] = createFloatTexture(gl, textureSize, new Float32Array(stateData))
  originTexture = createFloatTexture(gl, textureSize, originData)

  for (let i = 0; i < 2; i++) {
    framebufferList[i] = gl.createFramebuffer()
    gl.bindFramebuffer(gl.FRAMEBUFFER, framebufferList[i])
    gl.framebufferTexture2D(
      gl.FRAMEBUFFER,
      gl.COLOR_ATTACHMENT0,
      gl.TEXTURE_2D,
      stateTextureList[i],
      0,
    )
  }
  gl.bindFramebuffer(gl.FRAMEBUFFER, null)
}

function generateNoiseTexture(gl: WebGL2RenderingContext): void {
  noiseProgram = linkProgram(gl, noiseVertexSource, noiseFragmentSource)
  if (!noiseProgram)
    return

  noiseTexture = gl.createTexture()
  gl.bindTexture(gl.TEXTURE_2D, noiseTexture)
  gl.texImage2D(
    gl.TEXTURE_2D,
    0,
    gl.RGBA32F,
    NOISE_SIZE,
    NOISE_SIZE,
    0,
    gl.RGBA,
    gl.FLOAT,
    null,
  )

  const hasFloatLinear = !!gl.getExtension('OES_texture_float_linear')
  const noiseFilter = hasFloatLinear ? gl.LINEAR : gl.NEAREST
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, noiseFilter)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, noiseFilter)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT)

  noiseFramebuffer = gl.createFramebuffer()
  gl.bindFramebuffer(gl.FRAMEBUFFER, noiseFramebuffer)
  gl.framebufferTexture2D(
    gl.FRAMEBUFFER,
    gl.COLOR_ATTACHMENT0,
    gl.TEXTURE_2D,
    noiseTexture,
    0,
  )

  gl.viewport(0, 0, NOISE_SIZE, NOISE_SIZE)
  gl.useProgram(noiseProgram)
  gl.bindVertexArray(quadVertexArray)
  gl.drawArrays(gl.TRIANGLES, 0, 6)
  gl.bindVertexArray(null)

  gl.bindFramebuffer(gl.FRAMEBUFFER, null)
  gl.deleteProgram(noiseProgram)
  noiseProgram = null
}

function setupBuffers(gl: WebGL2RenderingContext): void {
  const positionList = new Float32Array([
    -1,
    -1,
    1,
    -1,
    -1,
    1,
    -1,
    1,
    1,
    -1,
    1,
    1,
  ])

  quadVertexArray = gl.createVertexArray()
  gl.bindVertexArray(quadVertexArray)

  quadVertexBuffer = gl.createBuffer()
  gl.bindBuffer(gl.ARRAY_BUFFER, quadVertexBuffer)
  gl.bufferData(gl.ARRAY_BUFFER, positionList, gl.STATIC_DRAW)

  const positionLocation = 0
  gl.enableVertexAttribArray(positionLocation)
  gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0)
  gl.bindVertexArray(null)

  particleVertexArray = gl.createVertexArray()
}

// ----- Pointer events -----
function handlePointerDown(event: PointerEvent): void {
  const target = event.currentTarget
  if (target instanceof Element)
    target.setPointerCapture(event.pointerId)
  handlePointerMove(event)
}

function handlePointerMove(event: PointerEvent): void {
  const newX = event.offsetX
  const newY = event.offsetY

  if (previousMouseX > -9000 && previousMouseY > -9000) {
    mouseVelocityX = newX - previousMouseX
    mouseVelocityY = newY - previousMouseY
  }

  previousMouseX = newX
  previousMouseY = newY
  mouseX = newX
  mouseY = newY
  isMouseInside = true
}

function handlePointerLeave(): void {
  isMouseInside = false
  previousMouseX = -9999
  previousMouseY = -9999
}

// ----- Init and render -----
function initWebGL(): void {
  const canvas = canvasRef.value
  if (!canvas)
    return

  const dpr = window.devicePixelRatio || 1
  logicalWidth = canvas.clientWidth
  logicalHeight = canvas.clientHeight
  canvas.width = logicalWidth * dpr
  canvas.height = logicalHeight * dpr

  glContext = canvas.getContext('webgl2', {
    premultipliedAlpha: false,
    alpha: true,
  })
  if (!glContext) {
    console.error('WebGL2 not supported')
    return
  }

  const extColorFloat = glContext.getExtension('EXT_color_buffer_float')
  if (!extColorFloat) {
    console.error('EXT_color_buffer_float not supported')
    return
  }

  physicsProgram = linkProgram(glContext, physicsVertexSource, physicsFragmentSource)
  renderProgram = linkProgram(glContext, renderVertexSource, renderFragmentSource)
  if (!physicsProgram || !renderProgram)
    return

  setupBuffers(glContext)
  generateNoiseTexture(glContext)

  const sampledParticleList = sampleParticleListFromText(
    DISPLAY_TEXT,
    logicalWidth,
    logicalHeight,
  )
  setupParticleTextures(glContext, sampledParticleList)

  glContext.enable(glContext.BLEND)
  glContext.blendFuncSeparate(
    glContext.SRC_ALPHA,
    glContext.ONE_MINUS_SRC_ALPHA,
    glContext.ONE,
    glContext.ONE_MINUS_SRC_ALPHA,
  )

  previousMouseX = -9999
  previousMouseY = -9999
  mouseVelocityX = 0
  mouseVelocityY = 0
  startTime = performance.now()

  canvas.addEventListener('pointerdown', handlePointerDown)
  canvas.addEventListener('pointermove', handlePointerMove)
  canvas.addEventListener('pointerup', handlePointerLeave)
  canvas.addEventListener('pointerleave', handlePointerLeave)
}

function render(): void {
  if (!glContext || !physicsProgram || !renderProgram)
    return

  const canvas = canvasRef.value
  if (!canvas)
    return

  const gl = glContext
  const currentTime = (performance.now() - startTime) * 0.001

  mouseVelocityX *= 0.92
  mouseVelocityY *= 0.92

  const readIndex = pingPongIndex
  const writeIndex = 1 - pingPongIndex

  gl.disable(gl.BLEND)
  gl.bindFramebuffer(gl.FRAMEBUFFER, framebufferList[writeIndex])
  gl.viewport(0, 0, textureSize, textureSize)

  gl.useProgram(physicsProgram)

  gl.activeTexture(gl.TEXTURE0)
  gl.bindTexture(gl.TEXTURE_2D, stateTextureList[readIndex])
  gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uState'), 0)

  gl.activeTexture(gl.TEXTURE1)
  gl.bindTexture(gl.TEXTURE_2D, originTexture)
  gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uOrigin'), 1)

  gl.activeTexture(gl.TEXTURE2)
  gl.bindTexture(gl.TEXTURE_2D, noiseTexture)
  gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uNoise'), 2)

  gl.uniform2f(gl.getUniformLocation(physicsProgram, 'uMouse'), mouseX, mouseY)
  gl.uniform2f(gl.getUniformLocation(physicsProgram, 'uMouseVelocity'), mouseVelocityX, mouseVelocityY)
  gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uMouseInside'), isMouseInside ? 1 : 0)
  gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uScatterRadius'), 40.0)
  gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uScatterForce'), 40.0)
  gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uReturnSpeed'), 0.1)
  gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uFriction'), 0.92)
  gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uTime'), currentTime)

  gl.bindVertexArray(quadVertexArray)
  gl.drawArrays(gl.TRIANGLES, 0, 6)
  gl.bindVertexArray(null)

  pingPongIndex = writeIndex

  gl.bindFramebuffer(gl.FRAMEBUFFER, null)
  gl.viewport(0, 0, canvas.width, canvas.height)
  gl.clearColor(0, 0, 0, 0)
  gl.clear(gl.COLOR_BUFFER_BIT)

  gl.enable(gl.BLEND)
  gl.blendFuncSeparate(
    gl.SRC_ALPHA,
    gl.ONE_MINUS_SRC_ALPHA,
    gl.ONE,
    gl.ONE_MINUS_SRC_ALPHA,
  )

  gl.useProgram(renderProgram)

  gl.activeTexture(gl.TEXTURE0)
  gl.bindTexture(gl.TEXTURE_2D, stateTextureList[writeIndex])
  gl.uniform1i(gl.getUniformLocation(renderProgram, 'uState'), 0)

  gl.activeTexture(gl.TEXTURE1)
  gl.bindTexture(gl.TEXTURE_2D, originTexture)
  gl.uniform1i(gl.getUniformLocation(renderProgram, 'uOrigin'), 1)

  const dpr = window.devicePixelRatio || 1
  gl.uniform1i(gl.getUniformLocation(renderProgram, 'uTextureSize'), textureSize)
  gl.uniform2f(
    gl.getUniformLocation(renderProgram, 'uResolution'),
    logicalWidth,
    logicalHeight,
  )
  gl.uniform1f(gl.getUniformLocation(renderProgram, 'uPointScale'), dpr)
  gl.uniform1f(gl.getUniformLocation(renderProgram, 'uTime'), performance.now())
  gl.uniform3f(gl.getUniformLocation(renderProgram, 'uColor'), 0.53, 0.53, 0.53)

  gl.bindVertexArray(particleVertexArray)
  gl.drawArrays(gl.POINTS, 0, particleCount)
  gl.bindVertexArray(null)

  animationFrameId = requestAnimationFrame(render)
}

onMounted(() => {
  initWebGL()
  animationFrameId = requestAnimationFrame(render)
})

onBeforeUnmount(() => {
  cancelAnimationFrame(animationFrameId)

  const canvas = canvasRef.value
  if (canvas) {
    canvas.removeEventListener('pointerdown', handlePointerDown)
    canvas.removeEventListener('pointermove', handlePointerMove)
    canvas.removeEventListener('pointerup', handlePointerLeave)
    canvas.removeEventListener('pointerleave', handlePointerLeave)
  }

  if (glContext) {
    if (physicsProgram)
      glContext.deleteProgram(physicsProgram)
    if (renderProgram)
      glContext.deleteProgram(renderProgram)
    for (const texture of stateTextureList) {
      if (texture)
        glContext.deleteTexture(texture)
    }
    if (originTexture)
      glContext.deleteTexture(originTexture)
    if (noiseTexture)
      glContext.deleteTexture(noiseTexture)
    for (const framebuffer of framebufferList) {
      if (framebuffer)
        glContext.deleteFramebuffer(framebuffer)
    }
    if (noiseFramebuffer)
      glContext.deleteFramebuffer(noiseFramebuffer)
    if (quadVertexBuffer)
      glContext.deleteBuffer(quadVertexBuffer)
    if (quadVertexArray)
      glContext.deleteVertexArray(quadVertexArray)
    if (particleVertexArray)
      glContext.deleteVertexArray(particleVertexArray)
  }
})
</script>

<template>
  <div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6">
    <span class="text-sm text-gray-600">
      多層發光效果:位移越遠顏色越亮、邊緣隨位移柔化
    </span>

    <canvas
      ref="canvasRef"
      class="w-full rounded-lg"
      style="height: 320px;"
    />
  </div>
</template>

亮度取決於兩個因子

  1. 離家多遠(displacement):被推得越遠越亮
  2. 移動多快(speed):速度越快越亮

這兩個值在 Vertex Shader(Step 16)已經算好,透過 Varying 傳到 Fragment Shader。

三層 displacement

glsl
float displacementNear = smoothstep(0.0, 8.0, distFromOrigin);
float displacementMid = smoothstep(8.0, 30.0, distFromOrigin);
float displacementFar = smoothstep(30.0, 80.0, distFromOrigin);
float displacement = displacementNear * 0.3
                   + displacementMid * 0.4
                   + displacementFar * 0.3;
距離效果
近距微擾0~8px微微亮起,暗示被輕輕推了一下
中距飄散8~30px明顯變亮
遠距爆開30px+接近全白

為什麼要分三層而不是一個 smoothstep 搞定?因為單一 smoothstep 的漸變太平均了。三層分別控制不同距離帶的亮度曲線,近距微微亮、中距明顯亮、遠距爆亮,層次更豐富。

暖白漸變

speedFactor 是從 Vertex Shader 傳來的 vSpeed(Step 16 算好的速度強度 0~1),代表粒子目前跑多快。和 displacement 搭配,讓「離家遠」和「移動快」的粒子都會變亮。

glsl
vec3 warmHighlight = mix(
  uColor,
  uColor + vec3(0.15, 0.1, 0.05),
  displacement
);
vec3 color = mix(
  warmHighlight,
  vec3(1.0),
  displacement * 0.5 + speedFactor * 0.3
);

粒子不是直接從原色變成白色,中間會經過一個暖色調(偏橘白)。vec3(0.15, 0.1, 0.05) 讓紅色多加一點、綠色少一點、藍色更少,就是暖色偏移。

高速白芯

glsl
float coreBright = (1.0 - smoothstep(0.0, 0.5, dist))
                 * speedFactor * 0.4;
color += vec3(coreBright);

快速移動的粒子中心額外疊加一層白芯。dist 是到粒子中心的距離,1.0 - smoothstep(0.0, 0.5, dist) 只在粒子正中央才亮。

效果是剛被推開的粒子中心特別亮,有種「衝擊波」的感覺。離中心遠的部分維持原本的暖色調。

柔化邊緣與 displacement 的連動

glsl
float coreEdge = mix(0.4, 0.25, vDisplacement);
float alpha = smoothstep(1.0, coreEdge, dist) * vOpacity;

粒子被推得越遠,coreEdge 越小,光暈越散越柔。靜止時邊緣清晰(0.4),飛散時邊緣模糊(0.25)。

完成!🎉

恭喜各位走完了所有步驟!讓我們回顧一下整趟旅程:

Canvas 2D 篇

步驟概念關鍵技術
1Canvas 基礎getContext('2d')fillText、DPR
2像素取樣getImageData、alpha 通道判斷
3粒子渲染requestAnimationFramefillRect
4速度與摩擦力velocity *= friction
5回歸原位lerp(線性內插)
6追蹤滑鼠Pointer Events、座標轉換
7徑向推力單位方向向量 × 衰減力道
8風力滑鼠速度追蹤、方向性推力
9飄盪隨機飄移、displacement 閾值
10效能瓶頸繪圖呼叫開銷分析

WebGL2 Shader 篇

步驟概念關鍵技術
11GPU 思維平行運算、Shader 類型
12Shader 編譯Program、Uniform
13浮點紋理RGBA32F、texelFetch
14Ping-Pong雙紋理交替、Framebuffer
15GPU 物理smoothstep、mix、GLSL 物理模擬
16Vertex Shadergl_VertexID、clip space、Varying
17圓形粒子gl_PointCoord、Alpha Blending
18Curl NoiseFBM、預計算噪聲紋理
19多層發光displacement 亮度、暖白漸變

從最簡單的 Canvas 2D 一路到 WebGL2 Shader,核心物理邏輯其實沒甚麼變,變的是「誰來算」。

從 CPU 一個一個慢慢來,改成 GPU 小精靈大軍一起算。∠( ᐛ 」∠)_

完整元件請見:蟲群文字 🐟

以上如有任何錯誤,還請各位大大多多指教。

感謝您讀到這裡,如果您覺得有收穫,歡迎分享出去。◝( •ω• )◟

v0.63.0