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

蟲群文字 text

從零開始,一步步打造文字化為蟲群粒子,滑鼠靠近時散開、離開後聚集的互動效果。◝( •ω• )◟

完成品請見 蟲群文字元件

前言

讓我們來開發文字會散開的粒子效果?滑鼠一靠近就像蟲子受到驚嚇四散奔逃,離開後又慢慢聚回來排成原本的文字。

其實這種效果不難,難在如何讓上萬個粒子跑得又順又好看。


路人:「數大便是美的概念?」

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

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


讓我們從最基本的 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) {
    if (isPointerInside) {
      const deltaX = particle.x - pointerX
      const deltaY = particle.y - pointerY
      const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY)

      // Radial push force
      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
      }

      // Wind force based on mouse velocity
      if (distance < SCATTER_RADIUS && currentMouseSpeed > 0.5) {
        const influence = 1 - distance / SCATTER_RADIUS
        particle.velocityX += pointerVelocityX * influence * 0.3
        particle.velocityY += pointerVelocityY * influence * 0.3
      }
    }

    // Random drift for displaced particles
    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 / 50, 1)
      const driftAngle = Math.sin(time * 0.001 + particle.randomSeed)
      const driftForce = driftScale * 0.3

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

    // 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()

  // 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 要做多少事?

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

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

每個粒子約 15~20 個數學運算 + 1 次繪圖呼叫。5,000 個粒子就是 10 萬次運算 + 5,000 次 fillRect

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

瓶頸在哪裡?

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

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

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

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

從這裡開始搬上 GPU

接下來的步驟我們會把物理計算和渲染全部搬到 GPU 上。CPU 只負責傳滑鼠座標等少量資料,其他全交給 Shader。

粒子數量從 5,000 變成 100,000+,幀率反而更高。這就是 GPU 平行運算的威力。

Step 11:WebGL2 基礎,GPU 思維

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

下面這個範例同時用 Canvas 2D(CPU)和 WebGL2(GPU)渲染相同數量的粒子。拉高粒子數量,感受一下差異:

Canvas 2D(CPU)
FPS: 0|渲染: 0.0ms
WebGL2(GPU)
FPS: 0|渲染: 0.0ms

兩邊都是純渲染(無物理),比的是 N 次 fillRect vs 1 次 drawArrays

查看範例原始碼
vue
<template>
  <div class="flex flex-col gap-3 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">
        粒子數量
        <input
          v-model.number="particleCountLevel"
          type="range"
          min="0"
          max="3"
          step="1"
          class="w-24"
        >
        <span class="w-20">{{ particleCountLabel }}</span>
      </label>
    </div>

    <div class="grid grid-cols-2 gap-3">
      <div class="flex flex-col gap-1">
        <div class="text-xs font-bold text-gray-400">
          Canvas 2D(CPU)
        </div>
        <canvas ref="cpuCanvasRef" class="w-full rounded" />
        <div class="text-xs text-gray-400">
          FPS: {{ cpuFps }}|渲染: {{ cpuRenderTime }}ms
        </div>
      </div>
      <div class="flex flex-col gap-1">
        <div class="text-xs font-bold text-gray-400">
          WebGL2(GPU)
        </div>
        <canvas ref="gpuCanvasRef" class="w-full rounded" />
        <div class="text-xs text-gray-400">
          FPS: {{ gpuFps }}|渲染: {{ gpuRenderTime }}ms
        </div>
      </div>
    </div>

    <p class="text-xs text-gray-400">
      兩邊都是純渲染(無物理),比的是 N 次 fillRect vs 1 次 drawArrays
    </p>
  </div>
</template>

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

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

const CANVAS_HEIGHT = 150
const PARTICLE_COUNT_LIST = [5000, 20000, 100000, 500000]

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

const cpuFps = ref(0)
const gpuFps = ref(0)
const cpuRenderTime = ref('0.0')
const gpuRenderTime = ref('0.0')

let gpuAnimationId = 0
let canvasWidth = 0

// 共用的靜態粒子位置(兩邊畫同一份資料)
let positionList: Float32Array = new Float32Array(0)

// GPU
let gl: WebGL2RenderingContext | null = null
let gpuProgram: WebGLProgram | null = null
let gpuPositionBuffer: WebGLBuffer | null = null
let smoothGpuTime = 0
let gpuFrameCount = 0
let gpuLastFpsTime = 0

// Worker
let cpuWorker: Worker | null = null

