跳到內容

每條游來游去的小魚,都是正在瀏覽這一頁的訪客,有一條是你自己 ( ´ ▽ ` )ノ 覺得干擾閱讀的話,可以把小魚關掉。

蟲群文字

step-by-step

完成品請見 蟲群文字元件。

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


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

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

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


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

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

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

<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 是 HTML5 提供的畫布元素,可以用 JavaScript 在上面畫任何東西。第一步是取得 2D 繪圖上下文:

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

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

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 的重點不在「顯示」,而在於我們能拿到每個像素的資料。( •̀ ω •́ )✧

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

解法是把實際像素放大:

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:像素取樣,從文字生出粒子

Section titled “Step 2:像素取樣,從文字生出粒子”

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

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

粒子數量:0

<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>
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。

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

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

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

粒子數量:0影格:0
<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>
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?

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

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

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

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

畫粒子有兩種常見做法:

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

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


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

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

路人:(抽出生魚片刀)

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

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

粒子數量:0影格:0
<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>
interface Particle {
x: number;
y: number;
originX: number;
originY: number;
velocityX: number;
velocityY: number;
}

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

  • originX、originY:記住自己的「家」在哪裡(文字像素位置)
  • velocityX、velocityY:X、Y 方向的速度
function updateParticle(particle: Particle) {
particle.velocityX *= friction
particle.velocityY *= friction
particle.x += particle.velocityX
particle.y += particle.velocityY
}

就兩件事:

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

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

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

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

粒子數量:0影格:0
<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>
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 的經典用法。

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

pointerX: 0, pointerY: 0

<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>
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,好處是後續計算不需要做 null check。-9999 離任何粒子都超過 scatterRadius,自然不會觸發任何推力。省一個 if 少一個分支。

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

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

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

pointerX: 0, pointerY: 0

<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>
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 7)
徑向 + 風力(Step 8)
<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>

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

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 時,算出跟上一幀的位移差,就是滑鼠的速度向量。

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 成正比:滑越快風越大
力方向效果
徑向推力從滑鼠向外粒子四散
風力順著滑鼠移動方向粒子被「吹」走

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

pointerVelocityX *= 0.92
pointerVelocityY *= 0.92

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

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

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

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

無飄盪(直線回歸)
有飄盪(Step 9)
<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>
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 確保方向是平滑旋轉而不是亂跳。

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,飄移力才完全發揮。

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

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

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

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

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

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

粒子數量:0|FPS:0

<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
interface Particle {
x: number;
y: number;
originX: number;
originY: number;
velocityX: number;
velocityY: number;
randomSeed: number;
}
const canvasRef = ref<HTMLCanvasElement | null>(null)
const CANVAS_HEIGHT = 200
const TEXT_CONTENT = 'SWARM'
const FRICTION = 0.92
const RETURN_SPEED = 0.1
const SCATTER_RADIUS = 60
const sampleGap = ref(3)
const particleCount = ref(0)
const currentFps = ref(0)
let particleList: Particle[] = []
let animationFrameId = 0
let canvasWidth = 0
let pointerX = 0
let pointerY = 0
let isPointerInside = false
let previousPointerX = 0
let previousPointerY = 0
let pointerVelocityX = 0
let pointerVelocityY = 0
let frameCount = 0
let lastFpsTime = performance.now()
function sampleTextParticleList(
width: number,
height: number,
gap: number,
): Particle[] {
const offscreenCanvas = document.createElement('canvas')
offscreenCanvas.width = width
offscreenCanvas.height = height
const offscreenContext = offscreenCanvas.getContext('2d')
if (!offscreenContext)
return []
offscreenContext.fillStyle = '#ffffff'
offscreenContext.font = 'bold 80px sans-serif'
offscreenContext.textAlign = 'center'
offscreenContext.textBaseline = 'middle'
offscreenContext.fillText(TEXT_CONTENT, width / 2, height / 2)
const imageData = offscreenContext.getImageData(0, 0, width, height)
const pixelDataList = imageData.data
const result: Particle[] = []
for (let y = 0; y < height; y += gap) {
for (let x = 0; x < width; x += gap) {
const pixelX = Math.floor(x)
const pixelY = Math.floor(y)
const index = (pixelY * width + pixelX) * 4
const alpha = pixelDataList[index + 3]
if (alpha !== undefined && alpha > 128) {
result.push({
x,
y,
originX: x,
originY: y,
velocityX: 0,
velocityY: 0,
randomSeed: Math.random() * Math.PI * 2,
})
}
}
}
return result
}
function initParticleList() {
const canvas = canvasRef.value
if (!canvas)
return
const devicePixelRatio = window.devicePixelRatio || 1
canvasWidth = canvas.clientWidth
canvas.style.height = `${CANVAS_HEIGHT}px`
canvas.width = canvasWidth * devicePixelRatio
canvas.height = CANVAS_HEIGHT * devicePixelRatio
particleList = sampleTextParticleList(
canvasWidth,
CANVAS_HEIGHT,
sampleGap.value,
)
particleCount.value = particleList.length
}
function updateParticleList() {
const time = performance.now()
// Decay pointer velocity each frame
pointerVelocityX *= 0.92
pointerVelocityY *= 0.92
const currentMouseSpeed = Math.sqrt(
pointerVelocityX * pointerVelocityX
+ pointerVelocityY * pointerVelocityY,
)
for (const particle of particleList) {
// 每粒子隨機力道係數(參考 GPU shader 的 forceScale)
const forceScale = 0.5 + (Math.sin(particle.randomSeed * 100) * 0.5 + 0.5)
if (isPointerInside) {
const deltaX = particle.x - pointerX
const deltaY = particle.y - pointerY
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY)
if (distance < SCATTER_RADIUS && distance > 0) {
// smoothstep 風格衰減(參考 GPU shader)
const ratio = distance / SCATTER_RADIUS
const influence = 1 - ratio * ratio * (3 - 2 * ratio)
const directionX = deltaX / distance
const directionY = deltaY / distance
// 徑向推力(參考 GPU: influence² × scatterForce × 0.5)
const radialForce = influence * influence * 8 * forceScale
particle.velocityX += directionX * radialForce
particle.velocityY += directionY * radialForce
// 風力(參考 GPU: mouseSpeed × influence × scatterForce × 0.025)
if (currentMouseSpeed > 0.5) {
particle.velocityX += pointerVelocityX * influence * 0.8 * forceScale
particle.velocityY += pointerVelocityY * influence * 0.8 * forceScale
}
}
}
// 飄盪效果(參考 step 9)
const displacementX = particle.x - particle.originX
const displacementY = particle.y - particle.originY
const displacement = Math.sqrt(
displacementX * displacementX + displacementY * displacementY,
)
if (displacement > 1) {
const driftScale = Math.min(displacement / 40, 1)
const driftAngle = Math.sin(
time * 0.001 + particle.randomSeed,
) * Math.PI
particle.velocityX += Math.cos(driftAngle) * driftScale * 1.2 * forceScale
particle.velocityY += Math.sin(driftAngle) * driftScale * 1.2 * forceScale
}
// 回歸原位
const distanceX = particle.originX - particle.x
const distanceY = particle.originY - particle.y
particle.velocityX += distanceX * RETURN_SPEED
particle.velocityY += distanceY * RETURN_SPEED
particle.velocityX *= FRICTION
particle.velocityY *= FRICTION
particle.x += particle.velocityX
particle.y += particle.velocityY
}
}
function render() {
const canvas = canvasRef.value
if (!canvas)
return
const devicePixelRatio = window.devicePixelRatio || 1
const context = canvas.getContext('2d')
if (!context)
return
context.save()
context.scale(devicePixelRatio, devicePixelRatio)
context.fillStyle = '#111827'
context.fillRect(0, 0, canvasWidth, CANVAS_HEIGHT)
context.fillStyle = '#9ca3af'
for (const particle of particleList) {
context.beginPath()
context.arc(particle.x, particle.y, 1, 0, Math.PI * 2)
context.fill()
}
context.restore()
}
function animationLoop() {
updateParticleList()
render()
// FPS calculation
frameCount++
const now = performance.now()
const elapsed = now - lastFpsTime
if (elapsed >= 1000) {
currentFps.value = Math.round((frameCount * 1000) / elapsed)
frameCount = 0
lastFpsTime = now
}
animationFrameId = requestAnimationFrame(animationLoop)
}
function handlePointerMove(event: PointerEvent) {
const canvas = canvasRef.value
if (!canvas)
return
const rect = canvas.getBoundingClientRect()
const currentX = event.clientX - rect.left
const currentY = event.clientY - rect.top
if (isPointerInside) {
pointerVelocityX = currentX - previousPointerX
pointerVelocityY = currentY - previousPointerY
}
previousPointerX = currentX
previousPointerY = currentY
pointerX = currentX
pointerY = currentY
isPointerInside = true
}
function handlePointerLeave() {
isPointerInside = false
}
function handleResize() {
initParticleList()
}
watch(sampleGap, () => {
initParticleList()
})
onMounted(() => {
initParticleList()
animationFrameId = requestAnimationFrame(animationLoop)
window.addEventListener('resize', handleResize)
})
onBeforeUnmount(() => {
cancelAnimationFrame(animationFrameId)
window.removeEventListener('resize', handleResize)
})
</script>
<template>
<div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6">
<div class="flex items-center gap-4 text-sm text-gray-500">
<label class="flex items-center gap-2">
粒子間距 (gap)
<input
v-model.number="sampleGap"
type="range"
min="0.5"
max="8"
step="0.5"
class="w-32"
>
<span class="w-4 text-center">{{ sampleGap }}</span>
</label>
</div>
<p class="text-sm text-gray-500">
粒子數量:{{ particleCount }}|FPS:{{ currentFps }}
</p>
<canvas
ref="canvasRef"
class="w-full"
@pointermove="handlePointerMove"
@pointerleave="handlePointerLeave"
/>
</div>
</template>

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

<template>
<div class="benchmark-root">
<!-- 控制列 -->
<div class="benchmark-controls">
<label class="benchmark-slider-group">
<span class="benchmark-label">粒子數量</span>
<input
v-model.number="particleCountLevel"
type="range"
:min="0"
:max="PARTICLE_COUNT_LIST.length - 1"
step="1"
class="benchmark-slider"
:disabled="phase !== 'idle'"
>
<span class="benchmark-count">
{{ particleCountLabel }}
</span>
</label>
<button
class="benchmark-button"
:class="{ 'is-running': phase !== 'idle' }"
:disabled="phase !== 'idle'"
@click="runBenchmark"
>
<template v-if="phase === 'idle'">
▶ 開始測試
</template>
<template v-else-if="phase === 'cpu'">
CPU 測試中⋯
</template>
<template v-else>
GPU 測試中⋯
</template>
</button>
</div>
<!-- Canvas 區 -->
<div class="benchmark-canvas-row">
<div class="benchmark-canvas-box">
<div class="benchmark-canvas-label">
<span class="benchmark-dot dot-cpu" />
Canvas 2D(CPU)
</div>
<canvas ref="cpuCanvasRef" class="benchmark-canvas" />
</div>
<div class="benchmark-canvas-box">
<div class="benchmark-canvas-label">
<span class="benchmark-dot dot-gpu" />
WebGL2(GPU)
</div>
<canvas ref="gpuCanvasRef" class="benchmark-canvas" />
</div>
</div>
<!-- 結果 -->
<div class="benchmark-result">
<div class="benchmark-bars">
<div class="benchmark-bar-row">
<span class="benchmark-bar-label">CPU</span>
<div class="benchmark-bar-track">
<div
class="benchmark-bar-fill bar-cpu"
:style="{ width: cpuBarWidth }"
/>
</div>
<span class="benchmark-bar-value">
{{ displayResult.cpuAverage }} ms
</span>
</div>
<div class="benchmark-bar-row">
<span class="benchmark-bar-label">GPU</span>
<div class="benchmark-bar-track">
<div
class="benchmark-bar-fill bar-gpu"
:style="{ width: gpuBarWidth }"
/>
</div>
<span class="benchmark-bar-value">
{{ displayResult.gpuAverage }} ms
</span>
</div>
</div>
<div class="benchmark-speedup">
GPU 快了約
<strong>{{ displayResult.speedup }}×</strong>
</div>
</div>
<p class="benchmark-hint">
點擊開始後,兩邊各連續渲染 {{ BENCHMARK_FRAME_COUNT }} 幀相同粒子,CPU 在 Worker 中執行不阻塞頁面。
</p>
</div>
</template>
<script setup lang="ts">
import {
computed,
onBeforeUnmount,
onMounted,
ref,
shallowRef,
} from 'vue'
// ====== 常數 ======
const CANVAS_HEIGHT = 150
const PARTICLE_COUNT_LIST = [100, 1_000, 20_000]
const BENCHMARK_FRAME_COUNT = 120
// ====== Refs ======
const cpuCanvasRef = ref<HTMLCanvasElement | null>(null)
const gpuCanvasRef = ref<HTMLCanvasElement | null>(null)
const particleCountLevel = ref(1)
const particleCountLabel = computed(
() => `${PARTICLE_COUNT_LIST[particleCountLevel.value]?.toLocaleString() ?? 0} 個`,
)
type BenchmarkPhase = 'idle' | 'cpu' | 'gpu'
const phase = ref<BenchmarkPhase>('idle')
interface BenchmarkResult {
cpuAverage: string;
gpuAverage: string;
speedup: string;
/** 0~1,CPU 佔比 */
cpuRatio: number;
/** 0~1,GPU 佔比 */
gpuRatio: number;
}
const benchmarkResult = shallowRef<BenchmarkResult | null>(null)
const DEFAULT_RESULT: BenchmarkResult = {
cpuAverage: '-',
gpuAverage: '-',
speedup: '-',
cpuRatio: 0,
gpuRatio: 0,
}
const displayResult = computed(
() => benchmarkResult.value ?? DEFAULT_RESULT,
)
const cpuBarWidth = computed(
() => `${(displayResult.value.cpuRatio * 100).toFixed(1)}%`,
)
const gpuBarWidth = computed(
() => `${(displayResult.value.gpuRatio * 100).toFixed(1)}%`,
)
// ====== 內部狀態 ======
let canvasWidth = 0
let aborted = false
// GPU 資源
let gl: WebGL2RenderingContext | null = null
let gpuProgram: WebGLProgram | null = null
let gpuPositionBuffer: WebGLBuffer | null = null
let gpuResolutionLoc: WebGLUniformLocation | null = null
let gpuPositionLoc = -1
// Worker
let cpuWorker: Worker | null = null
// ====== Worker 原始碼 ======
const WORKER_SOURCE = /* js */ `
let canvas = null
let ctx = null
self.onmessage = function (event) {
const msg = event.data
if (msg.type === 'init') {
canvas = msg.canvas
canvas.width = msg.width
canvas.height = msg.height
ctx = canvas.getContext('2d')
return
}
if (msg.type === 'resize') {
if (!canvas) return
canvas.width = msg.width
canvas.height = msg.height
ctx = canvas.getContext('2d')
return
}
if (msg.type === 'benchmark') {
if (!ctx || !canvas) {
self.postMessage({ type: 'result', totalTime: -1 })
return
}
const positionList = new Float32Array(msg.positionBuffer)
const count = msg.count
const frameCount = msg.frameCount
const width = canvas.width
const height = canvas.height
// 暖機 2 幀
for (let w = 0; w < 2; w++) {
ctx.fillStyle = '#111827'
ctx.fillRect(0, 0, width, height)
ctx.fillStyle = '#9ca3af'
for (let i = 0; i < count; i++) {
ctx.fillRect(positionList[i * 2], positionList[i * 2 + 1], 2, 2)
}
}
// 正式測試
const start = performance.now()
for (let f = 0; f < frameCount; f++) {
ctx.fillStyle = '#111827'
ctx.fillRect(0, 0, width, height)
ctx.fillStyle = '#9ca3af'
for (let i = 0; i < count; i++) {
ctx.fillRect(positionList[i * 2], positionList[i * 2 + 1], 2, 2)
}
}
const totalTime = performance.now() - start
self.postMessage({ type: 'result', totalTime })
}
}
`
// ====== Shader ======
const VERT_SOURCE = `#version 300 es
in vec2 aPosition;
uniform vec2 uResolution;
void main() {
vec2 clip = (aPosition / uResolution) * 2.0 - 1.0;
clip.y = -clip.y;
gl_Position = vec4(clip, 0.0, 1.0);
gl_PointSize = 2.0;
}
`
const FRAG_SOURCE = `#version 300 es
precision highp float;
out vec4 fragColor;
void main() {
fragColor = vec4(0.61, 0.64, 0.69, 1.0);
}
`
// ====== 工具函式 ======
function compileShader(
glCtx: WebGL2RenderingContext,
type: number,
source: string,
): WebGLShader | null {
const shader = glCtx.createShader(type)
if (!shader)
return null
glCtx.shaderSource(shader, source)
glCtx.compileShader(shader)
if (!glCtx.getShaderParameter(shader, glCtx.COMPILE_STATUS)) {
console.error(glCtx.getShaderInfoLog(shader))
glCtx.deleteShader(shader)
return null
}
return shader
}
function generatePositionList(count: number): Float32Array {
const list = new Float32Array(count * 2)
for (let i = 0; i < count; i++) {
list[i * 2] = Math.random() * canvasWidth
list[i * 2 + 1] = Math.random() * CANVAS_HEIGHT
}
return list
}
// ====== GPU 渲染 ======
function renderGpuFrame(count: number) {
if (!gl || !gpuProgram || !gpuPositionBuffer)
return
const canvas = gpuCanvasRef.value
if (!canvas)
return
gl.viewport(0, 0, canvas.width, canvas.height)
gl.clearColor(0.067, 0.094, 0.153, 1.0)
gl.clear(gl.COLOR_BUFFER_BIT)
gl.useProgram(gpuProgram)
gl.uniform2f(gpuResolutionLoc, canvasWidth, CANVAS_HEIGHT)
gl.bindBuffer(gl.ARRAY_BUFFER, gpuPositionBuffer)
gl.enableVertexAttribArray(gpuPositionLoc)
gl.vertexAttribPointer(gpuPositionLoc, 2, gl.FLOAT, false, 0, 0)
gl.drawArrays(gl.POINTS, 0, count)
gl.finish()
}
// ====== 初始化 ======
function initGpu() {
const canvas = gpuCanvasRef.value
if (!canvas)
return
gl = canvas.getContext('webgl2', { alpha: false })
if (!gl)
return
const vert = compileShader(gl, gl.VERTEX_SHADER, VERT_SOURCE)
const frag = compileShader(gl, gl.FRAGMENT_SHADER, FRAG_SOURCE)
if (!vert || !frag)
return
gpuProgram = gl.createProgram()
if (!gpuProgram)
return
gl.attachShader(gpuProgram, vert)
gl.attachShader(gpuProgram, frag)
gl.linkProgram(gpuProgram)
gl.deleteShader(vert)
gl.deleteShader(frag)
gpuPositionBuffer = gl.createBuffer()
gl.useProgram(gpuProgram)
gpuResolutionLoc = gl.getUniformLocation(gpuProgram, 'uResolution')
gpuPositionLoc = gl.getAttribLocation(gpuProgram, 'aPosition')
}
function initWorker() {
const cpuCanvas = cpuCanvasRef.value
if (!cpuCanvas)
return
const blob = new Blob([WORKER_SOURCE], { type: 'application/javascript' })
const url = URL.createObjectURL(blob)
cpuWorker = new Worker(url)
URL.revokeObjectURL(url)
const offscreen = cpuCanvas.transferControlToOffscreen()
cpuWorker.postMessage(
{ type: 'init', canvas: offscreen, width: canvasWidth, height: CANVAS_HEIGHT },
[offscreen],
)
}
function setupCanvas(canvas: HTMLCanvasElement) {
canvasWidth = canvas.clientWidth
canvas.width = canvasWidth
canvas.height = CANVAS_HEIGHT
canvas.style.height = `${CANVAS_HEIGHT}px`
}
// ====== CPU 基準測試(透過 Worker) ======
function runCpuBenchmark(
positionList: Float32Array,
count: number,
): Promise<number> {
return new Promise((resolve) => {
if (!cpuWorker) {
resolve(-1)
return
}
const handler = (event: MessageEvent) => {
if (event.data.type === 'result') {
cpuWorker?.removeEventListener('message', handler)
resolve(event.data.totalTime as number)
}
}
cpuWorker.addEventListener('message', handler)
const buffer = positionList.buffer.slice(0)
cpuWorker.postMessage(
{
type: 'benchmark',
positionBuffer: buffer,
count,
frameCount: BENCHMARK_FRAME_COUNT,
},
[buffer],
)
})
}
// ====== GPU 基準測試(每幀透過 rAF 分散,不阻塞主執行緒) ======
function runGpuBenchmark(count: number): Promise<number> {
return new Promise((resolve) => {
if (!gl) {
resolve(-1)
return
}
// 暖機 2 幀
renderGpuFrame(count)
renderGpuFrame(count)
let frameIndex = 0
let totalTime = 0
function tick() {
if (aborted) {
resolve(-1)
return
}
const start = performance.now()
renderGpuFrame(count)
totalTime += performance.now() - start
frameIndex++
if (frameIndex < BENCHMARK_FRAME_COUNT) {
requestAnimationFrame(tick)
}
else {
resolve(totalTime)
}
}
requestAnimationFrame(tick)
})
}
// ====== 執行測試 ======
async function runBenchmark() {
if (!gl || !cpuWorker)
return
phase.value = 'cpu'
benchmarkResult.value = null
aborted = false
const count = PARTICLE_COUNT_LIST[particleCountLevel.value] ?? 5_000
const positionList = generatePositionList(count)
// 上傳到 GPU buffer
gl.bindBuffer(gl.ARRAY_BUFFER, gpuPositionBuffer)
gl.bufferData(gl.ARRAY_BUFFER, positionList, gl.STATIC_DRAW)
// 等一幀讓 UI 更新
await new Promise((resolve) => requestAnimationFrame(resolve))
if (aborted)
return
// --- CPU(Worker) ---
const cpuTotal = await runCpuBenchmark(positionList, count)
if (aborted)
return
// --- GPU ---
phase.value = 'gpu'
await new Promise((resolve) => requestAnimationFrame(resolve))
if (aborted)
return
const gpuTotal = await runGpuBenchmark(count)
if (aborted || gpuTotal < 0)
return
// --- 結果 ---
const cpuAverage = cpuTotal / BENCHMARK_FRAME_COUNT
const gpuAverage = gpuTotal / BENCHMARK_FRAME_COUNT
const maxTime = Math.max(cpuAverage, gpuAverage)
const speedup = gpuAverage > 0 ? cpuAverage / gpuAverage : 0
benchmarkResult.value = {
cpuAverage: cpuAverage.toFixed(2),
gpuAverage: gpuAverage.toFixed(2),
speedup: speedup.toFixed(1),
cpuRatio: maxTime > 0 ? cpuAverage / maxTime : 0,
gpuRatio: maxTime > 0 ? gpuAverage / maxTime : 0,
}
phase.value = 'idle'
}
// ====== 生命週期 ======
onMounted(() => {
const cpuCanvas = cpuCanvasRef.value
const gpuCanvas = gpuCanvasRef.value
if (!cpuCanvas || !gpuCanvas)
return
// GPU canvas 先 setup(取得 canvasWidth)
setupCanvas(gpuCanvas)
// CPU canvas 只設 style,實際 size 由 Worker 控制
cpuCanvas.style.height = `${CANVAS_HEIGHT}px`
initGpu()
initWorker()
})
onBeforeUnmount(() => {
aborted = true
cpuWorker?.terminate()
if (gl) {
if (gpuProgram)
gl.deleteProgram(gpuProgram)
if (gpuPositionBuffer)
gl.deleteBuffer(gpuPositionBuffer)
}
})
</script>
<style scoped>
.benchmark-root {
--bar-cpu: #f59e0b;
--bar-gpu: #22d3ee;
--surface: #f8fafc;
--surface-dark: #111827;
--border: #e2e8f0;
--text-1: #1e293b;
--text-2: #64748b;
--text-3: #94a3b8;
display: flex;
flex-direction: column;
gap: 16px;
border: 1px solid var(--border);
border-radius: 14px;
padding: 24px;
font-family: 'PingFang TC', 'Microsoft JhengHei', system-ui, sans-serif;
}
/* ---- 控制列 ---- */
.benchmark-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.benchmark-slider-group {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--text-2);
}
.benchmark-label {
white-space: nowrap;
}
.benchmark-slider {
width: 96px;
accent-color: var(--text-2);
}
.benchmark-count {
min-width: 72px;
font-variant-numeric: tabular-nums;
text-align: right;
}
.benchmark-button {
padding: 6px 16px;
border: none;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
background: var(--surface-dark);
color: #fff;
transition: background 0.15s, opacity 0.15s;
line-height: 1.5;
}
.benchmark-button:hover:not(:disabled) {
background: #374151;
}
.benchmark-button.is-running {
background: var(--text-3);
cursor: wait;
animation: benchmark-pulse 1.6s ease-in-out infinite;
}
@keyframes benchmark-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
/* ---- Canvas ---- */
.benchmark-canvas-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.benchmark-canvas-box {
display: flex;
flex-direction: column;
gap: 6px;
}
.benchmark-canvas-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: var(--text-3);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.benchmark-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
}
.dot-cpu { background: var(--bar-cpu); }
.dot-gpu { background: var(--bar-gpu); }
.benchmark-canvas {
width: 100%;
border-radius: 8px;
background: var(--surface-dark);
}
/* ---- 結果 ---- */
.benchmark-result {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
background: var(--surface);
border-radius: 10px;
}
.benchmark-bars {
display: flex;
flex-direction: column;
gap: 10px;
}
.benchmark-bar-row {
display: grid;
grid-template-columns: 36px 1fr auto;
align-items: center;
gap: 10px;
}
.benchmark-bar-label {
font-size: 12px;
font-weight: 700;
color: var(--text-2);
text-align: right;
}
.benchmark-bar-track {
height: 22px;
border-radius: 6px;
background: #e2e8f0;
overflow: hidden;
}
.benchmark-bar-fill {
height: 100%;
border-radius: 6px;
transition: width 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}
.bar-cpu {
background: linear-gradient(90deg, #f59e0b, #fbbf24);
}
.bar-gpu {
background: linear-gradient(90deg, #06b6d4, #22d3ee);
}
.benchmark-bar-value {
min-width: 80px;
text-align: right;
font-size: 13px;
font-weight: 600;
font-variant-numeric: tabular-nums;
color: var(--text-1);
}
.benchmark-speedup {
text-align: center;
font-size: 13px;
color: var(--text-2);
}
.benchmark-speedup strong {
font-size: 18px;
color: #0891b2;
}
/* ---- 提示 ---- */
.benchmark-hint {
margin: 0;
font-size: 12px;
color: var(--text-3);
}
/* ---- Dark mode(html.dark) ---- */
.dark .benchmark-root {
--surface: #1e293b;
--border: #334155;
--text-1: #f1f5f9;
--text-2: #94a3b8;
--text-3: #64748b;
}
.dark .benchmark-bar-track {
background: #334155;
}
.dark .benchmark-speedup strong {
color: #22d3ee;
}
</style>
CPU逐一處理
GPU全部同時

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

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

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

WebGL2 只有兩種 Shader:

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

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

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

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

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

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

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

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

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

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

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

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

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

先來看最簡單的例子:

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

<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
const canvasRef = ref<HTMLCanvasElement | null>(null)
const radius = ref(30)
let glContext: WebGL2RenderingContext | null = null
let program: WebGLProgram | null = null
let vertexBuffer: WebGLBuffer | null = null
let animationFrameId = 0
/** Vertex Shader:把全螢幕三角形的頂點直接傳到裁剪空間 */
const vertexShaderSource = `#version 300 es
in vec2 aPosition;
void main() {
gl_Position = vec4(aPosition, 0.0, 1.0);
}
`
/**
* Fragment Shader:每個像素各自執行一次
* 用 uResolution 把像素座標換算成 0~1 的 UV,再判斷是否在圓內
*/
const fragmentShaderSource = `#version 300 es
precision highp float;
uniform vec2 uResolution;
uniform float uRadius;
out vec4 fragColor;
void main() {
// 將像素座標正規化為 0~1
vec2 uv = gl_FragCoord.xy / uResolution;
// 圓心在畫面正中央
vec2 center = vec2(0.5, 0.5);
// 計算此像素到圓心的距離
float dist = length(uv - center);
// 半徑從百分比轉為 0~1 範圍
float r = uRadius / 100.0;
// smoothstep 讓邊緣柔和,避免鋸齒
float circle = smoothstep(r + 0.01, r - 0.01, dist);
// 圓的顏色
vec3 color = vec3(0.26, 0.52, 0.96) * circle;
fragColor = vec4(color, 1.0);
}
`
function compileShader(
glContext: WebGL2RenderingContext,
type: number,
source: string
): WebGLShader | null {
const shader = glContext.createShader(type)
if (!shader) return null
glContext.shaderSource(shader, source)
glContext.compileShader(shader)
if (!glContext.getShaderParameter(shader, glContext.COMPILE_STATUS)) {
console.error(glContext.getShaderInfoLog(shader))
glContext.deleteShader(shader)
return null
}
return shader
}
function linkProgram(
glContext: WebGL2RenderingContext,
vertSource: string,
fragSource: string
): WebGLProgram | null {
const vert = compileShader(glContext, glContext.VERTEX_SHADER, vertSource)
const frag = compileShader(glContext, glContext.FRAGMENT_SHADER, fragSource)
if (!vert || !frag) return null
const program = glContext.createProgram()
if (!program) return null
glContext.attachShader(program, vert)
glContext.attachShader(program, frag)
glContext.linkProgram(program)
if (!glContext.getProgramParameter(program, glContext.LINK_STATUS)) {
console.error(glContext.getProgramInfoLog(program))
glContext.deleteProgram(program)
return null
}
glContext.deleteShader(vert)
glContext.deleteShader(frag)
return program
}
function initWebGL(): void {
const canvas = canvasRef.value
if (!canvas) return
canvas.width = canvas.clientWidth
canvas.height = canvas.clientHeight
glContext = canvas.getContext('webgl2')
if (!glContext) {
console.error('WebGL2 not supported')
return
}
program = linkProgram(glContext, vertexShaderSource, fragmentShaderSource)
if (!program) return
// 全螢幕四邊形(兩個三角形)
const positionList = new Float32Array([
-1, -1,
1, -1,
-1, 1,
-1, 1,
1, -1,
1, 1,
])
vertexBuffer = glContext.createBuffer()
glContext.bindBuffer(glContext.ARRAY_BUFFER, vertexBuffer)
glContext.bufferData(glContext.ARRAY_BUFFER, positionList, glContext.STATIC_DRAW)
const positionLocation = glContext.getAttribLocation(program, 'aPosition')
glContext.enableVertexAttribArray(positionLocation)
glContext.vertexAttribPointer(positionLocation, 2, glContext.FLOAT, false, 0, 0)
}
function render(): void {
if (!glContext || !program) return
const canvas = canvasRef.value
if (!canvas) return
canvas.width = canvas.clientWidth
canvas.height = canvas.clientHeight
glContext.viewport(0, 0, canvas.width, canvas.height)
glContext.useProgram(program)
const resolutionLocation = glContext.getUniformLocation(program, 'uResolution')
const radiusLocation = glContext.getUniformLocation(program, 'uRadius')
glContext.uniform2f(resolutionLocation, canvas.width, canvas.height)
glContext.uniform1f(radiusLocation, radius.value)
glContext.drawArrays(glContext.TRIANGLES, 0, 6)
animationFrameId = requestAnimationFrame(render)
}
onMounted(() => {
initWebGL()
animationFrameId = requestAnimationFrame(render)
})
onBeforeUnmount(() => {
cancelAnimationFrame(animationFrameId)
if (glContext) {
if (program)
glContext.deleteProgram(program)
if (vertexBuffer)
glContext.deleteBuffer(vertexBuffer)
glContext.getExtension('WEBGL_lose_context')?.loseContext()
}
})
</script>
<template>
<div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6">
<label class="flex items-center gap-2 text-sm text-gray-700">
半徑:{{ radius }}%
<input
v-model.number="radius"
type="range"
min="5"
max="50"
step="1"
class="flex-1"
>
</label>
<canvas
ref="canvasRef"
class="w-full rounded-lg"
style="height: 320px;"
/>
</div>
</template>

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

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

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

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

#version 300 es
in vec2 aPosition;
void main() {
gl_Position = vec4(aPosition, 0.0, 1.0);
}

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

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

WebGL 提供 7 種繪圖模式:

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

1234

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

1234

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

1234

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

1234

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

123456

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

123456

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

12345

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

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

對應到 JavaScript 就是:

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

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

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

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

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

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

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

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

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

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

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

有三種可以選:

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

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

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

  • uResolution:畫布的寬高(像素),用來把像素座標正規化成 0~1
  • uRadius:圓的半徑(百分比),來自範例中的滑桿數值
#version 300 es
precision highp float;
uniform vec2 uResolution;
uniform float uRadius;
out vec4 fragColor;
void main() {
// 將像素座標正規化為 0~1
vec2 uv = gl_FragCoord.xy / uResolution;
// 圓心在畫面正中央
vec2 center = vec2(0.5, 0.5);
// 計算此像素到圓心的距離
float dist = length(uv - center);
// 半徑從百分比轉為 0~1 範圍
float r = uRadius / 100.0;
// smoothstep:圓內 → 1.0,圓外 → 0.0,邊緣柔和過渡
float circle = smoothstep(r + 0.01, r - 0.01, dist);
vec3 color = vec3(0.26, 0.52, 0.96) * circle;
fragColor = vec4(color, 1.0);
}

核心邏輯只有三步:

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

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

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

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

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

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

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

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

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

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

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

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

<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
interface ParticleState {
x: number;
y: number;
velocityX: number;
velocityY: number;
}
const canvasRef = ref<HTMLCanvasElement | null>(null)
const infoText = ref('')
const GRID_SIZE = 8
const TOTAL_PARTICLES = GRID_SIZE * GRID_SIZE
let glContext: WebGL2RenderingContext | null = null
let floatTexture: WebGLTexture | null = null
let canvasContext: CanvasRenderingContext2D | null = null
let textureData = new Float32Array(GRID_SIZE * GRID_SIZE * 4)
function compileShader(
glContext: WebGL2RenderingContext,
type: number,
source: string
): WebGLShader | null {
const shader = glContext.createShader(type)
if (!shader) return null
glContext.shaderSource(shader, source)
glContext.compileShader(shader)
if (!glContext.getShaderParameter(shader, glContext.COMPILE_STATUS)) {
console.error(glContext.getShaderInfoLog(shader))
glContext.deleteShader(shader)
return null
}
return shader
}
function linkProgram(
glContext: WebGL2RenderingContext,
vertSource: string,
fragSource: string
): WebGLProgram | null {
const vert = compileShader(glContext, glContext.VERTEX_SHADER, vertSource)
const frag = compileShader(glContext, glContext.FRAGMENT_SHADER, fragSource)
if (!vert || !frag) return null
const program = glContext.createProgram()
if (!program) return null
glContext.attachShader(program, vert)
glContext.attachShader(program, frag)
glContext.linkProgram(program)
if (!glContext.getProgramParameter(program, glContext.LINK_STATUS)) {
console.error(glContext.getProgramInfoLog(program))
glContext.deleteProgram(program)
return null
}
glContext.deleteShader(vert)
glContext.deleteShader(frag)
return program
}
function generateRandomData(): Float32Array {
const data = new Float32Array(GRID_SIZE * GRID_SIZE * 4)
for (let index = 0; index < GRID_SIZE * GRID_SIZE; index++) {
const offset = index * 4
data[offset + 0] = Math.random() * 400
data[offset + 1] = Math.random() * 300
data[offset + 2] = (Math.random() - 0.5) * 4
data[offset + 3] = (Math.random() - 0.5) * 4
}
return data
}
function initWebGL(): void {
const offscreenCanvas = document.createElement('canvas')
offscreenCanvas.width = GRID_SIZE
offscreenCanvas.height = GRID_SIZE
glContext = offscreenCanvas.getContext('webgl2')
if (!glContext) {
console.error('WebGL2 not supported')
return
}
const extensionColorBufferFloat = glContext.getExtension('EXT_color_buffer_float')
if (!extensionColorBufferFloat) {
console.error('EXT_color_buffer_float not supported')
return
}
floatTexture = glContext.createTexture()
glContext.bindTexture(glContext.TEXTURE_2D, floatTexture)
glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MIN_FILTER, glContext.NEAREST)
glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_MAG_FILTER, glContext.NEAREST)
glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_S, glContext.CLAMP_TO_EDGE)
glContext.texParameteri(glContext.TEXTURE_2D, glContext.TEXTURE_WRAP_T, glContext.CLAMP_TO_EDGE)
textureData = generateRandomData()
uploadTextureData(textureData)
infoText.value = `Texture 大小:${GRID_SIZE}x${GRID_SIZE}(共 ${TOTAL_PARTICLES} 個粒子)`
}
function uploadTextureData(data: Float32Array): void {
if (!glContext || !floatTexture) return
glContext.bindTexture(glContext.TEXTURE_2D, floatTexture)
glContext.texImage2D(
glContext.TEXTURE_2D,
0,
glContext.RGBA32F,
GRID_SIZE,
GRID_SIZE,
0,
glContext.RGBA,
glContext.FLOAT,
data
)
}
function readParticleState(index: number): ParticleState {
const offset = index * 4
return {
x: textureData[offset + 0],
y: textureData[offset + 1],
velocityX: textureData[offset + 2],
velocityY: textureData[offset + 3],
}
}
function drawVisualization(): void {
const canvas = canvasRef.value
if (!canvas) return
if (!canvasContext) {
canvasContext = canvas.getContext('2d')
}
if (!canvasContext) return
canvas.width = canvas.clientWidth
canvas.height = canvas.clientHeight
const context = canvasContext
context.clearRect(0, 0, canvas.width, canvas.height)
const cellWidth = canvas.width / GRID_SIZE
const cellHeight = (canvas.height - 40) / GRID_SIZE
const offsetY = 40
context.fillStyle = '#111'
context.font = '12px monospace'
context.fillText('每個格子 = 1 個像素 = 1 個粒子的狀態 (x, y, vx, vy)', 10, 20)
for (let row = 0; row < GRID_SIZE; row++) {
for (let column = 0; column < GRID_SIZE; column++) {
const index = row * GRID_SIZE + column
const particle = readParticleState(index)
const normalizedX = Math.min(1, Math.max(0, particle.x / 400))
const normalizedY = Math.min(1, Math.max(0, particle.y / 300))
const speed = Math.sqrt(
particle.velocityX * particle.velocityX +
particle.velocityY * particle.velocityY
)
const normalizedSpeed = Math.min(1, speed / 4)
const red = Math.floor(normalizedX * 255)
const green = Math.floor(normalizedY * 255)
const blue = Math.floor(normalizedSpeed * 255)
const drawX = column * cellWidth
const drawY = row * cellHeight + offsetY
context.fillStyle = `rgb(${red}, ${green}, ${blue})`
context.fillRect(drawX + 1, drawY + 1, cellWidth - 2, cellHeight - 2)
context.strokeStyle = '#333'
context.strokeRect(drawX, drawY, cellWidth, cellHeight)
if (cellWidth > 50) {
context.fillStyle = '#fff'
context.font = '9px monospace'
context.fillText(
`${particle.x.toFixed(0)},${particle.y.toFixed(0)}`,
drawX + 3,
drawY + 14
)
context.fillText(
`v:${particle.velocityX.toFixed(1)},${particle.velocityY.toFixed(1)}`,
drawX + 3,
drawY + 26
)
}
}
}
}
function handleRandomize(): void {
textureData = generateRandomData()
uploadTextureData(textureData)
drawVisualization()
}
onMounted(() => {
initWebGL()
drawVisualization()
})
onBeforeUnmount(() => {
if (glContext && floatTexture) {
glContext.deleteTexture(floatTexture)
}
})
</script>
<template>
<div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6">
<div class="flex items-center justify-between">
<p class="text-sm text-gray-600">
{{ infoText }}
</p>
<button
class="rounded-lg bg-blue-500 px-4 py-1.5 text-sm text-white hover:bg-blue-600"
@click="handleRandomize"
>
隨機化
</button>
</div>
<canvas
ref="canvasRef"
class="w-full rounded-lg bg-gray-50"
style="height: 360px;"
/>
</div>
</template>

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

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

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

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

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

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 用它來算回歸力(知道「家」在哪)。

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

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

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

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

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

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

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

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

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

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

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 就會寫入對應的紋理而不是螢幕。

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

const currentStateIndex = 0

每幀的 Ping-Pong 邏輯:

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

逐幀展開來看:

第 1 幀:currentStateIndex = 0
readIndex = 0, writeIndex = 1
讀紋理 A → Shader 計算 → 寫入紋理 B
currentStateIndex = 1
第 2 幀:currentStateIndex = 1
readIndex = 1, writeIndex = 0
讀紋理 B → Shader 計算 → 寫入紋理 A
currentStateIndex = 0
第 3 幀:又回到讀 A → 寫 B,不斷交替…

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

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

#version 300 es
precision highp float;
uniform sampler2D uState;
uniform sampler2D uOrigin;
uniform vec2 uMouse;
uniform vec2 uMouseVelocity;
uniform int uMouseInside;
uniform float uScatterRadius;
uniform float uScatterForce;
uniform float uReturnSpeed;
uniform float uFriction;
out vec4 outState;
void main() {
ivec2 coord = ivec2(gl_FragCoord.xy);
vec4 state = texelFetch(uState, coord, 0);
vec4 origin = texelFetch(uOrigin, coord, 0);
vec2 pos = state.xy;
vec2 vel = state.zw;
vec2 originPos = origin.xy;
// 滑鼠推力
if (uMouseInside != 0) {
vec2 toParticle = pos - uMouse;
float dist = length(toParticle);
if (dist < uScatterRadius) {
float influence = smoothstep(
uScatterRadius, 0.0, dist
);
// 風力
float mouseSpeed = length(uMouseVelocity);
if (mouseSpeed > 0.5) {
vec2 windDir = uMouseVelocity / mouseSpeed;
vel += windDir * mouseSpeed
* influence * uScatterForce * 0.025;
}
// 徑向推力
vel += normalize(toParticle)
* influence * influence
* uScatterForce * 0.5;
}
}
// 摩擦力
vel *= uFriction;
// 更新位置
pos += vel;
// 回歸
pos = mix(pos, originPos, uReturnSpeed);
outState = vec4(pos, vel);
}
ivec2 coord = ivec2(gl_FragCoord.xy);

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

類似的型別還有:

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

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

// 紋理:把粒子狀態和原始位置以紋理方式綁定
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, stateTexture) // → uState
gl.activeTexture(gl.TEXTURE1)
gl.bindTexture(gl.TEXTURE_2D, originTexture) // → uOrigin
// 滑鼠相關
gl.uniform2f(loc.uMouse, mouseX, mouseY) // 滑鼠座標
gl.uniform2f(loc.uMouseVelocity, velX, velY) // 滑鼠移動速度
gl.uniform1i(loc.uMouseInside, isInside ? 1 : 0) // 滑鼠是否在畫布內
// 物理參數(來自元件 props)
gl.uniform1f(loc.uScatterRadius, props.scatterRadius) // 散開半徑
gl.uniform1f(loc.uScatterForce, props.scatterForce) // 推力強度
gl.uniform1f(loc.uReturnSpeed, props.returnSpeed) // 回歸速度
gl.uniform1f(loc.uFriction, props.friction) // 摩擦力

對照表:

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

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

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

注意到了嗎?邏輯跟 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 是更平滑的衰減曲線。差別只在語法和「誰來算」。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

改良頂點著色器:每個粒子有獨立的大小、透明度、位移量與速度
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
const canvasRef = ref<HTMLCanvasElement | null>(null)
let glContext: WebGL2RenderingContext | null = null
let physicsProgram: WebGLProgram | null = null
let renderProgram: WebGLProgram | null = null
let animationFrameId = 0
const stateTextureList: [WebGLTexture | null, WebGLTexture | null] = [null, null]
const framebufferList: [WebGLFramebuffer | null, WebGLFramebuffer | null] = [null, null]
let originTexture: WebGLTexture | null = null
let quadVertexBuffer: WebGLBuffer | null = null
let quadVertexArray: WebGLVertexArrayObject | null = null
let particleCount = 0
let textureSize = 0
let pingPongIndex = 0
let mouseX = 0
let mouseY = 0
let isMouseInside = false
const DISPLAY_TEXT = 'SWARM'
// ----- Physics pass shaders -----
const physicsVertexSource = `#version 300 es
in vec2 aPosition;
void main() {
gl_Position = vec4(aPosition, 0.0, 1.0);
}
`
const physicsFragmentSource = `#version 300 es
precision highp float;
uniform sampler2D uState;
uniform sampler2D uOrigin;
uniform vec2 uMouse;
uniform int uMouseInside;
uniform float uScatterRadius;
uniform float uScatterForce;
uniform float uReturnSpeed;
uniform float uFriction;
out vec4 fragColor;
void main() {
ivec2 coord = ivec2(gl_FragCoord.xy);
vec4 state = texelFetch(uState, coord, 0);
vec4 origin = texelFetch(uOrigin, coord, 0);
float x = state.x;
float y = state.y;
float velocityX = state.z;
float velocityY = state.w;
float originX = origin.x;
float originY = origin.y;
if (originX < -9999.0) {
fragColor = state;
return;
}
// Mouse radial push
if (uMouseInside == 1) {
vec2 delta = vec2(x, y) - uMouse;
float distance = length(delta);
float influence = smoothstep(uScatterRadius, 0.0, distance);
if (distance > 0.001) {
vec2 direction = delta / distance;
velocityX += direction.x * influence * uScatterForce;
velocityY += direction.y * influence * uScatterForce;
}
}
// Return to origin
velocityX += (originX - x) * uReturnSpeed;
velocityY += (originY - y) * uReturnSpeed;
// Friction
velocityX *= uFriction;
velocityY *= uFriction;
// Update position
x += velocityX;
y += velocityY;
fragColor = vec4(x, y, velocityX, velocityY);
}
`
// ----- Render pass shaders -----
const renderVertexSource = `#version 300 es
precision highp float;
uniform sampler2D uState;
uniform sampler2D uOrigin;
uniform int uTextureSize;
uniform vec2 uResolution;
out float vOpacity;
out float vDisplacement;
out float vSpeed;
void main() {
int texelX = gl_VertexID % uTextureSize;
int texelY = gl_VertexID / uTextureSize;
vec4 state = texelFetch(uState, ivec2(texelX, texelY), 0);
vec4 origin = texelFetch(uOrigin, ivec2(texelX, texelY), 0);
float x = state.x;
float y = state.y;
float velocityX = state.z;
float velocityY = state.w;
float originX = origin.x;
float originY = origin.y;
float size = origin.z;
float opacity = origin.w;
if (originX < -9999.0) {
gl_Position = vec4(-9999.0, -9999.0, 0.0, 1.0);
gl_PointSize = 0.0;
vOpacity = 0.0;
vDisplacement = 0.0;
vSpeed = 0.0;
return;
}
vec2 clipPosition = (vec2(x, y) / uResolution) * 2.0 - 1.0;
clipPosition.y *= -1.0;
gl_Position = vec4(clipPosition, 0.0, 1.0);
gl_PointSize = size;
vOpacity = opacity;
float distanceFromOrigin = length(vec2(x, y) - vec2(originX, originY));
vDisplacement = smoothstep(0.0, 40.0, distanceFromOrigin);
float speed = length(vec2(velocityX, velocityY));
vSpeed = smoothstep(0.0, 8.0, speed);
}
`
const renderFragmentSource = `#version 300 es
precision highp float;
in float vOpacity;
in float vDisplacement;
in float vSpeed;
out vec4 fragColor;
void main() {
fragColor = vec4(0.53, 0.53, 0.53, vOpacity);
}
`
// ----- Helper functions -----
function compileShader(
gl: WebGL2RenderingContext,
type: number,
source: string,
): WebGLShader | null {
const shader = gl.createShader(type)
if (!shader)
return null
gl.shaderSource(shader, source)
gl.compileShader(shader)
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error(gl.getShaderInfoLog(shader))
gl.deleteShader(shader)
return null
}
return shader
}
function linkProgram(
gl: WebGL2RenderingContext,
vertSource: string,
fragSource: string,
): WebGLProgram | null {
const vert = compileShader(gl, gl.VERTEX_SHADER, vertSource)
const frag = compileShader(gl, gl.FRAGMENT_SHADER, fragSource)
if (!vert || !frag)
return null
const program = gl.createProgram()
if (!program)
return null
gl.attachShader(program, vert)
gl.attachShader(program, frag)
gl.linkProgram(program)
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error(gl.getProgramInfoLog(program))
gl.deleteProgram(program)
return null
}
gl.deleteShader(vert)
gl.deleteShader(frag)
return program
}
function createFloatTexture(
gl: WebGL2RenderingContext,
size: number,
data: Float32Array,
): WebGLTexture | null {
const texture = gl.createTexture()
if (!texture)
return null
gl.bindTexture(gl.TEXTURE_2D, texture)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, size, size, 0, gl.RGBA, gl.FLOAT, data)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
return texture
}
// ----- Particle sampling from text -----
interface SampledParticle {
x: number;
y: number;
size: number;
opacity: number;
}
function sampleParticleListFromText(
text: string,
canvasWidth: number,
canvasHeight: number,
): SampledParticle[] {
const offscreen = document.createElement('canvas')
offscreen.width = canvasWidth
offscreen.height = canvasHeight
const context = offscreen.getContext('2d')
if (!context)
return []
const fontSize = Math.min(canvasWidth / (text.length * 0.7), canvasHeight * 0.6)
context.fillStyle = 'white'
context.font = `bold ${fontSize}px Arial, sans-serif`
context.textAlign = 'center'
context.textBaseline = 'middle'
context.fillText(text, canvasWidth / 2, canvasHeight / 2)
const imageData = context.getImageData(0, 0, canvasWidth, canvasHeight)
const pixelDataList = imageData.data
const gap = 1
const resultList: SampledParticle[] = []
for (let y = 0; y < canvasHeight; y += gap) {
for (let x = 0; x < canvasWidth; x += gap) {
const index = (y * canvasWidth + x) * 4
const alpha = pixelDataList[index + 3]
if (alpha > 128) {
resultList.push({
x,
y,
size: 2.0,
opacity: alpha / 255,
})
}
}
}
return resultList
}
// ----- Setup textures and framebuffers -----
function setupParticleTextures(
gl: WebGL2RenderingContext,
sampledParticleList: SampledParticle[],
): void {
particleCount = sampledParticleList.length
textureSize = Math.ceil(Math.sqrt(particleCount))
const totalPixelCount = textureSize * textureSize
const stateData = new Float32Array(totalPixelCount * 4)
const originData = new Float32Array(totalPixelCount * 4)
for (let i = 0; i < totalPixelCount; i++) {
if (i < particleCount) {
const particle = sampledParticleList[i]
stateData[i * 4 + 0] = particle.x
stateData[i * 4 + 1] = particle.y
stateData[i * 4 + 2] = 0
stateData[i * 4 + 3] = 0
originData[i * 4 + 0] = particle.x
originData[i * 4 + 1] = particle.y
originData[i * 4 + 2] = particle.size
originData[i * 4 + 3] = particle.opacity
}
else {
stateData[i * 4 + 0] = -99999
stateData[i * 4 + 1] = -99999
stateData[i * 4 + 2] = 0
stateData[i * 4 + 3] = 0
originData[i * 4 + 0] = -99999
originData[i * 4 + 1] = -99999
originData[i * 4 + 2] = 0
originData[i * 4 + 3] = 0
}
}
stateTextureList[0] = createFloatTexture(gl, textureSize, stateData)
stateTextureList[1] = createFloatTexture(gl, textureSize, new Float32Array(stateData))
originTexture = createFloatTexture(gl, textureSize, originData)
for (let i = 0; i < 2; i++) {
framebufferList[i] = gl.createFramebuffer()
gl.bindFramebuffer(gl.FRAMEBUFFER, framebufferList[i])
gl.framebufferTexture2D(
gl.FRAMEBUFFER,
gl.COLOR_ATTACHMENT0,
gl.TEXTURE_2D,
stateTextureList[i],
0,
)
}
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
}
function setupQuadBuffer(gl: WebGL2RenderingContext): void {
const positionList = new Float32Array([
-1,
-1,
1,
-1,
-1,
1,
-1,
1,
1,
-1,
1,
1,
])
quadVertexArray = gl.createVertexArray()
gl.bindVertexArray(quadVertexArray)
quadVertexBuffer = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, quadVertexBuffer)
gl.bufferData(gl.ARRAY_BUFFER, positionList, gl.STATIC_DRAW)
gl.enableVertexAttribArray(0)
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0)
gl.bindVertexArray(null)
}
// ----- Pointer events -----
function handlePointerMove(event: PointerEvent): void {
const canvas = canvasRef.value
if (!canvas)
return
const rect = canvas.getBoundingClientRect()
const devicePixelRatio = window.devicePixelRatio || 1
mouseX = (event.clientX - rect.left) * devicePixelRatio
mouseY = (event.clientY - rect.top) * devicePixelRatio
isMouseInside = true
}
function handlePointerLeave(): void {
isMouseInside = false
}
// ----- Init and render -----
function initWebGL(): void {
const canvas = canvasRef.value
if (!canvas)
return
const devicePixelRatio = window.devicePixelRatio || 1
const displayWidth = canvas.clientWidth
const displayHeight = canvas.clientHeight
canvas.width = displayWidth * devicePixelRatio
canvas.height = displayHeight * devicePixelRatio
glContext = canvas.getContext('webgl2')
if (!glContext) {
console.error('WebGL2 not supported')
return
}
const extColorFloat = glContext.getExtension('EXT_color_buffer_float')
if (!extColorFloat) {
console.error('EXT_color_buffer_float not supported')
return
}
physicsProgram = linkProgram(glContext, physicsVertexSource, physicsFragmentSource)
renderProgram = linkProgram(glContext, renderVertexSource, renderFragmentSource)
if (!physicsProgram || !renderProgram)
return
setupQuadBuffer(glContext)
const sampledParticleList = sampleParticleListFromText(
DISPLAY_TEXT,
canvas.width,
canvas.height,
)
setupParticleTextures(glContext, sampledParticleList)
canvas.addEventListener('pointermove', handlePointerMove)
canvas.addEventListener('pointerleave', handlePointerLeave)
}
function render(): void {
if (!glContext || !physicsProgram || !renderProgram)
return
const canvas = canvasRef.value
if (!canvas)
return
const gl = glContext
const readIndex = pingPongIndex
const writeIndex = 1 - pingPongIndex
// ----- Physics pass -----
gl.bindFramebuffer(gl.FRAMEBUFFER, framebufferList[writeIndex])
gl.viewport(0, 0, textureSize, textureSize)
gl.useProgram(physicsProgram)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, stateTextureList[readIndex])
gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uState'), 0)
gl.activeTexture(gl.TEXTURE1)
gl.bindTexture(gl.TEXTURE_2D, originTexture)
gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uOrigin'), 1)
gl.uniform2f(gl.getUniformLocation(physicsProgram, 'uMouse'), mouseX, mouseY)
gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uMouseInside'), isMouseInside ? 1 : 0)
gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uScatterRadius'), 60.0)
gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uScatterForce'), 40.0)
gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uReturnSpeed'), 0.1)
gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uFriction'), 0.92)
gl.bindVertexArray(quadVertexArray)
gl.drawArrays(gl.TRIANGLES, 0, 6)
gl.bindVertexArray(null)
pingPongIndex = writeIndex
// ----- Render pass -----
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
gl.viewport(0, 0, canvas.width, canvas.height)
gl.clearColor(0, 0, 0, 1)
gl.clear(gl.COLOR_BUFFER_BIT)
gl.useProgram(renderProgram)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, stateTextureList[writeIndex])
gl.uniform1i(gl.getUniformLocation(renderProgram, 'uState'), 0)
gl.activeTexture(gl.TEXTURE1)
gl.bindTexture(gl.TEXTURE_2D, originTexture)
gl.uniform1i(gl.getUniformLocation(renderProgram, 'uOrigin'), 1)
gl.uniform1i(gl.getUniformLocation(renderProgram, 'uTextureSize'), textureSize)
gl.uniform2f(
gl.getUniformLocation(renderProgram, 'uResolution'),
canvas.width,
canvas.height,
)
gl.drawArrays(gl.POINTS, 0, textureSize * textureSize)
animationFrameId = requestAnimationFrame(render)
}
onMounted(() => {
initWebGL()
animationFrameId = requestAnimationFrame(render)
})
onBeforeUnmount(() => {
cancelAnimationFrame(animationFrameId)
const canvas = canvasRef.value
if (canvas) {
canvas.removeEventListener('pointermove', handlePointerMove)
canvas.removeEventListener('pointerleave', handlePointerLeave)
}
if (glContext) {
if (physicsProgram)
glContext.deleteProgram(physicsProgram)
if (renderProgram)
glContext.deleteProgram(renderProgram)
for (const texture of stateTextureList) {
if (texture)
glContext.deleteTexture(texture)
}
if (originTexture)
glContext.deleteTexture(originTexture)
for (const framebuffer of framebufferList) {
if (framebuffer)
glContext.deleteFramebuffer(framebuffer)
}
if (quadVertexBuffer)
glContext.deleteBuffer(quadVertexBuffer)
if (quadVertexArray)
glContext.deleteVertexArray(quadVertexArray)
}
})
</script>
<template>
<div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6">
<span class="text-sm text-gray-600">
改良頂點著色器:每個粒子有獨立的大小、透明度、位移量與速度
</span>
<canvas
ref="canvasRef"
class="w-full rounded-lg"
style="height: 320px;"
/>
</div>
</template>

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

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

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

除了前面見過的 uState、uOrigin、uResolution,這裡多了兩個 uniform:

  • uTextureSize:狀態紋理的邊長(例如 128),用來把一維的頂點 ID 轉換成二維的紋理座標
  • uPointScale:粒子大小的縮放倍率,來自元件 props,讓外部能控制粒子的視覺大小
#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 是 WebGL2 的內建變數,代表「第幾個頂點」。我們不需要傳入任何頂點資料,只要知道 ID 就能從紋理中查到位置。

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

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

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

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

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

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

// Vertex Shader
out float vDisplacement; // 送出去
// Fragment Shader
in float vDisplacement; // 接收(名稱必須一致)

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

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

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

字首意思例子
aAttribute(來自 JavaScript 的頂點資料)aPosition
uUniform(來自 JavaScript 的全域常數)uMouse
vVarying(來自 Vertex Shader)vDisplacement

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

原始正方形
discard 裁圓
柔邊 + Alpha Blending
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
const canvasListRef = ref<HTMLCanvasElement[]>([])
interface GlResource {
gl: WebGL2RenderingContext;
program: WebGLProgram;
vao: WebGLVertexArrayObject;
buffer: WebGLBuffer;
}
const resourceList: GlResource[] = []
const PARTICLE_LIST = [
{ x: 0.3, y: 0.5, size: 80.0, opacity: 0.7 },
{ x: 0.5, y: 0.5, size: 80.0, opacity: 0.7 },
{ x: 0.7, y: 0.5, size: 80.0, opacity: 0.7 },
{ x: 0.4, y: 0.35, size: 60.0, opacity: 0.5 },
{ x: 0.6, y: 0.35, size: 60.0, opacity: 0.5 },
{ x: 0.5, y: 0.65, size: 60.0, opacity: 0.5 },
]
const VERTEX_SOURCE = `#version 300 es
precision highp float;
in vec2 aPosition;
in float aSize;
in float aOpacity;
out float vOpacity;
void main() {
vec2 clip = aPosition * 2.0 - 1.0;
clip.y *= -1.0;
gl_Position = vec4(clip, 0.0, 1.0);
gl_PointSize = aSize;
vOpacity = aOpacity;
}
`
// 1) 原始正方形:不做任何處理
const FRAG_SQUARE = `#version 300 es
precision highp float;
in float vOpacity;
out vec4 fragColor;
void main() {
fragColor = vec4(0.53, 0.53, 0.53, 1.0);
}
`
// 2) discard 裁圓,硬邊
const FRAG_CIRCLE_HARD = `#version 300 es
precision highp float;
in float vOpacity;
out vec4 fragColor;
void main() {
float dist = length(gl_PointCoord - 0.5) * 2.0;
if (dist > 1.0) discard;
fragColor = vec4(0.53, 0.53, 0.53, 1.0);
}
`
// 3) smoothstep 柔邊 + opacity + alpha blending
const FRAG_SOFT_BLEND = `#version 300 es
precision highp float;
in float vOpacity;
out vec4 fragColor;
void main() {
float dist = length(gl_PointCoord - 0.5) * 2.0;
if (dist > 1.0) discard;
float alpha = smoothstep(1.0, 0.4, dist);
fragColor = vec4(0.53, 0.53, 0.53, vOpacity * alpha);
}
`
const FRAGMENT_SOURCE_LIST = [FRAG_SQUARE, FRAG_CIRCLE_HARD, FRAG_SOFT_BLEND]
function compileShader(
gl: WebGL2RenderingContext,
type: number,
source: string,
): WebGLShader | null {
const shader = gl.createShader(type)
if (!shader)
return null
gl.shaderSource(shader, source)
gl.compileShader(shader)
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error(gl.getShaderInfoLog(shader))
gl.deleteShader(shader)
return null
}
return shader
}
function linkProgram(
gl: WebGL2RenderingContext,
vertSource: string,
fragSource: string,
): WebGLProgram | null {
const vert = compileShader(gl, gl.VERTEX_SHADER, vertSource)
const frag = compileShader(gl, gl.FRAGMENT_SHADER, fragSource)
if (!vert || !frag)
return null
const program = gl.createProgram()
if (!program)
return null
gl.attachShader(program, vert)
gl.attachShader(program, frag)
gl.linkProgram(program)
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error(gl.getProgramInfoLog(program))
gl.deleteProgram(program)
return null
}
gl.deleteShader(vert)
gl.deleteShader(frag)
return program
}
function initCanvas(canvas: HTMLCanvasElement, fragSource: string, enableBlend: boolean): void {
const dpr = window.devicePixelRatio || 1
const logicalWidth = canvas.clientWidth
const logicalHeight = canvas.clientHeight
canvas.width = logicalWidth * dpr
canvas.height = logicalHeight * dpr
const gl = canvas.getContext('webgl2', {
premultipliedAlpha: false,
alpha: true,
})
if (!gl)
return
const program = linkProgram(gl, VERTEX_SOURCE, fragSource)
if (!program)
return
const vertexData = new Float32Array(PARTICLE_LIST.length * 4)
for (let i = 0; i < PARTICLE_LIST.length; i++) {
const particle = PARTICLE_LIST[i]!
vertexData[i * 4 + 0] = particle.x
vertexData[i * 4 + 1] = particle.y
vertexData[i * 4 + 2] = particle.size * dpr
vertexData[i * 4 + 3] = particle.opacity
}
const vao = gl.createVertexArray()!
gl.bindVertexArray(vao)
const buffer = gl.createBuffer()!
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
gl.bufferData(gl.ARRAY_BUFFER, vertexData, gl.STATIC_DRAW)
const stride = 4 * 4
const posLoc = gl.getAttribLocation(program, 'aPosition')
gl.enableVertexAttribArray(posLoc)
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, stride, 0)
const sizeLoc = gl.getAttribLocation(program, 'aSize')
gl.enableVertexAttribArray(sizeLoc)
gl.vertexAttribPointer(sizeLoc, 1, gl.FLOAT, false, stride, 8)
const opacityLoc = gl.getAttribLocation(program, 'aOpacity')
gl.enableVertexAttribArray(opacityLoc)
gl.vertexAttribPointer(opacityLoc, 1, gl.FLOAT, false, stride, 12)
gl.bindVertexArray(null)
resourceList.push({ gl, program, vao, buffer })
// Draw
gl.viewport(0, 0, canvas.width, canvas.height)
gl.clearColor(0.07, 0.07, 0.07, 1)
gl.clear(gl.COLOR_BUFFER_BIT)
if (enableBlend) {
gl.enable(gl.BLEND)
gl.blendFuncSeparate(
gl.SRC_ALPHA,
gl.ONE_MINUS_SRC_ALPHA,
gl.ONE,
gl.ONE_MINUS_SRC_ALPHA,
)
}
gl.useProgram(program)
gl.bindVertexArray(vao)
gl.drawArrays(gl.POINTS, 0, PARTICLE_LIST.length)
gl.bindVertexArray(null)
}
onMounted(() => {
const canvasList = canvasListRef.value
if (canvasList.length < 3)
return
initCanvas(canvasList[0]!, FRAGMENT_SOURCE_LIST[0]!, false)
initCanvas(canvasList[1]!, FRAGMENT_SOURCE_LIST[1]!, false)
initCanvas(canvasList[2]!, FRAGMENT_SOURCE_LIST[2]!, true)
})
onBeforeUnmount(() => {
for (const resource of resourceList) {
resource.gl.deleteProgram(resource.program)
resource.gl.deleteBuffer(resource.buffer)
resource.gl.deleteVertexArray(resource.vao)
}
resourceList.length = 0
})
</script>
<template>
<div class="flex flex-col gap-3 border border-gray-200 rounded-xl p-6">
<div class="grid grid-cols-3 gap-3">
<div class="flex flex-col items-center gap-1">
<canvas
:ref="(el: any) => { if (el) canvasListRef[0] = el }"
class="w-full rounded-lg"
style="height: 180px;"
/>
<span class="text-xs text-gray-500">
原始正方形
</span>
</div>
<div class="flex flex-col items-center gap-1">
<canvas
:ref="(el: any) => { if (el) canvasListRef[1] = el }"
class="w-full rounded-lg"
style="height: 180px;"
/>
<span class="text-xs text-gray-500">
discard 裁圓
</span>
</div>
<div class="flex flex-col items-center gap-1">
<canvas
:ref="(el: any) => { if (el) canvasListRef[2] = el }"
class="w-full rounded-lg"
style="height: 180px;"
/>
<span class="text-xs text-gray-500">
柔邊 + Alpha Blending
</span>
</div>
</div>
</div>
</template>

gl.POINTS 畫出來的是正方形。gl_PointCoord 提供正方形內的 UV 座標(0~1)。

uColor 是粒子的顏色,由 JavaScript 將元件的 particleColor prop 轉成 vec3(RGB 0~1)後傳入。

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 是 GLSL 的關鍵字,意思是「這個像素不要了,什麼都不畫」。被 discard 的像素不會影響 Framebuffer,就像從來沒存在過一樣。

smoothstep(1.0, 0.4, dist) 讓粒子邊緣不是硬切的。距離從 0.4 到 1.0 之間會平滑過渡到透明,看起來像微微發光的小點,而不是銳利的圓形。

粒子互相重疊時需要混色:

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

這是標準的 alpha 混合模式。多個半透明粒子疊在一起,顏色自然地混合,不會互相蓋掉。

模擬與繪製邏輯都完成了!✧⁑。٩(ˊᗜˋ*)و✧⁕。

最後讓我們加入 Curl Noise 和顏色變化,讓粒子更有層次,動作更像蟲群 ◝( •ω• )◟

目前粒子被推開後直線回來,少了自然界那種混沌又有秩序的美感。(´・ω・`)