// ====== Worker(純渲染,無物理) ======
const workerSource = `
let canvas = null
let ctx = null
let positionList = new Float32Array(0)
let canvasWidth = 0
let canvasHeight = 0
let running = false
let frameCount = 0
let lastFpsTime = 0
let smoothRenderTime = 0
let particleCount = 0

function animate() {
  if (!running || !ctx) return

  const start = performance.now()

  ctx.fillStyle = '#111827'
  ctx.fillRect(0, 0, canvasWidth, canvasHeight)
  ctx.fillStyle = '#9ca3af'
  for (let i = 0; i < particleCount; i++) {
    ctx.beginPath()
    ctx.arc(positionList[i * 2], positionList[i * 2 + 1], 1, 0, Math.PI * 2)
    ctx.fill()
  }

  const renderTime = performance.now() - start
  smoothRenderTime += (renderTime - smoothRenderTime) * 0.1

  frameCount++
  const now = performance.now()
  if (now - lastFpsTime >= 1000) {
    const elapsed = (now - lastFpsTime) / 1000
    self.postMessage({
      type: 'stats',
      fps: Math.round(frameCount / elapsed),
      renderTime: smoothRenderTime.toFixed(1),
    })
    frameCount = 0
    lastFpsTime = now
  }

  setTimeout(animate, 16)
}

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

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

  if (msg.type === 'rebuild') {
    running = false
    positionList = msg.positionList
    particleCount = msg.count
    canvasWidth = msg.width
    canvasHeight = msg.height
    if (canvas) {
      canvas.width = canvasWidth
      canvas.height = canvasHeight
      ctx = canvas.getContext('2d')
    }
    frameCount = 0
    lastFpsTime = performance.now()
    smoothRenderTime = 0
    running = true
    animate()
  }

  if (msg.type === 'stop') {
    running = false
  }
}
`

// ====== GPU Shader ======
const GPU_VERT = `#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 = 1.5;
}
`

const GPU_FRAG = `#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
}

function initGpu() {
  const canvas = gpuCanvasRef.value
  if (!canvas)
    return

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

  const vert = compileShader(gl, gl.VERTEX_SHADER, GPU_VERT)
  const frag = compileShader(gl, gl.FRAGMENT_SHADER, GPU_FRAG)
  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()
}

// GPU 每幀:只做 drawArrays,位置不變
function gpuAnimate() {
  if (!gl || !gpuProgram || !gpuPositionBuffer)
    return

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

  const count = positionList.length / 2
  const start = performance.now()

  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)
  const resLoc = gl.getUniformLocation(gpuProgram, 'uResolution')
  gl.uniform2f(resLoc, canvasWidth, CANVAS_HEIGHT)

  // 位置不變,不需要每幀重新 bufferData
  gl.bindBuffer(gl.ARRAY_BUFFER, gpuPositionBuffer)
  const posLoc = gl.getAttribLocation(gpuProgram, 'aPosition')
  gl.enableVertexAttribArray(posLoc)
  gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0)

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

  const renderTime = performance.now() - start
  smoothGpuTime += (renderTime - smoothGpuTime) * 0.1
  gpuRenderTime.value = smoothGpuTime.toFixed(1)

  gpuFrameCount++
  const now = performance.now()
  if (now - gpuLastFpsTime >= 1000) {
    const elapsed = (now - gpuLastFpsTime) / 1000
    gpuFps.value = Math.round(gpuFrameCount / elapsed)
    gpuFrameCount = 0
    gpuLastFpsTime = now
  }

  gpuAnimationId = requestAnimationFrame(gpuAnimate)
}

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

function rebuild() {
  cancelAnimationFrame(gpuAnimationId)

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

  // GPU:上傳一次位置到 buffer
  if (gl && gpuPositionBuffer) {
    gl.bindBuffer(gl.ARRAY_BUFFER, gpuPositionBuffer)
    gl.bufferData(gl.ARRAY_BUFFER, positionList, gl.STATIC_DRAW)
  }

  smoothGpuTime = 0
  gpuFrameCount = 0
  gpuLastFpsTime = performance.now()
  gpuAnimationId = requestAnimationFrame(gpuAnimate)

  // CPU Worker:傳一份 copy 過去
  const positionCopy = new Float32Array(positionList)
  cpuWorker?.postMessage(
    {
      type: 'rebuild',
      positionList: positionCopy.buffer,
      count,
      width: canvasWidth,
      height: CANVAS_HEIGHT,
    },
    [positionCopy.buffer],
  )
}

watch(particleCountLevel, rebuild)

onMounted(() => {
  const cpuCanvas = cpuCanvasRef.value
  const gpuCanvas = gpuCanvasRef.value
  if (!cpuCanvas || !gpuCanvas)
    return

  setupCanvas(gpuCanvas)
  canvasWidth = cpuCanvas.clientWidth
  cpuCanvas.style.height = `${CANVAS_HEIGHT}px`

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

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

  cpuWorker.onmessage = (event) => {
    const msg = event.data
    if (msg.type === 'stats') {
      cpuFps.value = msg.fps
      cpuRenderTime.value = msg.renderTime
    }
  }

  initGpu()
  rebuild()
})

onBeforeUnmount(() => {
  cancelAnimationFrame(gpuAnimationId)
  cpuWorker?.postMessage({ type: 'stop' })
  cpuWorker?.terminate()

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

CPU vs GPU

Canvas 2D(CPU)的做法是:

txt
for 每個粒子:
    計算新位置
    畫出來

一個一個來,慢慢做。CPU 很擅長複雜的邏輯,但不擅長同時做很多簡單的事。

GPU 的做法是:

txt
同時對所有粒子:
    每個粒子各自計算新位置
    每個粒子各自畫出來

GPU 有成千上萬個小核心,每個核心做的事很簡單,但大家同時做。一萬個粒子對 CPU 是一萬次迴圈,對 GPU 只是一瞬間。(⌐■_■)✧

什麼是 Shader?

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

蟲群文字用到兩種 Shader:

類型用途執行頻率
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 最大的不同是:你不能迴圈遍歷所有粒子,你只能控制「自己這一個」

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

路人:「那怎麼知道滑鼠在哪?」 鱈魚:「用 Uniform,所有粒子共用同一份資料 (●'◡'●)」

Step 12:Shader 編譯與 Uniform

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

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

const canvasRef = ref<HTMLCanvasElement | null>(null)
const scatterRadius = ref(40)
const force = ref(50)

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

const vertexShaderSource = `#version 300 es
in vec2 aPosition;

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

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

uniform vec2 uResolution;
uniform float uScatterRadius;
uniform float uForce;

out vec4 fragColor;

void main() {
  vec2 uv = gl_FragCoord.xy / uResolution;
  vec2 center = vec2(0.5, 0.5);

  float normalizedRadius = uScatterRadius / 100.0;
  float normalizedForce = uForce / 100.0;

  float distance = length(uv - center);
  float circle = smoothstep(normalizedRadius + 0.02, normalizedRadius - 0.02, distance);

  vec3 circleColor = vec3(
    0.2 + 0.8 * normalizedForce,
    0.4 * (1.0 - normalizedForce),
    0.8 * (1.0 - normalizedForce)
  ) * circle;

  float ring = smoothstep(normalizedRadius + 0.01, normalizedRadius, distance)
             - smoothstep(normalizedRadius, normalizedRadius - 0.01, distance);
  vec3 ringColor = vec3(1.0, 1.0, 1.0) * ring * 0.5;

  float backgroundGradient = 0.05 + 0.05 * uv.y;
  vec3 background = vec3(backgroundGradient) * (1.0 - circle);

  fragColor = vec4(circleColor + ringColor + background, 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,
  ])

  const 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 scatterRadiusLocation = glContext.getUniformLocation(program, 'uScatterRadius')
  const forceLocation = glContext.getUniformLocation(program, 'uForce')

  glContext.uniform2f(resolutionLocation, canvas.width, canvas.height)
  glContext.uniform1f(scatterRadiusLocation, scatterRadius.value)
  glContext.uniform1f(forceLocation, force.value)

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

  animationFrameId = requestAnimationFrame(render)
}

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

onBeforeUnmount(() => {
  cancelAnimationFrame(animationFrameId)

  if (glContext && program) {
    glContext.deleteProgram(program)
  }
})
</script>

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

      <label class="flex items-center gap-2 text-sm text-gray-700">
        力道:{{ force }}
        <input
          v-model.number="force"
          type="range"
          min="1"
          max="100"
          step="1"
          class="flex-1"
        >
      </label>
    </div>

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

編譯流程

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

ts
const mouseLocation = gl.getUniformLocation(program, 'uMouse')
gl.uniform2f(mouseLocation, pointerX, pointerY)

Uniform 是「每幀全部粒子共用」的常數。滑鼠位置、散開半徑、摩擦力這些值對所有粒子都一樣,所以用 Uniform 傳入。

蟲群文字用到的 Uniform:

Uniform類型用途
uMousevec2滑鼠位置
uMouseVelocityvec2滑鼠速度
uMouseInsideint滑鼠是否在元素內
uScatterRadiusfloat散開半徑
uScatterForcefloat散開力道
uReturnSpeedfloat回歸速度
uFrictionfloat摩擦力
uTimefloat時間(用於噪聲動畫)

路人:「那每個粒子各自的位置和速度呢?那些都不一樣啊」 鱈魚:「好問題,那些要塞進 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>

紋理不只是圖片

路人:「紋理不是拿來貼圖片的嗎?」 鱈魚:「表面上是啦,但其實紋理就是一個 2D 陣列,每個像素可以存 4 個值(RGBA)」

假設有 10,000 個粒子。我們建立一張 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

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

點擊畫布新增亮點(Ping-Pong 擴散模擬)

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

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

let glContext: WebGL2RenderingContext | null = null
let physicsProgram: WebGLProgram | null = null
let renderProgram: WebGLProgram | null = null
let textureList: Array<WebGLTexture | null> = [null, null]
let framebufferList: Array<WebGLFramebuffer | null> = [null, null]
let currentTextureIndex = 0
let animationFrameId = 0