Curl Noise 會讓粒子沿著漩渦走,看起來就像被風捲著跑。

回補原始版的渲染層次:多段位移、時間性微飄動、位移混色與完整指標慣性,讓回歸時更有渦流感。
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
const canvasRef = ref<HTMLCanvasElement | null>(null)
let glContext: WebGL2RenderingContext | null = null
let physicsProgram: WebGLProgram | null = null
let renderProgram: WebGLProgram | null = null
let noiseProgram: WebGLProgram | null = null
let animationFrameId = 0
const stateTextureList: [WebGLTexture | null, WebGLTexture | null] = [null, null]
const framebufferList: [WebGLFramebuffer | null, WebGLFramebuffer | null] = [null, null]
let originTexture: WebGLTexture | null = null
let noiseTexture: WebGLTexture | null = null
let noiseFramebuffer: WebGLFramebuffer | null = null
let quadVertexBuffer: WebGLBuffer | null = null
let quadVertexArray: WebGLVertexArrayObject | null = null
let particleVertexArray: WebGLVertexArrayObject | null = null
let particleCount = 0
let textureSize = 0
let pingPongIndex = 0
let startTime = 0
let logicalWidth = 0
let logicalHeight = 0
let mouseX = 0
let mouseY = 0
let previousMouseX = -9999
let previousMouseY = -9999
let mouseVelocityX = 0
let mouseVelocityY = 0
let isMouseInside = false
const DISPLAY_TEXT = 'SWARM'
const NOISE_SIZE = 256
// ----- Noise generation shader -----
const noiseVertexSource = `#version 300 es
in vec2 aPosition;
void main() {
gl_Position = vec4(aPosition, 0.0, 1.0);
}
`
const noiseFragmentSource = `#version 300 es
precision highp float;
out vec4 fragColor;
float hash(vec2 p) {
vec3 p3 = fract(vec3(p.xyx) * 0.13);
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
float noise(vec2 x) {
vec2 i = floor(x); vec2 f = fract(x);
float a = hash(i); float b = hash(i + vec2(1.0, 0.0));
float c = hash(i + vec2(0.0, 1.0)); float d = hash(i + vec2(1.0, 1.0));
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y;
}
float fbm(vec2 p) {
float v = 0.0; float a = 0.5;
for (int i = 0; i < 4; i++) { v += a * noise(p); p *= 2.0; a *= 0.5; }
return v;
}
void main() {
vec2 uv = gl_FragCoord.xy / 256.0;
vec2 p = uv * 6.0;
const float e = 0.1;
float a = (fbm(p + vec2(0.0, e)) - fbm(p - vec2(0.0, e))) / (2.0 * e);
float b = (fbm(p + vec2(e, 0.0)) - fbm(p - vec2(e, 0.0))) / (2.0 * e);
fragColor = vec4(a, -b, 0.0, 1.0);
}
`
// ----- Physics pass shaders -----
const physicsVertexSource = `#version 300 es
in vec2 aPosition;
void main() {
gl_Position = vec4(aPosition, 0.0, 1.0);
}
`
const physicsFragmentSource = `#version 300 es
precision highp float;
uniform sampler2D uState;
uniform sampler2D uOrigin;
uniform sampler2D uNoise;
uniform vec2 uMouse;
uniform vec2 uMouseVelocity;
uniform int uMouseInside;
uniform float uScatterRadius;
uniform float uScatterForce;
uniform float uReturnSpeed;
uniform float uFriction;
uniform float uTime;
out vec4 fragColor;
float hash(vec2 p) {
vec3 p3 = fract(vec3(p.xyx) * 0.13);
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
void main() {
ivec2 coord = ivec2(gl_FragCoord.xy);
vec4 state = texelFetch(uState, coord, 0);
vec4 origin = texelFetch(uOrigin, coord, 0);
vec2 pos = state.xy;
vec2 vel = state.zw;
vec2 originPos = origin.xy;
if (originPos.x < -9999.0) {
fragColor = state;
return;
}
float pRand = hash(vec2(float(coord.x), float(coord.y)));
float forceScale = 0.5 + pRand * 1.0;
if (uMouseInside == 1) {
vec2 toParticle = pos - uMouse;
float dist = length(toParticle);
if (dist < uScatterRadius) {
float influence = smoothstep(uScatterRadius, 0.0, dist);
float mouseSpeed = length(uMouseVelocity);
if (mouseSpeed > 0.5) {
vec2 windDir = uMouseVelocity / mouseSpeed;
vel += windDir * mouseSpeed * influence * uScatterForce * 0.025 * forceScale;
}
vec2 noiseUV = pos * 0.005 + uTime * 0.15 + pRand * 10.0;
vec2 curl = texture(uNoise, noiseUV).xy;
float turbStrength = (1.5 + mouseSpeed * 0.4) * influence * uScatterForce * 0.12 * forceScale;
vel += curl * turbStrength;
if (dist > 0.01) {
vel += (toParticle / dist) * influence * influence * uScatterForce * 0.5 * forceScale;
}
}
}
vec2 toOrigin = originPos - pos;
float distFromOrigin = length(toOrigin);
if (distFromOrigin > 2.0) {
float displacement = smoothstep(2.0, 40.0, distFromOrigin);
vec2 driftUV = pos * 0.002 + uTime * 0.08 + pRand * 0.3;
vec2 drift = texture(uNoise, driftUV).xy;
vel += drift * displacement * 1.5 * forceScale;
}
float particleFriction = uFriction - pRand * 0.04;
vel *= particleFriction;
pos += vel;
float returnRate = uReturnSpeed * (0.6 + pRand * 0.8);
pos = mix(pos, originPos, returnRate);
fragColor = vec4(pos, vel);
}
`
// ----- Render pass shaders -----
const renderVertexSource = `#version 300 es
precision highp float;
uniform sampler2D uState;
uniform sampler2D uOrigin;
uniform int uTextureSize;
uniform vec2 uResolution;
uniform float uPointScale;
uniform float uTime;
out float vOpacity;
out float vDisplacement;
out float vSpeed;
void main() {
int index = gl_VertexID;
int texelX = index % uTextureSize;
int texelY = index / uTextureSize;
vec4 state = texelFetch(uState, ivec2(texelX, texelY), 0);
vec4 origin = texelFetch(uOrigin, ivec2(texelX, texelY), 0);
vec2 pos = state.xy;
vec2 vel = state.zw;
vec2 originPos = origin.xy;
float size = origin.z;
float opacity = origin.w;
if (originPos.x < -9999.0) {
gl_Position = vec4(-2.0, -2.0, 0.0, 1.0);
gl_PointSize = 0.0;
vOpacity = 0.0;
vDisplacement = 0.0;
vSpeed = 0.0;
return;
}
float distFromOrigin = length(pos - originPos);
float speed = length(vel);
float displacementNear = smoothstep(0.0, 8.0, distFromOrigin);
float displacementMid = smoothstep(8.0, 30.0, distFromOrigin);
float displacementFar = smoothstep(30.0, 80.0, distFromOrigin);
float displacement = displacementNear * 0.3 + displacementMid * 0.4 + displacementFar * 0.3;
float speedFactor = smoothstep(0.0, 8.0, speed);
float phase = float(index) * 1.618033988;
pos.x += sin(phase + uTime * 0.001) * 0.2;
pos.y += cos(phase * 1.3 + uTime * 0.0008) * 0.2;
vec2 clipPosition = (pos / uResolution) * 2.0 - 1.0;
clipPosition.y *= -1.0;
gl_Position = vec4(clipPosition, 0.0, 1.0);
gl_PointSize = size * 2.0 * uPointScale;
vOpacity = opacity;
vDisplacement = displacement;
vSpeed = speedFactor;
}
`
const renderFragmentSource = `#version 300 es
precision highp float;
in float vOpacity;
in float vDisplacement;
in float vSpeed;
out vec4 fragColor;
void main() {
float dist = length(gl_PointCoord - 0.5) * 2.0;
if (dist > 1.0) discard;
float alpha = smoothstep(1.0, 0.4, dist) * vOpacity;
fragColor = vec4(0.53, 0.53, 0.53, alpha);
}
`
// ----- Helper functions -----
function compileShader(
gl: WebGL2RenderingContext,
type: number,
source: string,
): WebGLShader | null {
const shader = gl.createShader(type)
if (!shader)
return null
gl.shaderSource(shader, source)
gl.compileShader(shader)
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error(gl.getShaderInfoLog(shader))
gl.deleteShader(shader)
return null
}
return shader
}
function linkProgram(
gl: WebGL2RenderingContext,
vertSource: string,
fragSource: string,
): WebGLProgram | null {
const vert = compileShader(gl, gl.VERTEX_SHADER, vertSource)
const frag = compileShader(gl, gl.FRAGMENT_SHADER, fragSource)
if (!vert || !frag)
return null
const program = gl.createProgram()
if (!program)
return null
gl.attachShader(program, vert)
gl.attachShader(program, frag)
gl.linkProgram(program)
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error(gl.getProgramInfoLog(program))
gl.deleteProgram(program)
return null
}
gl.deleteShader(vert)
gl.deleteShader(frag)
return program
}
function createFloatTexture(
gl: WebGL2RenderingContext,
size: number,
data: Float32Array,
): WebGLTexture | null {
const texture = gl.createTexture()
if (!texture)
return null
gl.bindTexture(gl.TEXTURE_2D, texture)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, size, size, 0, gl.RGBA, gl.FLOAT, data)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
return texture
}
// ----- Particle sampling from text -----
interface SampledParticle {
x: number;
y: number;
size: number;
opacity: number;
}
function sampleParticleListFromText(
text: string,
canvasWidth: number,
canvasHeight: number,
): SampledParticle[] {
const offscreen = document.createElement('canvas')
offscreen.width = canvasWidth
offscreen.height = canvasHeight
const context = offscreen.getContext('2d')
if (!context)
return []
const fontSize = Math.min(canvasWidth / (text.length * 0.7), canvasHeight * 0.6)
context.fillStyle = 'white'
context.font = `bold ${fontSize}px Arial, sans-serif`
context.textAlign = 'center'
context.textBaseline = 'middle'
context.fillText(text, canvasWidth / 2, canvasHeight / 2)
const imageData = context.getImageData(0, 0, canvasWidth, canvasHeight)
const pixelDataList = imageData.data
const gap = 1
const resultList: SampledParticle[] = []
for (let y = 0; y < canvasHeight; y += gap) {
for (let x = 0; x < canvasWidth; x += gap) {
const pixelX = Math.floor(x)
const pixelY = Math.floor(y)
const index = (pixelY * canvasWidth + pixelX) * 4
const alpha = (pixelDataList[index + 3] ?? 0) / 255
if (alpha > 0.15) {
resultList.push({
x,
y,
size: 1.5 * (0.4 + alpha * 0.6),
opacity: alpha * (0.8 + Math.random() * 0.2),
})
}
}
}
return resultList
}
// ----- Setup textures and framebuffers -----
function setupParticleTextures(
gl: WebGL2RenderingContext,
sampledParticleList: SampledParticle[],
): void {
particleCount = sampledParticleList.length
textureSize = Math.ceil(Math.sqrt(particleCount))
const totalPixelCount = textureSize * textureSize
const stateData = new Float32Array(totalPixelCount * 4)
const originData = new Float32Array(totalPixelCount * 4)
for (let i = 0; i < totalPixelCount; i++) {
if (i < particleCount) {
const particle = sampledParticleList[i]
stateData[i * 4 + 0] = particle?.x ?? 0
stateData[i * 4 + 1] = particle?.y ?? 0
stateData[i * 4 + 2] = 0
stateData[i * 4 + 3] = 0
originData[i * 4 + 0] = particle?.x ?? 0
originData[i * 4 + 1] = particle?.y ?? 0
originData[i * 4 + 2] = particle?.size ?? 0
originData[i * 4 + 3] = particle?.opacity ?? 0
}
else {
stateData[i * 4 + 0] = -99999
stateData[i * 4 + 1] = -99999
stateData[i * 4 + 2] = 0
stateData[i * 4 + 3] = 0
originData[i * 4 + 0] = -99999
originData[i * 4 + 1] = -99999
originData[i * 4 + 2] = 0
originData[i * 4 + 3] = 0
}
}
stateTextureList[0] = createFloatTexture(gl, textureSize, stateData)
stateTextureList[1] = createFloatTexture(gl, textureSize, new Float32Array(stateData))
originTexture = createFloatTexture(gl, textureSize, originData)
for (let i = 0; i < 2; i++) {
framebufferList[i] = gl.createFramebuffer()
gl.bindFramebuffer(gl.FRAMEBUFFER, framebufferList[i])
gl.framebufferTexture2D(
gl.FRAMEBUFFER,
gl.COLOR_ATTACHMENT0,
gl.TEXTURE_2D,
stateTextureList[i],
0,
)
}
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
}
function generateNoiseTexture(gl: WebGL2RenderingContext): void {
noiseProgram = linkProgram(gl, noiseVertexSource, noiseFragmentSource)
if (!noiseProgram)
return
noiseTexture = gl.createTexture()
gl.bindTexture(gl.TEXTURE_2D, noiseTexture)
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA32F,
NOISE_SIZE,
NOISE_SIZE,
0,
gl.RGBA,
gl.FLOAT,
null,
)
const hasFloatLinear = !!gl.getExtension('OES_texture_float_linear')
const noiseFilter = hasFloatLinear ? gl.LINEAR : gl.NEAREST
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, noiseFilter)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, noiseFilter)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT)
noiseFramebuffer = gl.createFramebuffer()
gl.bindFramebuffer(gl.FRAMEBUFFER, noiseFramebuffer)
gl.framebufferTexture2D(
gl.FRAMEBUFFER,
gl.COLOR_ATTACHMENT0,
gl.TEXTURE_2D,
noiseTexture,
0,
)
gl.viewport(0, 0, NOISE_SIZE, NOISE_SIZE)
gl.useProgram(noiseProgram)
gl.bindVertexArray(quadVertexArray)
gl.drawArrays(gl.TRIANGLES, 0, 6)
gl.bindVertexArray(null)
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
gl.deleteProgram(noiseProgram)
noiseProgram = null
}
function setupBuffers(gl: WebGL2RenderingContext): void {
const positionList = new Float32Array([
-1,
-1,
1,
-1,
-1,
1,
-1,
1,
1,
-1,
1,
1,
])
quadVertexArray = gl.createVertexArray()
gl.bindVertexArray(quadVertexArray)
quadVertexBuffer = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, quadVertexBuffer)
gl.bufferData(gl.ARRAY_BUFFER, positionList, gl.STATIC_DRAW)
const positionLocation = 0
gl.enableVertexAttribArray(positionLocation)
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0)
gl.bindVertexArray(null)
particleVertexArray = gl.createVertexArray()
}
// ----- Pointer events -----
function handlePointerDown(event: PointerEvent): void {
const target = event.currentTarget
if (target instanceof Element)
target.setPointerCapture(event.pointerId)
handlePointerMove(event)
}
function handlePointerMove(event: PointerEvent): void {
const newX = event.offsetX
const newY = event.offsetY
if (previousMouseX > -9000 && previousMouseY > -9000) {
mouseVelocityX = newX - previousMouseX
mouseVelocityY = newY - previousMouseY
}
previousMouseX = newX
previousMouseY = newY
mouseX = newX
mouseY = newY
isMouseInside = true
}
function handlePointerLeave(): void {
isMouseInside = false
previousMouseX = -9999
previousMouseY = -9999
}
// ----- Init and render -----
function initWebGL(): void {
const canvas = canvasRef.value
if (!canvas)
return
const dpr = window.devicePixelRatio || 1
logicalWidth = canvas.clientWidth
logicalHeight = canvas.clientHeight
canvas.width = logicalWidth * dpr
canvas.height = logicalHeight * dpr
glContext = canvas.getContext('webgl2', {
premultipliedAlpha: false,
alpha: true,
})
if (!glContext) {
console.error('WebGL2 not supported')
return
}
const extColorFloat = glContext.getExtension('EXT_color_buffer_float')
if (!extColorFloat) {
console.error('EXT_color_buffer_float not supported')
return
}
physicsProgram = linkProgram(glContext, physicsVertexSource, physicsFragmentSource)
renderProgram = linkProgram(glContext, renderVertexSource, renderFragmentSource)
if (!physicsProgram || !renderProgram)
return
setupBuffers(glContext)
generateNoiseTexture(glContext)
const sampledParticleList = sampleParticleListFromText(
DISPLAY_TEXT,
logicalWidth,
logicalHeight,
)
setupParticleTextures(glContext, sampledParticleList)
glContext.enable(glContext.BLEND)
glContext.blendFuncSeparate(
glContext.SRC_ALPHA,
glContext.ONE_MINUS_SRC_ALPHA,
glContext.ONE,
glContext.ONE_MINUS_SRC_ALPHA,
)
previousMouseX = -9999
previousMouseY = -9999
mouseVelocityX = 0
mouseVelocityY = 0
startTime = performance.now()
canvas.addEventListener('pointerdown', handlePointerDown)
canvas.addEventListener('pointermove', handlePointerMove)
canvas.addEventListener('pointerup', handlePointerLeave)
canvas.addEventListener('pointerleave', handlePointerLeave)
}
function render(): void {
if (!glContext || !physicsProgram || !renderProgram)
return
const canvas = canvasRef.value
if (!canvas)
return
const gl = glContext
const currentTime = (performance.now() - startTime) * 0.001
mouseVelocityX *= 0.92
mouseVelocityY *= 0.92
const readIndex = pingPongIndex
const writeIndex = 1 - pingPongIndex
gl.disable(gl.BLEND)
gl.bindFramebuffer(gl.FRAMEBUFFER, framebufferList[writeIndex])
gl.viewport(0, 0, textureSize, textureSize)
gl.useProgram(physicsProgram)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, stateTextureList[readIndex])
gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uState'), 0)
gl.activeTexture(gl.TEXTURE1)
gl.bindTexture(gl.TEXTURE_2D, originTexture)
gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uOrigin'), 1)
gl.activeTexture(gl.TEXTURE2)
gl.bindTexture(gl.TEXTURE_2D, noiseTexture)
gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uNoise'), 2)
gl.uniform2f(gl.getUniformLocation(physicsProgram, 'uMouse'), mouseX, mouseY)
gl.uniform2f(gl.getUniformLocation(physicsProgram, 'uMouseVelocity'), mouseVelocityX, mouseVelocityY)
gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uMouseInside'), isMouseInside ? 1 : 0)
gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uScatterRadius'), 40.0)
gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uScatterForce'), 40.0)
gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uReturnSpeed'), 0.1)
gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uFriction'), 0.92)
gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uTime'), currentTime)
gl.bindVertexArray(quadVertexArray)
gl.drawArrays(gl.TRIANGLES, 0, 6)
gl.bindVertexArray(null)
pingPongIndex = writeIndex
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
gl.viewport(0, 0, canvas.width, canvas.height)
gl.clearColor(0, 0, 0, 0)
gl.clear(gl.COLOR_BUFFER_BIT)
gl.enable(gl.BLEND)
gl.blendFuncSeparate(
gl.SRC_ALPHA,
gl.ONE_MINUS_SRC_ALPHA,
gl.ONE,
gl.ONE_MINUS_SRC_ALPHA,
)
gl.useProgram(renderProgram)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, stateTextureList[writeIndex])
gl.uniform1i(gl.getUniformLocation(renderProgram, 'uState'), 0)
gl.activeTexture(gl.TEXTURE1)
gl.bindTexture(gl.TEXTURE_2D, originTexture)
gl.uniform1i(gl.getUniformLocation(renderProgram, 'uOrigin'), 1)
const dpr = window.devicePixelRatio || 1
gl.uniform1i(gl.getUniformLocation(renderProgram, 'uTextureSize'), textureSize)
gl.uniform2f(
gl.getUniformLocation(renderProgram, 'uResolution'),
logicalWidth,
logicalHeight,
)
gl.uniform1f(gl.getUniformLocation(renderProgram, 'uPointScale'), dpr)
gl.uniform1f(gl.getUniformLocation(renderProgram, 'uTime'), performance.now())
gl.bindVertexArray(particleVertexArray)
gl.drawArrays(gl.POINTS, 0, particleCount)
gl.bindVertexArray(null)
animationFrameId = requestAnimationFrame(render)
}
onMounted(() => {
initWebGL()
animationFrameId = requestAnimationFrame(render)
})
onBeforeUnmount(() => {
cancelAnimationFrame(animationFrameId)
const canvas = canvasRef.value
if (canvas) {
canvas.removeEventListener('pointerdown', handlePointerDown)
canvas.removeEventListener('pointermove', handlePointerMove)
canvas.removeEventListener('pointerup', handlePointerLeave)
canvas.removeEventListener('pointerleave', handlePointerLeave)
}
if (glContext) {
if (physicsProgram)
glContext.deleteProgram(physicsProgram)
if (renderProgram)
glContext.deleteProgram(renderProgram)
for (const texture of stateTextureList) {
if (texture)
glContext.deleteTexture(texture)
}
if (originTexture)
glContext.deleteTexture(originTexture)
if (noiseTexture)
glContext.deleteTexture(noiseTexture)
for (const framebuffer of framebufferList) {
if (framebuffer)
glContext.deleteFramebuffer(framebuffer)
}
if (noiseFramebuffer)
glContext.deleteFramebuffer(noiseFramebuffer)
if (quadVertexBuffer)
glContext.deleteBuffer(quadVertexBuffer)
if (quadVertexArray)
glContext.deleteVertexArray(quadVertexArray)
if (particleVertexArray)
glContext.deleteVertexArray(particleVertexArray)
}
})
</script>
<template>
<div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6">
<span class="text-sm text-gray-600">
回補原始版的渲染層次:多段位移、時間性微飄動、位移混色與完整指標慣性,讓回歸時更有渦流感。
</span>
<canvas
ref="canvasRef"
class="w-full rounded-lg"
style="height: 320px;"
/>
</div>
</template>

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

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