const SIMULATION_WIDTH = 256
const SIMULATION_HEIGHT = 256

const fullscreenQuadVertexSource = `#version 300 es
in vec2 aPosition;
out vec2 vUv;

void main() {
  vUv = aPosition * 0.5 + 0.5;
  gl_Position = vec4(aPosition, 0.0, 1.0);
}
`

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

uniform sampler2D uStateTexture;
uniform vec2 uTexelSize;
uniform vec2 uClickPosition;
uniform float uClickActive;

in vec2 vUv;
out vec4 fragColor;

void main() {
  vec4 center = texture(uStateTexture, vUv);
  vec4 left = texture(uStateTexture, vUv + vec2(-uTexelSize.x, 0.0));
  vec4 right = texture(uStateTexture, vUv + vec2(uTexelSize.x, 0.0));
  vec4 up = texture(uStateTexture, vUv + vec2(0.0, uTexelSize.y));
  vec4 down = texture(uStateTexture, vUv + vec2(0.0, -uTexelSize.y));

  vec4 average = (center + left + right + up + down) / 5.0;

  vec4 result = average * 0.995;

  if (uClickActive > 0.5) {
    float distance = length(vUv - uClickPosition);
    float spot = exp(-distance * distance * 800.0);
    result += vec4(spot * 2.0, spot * 1.5, spot * 0.8, 0.0);
  }

  fragColor = result;
}
`

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

uniform sampler2D uStateTexture;

in vec2 vUv;
out vec4 fragColor;

void main() {
  vec4 state = texture(uStateTexture, vUv);
  float brightness = length(state.rgb);

  vec3 color = vec3(
    state.r * 0.8 + brightness * 0.2,
    state.g * 0.6 + brightness * 0.15,
    state.b * 0.9 + brightness * 0.1
  );

  color = pow(color, vec3(0.8));

  fragColor = vec4(color, 1.0);
}
`

let pendingClickPosition: { x: number; y: number } | null = null

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 createFloatTexture(
  context: WebGL2RenderingContext,
  width: number,
  height: number,
  data: Float32Array | null
): WebGLTexture | null {
  const texture = context.createTexture()
  if (!texture) return null

  context.bindTexture(context.TEXTURE_2D, texture)
  context.texImage2D(
    context.TEXTURE_2D,
    0,
    context.RGBA32F,
    width,
    height,
    0,
    context.RGBA,
    context.FLOAT,
    data
  )
  context.texParameteri(context.TEXTURE_2D, context.TEXTURE_MIN_FILTER, context.LINEAR)
  context.texParameteri(context.TEXTURE_2D, context.TEXTURE_MAG_FILTER, context.LINEAR)
  context.texParameteri(context.TEXTURE_2D, context.TEXTURE_WRAP_S, context.CLAMP_TO_EDGE)
  context.texParameteri(context.TEXTURE_2D, context.TEXTURE_WRAP_T, context.CLAMP_TO_EDGE)

  return texture
}