Math.random()
Curl Noise
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
const containerRef = ref<HTMLElement | null>(null)
const randomCanvasRef = ref<HTMLCanvasElement | null>(null)
const curlCanvasRef = ref<HTMLCanvasElement | null>(null)
const PARTICLE_COUNT = 600
const CANVAS_SIZE = 280
const TRAIL_ALPHA = 0.06
let animationFrameId = 0
interface Particle {
x: number;
y: number;
velocityX: number;
velocityY: number;
}
// ---- Curl Noise utilities ----
function hash(x: number, y: number): number {
const p3x = ((x * 0.13) % 1 + 1) % 1
const p3y = ((y * 0.13) % 1 + 1) % 1
const p3z = ((x * 0.13) % 1 + 1) % 1
const dot = p3x * (p3y + 33.33) + p3y * (p3z + 33.33) + p3z * (p3x + 33.33)
return ((dot * dot) % 1 + 1) % 1
}
function smoothNoise(x: number, y: number): number {
const ix = Math.floor(x)
const iy = Math.floor(y)
const fx = x - ix
const fy = y - iy
const a = hash(ix, iy)
const b = hash(ix + 1, iy)
const c = hash(ix, iy + 1)
const d = hash(ix + 1, iy + 1)
const ux = fx * fx * (3 - 2 * fx)
const uy = fy * fy * (3 - 2 * fy)
return a + (b - a) * ux + (c - a) * uy * (1 - ux) + (d - b) * ux * uy
}
function fbm(x: number, y: number): number {
let value = 0
let amplitude = 0.5
let px = x
let py = y
for (let i = 0; i < 4; i++) {
value += amplitude * smoothNoise(px, py)
px *= 2
py *= 2
amplitude *= 0.5
}
return value
}
function curlNoise(x: number, y: number): { x: number; y: number } {
const epsilon = 0.1
const a = (fbm(x, y + epsilon) - fbm(x, y - epsilon)) / (2 * epsilon)
const b = (fbm(x + epsilon, y) - fbm(x - epsilon, y)) / (2 * epsilon)
return { x: a, y: -b }
}
// ---- Particle creation ----
function createParticleList(): Particle[] {
const list: Particle[] = []
for (let i = 0; i < PARTICLE_COUNT; i++) {
list.push({
x: CANVAS_SIZE / 2 + (Math.random() - 0.5) * 40,
y: CANVAS_SIZE / 2 + (Math.random() - 0.5) * 40,
velocityX: 0,
velocityY: 0,
})
}
return list
}
let randomParticleList: Particle[] = []
let curlParticleList: Particle[] = []
let time = 0
function updateRandomParticleList(): void {
for (const particle of randomParticleList) {
const angle = Math.random() * Math.PI * 2
const strength = 1.2
particle.velocityX += Math.cos(angle) * strength
particle.velocityY += Math.sin(angle) * strength
particle.velocityX *= 0.92
particle.velocityY *= 0.92
particle.x += particle.velocityX
particle.y += particle.velocityY
// 邊界回彈
if (particle.x < 0 || particle.x > CANVAS_SIZE) {
particle.velocityX *= -0.5
particle.x = Math.max(0, Math.min(CANVAS_SIZE, particle.x))
}
if (particle.y < 0 || particle.y > CANVAS_SIZE) {
particle.velocityY *= -0.5
particle.y = Math.max(0, Math.min(CANVAS_SIZE, particle.y))
}
}
}
function updateCurlParticleList(): void {
time += 0.01
for (const particle of curlParticleList) {
const scale = 0.012
const curl = curlNoise(
particle.x * scale + time * 0.8,
particle.y * scale + time * 0.5,
)
const strength = 1.2
particle.velocityX += curl.x * strength
particle.velocityY += curl.y * strength
particle.velocityX *= 0.92
particle.velocityY *= 0.92
particle.x += particle.velocityX
particle.y += particle.velocityY
// 邊界回彈
if (particle.x < 0 || particle.x > CANVAS_SIZE) {
particle.velocityX *= -0.5
particle.x = Math.max(0, Math.min(CANVAS_SIZE, particle.x))
}
if (particle.y < 0 || particle.y > CANVAS_SIZE) {
particle.velocityY *= -0.5
particle.y = Math.max(0, Math.min(CANVAS_SIZE, particle.y))
}
}
}
function drawParticleList(
context: CanvasRenderingContext2D,
particleList: Particle[],
color: string,
dpr: number,
): void {
// 半透明背景覆蓋產生拖尾
context.fillStyle = `rgba(24, 24, 27, ${TRAIL_ALPHA})`
context.fillRect(0, 0, CANVAS_SIZE * dpr, CANVAS_SIZE * dpr)
context.fillStyle = color
for (const particle of particleList) {
const speed = Math.sqrt(
particle.velocityX * particle.velocityX
+ particle.velocityY * particle.velocityY,
)
const radius = Math.min(1 + speed * 0.15, 2.5) * dpr
context.beginPath()
context.arc(particle.x * dpr, particle.y * dpr, radius, 0, Math.PI * 2)
context.fill()
}
}
function animate(): void {
const randomCanvas = randomCanvasRef.value
const curlCanvas = curlCanvasRef.value
if (!randomCanvas || !curlCanvas)
return
const dpr = window.devicePixelRatio || 1
const randomContext = randomCanvas.getContext('2d')
const curlContext = curlCanvas.getContext('2d')
if (!randomContext || !curlContext)
return
updateRandomParticleList()
updateCurlParticleList()
drawParticleList(randomContext, randomParticleList, 'rgba(160, 160, 170, 0.8)', dpr)
drawParticleList(curlContext, curlParticleList, 'rgba(130, 180, 255, 0.8)', dpr)
animationFrameId = requestAnimationFrame(animate)
}
function initCanvas(): void {
const randomCanvas = randomCanvasRef.value
const curlCanvas = curlCanvasRef.value
if (!randomCanvas || !curlCanvas)
return
const dpr = window.devicePixelRatio || 1
randomCanvas.width = CANVAS_SIZE * dpr
randomCanvas.height = CANVAS_SIZE * dpr
curlCanvas.width = CANVAS_SIZE * dpr
curlCanvas.height = CANVAS_SIZE * dpr
// 初始填充背景
const randomContext = randomCanvas.getContext('2d')
const curlContext = curlCanvas.getContext('2d')
if (randomContext) {
randomContext.fillStyle = 'rgb(24, 24, 27)'
randomContext.fillRect(0, 0, CANVAS_SIZE * dpr, CANVAS_SIZE * dpr)
}
if (curlContext) {
curlContext.fillStyle = 'rgb(24, 24, 27)'
curlContext.fillRect(0, 0, CANVAS_SIZE * dpr, CANVAS_SIZE * dpr)
}
randomParticleList = createParticleList()
curlParticleList = createParticleList()
time = 0
}
onMounted(() => {
initCanvas()
animationFrameId = requestAnimationFrame(animate)
})
onBeforeUnmount(() => {
cancelAnimationFrame(animationFrameId)
})
</script>
<template>
<div
ref="containerRef"
class="flex flex-col gap-4 border border-gray-200 rounded-xl p-6"
>
<div class="grid grid-cols-2 gap-4">
<div class="flex flex-col items-center gap-2">
<span class="text-sm text-gray-500 font-bold">
Math.random()
</span>
<canvas
ref="randomCanvasRef"
class="w-full rounded-lg"
:style="{ aspectRatio: '1 / 1' }"
/>
</div>
<div class="flex flex-col items-center gap-2">
<span class="text-sm text-blue-400 font-bold">
Curl Noise
</span>
<canvas
ref="curlCanvasRef"
class="w-full rounded-lg"
:style="{ aspectRatio: '1 / 1' }"
/>
</div>
</div>
</div>
</template>