function createInitialData(): Float32Array {
  const data = new Float32Array(SIMULATION_WIDTH * SIMULATION_HEIGHT * 4)

  const spotPositionList = [
    { x: 0.3, y: 0.3 },
    { x: 0.7, y: 0.5 },
    { x: 0.5, y: 0.7 },
    { x: 0.2, y: 0.6 },
    { x: 0.8, y: 0.2 },
  ]

  for (let row = 0; row < SIMULATION_HEIGHT; row++) {
    for (let column = 0; column < SIMULATION_WIDTH; column++) {
      const index = (row * SIMULATION_WIDTH + column) * 4
      const normalizedX = column / SIMULATION_WIDTH
      const normalizedY = row / SIMULATION_HEIGHT

      let value = 0
      for (const spot of spotPositionList) {
        const distanceX = normalizedX - spot.x
        const distanceY = normalizedY - spot.y
        const distanceSquared = distanceX * distanceX + distanceY * distanceY
        value += Math.exp(-distanceSquared * 200)
      }

      data[index + 0] = value * 2.0
      data[index + 1] = value * 1.5
      data[index + 2] = value * 0.8
      data[index + 3] = 0
    }
  }

  return data
}

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
  }

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

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

  const positionList = new Float32Array([
    -1, -1,
     1, -1,
    -1,  1,
    -1,  1,
     1, -1,
     1,  1,
  ])

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

  const physicsPositionLocation = glContext.getAttribLocation(physicsProgram, 'aPosition')
  const renderPositionLocation = glContext.getAttribLocation(renderProgram, 'aPosition')

  glContext.enableVertexAttribArray(physicsPositionLocation)
  glContext.vertexAttribPointer(physicsPositionLocation, 2, glContext.FLOAT, false, 0, 0)

  if (renderPositionLocation !== physicsPositionLocation) {
    glContext.enableVertexAttribArray(renderPositionLocation)
    glContext.vertexAttribPointer(renderPositionLocation, 2, glContext.FLOAT, false, 0, 0)
  }

  const initialData = createInitialData()

  for (let index = 0; index < 2; index++) {
    textureList[index] = createFloatTexture(
      glContext,
      SIMULATION_WIDTH,
      SIMULATION_HEIGHT,
      initialData
    )

    framebufferList[index] = glContext.createFramebuffer()
    glContext.bindFramebuffer(glContext.FRAMEBUFFER, framebufferList[index])
    glContext.framebufferTexture2D(
      glContext.FRAMEBUFFER,
      glContext.COLOR_ATTACHMENT0,
      glContext.TEXTURE_2D,
      textureList[index],
      0
    )
  }

  glContext.bindFramebuffer(glContext.FRAMEBUFFER, null)
}

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

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

  const readIndex = currentTextureIndex
  const writeIndex = 1 - currentTextureIndex

  glContext.useProgram(physicsProgram)
  glContext.bindFramebuffer(glContext.FRAMEBUFFER, framebufferList[writeIndex])
  glContext.viewport(0, 0, SIMULATION_WIDTH, SIMULATION_HEIGHT)

  glContext.activeTexture(glContext.TEXTURE0)
  glContext.bindTexture(glContext.TEXTURE_2D, textureList[readIndex])

  const stateTextureLocation = glContext.getUniformLocation(physicsProgram, 'uStateTexture')
  const texelSizeLocation = glContext.getUniformLocation(physicsProgram, 'uTexelSize')
  const clickPositionLocation = glContext.getUniformLocation(physicsProgram, 'uClickPosition')
  const clickActiveLocation = glContext.getUniformLocation(physicsProgram, 'uClickActive')

  glContext.uniform1i(stateTextureLocation, 0)
  glContext.uniform2f(texelSizeLocation, 1.0 / SIMULATION_WIDTH, 1.0 / SIMULATION_HEIGHT)

  if (pendingClickPosition) {
    glContext.uniform2f(clickPositionLocation, pendingClickPosition.x, pendingClickPosition.y)
    glContext.uniform1f(clickActiveLocation, 1.0)
    pendingClickPosition = null
  } else {
    glContext.uniform2f(clickPositionLocation, 0.0, 0.0)
    glContext.uniform1f(clickActiveLocation, 0.0)
  }

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

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

  glContext.useProgram(renderProgram)
  glContext.bindFramebuffer(glContext.FRAMEBUFFER, null)
  glContext.viewport(0, 0, canvas.width, canvas.height)

  glContext.activeTexture(glContext.TEXTURE0)
  glContext.bindTexture(glContext.TEXTURE_2D, textureList[writeIndex])

  const renderStateLocation = glContext.getUniformLocation(renderProgram, 'uStateTexture')
  glContext.uniform1i(renderStateLocation, 0)

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

  currentTextureIndex = writeIndex

  animationFrameId = requestAnimationFrame(render)
}

function handleCanvasClick(event: MouseEvent): void {
  const canvas = canvasRef.value
  if (!canvas) return

  const rect = canvas.getBoundingClientRect()
  const normalizedX = (event.clientX - rect.left) / rect.width
  const normalizedY = 1.0 - (event.clientY - rect.top) / rect.height

  pendingClickPosition = { x: normalizedX, y: normalizedY }
}

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

onBeforeUnmount(() => {
  cancelAnimationFrame(animationFrameId)

  if (glContext) {
    if (physicsProgram) glContext.deleteProgram(physicsProgram)
    if (renderProgram) glContext.deleteProgram(renderProgram)

    for (const texture of textureList) {
      if (texture) glContext.deleteTexture(texture)
    }
    for (const framebuffer of framebufferList) {
      if (framebuffer) glContext.deleteFramebuffer(framebuffer)
    }
  }
})
</script>

<template>
  <div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6">
    <p class="text-sm text-gray-600">
      點擊畫布新增亮點(Ping-Pong 擴散模擬)
    </p>

    <canvas
      ref="canvasRef"
      class="w-full cursor-crosshair rounded-lg"
      style="height: 400px;"
      @click="handleCanvasClick"
    />
  </div>
</template>

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 流程

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

路人:「全螢幕四邊形?不是在算粒子嗎,為什麼要畫四邊形?」 鱈魚:「因為 Fragment Shader 是『每個像素跑一次』。畫一個跟紋理一樣大的四邊形,就能觸發每個像素(每個粒子)的計算 (・∀・)9」

Step 15:GPU 物理 Shader

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

GPU 物理模擬:滑鼠推開粒子,鬆開後回歸原位

查看範例原始碼
vue
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } 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

let stateTextureList: [WebGLTexture | null, WebGLTexture | null] = [null, null]
let 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 int uTextureSize;
uniform vec2 uResolution;

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

  float x = state.x;
  float y = state.y;

  if (x < -9999.0) {
    gl_Position = vec4(-9999.0, -9999.0, 0.0, 1.0);
    gl_PointSize = 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 = 2.0;
}
`

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

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

// ----- 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]
      // State: x, y, vx, vy
      stateData[i * 4 + 0] = particle.x
      stateData[i * 4 + 1] = particle.y
      stateData[i * 4 + 2] = 0
      stateData[i * 4 + 3] = 0
      // Origin: originX, originY, size, opacity
      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.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">
    <p class="text-sm text-gray-600">
      GPU 物理模擬:滑鼠推開粒子,鬆開後回歸原位
    </p>

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

完整的 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);
}

跟 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

物理算完了,粒子位置存在紋理裡。接下來要把它們畫到螢幕上。

改良頂點著色器:每個粒子有獨立的大小、透明度、位移量與速度

查看範例原始碼
vue
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } 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

let stateTextureList: [WebGLTexture | null, WebGLTexture | null] = [null, null]
let 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">
    <p class="text-sm text-gray-600">
      改良頂點著色器:每個粒子有獨立的大小、透明度、位移量與速度
    </p>

    <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:從紋理讀取位置

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 傳遞

out float vDisplacementout float vSpeed 會從 Vertex Shader 傳到 Fragment Shader。v 字首是慣例,代表 Varying。

Step 17:圓形粒子與 Alpha Blending

Vertex Shader 決定了粒子的「位置」和「大小」,Fragment Shader 決定它「長什麼樣」。

圓形粒子 + Alpha 混合:柔和邊緣與透明度

查看範例原始碼
vue
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } 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

let stateTextureList: [WebGLTexture | null, WebGLTexture | null] = [null, null]
let 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() {
  // Round particle shape using gl_PointCoord
  vec2 center = gl_PointCoord - vec2(0.5);
  float distance = length(center) * 2.0;

  if (distance > 1.0) {
    discard;
  }

  // Soft edges
  float alpha = smoothstep(1.0, 0.4, distance);

  fragColor = vec4(0.53, 0.53, 0.53, vOpacity * 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 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)

  // Enable alpha blending
  glContext.enable(glContext.BLEND)
  glContext.blendFuncSeparate(
    glContext.SRC_ALPHA,
    glContext.ONE_MINUS_SRC_ALPHA,
    glContext.ONE,
    glContext.ONE_MINUS_SRC_ALPHA
  )

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

  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">
    <p class="text-sm text-gray-600">
      圓形粒子 + Alpha 混合:柔和邊緣與透明度
    </p>

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

正方形變圓形

gl.POINTS 畫出來的是正方形。gl_PointCoord 提供正方形內的 UV 座標(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 會讓粒子沿著漩渦走,看起來就像被風捲著跑。

Curl Noise 擾動:滑鼠附近產生渦流效果,遠離原點的粒子自然飄動

查看範例原始碼
vue
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } 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

let stateTextureList: [WebGLTexture | null, WebGLTexture | null] = [null, null]
let 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 particleCount = 0
let textureSize = 0
let pingPongIndex = 0
let startTime = 0

let mouseX = 0
let mouseY = 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));
  float c = hash(i + vec2(0,1)); float d = hash(i + vec2(1,1));
  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,e))-fbm(p-vec2(0,e)))/(2.0*e);
  float b = (fbm(p+vec2(e,0))-fbm(p-vec2(e,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 int uMouseInside;
uniform float uScatterRadius;
uniform float uScatterForce;
uniform float uReturnSpeed;
uniform float uFriction;
uniform float uTime;
uniform vec2 uCanvasSize;

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;
  }

  float distanceFromOrigin = length(vec2(x, y) - vec2(originX, originY));

  // 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;
    }

    // Curl noise turbulence near mouse
    if (distance < uScatterRadius * 1.5) {
      vec2 noiseCoord = vec2(x, y) / uCanvasSize + vec2(uTime * 0.05, uTime * 0.03);
      noiseCoord = fract(noiseCoord);
      vec2 curlForce = texture(uNoise, noiseCoord).xy;
      float turbulenceStrength = influence * 15.0;
      velocityX += curlForce.x * turbulenceStrength;
      velocityY += curlForce.y * turbulenceStrength;
    }
  }

  // Drift noise for particles far from origin
  if (distanceFromOrigin > 5.0) {
    vec2 driftCoord = vec2(x * 0.003, y * 0.003) + vec2(uTime * 0.02);
    driftCoord = fract(driftCoord);
    vec2 driftForce = texture(uNoise, driftCoord).xy;
    float driftStrength = smoothstep(5.0, 30.0, distanceFromOrigin) * 2.0;
    velocityX += driftForce.x * driftStrength;
    velocityY += driftForce.y * driftStrength;
  }

  // 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() {
  // Round particle shape using gl_PointCoord
  vec2 center = gl_PointCoord - vec2(0.5);
  float distance = length(center) * 2.0;

  if (distance > 1.0) {
    discard;
  }

  // Soft edges
  float alpha = smoothstep(1.0, 0.4, distance);

  fragColor = vec4(0.53, 0.53, 0.53, vOpacity * 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 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 generateNoiseTexture(gl: WebGL2RenderingContext): void {
  noiseProgram = linkProgram(gl, noiseVertexSource, noiseFragmentSource)
  if (!noiseProgram) return

  // Create noise texture and framebuffer
  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
  )
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
  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
  )

  // Render noise
  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 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)
  generateNoiseTexture(glContext)

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

  glContext.enable(glContext.BLEND)
  glContext.blendFuncSeparate(
    glContext.SRC_ALPHA,
    glContext.ONE_MINUS_SRC_ALPHA,
    glContext.ONE,
    glContext.ONE_MINUS_SRC_ALPHA
  )

  startTime = performance.now()

  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 currentTime = (performance.now() - startTime) / 1000
  const readIndex = pingPongIndex
  const writeIndex = 1 - pingPongIndex

  // ----- Physics pass -----
  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.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.uniform1f(gl.getUniformLocation(physicsProgram, 'uTime'), currentTime)
  gl.uniform2f(
    gl.getUniformLocation(physicsProgram, 'uCanvasSize'),
    canvas.width,
    canvas.height
  )

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

  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)
    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)
  }
})
</script>

<template>
  <div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6">
    <p class="text-sm text-gray-600">
      Curl Noise 擾動:滑鼠附近產生渦流效果,遠離原點的粒子自然飄動
    </p>

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

普通亂數 vs Curl Noise

一般的 Math.random() 會讓粒子亂跳,像喝醉一樣毫無方向感。

Curl Noise 不一樣,它產生的是旋轉場。鄰近的點會有相似的方向,粒子沿著它走會形成漩渦般的軌跡。

原理

對一個 2D 噪聲場取偏導數,然後旋轉 90 度:

glsl
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);

路人:「偏導數?微積分?我頭好痛 (╥ω╥`)」 鱈魚:「其實不用真的懂數學啦,記住結果就好:Curl Noise 讓粒子沿著漩渦走 ♪( ◜ω◝و(و」

FBM(Fractal Brownian Motion)

fbm 是分形布朗運動,把多個頻率的噪聲疊在一起:

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;
}

低頻的大漩渦 + 高頻的小擾動,看起來就很像自然界的紊流。

預計算噪聲紋理

每幀在 Shader 裡即時算 FBM 太貴了(4 層巢狀噪聲)。所以我們在初始化時用一個專門的 Shader 把整張噪聲場算好,烘焙成 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 中加入擾動

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 { ref, onMounted, onBeforeUnmount } 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

let stateTextureList: [WebGLTexture | null, WebGLTexture | null] = [null, null]
let 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 particleCount = 0
let textureSize = 0
let pingPongIndex = 0
let startTime = 0

let mouseX = 0
let mouseY = 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));
  float c = hash(i + vec2(0,1)); float d = hash(i + vec2(1,1));
  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,e))-fbm(p-vec2(0,e)))/(2.0*e);
  float b = (fbm(p+vec2(e,0))-fbm(p-vec2(e,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 int uMouseInside;
uniform float uScatterRadius;
uniform float uScatterForce;
uniform float uReturnSpeed;
uniform float uFriction;
uniform float uTime;
uniform vec2 uCanvasSize;

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;
  }

  float distanceFromOrigin = length(vec2(x, y) - vec2(originX, originY));

  // 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;
    }

    // Curl noise turbulence near mouse
    if (distance < uScatterRadius * 1.5) {
      vec2 noiseCoord = vec2(x, y) / uCanvasSize + vec2(uTime * 0.05, uTime * 0.03);
      noiseCoord = fract(noiseCoord);
      vec2 curlForce = texture(uNoise, noiseCoord).xy;
      float turbulenceStrength = influence * 15.0;
      velocityX += curlForce.x * turbulenceStrength;
      velocityY += curlForce.y * turbulenceStrength;
    }
  }

  // Drift noise for particles far from origin
  if (distanceFromOrigin > 5.0) {
    vec2 driftCoord = vec2(x * 0.003, y * 0.003) + vec2(uTime * 0.02);
    driftCoord = fract(driftCoord);
    vec2 driftForce = texture(uNoise, driftCoord).xy;
    float driftStrength = smoothstep(5.0, 30.0, distanceFromOrigin) * 2.0;
    velocityX += driftForce.x * driftStrength;
    velocityY += driftForce.y * driftStrength;
  }

  // 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;