對一個 2D 噪聲場取偏導數,然後旋轉 90 度。e 是微小的取樣間距(例如 0.01),用來做數值微分:

float e = 0.01;
float a = (fbm(p + vec2(0.0, e))
- fbm(p - vec2(0.0, e))) / (2.0 * e);
float b = (fbm(p + vec2(e, 0.0))
- fbm(p - vec2(e, 0.0))) / (2.0 * e);
curl = vec2(a, -b);

fbm 是分形布朗運動,只要把多個頻率的噪聲疊在一起。裡面的 noise() 是自己實作的 2D 雜訊函式(例如 Simplex Noise 或 Value Noise),輸入一個座標,回傳 -1~1 的浮點數:

float fbm(vec2 p) {
float value = 0.0;
float amplitude = 0.5;
for (int i = 0; i < 4; i++) {
value += amplitude * noise(p);
p *= 2.0; // 頻率加倍(看得更細)
amplitude *= 0.5; // 振幅減半(影響力變小)
}
return value;
}

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

FBM 層數:

1 層:只有大塊色斑,像馬賽克

<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
const canvasRef = ref<HTMLCanvasElement | null>(null)
const octaveCount = ref(1)
const CANVAS_WIDTH = 600
const CANVAS_HEIGHT = 200
const NOISE_SCALE = 0.02
// ---- 噪聲工具 ----
function hash(x: number, y: number): number {
const p3x = ((x * 0.1031) % 1 + 1) % 1
const p3y = ((y * 0.1030) % 1 + 1) % 1
const p3z = ((x * 0.0973) % 1 + 1) % 1
const dotValue = p3x * (p3y + 33.33) + p3y * (p3z + 33.33) + p3z * (p3x + 33.33)
return ((dotValue * (dotValue + 23.45)) % 1 + 1) % 1
}
function smoothNoise(x: number, y: number): number {
const ix = Math.floor(x)
const iy = Math.floor(y)
const fx = x - ix
const fy = y - iy
const a = hash(ix, iy)
const b = hash(ix + 1, iy)
const c = hash(ix, iy + 1)
const d = hash(ix + 1, iy + 1)
// smoothstep 插值
const ux = fx * fx * (3 - 2 * fx)
const uy = fy * fy * (3 - 2 * fy)
return a + (b - a) * ux + (c - a) * uy * (1 - ux) + (d - b) * ux * uy
}
/** 指定層數的 FBM */
function fbm(x: number, y: number, octaveMax: number): number {
let value = 0
let amplitude = 0.5
let px = x
let py = y
for (let i = 0; i < octaveMax; i++) {
value += amplitude * smoothNoise(px, py)
px *= 2
py *= 2
amplitude *= 0.5
}
return value
}
function drawNoise(): void {
const canvas = canvasRef.value
if (!canvas)
return
const dpr = window.devicePixelRatio || 1
const logicalWidth = canvas.clientWidth
const logicalHeight = canvas.clientHeight
canvas.width = logicalWidth * dpr
canvas.height = logicalHeight * dpr
const context = canvas.getContext('2d')
if (!context)
return
const imageData = context.createImageData(canvas.width, canvas.height)
const pixelDataList = imageData.data
const currentOctaveCount = octaveCount.value
for (let pixelY = 0; pixelY < canvas.height; pixelY++) {
for (let pixelX = 0; pixelX < canvas.width; pixelX++) {
const noiseX = (pixelX / dpr) * NOISE_SCALE
const noiseY = (pixelY / dpr) * NOISE_SCALE
const noiseValue = fbm(noiseX, noiseY, currentOctaveCount)
const brightness = Math.floor(noiseValue * 255)
const index = (pixelY * canvas.width + pixelX) * 4
pixelDataList[index] = brightness
pixelDataList[index + 1] = brightness
pixelDataList[index + 2] = brightness
pixelDataList[index + 3] = 255
}
}
context.putImageData(imageData, 0, 0)
}
const octaveLabelList = [
'1 層:只有大塊色斑,像馬賽克',
'2 層:加入中頻細節',
'3 層:開始有自然紋理感',
'4 層:接近真實紊流',
]
let resizeObserver: ResizeObserver | null = null
onMounted(() => {
drawNoise()
resizeObserver = new ResizeObserver(() => {
drawNoise()
})
if (canvasRef.value) {
resizeObserver.observe(canvasRef.value)
}
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
})
watch(octaveCount, () => {
drawNoise()
})
</script>
<template>
<div class="flex flex-col gap-3 border border-gray-200 rounded-xl p-6">
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm text-gray-500 shrink-0">
FBM 層數:
</span>
<div class="flex gap-1.5">
<button
v-for="n in 4"
:key="n"
class="min-w-9 rounded-lg px-3 py-1.5 text-sm transition-colors"
:class="octaveCount === n
? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'"
@click="octaveCount = n"
>
{{ n }}
</button>
</div>
</div>
<canvas
ref="canvasRef"
class="w-full rounded-lg"
:style="{ height: '200px' }"
/>
<p class="m-0 text-sm text-gray-400">
{{ octaveLabelList[octaveCount - 1] }}
</p>
</div>
</template>

每幀在 Shader 裡即時算 FBM 太耗時,(4 層巢狀噪聲)。

最簡單的方法是初始化時先將整張噪聲場算好,烘焙成 256×256 的紋理:

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 時自動循環,等於無限大的噪聲場。

這段程式碼用到三個新變數:

  • uNoise:上一節烘焙好的噪聲紋理,用 texture() 查表讀取(和 texelFetch 不同,texture 用 0~1 的浮點 UV 座標,且會自動插值)
  • uTime:JavaScript 每幀傳入的累計時間(秒),讓噪聲場隨時間漂移
  • pRand:每個粒子的隨機種子,在初始化時存進原始位置紋理的空閒通道,Shader 裡用 origin.z 或 origin.w 讀出來
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:每個粒子加一點偏移,避免完全同步

被吹散的粒子在回家路上也會查噪聲紋理,產生飄盪效果:

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

粒子飄起來了!但看起來都是一樣亮度的白點,讓我們加入一點顏色變化提高視覺層次感。✧⁑。٩(ˊᗜˋ*)و✧⁕。


路人:「蟲蟲會變色?(́⊙◞౪◟⊙‵)」

鱈魚:「我說會就會ヽ(́◕◞౪◟◕‵)ノ」

多層發光效果:位移越遠顏色越亮、邊緣隨位移柔化
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
const canvasRef = ref<HTMLCanvasElement | null>(null)
let glContext: WebGL2RenderingContext | null = null
let physicsProgram: WebGLProgram | null = null
let renderProgram: WebGLProgram | null = null
let noiseProgram: WebGLProgram | null = null
let animationFrameId = 0
const stateTextureList: [WebGLTexture | null, WebGLTexture | null] = [null, null]
const framebufferList: [WebGLFramebuffer | null, WebGLFramebuffer | null] = [null, null]
let originTexture: WebGLTexture | null = null
let noiseTexture: WebGLTexture | null = null
let noiseFramebuffer: WebGLFramebuffer | null = null
let quadVertexBuffer: WebGLBuffer | null = null
let quadVertexArray: WebGLVertexArrayObject | null = null
let particleVertexArray: WebGLVertexArrayObject | null = null
let particleCount = 0
let textureSize = 0
let pingPongIndex = 0
let startTime = 0
let logicalWidth = 0
let logicalHeight = 0
let mouseX = 0
let mouseY = 0
let previousMouseX = -9999
let previousMouseY = -9999
let mouseVelocityX = 0
let mouseVelocityY = 0
let isMouseInside = false
const DISPLAY_TEXT = 'SWARM'
const NOISE_SIZE = 256
// ----- Noise generation shader -----
const noiseVertexSource = `#version 300 es
in vec2 aPosition;
void main() {
gl_Position = vec4(aPosition, 0.0, 1.0);
}
`
const noiseFragmentSource = `#version 300 es
precision highp float;
out vec4 fragColor;
float hash(vec2 p) {
vec3 p3 = fract(vec3(p.xyx) * 0.13);
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
float noise(vec2 x) {
vec2 i = floor(x); vec2 f = fract(x);
float a = hash(i); float b = hash(i + vec2(1.0, 0.0));
float c = hash(i + vec2(0.0, 1.0)); float d = hash(i + vec2(1.0, 1.0));
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y;
}
float fbm(vec2 p) {
float v = 0.0; float a = 0.5;
for (int i = 0; i < 4; i++) { v += a * noise(p); p *= 2.0; a *= 0.5; }
return v;
}
void main() {
vec2 uv = gl_FragCoord.xy / 256.0;
vec2 p = uv * 6.0;
const float e = 0.1;
float a = (fbm(p + vec2(0.0, e)) - fbm(p - vec2(0.0, e))) / (2.0 * e);
float b = (fbm(p + vec2(e, 0.0)) - fbm(p - vec2(e, 0.0))) / (2.0 * e);
fragColor = vec4(a, -b, 0.0, 1.0);
}
`
// ----- Physics pass shaders -----
const physicsVertexSource = `#version 300 es
in vec2 aPosition;
void main() {
gl_Position = vec4(aPosition, 0.0, 1.0);
}
`
const physicsFragmentSource = `#version 300 es
precision highp float;
uniform sampler2D uState;
uniform sampler2D uOrigin;
uniform sampler2D uNoise;
uniform vec2 uMouse;
uniform vec2 uMouseVelocity;
uniform int uMouseInside;
uniform float uScatterRadius;
uniform float uScatterForce;
uniform float uReturnSpeed;
uniform float uFriction;
uniform float uTime;
out vec4 fragColor;
float hash(vec2 p) {
vec3 p3 = fract(vec3(p.xyx) * 0.13);
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
void main() {
ivec2 coord = ivec2(gl_FragCoord.xy);
vec4 state = texelFetch(uState, coord, 0);
vec4 origin = texelFetch(uOrigin, coord, 0);
vec2 pos = state.xy;
vec2 vel = state.zw;
vec2 originPos = origin.xy;
if (originPos.x < -9999.0) {
fragColor = state;
return;
}
float pRand = hash(vec2(float(coord.x), float(coord.y)));
float forceScale = 0.5 + pRand * 1.0;
if (uMouseInside == 1) {
vec2 toParticle = pos - uMouse;
float dist = length(toParticle);
if (dist < uScatterRadius) {
float influence = smoothstep(uScatterRadius, 0.0, dist);
float mouseSpeed = length(uMouseVelocity);
if (mouseSpeed > 0.5) {
vec2 windDir = uMouseVelocity / mouseSpeed;
vel += windDir * mouseSpeed * influence * uScatterForce * 0.025 * forceScale;
}
vec2 noiseUV = pos * 0.005 + uTime * 0.15 + pRand * 10.0;
vec2 curl = texture(uNoise, noiseUV).xy;
float turbStrength = (1.5 + mouseSpeed * 0.4) * influence * uScatterForce * 0.12 * forceScale;
vel += curl * turbStrength;
if (dist > 0.01) {
vel += (toParticle / dist) * influence * influence * uScatterForce * 0.5 * forceScale;
}
}
}
vec2 toOrigin = originPos - pos;
float distFromOrigin = length(toOrigin);
if (distFromOrigin > 2.0) {
float displacement = smoothstep(2.0, 40.0, distFromOrigin);
vec2 driftUV = pos * 0.002 + uTime * 0.08 + pRand * 0.3;
vec2 drift = texture(uNoise, driftUV).xy;
vel += drift * displacement * 1.5 * forceScale;
}
float particleFriction = uFriction - pRand * 0.04;
vel *= particleFriction;
pos += vel;
float returnRate = uReturnSpeed * (0.6 + pRand * 0.8);
pos = mix(pos, originPos, returnRate);
fragColor = vec4(pos, vel);
}
`
// ----- Render pass shaders -----
const renderVertexSource = `#version 300 es
precision highp float;
uniform sampler2D uState;
uniform sampler2D uOrigin;
uniform int uTextureSize;
uniform vec2 uResolution;
uniform float uPointScale;
uniform float uTime;
out float vOpacity;
out float vDisplacement;
out float vSpeed;
void main() {
int index = gl_VertexID;
int texelX = index % uTextureSize;
int texelY = index / uTextureSize;
vec4 state = texelFetch(uState, ivec2(texelX, texelY), 0);
vec4 origin = texelFetch(uOrigin, ivec2(texelX, texelY), 0);
vec2 pos = state.xy;
vec2 vel = state.zw;
vec2 originPos = origin.xy;
float size = origin.z;
float opacity = origin.w;
if (originPos.x < -9999.0) {
gl_Position = vec4(-2.0, -2.0, 0.0, 1.0);
gl_PointSize = 0.0;
vOpacity = 0.0;
vDisplacement = 0.0;
vSpeed = 0.0;
return;
}
float distFromOrigin = length(pos - originPos);
float speed = length(vel);
float displacementNear = smoothstep(0.0, 8.0, distFromOrigin);
float displacementMid = smoothstep(8.0, 30.0, distFromOrigin);
float displacementFar = smoothstep(30.0, 80.0, distFromOrigin);
float displacement = displacementNear * 0.3 + displacementMid * 0.4 + displacementFar * 0.3;
float speedFactor = smoothstep(0.0, 8.0, speed);
float phase = float(index) * 1.618033988;
pos.x += sin(phase + uTime * 0.001) * 0.2;
pos.y += cos(phase * 1.3 + uTime * 0.0008) * 0.2;
vec2 clipPosition = (pos / uResolution) * 2.0 - 1.0;
clipPosition.y *= -1.0;
gl_Position = vec4(clipPosition, 0.0, 1.0);
gl_PointSize = size * 2.0 * uPointScale;
vOpacity = opacity;
vDisplacement = displacement;
vSpeed = speedFactor;
}
`
const renderFragmentSource = `#version 300 es
precision highp float;
uniform vec3 uColor;
in float vOpacity;
in float vDisplacement;
in float vSpeed;
out vec4 fragColor;
void main() {
float dist = length(gl_PointCoord - 0.5) * 2.0;
if (dist > 1.0) discard;
// 邊緣柔化隨 displacement 變化
float coreEdge = mix(0.4, 0.25, vDisplacement);
float alpha = smoothstep(1.0, coreEdge, dist) * vOpacity;
// 暖白漸變:位移越遠越亮
vec3 warmHighlight = mix(
uColor,
uColor + vec3(0.15, 0.1, 0.05),
vDisplacement
);
vec3 color = mix(
warmHighlight,
vec3(1.0),
vDisplacement * 0.5 + vSpeed * 0.3
);
// 高速白芯
float coreBright = (1.0 - smoothstep(0.0, 0.5, dist))
* vSpeed * 0.4;
color += vec3(coreBright);
fragColor = vec4(color, alpha);
}
`
// ----- Helper functions -----
function compileShader(
gl: WebGL2RenderingContext,
type: number,
source: string,
): WebGLShader | null {
const shader = gl.createShader(type)
if (!shader)
return null
gl.shaderSource(shader, source)
gl.compileShader(shader)
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error(gl.getShaderInfoLog(shader))
gl.deleteShader(shader)
return null
}
return shader
}
function linkProgram(
gl: WebGL2RenderingContext,
vertSource: string,
fragSource: string,
): WebGLProgram | null {
const vert = compileShader(gl, gl.VERTEX_SHADER, vertSource)
const frag = compileShader(gl, gl.FRAGMENT_SHADER, fragSource)
if (!vert || !frag)
return null
const program = gl.createProgram()
if (!program)
return null
gl.attachShader(program, vert)
gl.attachShader(program, frag)
gl.linkProgram(program)
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error(gl.getProgramInfoLog(program))
gl.deleteProgram(program)
return null
}
gl.deleteShader(vert)
gl.deleteShader(frag)
return program
}
function createFloatTexture(
gl: WebGL2RenderingContext,
size: number,
data: Float32Array,
): WebGLTexture | null {
const texture = gl.createTexture()
if (!texture)
return null
gl.bindTexture(gl.TEXTURE_2D, texture)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, size, size, 0, gl.RGBA, gl.FLOAT, data)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
return texture
}
// ----- Particle sampling from text -----
interface SampledParticle {
x: number;
y: number;
size: number;
opacity: number;
}
function sampleParticleListFromText(
text: string,
canvasWidth: number,
canvasHeight: number,
): SampledParticle[] {
const offscreen = document.createElement('canvas')
offscreen.width = canvasWidth
offscreen.height = canvasHeight
const context = offscreen.getContext('2d')
if (!context)
return []
const fontSize = Math.min(canvasWidth / (text.length * 0.7), canvasHeight * 0.6)
context.fillStyle = 'white'
context.font = `bold ${fontSize}px Arial, sans-serif`
context.textAlign = 'center'
context.textBaseline = 'middle'
context.fillText(text, canvasWidth / 2, canvasHeight / 2)
const imageData = context.getImageData(0, 0, canvasWidth, canvasHeight)
const pixelDataList = imageData.data
const gap = 1
const resultList: SampledParticle[] = []
for (let y = 0; y < canvasHeight; y += gap) {
for (let x = 0; x < canvasWidth; x += gap) {
const pixelX = Math.floor(x)
const pixelY = Math.floor(y)
const index = (pixelY * canvasWidth + pixelX) * 4
const alpha = (pixelDataList[index + 3] ?? 0) / 255
if (alpha > 0.15) {
resultList.push({
x,
y,
size: 1.5 * (0.4 + alpha * 0.6),
opacity: alpha * (0.8 + Math.random() * 0.2),
})
}
}
}
return resultList
}
// ----- Setup textures and framebuffers -----
function setupParticleTextures(
gl: WebGL2RenderingContext,
sampledParticleList: SampledParticle[],
): void {
particleCount = sampledParticleList.length
textureSize = Math.ceil(Math.sqrt(particleCount))
const totalPixelCount = textureSize * textureSize
const stateData = new Float32Array(totalPixelCount * 4)
const originData = new Float32Array(totalPixelCount * 4)
for (let i = 0; i < totalPixelCount; i++) {
if (i < particleCount) {
const particle = sampledParticleList[i]
stateData[i * 4 + 0] = particle?.x ?? 0
stateData[i * 4 + 1] = particle?.y ?? 0
stateData[i * 4 + 2] = 0
stateData[i * 4 + 3] = 0
originData[i * 4 + 0] = particle?.x ?? 0
originData[i * 4 + 1] = particle?.y ?? 0
originData[i * 4 + 2] = particle?.size ?? 0
originData[i * 4 + 3] = particle?.opacity ?? 0
}
else {
stateData[i * 4 + 0] = -99999
stateData[i * 4 + 1] = -99999
stateData[i * 4 + 2] = 0
stateData[i * 4 + 3] = 0
originData[i * 4 + 0] = -99999
originData[i * 4 + 1] = -99999
originData[i * 4 + 2] = 0
originData[i * 4 + 3] = 0
}
}
stateTextureList[0] = createFloatTexture(gl, textureSize, stateData)
stateTextureList[1] = createFloatTexture(gl, textureSize, new Float32Array(stateData))
originTexture = createFloatTexture(gl, textureSize, originData)
for (let i = 0; i < 2; i++) {
framebufferList[i] = gl.createFramebuffer()
gl.bindFramebuffer(gl.FRAMEBUFFER, framebufferList[i])
gl.framebufferTexture2D(
gl.FRAMEBUFFER,
gl.COLOR_ATTACHMENT0,
gl.TEXTURE_2D,
stateTextureList[i],
0,
)
}
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
}
function generateNoiseTexture(gl: WebGL2RenderingContext): void {
noiseProgram = linkProgram(gl, noiseVertexSource, noiseFragmentSource)
if (!noiseProgram)
return
noiseTexture = gl.createTexture()
gl.bindTexture(gl.TEXTURE_2D, noiseTexture)
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA32F,
NOISE_SIZE,
NOISE_SIZE,
0,
gl.RGBA,
gl.FLOAT,
null,
)
const hasFloatLinear = !!gl.getExtension('OES_texture_float_linear')
const noiseFilter = hasFloatLinear ? gl.LINEAR : gl.NEAREST
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, noiseFilter)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, noiseFilter)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT)
noiseFramebuffer = gl.createFramebuffer()
gl.bindFramebuffer(gl.FRAMEBUFFER, noiseFramebuffer)
gl.framebufferTexture2D(
gl.FRAMEBUFFER,
gl.COLOR_ATTACHMENT0,
gl.TEXTURE_2D,
noiseTexture,
0,
)
gl.viewport(0, 0, NOISE_SIZE, NOISE_SIZE)
gl.useProgram(noiseProgram)
gl.bindVertexArray(quadVertexArray)
gl.drawArrays(gl.TRIANGLES, 0, 6)
gl.bindVertexArray(null)
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
gl.deleteProgram(noiseProgram)
noiseProgram = null
}
function setupBuffers(gl: WebGL2RenderingContext): void {
const positionList = new Float32Array([
-1,
-1,
1,
-1,
-1,
1,
-1,
1,
1,
-1,
1,
1,
])
quadVertexArray = gl.createVertexArray()
gl.bindVertexArray(quadVertexArray)
quadVertexBuffer = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, quadVertexBuffer)
gl.bufferData(gl.ARRAY_BUFFER, positionList, gl.STATIC_DRAW)
const positionLocation = 0
gl.enableVertexAttribArray(positionLocation)
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0)
gl.bindVertexArray(null)
particleVertexArray = gl.createVertexArray()
}
// ----- Pointer events -----
function handlePointerDown(event: PointerEvent): void {
const target = event.currentTarget
if (target instanceof Element)
target.setPointerCapture(event.pointerId)
handlePointerMove(event)
}
function handlePointerMove(event: PointerEvent): void {
const newX = event.offsetX
const newY = event.offsetY
if (previousMouseX > -9000 && previousMouseY > -9000) {
mouseVelocityX = newX - previousMouseX
mouseVelocityY = newY - previousMouseY
}
previousMouseX = newX
previousMouseY = newY
mouseX = newX
mouseY = newY
isMouseInside = true
}
function handlePointerLeave(): void {
isMouseInside = false
previousMouseX = -9999
previousMouseY = -9999
}
// ----- Init and render -----
function initWebGL(): void {
const canvas = canvasRef.value
if (!canvas)
return
const dpr = window.devicePixelRatio || 1
logicalWidth = canvas.clientWidth
logicalHeight = canvas.clientHeight
canvas.width = logicalWidth * dpr
canvas.height = logicalHeight * dpr
glContext = canvas.getContext('webgl2', {
premultipliedAlpha: false,
alpha: true,
})
if (!glContext) {
console.error('WebGL2 not supported')
return
}
const extColorFloat = glContext.getExtension('EXT_color_buffer_float')
if (!extColorFloat) {
console.error('EXT_color_buffer_float not supported')
return
}
physicsProgram = linkProgram(glContext, physicsVertexSource, physicsFragmentSource)
renderProgram = linkProgram(glContext, renderVertexSource, renderFragmentSource)
if (!physicsProgram || !renderProgram)
return
setupBuffers(glContext)
generateNoiseTexture(glContext)
const sampledParticleList = sampleParticleListFromText(
DISPLAY_TEXT,
logicalWidth,
logicalHeight,
)
setupParticleTextures(glContext, sampledParticleList)
glContext.enable(glContext.BLEND)
glContext.blendFuncSeparate(
glContext.SRC_ALPHA,
glContext.ONE_MINUS_SRC_ALPHA,
glContext.ONE,
glContext.ONE_MINUS_SRC_ALPHA,
)
previousMouseX = -9999
previousMouseY = -9999
mouseVelocityX = 0
mouseVelocityY = 0
startTime = performance.now()
canvas.addEventListener('pointerdown', handlePointerDown)
canvas.addEventListener('pointermove', handlePointerMove)
canvas.addEventListener('pointerup', handlePointerLeave)
canvas.addEventListener('pointerleave', handlePointerLeave)
}
function render(): void {
if (!glContext || !physicsProgram || !renderProgram)
return
const canvas = canvasRef.value
if (!canvas)
return
const gl = glContext
const currentTime = (performance.now() - startTime) * 0.001
mouseVelocityX *= 0.92
mouseVelocityY *= 0.92
const readIndex = pingPongIndex
const writeIndex = 1 - pingPongIndex
gl.disable(gl.BLEND)
gl.bindFramebuffer(gl.FRAMEBUFFER, framebufferList[writeIndex])
gl.viewport(0, 0, textureSize, textureSize)
gl.useProgram(physicsProgram)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, stateTextureList[readIndex])
gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uState'), 0)
gl.activeTexture(gl.TEXTURE1)
gl.bindTexture(gl.TEXTURE_2D, originTexture)
gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uOrigin'), 1)
gl.activeTexture(gl.TEXTURE2)
gl.bindTexture(gl.TEXTURE_2D, noiseTexture)
gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uNoise'), 2)
gl.uniform2f(gl.getUniformLocation(physicsProgram, 'uMouse'), mouseX, mouseY)
gl.uniform2f(gl.getUniformLocation(physicsProgram, 'uMouseVelocity'), mouseVelocityX, mouseVelocityY)
gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uMouseInside'), isMouseInside ? 1 : 0)
gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uScatterRadius'), 40.0)
gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uScatterForce'), 40.0)
gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uReturnSpeed'), 0.1)
gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uFriction'), 0.92)
gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uTime'), currentTime)
gl.bindVertexArray(quadVertexArray)
gl.drawArrays(gl.TRIANGLES, 0, 6)
gl.bindVertexArray(null)
pingPongIndex = writeIndex
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
gl.viewport(0, 0, canvas.width, canvas.height)
gl.clearColor(0, 0, 0, 0)
gl.clear(gl.COLOR_BUFFER_BIT)
gl.enable(gl.BLEND)
gl.blendFuncSeparate(
gl.SRC_ALPHA,
gl.ONE_MINUS_SRC_ALPHA,
gl.ONE,
gl.ONE_MINUS_SRC_ALPHA,
)
gl.useProgram(renderProgram)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, stateTextureList[writeIndex])
gl.uniform1i(gl.getUniformLocation(renderProgram, 'uState'), 0)
gl.activeTexture(gl.TEXTURE1)
gl.bindTexture(gl.TEXTURE_2D, originTexture)
gl.uniform1i(gl.getUniformLocation(renderProgram, 'uOrigin'), 1)
const dpr = window.devicePixelRatio || 1
gl.uniform1i(gl.getUniformLocation(renderProgram, 'uTextureSize'), textureSize)
gl.uniform2f(
gl.getUniformLocation(renderProgram, 'uResolution'),
logicalWidth,
logicalHeight,
)
gl.uniform1f(gl.getUniformLocation(renderProgram, 'uPointScale'), dpr)
gl.uniform1f(gl.getUniformLocation(renderProgram, 'uTime'), performance.now())
gl.uniform3f(gl.getUniformLocation(renderProgram, 'uColor'), 0.53, 0.53, 0.53)
gl.bindVertexArray(particleVertexArray)
gl.drawArrays(gl.POINTS, 0, particleCount)
gl.bindVertexArray(null)
animationFrameId = requestAnimationFrame(render)
}
onMounted(() => {
initWebGL()
animationFrameId = requestAnimationFrame(render)
})
onBeforeUnmount(() => {
cancelAnimationFrame(animationFrameId)
const canvas = canvasRef.value
if (canvas) {
canvas.removeEventListener('pointerdown', handlePointerDown)
canvas.removeEventListener('pointermove', handlePointerMove)
canvas.removeEventListener('pointerup', handlePointerLeave)
canvas.removeEventListener('pointerleave', handlePointerLeave)
}
if (glContext) {
if (physicsProgram)
glContext.deleteProgram(physicsProgram)
if (renderProgram)
glContext.deleteProgram(renderProgram)
for (const texture of stateTextureList) {
if (texture)
glContext.deleteTexture(texture)
}
if (originTexture)
glContext.deleteTexture(originTexture)
if (noiseTexture)
glContext.deleteTexture(noiseTexture)
for (const framebuffer of framebufferList) {
if (framebuffer)
glContext.deleteFramebuffer(framebuffer)
}
if (noiseFramebuffer)
glContext.deleteFramebuffer(noiseFramebuffer)
if (quadVertexBuffer)
glContext.deleteBuffer(quadVertexBuffer)
if (quadVertexArray)
glContext.deleteVertexArray(quadVertexArray)
if (particleVertexArray)
glContext.deleteVertexArray(particleVertexArray)
}
})
</script>
<template>
<div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6">
<span class="text-sm text-gray-600">
多層發光效果:位移越遠顏色越亮、邊緣隨位移柔化
</span>
<canvas
ref="canvasRef"
class="w-full rounded-lg"
style="height: 320px;"
/>
</div>
</template>
  1. 離家多遠(displacement):被推得越遠越亮
  2. 移動多快(speed):速度越快越亮