uniform float uTime;

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;
  }

  float distanceFromOrigin = length(vec2(x, y) - vec2(originX, originY));
  float displacement = smoothstep(0.0, 40.0, distanceFromOrigin);
  float speed = length(vec2(velocityX, velocityY));
  float speedNormalized = smoothstep(0.0, 8.0, speed);

  // Per-particle slight position oscillation
  float phase = float(gl_VertexID) * 0.1;
  float oscillationX = sin(uTime * 2.0 + phase) * displacement * 0.5;
  float oscillationY = cos(uTime * 1.7 + phase * 1.3) * displacement * 0.5;

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

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

  vOpacity = opacity;
  vDisplacement = displacement;
  vSpeed = speedNormalized;
}
`

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

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

uniform vec3 uColor;

out vec4 fragColor;

void main() {
  // Round particle shape using gl_PointCoord
  vec2 center = gl_PointCoord - vec2(0.5);
  float distance = length(center) * 2.0;

  if (distance > 1.0) {
    discard;
  }

  // Three-layer displacement classification
  float nearDisplacement = smoothstep(0.0, 0.1, vDisplacement);    // 0-8 range mapped
  float midDisplacement = smoothstep(0.1, 0.375, vDisplacement);   // 8-30 range mapped
  float farDisplacement = smoothstep(0.375, 1.0, vDisplacement);   // 30-80 range mapped
  float displacement = nearDisplacement * 0.3 + midDisplacement * 0.4 + farDisplacement * 0.3;

  // Warm white gradient based on displacement
  vec3 warmColor = mix(uColor, uColor + vec3(0.15, 0.1, 0.05), displacement);

  // Speed-based brightness
  float speedBrightness = 1.0 + vSpeed * 0.5;
  warmColor *= speedBrightness;

  // Core edge varies with displacement
  float coreEdge = mix(0.4, 0.25, vDisplacement);

  // Core bright for fast particles
  float coreBright = smoothstep(coreEdge, 0.0, distance);
  float coreFactor = 1.0 + coreBright * vSpeed * 0.8;

  // Soft edges
  float alpha = smoothstep(1.0, coreEdge, distance);

  // Glow layers
  float innerGlow = smoothstep(0.8, 0.0, distance) * 0.3;
  float outerGlow = smoothstep(1.0, 0.3, distance) * 0.15;
  float glowTotal = innerGlow + outerGlow;

  vec3 finalColor = warmColor * coreFactor + vec3(glowTotal);
  float finalAlpha = vOpacity * alpha;

  fragColor = vec4(finalColor, finalAlpha);
}
`