這兩個值在 Vertex Shader(Step 16)已經算好,透過 Varying 傳到 Fragment Shader。

float displacementNear = smoothstep(0.0, 8.0, distFromOrigin);
float displacementMid = smoothstep(8.0, 30.0, distFromOrigin);
float displacementFar = smoothstep(30.0, 80.0, distFromOrigin);
float displacement = displacementNear * 0.3
+ displacementMid * 0.4
+ displacementFar * 0.3;
層距離效果
近距微擾0~8px微微亮起,暗示被輕輕推了一下
中距飄散8~30px明顯變亮
遠距爆開30px+接近全白

為什麼要分三層而不是一個 smoothstep 搞定?因為單一 smoothstep 的漸變太平均了。三層分別控制不同距離帶的亮度曲線,近距微微亮、中距明顯亮、遠距爆亮,層次更豐富。

speedFactor 是從 Vertex Shader 傳來的 vSpeed(Step 16 算好的速度強度 0~1),代表粒子目前跑多快。和 displacement 搭配,讓「離家遠」和「移動快」的粒子都會變亮。

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) 讓紅色多加一點、綠色少一點、藍色更少,就是暖色偏移。

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) 只在粒子正中央才亮。

效果是剛被推開的粒子中心特別亮,有種「衝擊波」的感覺。離中心遠的部分維持原本的暖色調。

float coreEdge = mix(0.4, 0.25, vDisplacement);
float alpha = smoothstep(1.0, coreEdge, dist) * vOpacity;

粒子被推得越遠,coreEdge 越小,光暈越散越柔。靜止時邊緣清晰(0.4),飛散時邊緣模糊(0.25)。

恭喜各位走完了所有步驟!讓我們回顧一下整趟旅程:

步驟概念關鍵技術
1Canvas 基礎getContext('2d')、fillText、DPR
2像素取樣getImageData、alpha 通道判斷
3粒子渲染requestAnimationFrame、fillRect
4速度與摩擦力velocity *= friction
5回歸原位lerp(線性內插)
6追蹤滑鼠Pointer Events、座標轉換
7徑向推力單位方向向量 × 衰減力道
8風力滑鼠速度追蹤、方向性推力
9飄盪隨機飄移、displacement 閾值
10效能瓶頸繪圖呼叫開銷分析
步驟概念關鍵技術
11GPU 思維平行運算、Shader 類型
12Shader 編譯Program、Uniform
13浮點紋理RGBA32F、texelFetch
14Ping-Pong雙紋理交替、Framebuffer
15GPU 物理smoothstep、mix、GLSL 物理模擬
16Vertex Shadergl_VertexID、clip space、Varying
17圓形粒子gl_PointCoord、Alpha Blending
18Curl NoiseFBM、預計算噪聲紋理
19多層發光displacement 亮度、暖白漸變

從最簡單的 Canvas 2D 一路到 WebGL2 Shader,核心物理邏輯其實沒甚麼變,變的是「誰來算」。

從 CPU 一個一個慢慢來,改成 GPU 小精靈大軍一起算。∠( ᐛ 」∠)_

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

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

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