// ----- 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 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
  )
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
  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 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)
  generateNoiseTexture(glContext)

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

  glContext.enable(glContext.BLEND)
  glContext.blendFuncSeparate(
    glContext.SRC_ALPHA,
    glContext.ONE_MINUS_SRC_ALPHA,
    glContext.ONE,
    glContext.ONE_MINUS_SRC_ALPHA
  )

  startTime = performance.now()

  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 currentTime = (performance.now() - startTime) / 1000
  const readIndex = pingPongIndex
  const writeIndex = 1 - pingPongIndex

  // ----- Physics pass -----
  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.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.uniform1f(gl.getUniformLocation(physicsProgram, 'uTime'), currentTime)
  gl.uniform2f(
    gl.getUniformLocation(physicsProgram, 'uCanvasSize'),
    canvas.width,
    canvas.height
  )

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

  gl.uniform1i(gl.getUniformLocation(renderProgram, 'uTextureSize'), textureSize)
  gl.uniform2f(
    gl.getUniformLocation(renderProgram, 'uResolution'),
    canvas.width,
    canvas.height
  )
  gl.uniform1f(gl.getUniformLocation(renderProgram, 'uTime'), currentTime)
  gl.uniform3f(gl.getUniformLocation(renderProgram, 'uColor'), 0.53, 0.53, 0.53)

  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)
    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)
  }
})
</script>

<template>
  <div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6">
    <p class="text-sm text-gray-600">
      多層發光效果:位移漸層、速度亮度、核心高光與柔和光暈
    </p>

    <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 的漸變太平均了。三層分別控制不同距離帶的亮度曲線,近距微微亮、中距明顯亮、遠距爆亮,層次更豐富。

暖白漸變

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 物理Fragment Shader 計算
16Vertex Shadergl_VertexID、clip space
17圓形粒子gl_PointCoord、Alpha Blending
18Curl NoiseFBM、預計算噪聲紋理
19多層發光displacement 亮度、暖白漸變

從最簡單的 Canvas 2D 一路到 WebGL2 Shader,核心物理邏輯其實沒變太多,變的是「誰來算」。CPU 一個一個慢慢來,GPU 大家一起衝,就這樣而已。

完整元件請見:蟲群文字 🐟

以上如有任何錯誤,還請各位大大多多指教。

感謝您讀到這裡,如果您覺得有收穫,歡迎分享出去。◝( •ω• )◟

v0.61.0