蟲群文字 step-by-step
完成品請見 蟲群文字元件。
Shader 教學
本篇涉及 Shader 相關概念,可以參考更詳細的 Shader 教學。
文字化為蟲群,滑鼠靠近時會散開,離開後重新聚集。
路人:「數大便是美的概念?(ゝ∀・)b」
鱈魚:「哈哈大便!ᕕ( ゚ ∀。)ᕗ 」
路人:「你小學生喔?...(。-`ω´-)」
讓我們從最基本的 Canvas 2D 開始,先在 CPU 把整個效果做出來,然後再一步步搬到 WebGL2 Shader 上。
每一步只加入一個新概念,讓我們一步一腳印地理解。
Step 1:在 Canvas 上畫文字
萬事起頭難,先從一塊空白 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 2D Context
Canvas 是 HTML5 提供的畫布元素,可以用 JavaScript 在上面畫任何東西。第一步是取得 2D 繪圖上下文:
const canvas = document.querySelector('canvas')
const ctx = canvas.getContext('2d')ctx 就是我們的畫布,所有繪圖操作都在它身上進行。
fillText 繪製文字
ctx.font = '80px sans-serif'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillStyle = '#000'
ctx.fillText('SWARM', canvas.width / 2, canvas.height / 2)textAlign: 'center'+textBaseline: 'middle'讓文字以中心點定位- 座標
(canvas.width / 2, canvas.height / 2)就是畫布正中央
其實到這步為止,畫面上就只是一行靜態文字,跟 HTML 沒什麼兩樣。
不過 Canvas 的重點不在「顯示」,而在於我們能拿到每個像素的資料。( •̀ ω •́ )✧
DPR 匹配
在高解析度螢幕(如 Retina)上,Canvas 預設會模糊。
解法是把實際像素放大:
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 更高差異會更明顯:
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>getImageData:拿到所有像素
ctx.fillText('SWARM', width / 2, height / 2)
const imageData = ctx.getImageData(0, 0, width, height)
const { data } = imageDatadata 是一個超長的一維陣列,每 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 越小,粒子越多,效果越細緻,但效能壓力也越大。
| gap | 粒子數量(概估) | 效果 |
|---|---|---|
| 1 | 超多 | 幾乎還原原始文字,可能超卡 |
| 3 | 中等 | 看得出文字形狀,效能 OK |
| 6 | 較少 | 有點稀疏但跑起來很順 |
Step 3:Canvas 2D 粒子渲染
有了粒子座標,接下來要把它們畫出來。
這裡用 requestAnimationFrame 建立動畫循環,每幀重新繪製所有粒子。
查看範例原始碼
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
interface Particle {
x: number
y: number
}
const canvasRef = ref<HTMLCanvasElement | null>(null)
const particleCount = ref(0)
const frameCount = ref(0)
const CANVAS_HEIGHT = 200
const TEXT_CONTENT = 'SWARM'
const SAMPLE_GAP = 3
let particleList: Particle[] = []
let animationFrameId = 0
function sampleTextParticleList(
width: number,
height: number
): Particle[] {
const offscreenCanvas = document.createElement('canvas')
offscreenCanvas.width = width
offscreenCanvas.height = height
const offscreenContext = offscreenCanvas.getContext('2d')
if (!offscreenContext) return []
offscreenContext.fillStyle = '#ffffff'
offscreenContext.font = 'bold 80px sans-serif'
offscreenContext.textAlign = 'center'
offscreenContext.textBaseline = 'middle'
offscreenContext.fillText(TEXT_CONTENT, width / 2, height / 2)
const imageData = offscreenContext.getImageData(0, 0, width, height)
const pixelDataList = imageData.data
const result: Particle[] = []
for (let y = 0; y < height; y += SAMPLE_GAP) {
for (let x = 0; x < width; x += SAMPLE_GAP) {
const index = (y * width + x) * 4
const alpha = pixelDataList[index + 3]
if (alpha !== undefined && alpha > 128) {
result.push({ x, y })
}
}
}
return result
}
function initCanvas() {
const canvas = canvasRef.value
if (!canvas) return
const devicePixelRatio = window.devicePixelRatio || 1
const width = canvas.clientWidth
canvas.style.height = `${CANVAS_HEIGHT}px`
canvas.width = width * devicePixelRatio
canvas.height = CANVAS_HEIGHT * devicePixelRatio
particleList = sampleTextParticleList(width, CANVAS_HEIGHT)
particleCount.value = particleList.length
}
function render() {
const canvas = canvasRef.value
if (!canvas) return
const devicePixelRatio = window.devicePixelRatio || 1
const width = canvas.clientWidth
const context = canvas.getContext('2d')
if (!context) return
context.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0)
context.fillStyle = '#111827'
context.fillRect(0, 0, width, CANVAS_HEIGHT)
context.fillStyle = '#9ca3af'
for (const particle of particleList) {
context.beginPath()
context.arc(particle.x, particle.y, 1, 0, Math.PI * 2)
context.fill()
}
frameCount.value += 1
animationFrameId = requestAnimationFrame(render)
}
function handleResize() {
initCanvas()
}
onMounted(() => {
initCanvas()
animationFrameId = requestAnimationFrame(render)
window.addEventListener('resize', handleResize)
})
onBeforeUnmount(() => {
cancelAnimationFrame(animationFrameId)
window.removeEventListener('resize', handleResize)
})
</script>
<template>
<div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6">
<div class="flex items-center gap-4 text-sm text-gray-500">
<span>粒子數量:{{ particleCount }}</span>
<span>影格:{{ frameCount }}</span>
</div>
<canvas ref="canvasRef" class="w-full" />
</div>
</template>requestAnimationFrame 動畫循環
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。每一幀的流程很單純:
clearRect清空畫布- 遍歷所有粒子,畫出小方塊
- 預約下一幀
為什麼用 requestAnimationFrame 而不是 setInterval?
requestAnimationFrame | setInterval(fn, 16) | |
|---|---|---|
| 觸發時機 | 瀏覽器準備重繪時 | 固定間隔,不管瀏覽器忙不忙 |
| 頁面切到背景 | 自動暫停,省 CPU | 繼續跑,白白浪費資源 |
| 螢幕刷新率適配 | 自動配合(60Hz、120Hz、144Hz) | 永遠 16ms,高刷螢幕浪費、低刷螢幕卡頓 |
| 動畫流暢度 | 跟 vsync 同步,不會撕裂 | 可能跟重繪錯開,造成畫面抖動 |
requestAnimationFrame 會跟瀏覽器的重繪週期同步。
setInterval 不管瀏覽器有沒有準備好就硬塞,可能重繪前算了兩次(浪費),也可能兩次重繪之間都沒算到(掉幀)。
實際開發動畫使用 setInterval 幾乎沒有好處。乁( ◔ ௰◔)「
fillRect vs arc
畫粒子有兩種常見做法:
fillRect(x, y, size, size):畫方形,效能好arc(x, y, radius, 0, Math.PI * 2)+fill():畫圓形,好看但每次都要beginPath
粒子很小的時候其實方形和圓形看起來差不多,所以 Canvas 2D 版本用 fillRect 就夠了。◝( •ω• )◟
路人:「所以畫面怎麼不會動?」
鱈魚:「請付費解...ヽ(́◕◞౪◟◕‵)ノ」
路人:(抽出生魚片刀)
鱈魚:「因為還沒加入靈魂 ヽ(́⊙◞౪◟⊙‵)ノ」
Step 4:速度與摩擦力
接下來讓我們加入靈魂,讓每個粒子有「速度」的概念,再搭配「摩擦力」讓它不會永遠飛下去。
查看範例原始碼
<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 的數(例如 0.92),每幀吃掉 8% 的速度
- 移動:位置加上速度
摩擦力 0.92 實際衰減多快?
| 幀數 | 剩餘速度 |
|---|---|
| 0 | 100% |
| 10 | 43% |
| 30 | 8% |
| 60 | 0.6% |
大概半秒(30 幀)速度就只剩不到 10%,視覺上已經感覺快停了。不過還保留一點殘餘速度,讓收尾有飄飄的感覺。
Step 5:回歸原位
粒子有了速度和摩擦力,但如果被推走之後沒有力量拉它回來,就永遠迷路了。(╥ω╥`)
這一步加入回歸力,讓粒子慢慢飄回自己的「家」。
查看範例原始碼
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
interface Particle {
x: number
y: number
originX: number
originY: number
velocityX: number
velocityY: number
}
const canvasRef = ref<HTMLCanvasElement | null>(null)
const particleCount = ref(0)
const frameCount = ref(0)
const CANVAS_HEIGHT = 200
const TEXT_CONTENT = 'SWARM'
const SAMPLE_GAP = 3
const FRICTION = 0.92
const SCATTER_FORCE = 15
const RETURN_SPEED = 0.1
let particleList: Particle[] = []
let animationFrameId = 0
function sampleTextParticleList(
width: number,
height: number
): Particle[] {
const offscreenCanvas = document.createElement('canvas')
offscreenCanvas.width = width
offscreenCanvas.height = height
const offscreenContext = offscreenCanvas.getContext('2d')
if (!offscreenContext) return []
offscreenContext.fillStyle = '#ffffff'
offscreenContext.font = 'bold 80px sans-serif'
offscreenContext.textAlign = 'center'
offscreenContext.textBaseline = 'middle'
offscreenContext.fillText(TEXT_CONTENT, width / 2, height / 2)
const imageData = offscreenContext.getImageData(0, 0, width, height)
const pixelDataList = imageData.data
const result: Particle[] = []
for (let y = 0; y < height; y += SAMPLE_GAP) {
for (let x = 0; x < width; x += SAMPLE_GAP) {
const index = (y * width + x) * 4
const alpha = pixelDataList[index + 3]
if (alpha !== undefined && alpha > 128) {
result.push({
x,
y,
originX: x,
originY: y,
velocityX: 0,
velocityY: 0,
})
}
}
}
return result
}
function scatterParticleList() {
for (const particle of particleList) {
const angle = Math.random() * Math.PI * 2
const force = Math.random() * SCATTER_FORCE
particle.velocityX = Math.cos(angle) * force
particle.velocityY = Math.sin(angle) * force
}
}
function initCanvas() {
const canvas = canvasRef.value
if (!canvas) return
const devicePixelRatio = window.devicePixelRatio || 1
const width = canvas.clientWidth
canvas.style.height = `${CANVAS_HEIGHT}px`
canvas.width = width * devicePixelRatio
canvas.height = CANVAS_HEIGHT * devicePixelRatio
particleList = sampleTextParticleList(width, CANVAS_HEIGHT)
particleCount.value = particleList.length
scatterParticleList()
}
function updateParticleList() {
for (const particle of particleList) {
const distanceX = particle.originX - particle.x
const distanceY = particle.originY - particle.y
particle.velocityX += distanceX * RETURN_SPEED
particle.velocityY += distanceY * RETURN_SPEED
particle.velocityX *= FRICTION
particle.velocityY *= FRICTION
particle.x += particle.velocityX
particle.y += particle.velocityY
}
}
function render() {
const canvas = canvasRef.value
if (!canvas) return
const devicePixelRatio = window.devicePixelRatio || 1
const width = canvas.clientWidth
const context = canvas.getContext('2d')
if (!context) return
updateParticleList()
context.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0)
context.fillStyle = '#111827'
context.fillRect(0, 0, width, CANVAS_HEIGHT)
context.fillStyle = '#9ca3af'
for (const particle of particleList) {
context.beginPath()
context.arc(particle.x, particle.y, 1, 0, Math.PI * 2)
context.fill()
}
frameCount.value += 1
animationFrameId = requestAnimationFrame(render)
}
function handleResize() {
cancelAnimationFrame(animationFrameId)
initCanvas()
animationFrameId = requestAnimationFrame(render)
}
onMounted(() => {
initCanvas()
animationFrameId = requestAnimationFrame(render)
window.addEventListener('resize', handleResize)
})
onBeforeUnmount(() => {
cancelAnimationFrame(animationFrameId)
window.removeEventListener('resize', handleResize)
})
</script>
<template>
<div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6">
<div class="flex items-center gap-4 text-sm text-gray-500">
<span>粒子數量:{{ particleCount }}</span>
<span>影格:{{ frameCount }}</span>
</div>
<canvas ref="canvasRef" class="w-full" />
<div>
<button
class="rounded-lg bg-gray-700 px-4 py-1.5 text-sm text-white transition-colors hover:bg-gray-600"
@click="scatterParticleList"
>
重新散開
</button>
</div>
</div>
</template>Lerp(線性內插)
particle.x += (particle.originX - particle.x) * returnSpeed
particle.y += (particle.originY - particle.y) * returnSpeedreturnSpeed 設為 0.1,代表每幀移動「到家距離」的 10%。
舉個例子,假設粒子離家 100px:
| 幀數 | 離家距離 |
|---|---|
| 0 | 100px |
| 1 | 90px(移動了 10%) |
| 2 | 81px(移動了 9px) |
| 10 | 35px |
| 30 | 4px |
離家越遠回得越快,越接近家回得越慢,自然產生減速效果。這就是 lerp 的經典用法。
Step 6:追蹤滑鼠位置
粒子有了物理基礎,接下來要加入互動。首先得知道滑鼠在哪裡。
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>Pointer Events
let pointerX = -9999
let pointerY = -9999
canvas.addEventListener('pointermove', (event) => {
const rect = canvas.getBoundingClientRect()
pointerX = event.clientX - rect.left
pointerY = event.clientY - rect.top
})
canvas.addEventListener('pointerleave', () => {
pointerX = -9999
pointerY = -9999
})用 pointermove 而不是 mousemove,因為 Pointer Events 同時支援滑鼠和觸控。
為什麼用 -9999 而不是 null?
離開時把座標設成 -9999 而不是 null,好處是後續計算不需要做 null check。-9999 離任何粒子都超過 scatterRadius,自然不會觸發任何推力。省一個 if 少一個分支。
座標轉換
pointerX = event.clientX - rect.left
pointerY = event.clientY - rect.topevent.clientX 是滑鼠相對於瀏覽器視窗的座標,減掉 Canvas 左上角的偏移 rect.left,就得到滑鼠在 Canvas 內部的座標。
Step 7:徑向推力,吹散粒子
知道滑鼠在哪之後,就能對附近的粒子施加推力了。這一步是整個效果的靈魂。(ノ>ω<)ノ
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
}原理是這樣的:
- 算出粒子到滑鼠的方向向量
(dx, dy) - 算出距離
distance - 如果距離小於
scatterRadius,就施加推力 dx / distance是單位方向向量,乘以力道就是推力
影響力衰減
influence = 1 - distance / scatterRadius 是線性衰減。正中央影響力是 1(最強),邊緣是 0(沒影響)。
用折線圖來看衰減曲線:
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 * displacementdisplacement 是 0~1 之間的值。靠近原位時趨近 0,飄移力幾乎為零,粒子乖乖回家。被吹散的粒子趨近 1,飄移力才完全發揮。
這個細節很重要。不加這個判斷的話,粒子在原位也會不停抖動,文字永遠看不清楚。
每個粒子的個性
最終元件裡,每個粒子的摩擦力和回歸速度都有些微差異:
const particleFriction = friction - Math.random() * 0.04
const returnRate = returnSpeed * (0.6 + Math.random() * 0.8)這讓粒子群不會整齊劃一地動作,看起來更像一群各有性格的小蟲子,而不是同步的機器人。ヾ(◍'౪`◍)ノ゙
Step 10:CPU 的效能瓶頸
到這一步,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>每幀 CPU 要做多少事?
假設有 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 平行運算的威力。( •̀ ω •́ )✧
Step 11:WebGL2 基礎,GPU 思維
開始寫 Shader 之前,我們需要先了解 GPU 的思考方式。不用怕,其實核心概念不複雜。
下面這個範例讓你親手比較 Canvas 2D(CPU)和 WebGL2(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(VitePress) ---- */
.dark .benchmark-root {
--surface: #1e293b;
--border: #334155;
--text-1: #f1f5f9;
--text-2: #94a3b8;
--text-3: #64748b;
}
.dark .benchmark-bar-track {
background: #334155;
}
.dark .benchmark-speedup strong {
color: #22d3ee;
}
</style>CPU vs GPU
CPU 就像一個很強的資優生,運算能力很強,但是只能一個一個慢慢來。
GPU 就像一群幫手小精靈,單體能力不強,不過大家一起上,效率超高。(*´ω`)人(´ω`*)
什麼是 Shader?
Shader 是跑在 GPU 上的小程式,用 GLSL 寫。WebGL2 用的是 GLSL ES 3.0(#version 300 es)。
WebGL2 只有兩種 Shader:
| 類型 | 用途 | 執行頻率 |
|---|---|---|
| Vertex Shader | 決定每個頂點的位置 | 每個頂點跑一次 |
| Fragment Shader | 決定每個像素的顏色 | 每個像素跑一次 |
WebGPU / OpenGL 4.x+ / Vulkan / DirectX 還有其他 Shader 類型:
| 類型 | 用途 |
|---|---|
| Geometry Shader | 可以新增或刪除頂點,例如把一個點變成一個三角形 |
| Tessellation Shader | 把粗糙的網格細分成更多三角形,用於地形、曲面等 |
| Compute Shader | 通用 GPU 運算,不一定和圖形渲染有關(物理模擬、粒子系統等) |
| Mesh Shader | 較新的概念,取代 Vertex + Geometry 的組合,效能更好 |
蟲群文字用 GL_POINTS,每個粒子 = 一個頂點。gl_PointSize 決定每個頂點渲染成多大的方塊。所以:
- Vertex Shader 跑「粒子數量」次(決定每個粒子的位置)
- Fragment Shader 跑「粒子數量 × pointSize²」次(決定每個像素的顏色)
下面的圖解用 9 個粒子示範這個過程:
9 個粒子 → 跑 9 次
每個粒子 4×4 = 16 像素,共 144 次
gl_PointSize = 4.0 代表每個頂點渲染成 4×4 像素的方塊。 Vertex Shader 決定方塊的位置,Fragment Shader 決定方塊內每個像素的顏色。 取得 WebGL2 Context
const gl = canvas.getContext('webgl2', {
premultipliedAlpha: false,
alpha: true,
})跟 getContext('2d') 相同概念,不過多了一些設定:
premultipliedAlpha: false避免 alpha 預乘導致的混色問題alpha: true讓 canvas 支援透明背景。
Shader 與 Canvas 2D 最大的不同在於不能迴圈遍歷所有粒子,只能控制「自己這一個」。
沒有 for 迴圈跑全部粒子。每個粒子的 Shader 同時執行,各自只知道自己的資料,粒子之間沒辦法直接溝通。
那怎麼知道滑鼠在哪?用 Uniform 讓所有粒子共用同一份資料。(●'◡'●)
Step 12:Shader 編譯與 Uniform
WebGL 的 Shader 是純文字的 GLSL 程式碼,必須在執行時動態編譯。
先來看最簡單的例子:
畫一個圓,拖動滑桿可以改變半徑,半徑透過 Uniform 從 CPU 傳給 GPU。
查看範例原始碼
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
const canvasRef = ref<HTMLCanvasElement | null>(null)
const radius = ref(30)
let glContext: WebGL2RenderingContext | null = null
let program: WebGLProgram | null = null
let vertexBuffer: WebGLBuffer | null = null
let animationFrameId = 0
/** Vertex Shader:把全螢幕三角形的頂點直接傳到裁剪空間 */
const vertexShaderSource = `#version 300 es
in vec2 aPosition;
void main() {
gl_Position = vec4(aPosition, 0.0, 1.0);
}
`
/**
* Fragment Shader:每個像素各自執行一次
* 用 uResolution 把像素座標換算成 0~1 的 UV,再判斷是否在圓內
*/
const fragmentShaderSource = `#version 300 es
precision highp float;
uniform vec2 uResolution;
uniform float uRadius;
out vec4 fragColor;
void main() {
// 將像素座標正規化為 0~1
vec2 uv = gl_FragCoord.xy / uResolution;
// 圓心在畫面正中央
vec2 center = vec2(0.5, 0.5);
// 計算此像素到圓心的距離
float dist = length(uv - center);
// 半徑從百分比轉為 0~1 範圍
float r = uRadius / 100.0;
// smoothstep 讓邊緣柔和,避免鋸齒
float circle = smoothstep(r + 0.01, r - 0.01, dist);
// 圓的顏色
vec3 color = vec3(0.26, 0.52, 0.96) * circle;
fragColor = vec4(color, 1.0);
}
`
function compileShader(
glContext: WebGL2RenderingContext,
type: number,
source: string
): WebGLShader | null {
const shader = glContext.createShader(type)
if (!shader) return null
glContext.shaderSource(shader, source)
glContext.compileShader(shader)
if (!glContext.getShaderParameter(shader, glContext.COMPILE_STATUS)) {
console.error(glContext.getShaderInfoLog(shader))
glContext.deleteShader(shader)
return null
}
return shader
}
function linkProgram(
glContext: WebGL2RenderingContext,
vertSource: string,
fragSource: string
): WebGLProgram | null {
const vert = compileShader(glContext, glContext.VERTEX_SHADER, vertSource)
const frag = compileShader(glContext, glContext.FRAGMENT_SHADER, fragSource)
if (!vert || !frag) return null
const program = glContext.createProgram()
if (!program) return null
glContext.attachShader(program, vert)
glContext.attachShader(program, frag)
glContext.linkProgram(program)
if (!glContext.getProgramParameter(program, glContext.LINK_STATUS)) {
console.error(glContext.getProgramInfoLog(program))
glContext.deleteProgram(program)
return null
}
glContext.deleteShader(vert)
glContext.deleteShader(frag)
return program
}
function initWebGL(): void {
const canvas = canvasRef.value
if (!canvas) return
canvas.width = canvas.clientWidth
canvas.height = canvas.clientHeight
glContext = canvas.getContext('webgl2')
if (!glContext) {
console.error('WebGL2 not supported')
return
}
program = linkProgram(glContext, vertexShaderSource, fragmentShaderSource)
if (!program) return
// 全螢幕四邊形(兩個三角形)
const positionList = new Float32Array([
-1, -1,
1, -1,
-1, 1,
-1, 1,
1, -1,
1, 1,
])
vertexBuffer = glContext.createBuffer()
glContext.bindBuffer(glContext.ARRAY_BUFFER, vertexBuffer)
glContext.bufferData(glContext.ARRAY_BUFFER, positionList, glContext.STATIC_DRAW)
const positionLocation = glContext.getAttribLocation(program, 'aPosition')
glContext.enableVertexAttribArray(positionLocation)
glContext.vertexAttribPointer(positionLocation, 2, glContext.FLOAT, false, 0, 0)
}
function render(): void {
if (!glContext || !program) return
const canvas = canvasRef.value
if (!canvas) return
canvas.width = canvas.clientWidth
canvas.height = canvas.clientHeight
glContext.viewport(0, 0, canvas.width, canvas.height)
glContext.useProgram(program)
const resolutionLocation = glContext.getUniformLocation(program, 'uResolution')
const radiusLocation = glContext.getUniformLocation(program, 'uRadius')
glContext.uniform2f(resolutionLocation, canvas.width, canvas.height)
glContext.uniform1f(radiusLocation, radius.value)
glContext.drawArrays(glContext.TRIANGLES, 0, 6)
animationFrameId = requestAnimationFrame(render)
}
onMounted(() => {
initWebGL()
animationFrameId = requestAnimationFrame(render)
})
onBeforeUnmount(() => {
cancelAnimationFrame(animationFrameId)
if (glContext) {
if (program)
glContext.deleteProgram(program)
if (vertexBuffer)
glContext.deleteBuffer(vertexBuffer)
glContext.getExtension('WEBGL_lose_context')?.loseContext()
}
})
</script>
<template>
<div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6">
<label class="flex items-center gap-2 text-sm text-gray-700">
半徑:{{ radius }}%
<input
v-model.number="radius"
type="range"
min="5"
max="50"
step="1"
class="flex-1"
>
</label>
<canvas
ref="canvasRef"
class="w-full rounded-lg"
style="height: 320px;"
/>
</div>
</template>上一步提到 Vertex Shader 決定「頂點在哪」,Fragment Shader 決定「像素什麼顏色」。現在來看這兩段程式碼實際長什麼樣子。
不過先小小提醒一下,Step 11 說的是「每個粒子 = 一個頂點」,用 GL_POINTS 畫點。但這個範例的目的不是畫粒子,而是先學會 GLSL 語法。
這裡用頂點拼出一個覆蓋畫面的四邊形,讓 Fragment Shader 能在每個像素上執行,用數學畫出一個圓。
等到後面真正做粒子系統時,才會回到「一個頂點 = 一個粒子」的做法。
Vertex Shader:決定頂點位置
#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 決定。
線
gl.LINES:每 2 個頂點連一條線,多餘的丟掉。
gl.LINE_STRIP:依序連成一條折線。
gl.LINE_LOOP:跟 LINE_STRIP 一樣,但首尾會相連。
三角形
gl.TRIANGLES:每 3 個頂點組一個獨立三角形,最通用。需要 6 個頂點才能拼出四邊形。
gl.TRIANGLE_STRIP:每個新頂點和前兩個組成三角形,頂點共用。4 個頂點就能拼出四邊形。
gl.TRIANGLE_FAN:所有三角形共用第一個頂點,像扇子展開。適合畫扇形、圓形。
這個範例用 gl.TRIANGLES,所以要拼出一個覆蓋整個畫面的四邊形,得拆成兩個三角形,共 6 個頂點:
對應到 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 就好。
Fragment Shader:決定像素顏色
Vertex Shader 決定了頂點圍出的區域後,GPU 會自動把那個區域內的每個像素都交給 Fragment Shader 處理。
不同的繪圖模式,Fragment Shader 執行的範圍也不同:
POINTS:每個頂點周圍gl_PointSize × gl_PointSize的正方形區域LINES:每條線段涵蓋的像素(通常只有 1px 寬)TRIANGLES:三角形內部的所有像素
這個範例用兩個三角形拼成全螢幕四邊形,所以 Fragment Shader 會在畫面上的每一個像素都跑一次。
每個像素執行時,可以透過 gl_FragCoord.xy 知道「我是畫面上哪一個像素」。
這是 GLSL 的內建變數,單位是像素座標(例如 320.5, 240.5)。
有了位置,就能用數學決定這個像素該是什麼顏色。
WebGL 必須手動宣告的浮點精度,桌面 OpenGL 預設就是高精度,但 WebGL 繼承自行動端的 OpenGL ES,Fragment Shader 不會預設精度,不寫就會編譯錯誤。
有三種可以選:
| 精度 | 用途 |
|---|---|
lowp | 省電,但容易出現色階斷層 |
mediump | 夠用於大部分 2D 效果 |
highp | 精準計算距離、座標時必須用 |
畫圓需要精準的距離計算,所以這裡用 highp。
這個 Shader 有兩個 uniform,都是 JavaScript 在每幀傳進來的:
uResolution:畫布的寬高(像素),用來把像素座標正規化成 0~1uRadius:圓的半徑(百分比),來自範例中的滑桿數值
#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);
}核心邏輯只有三步:
- 取得位置:
gl_FragCoord.xy除以uResolution正規化成 0~1 的 UV - 算距離:
length(uv - center)算出此像素到圓心有多遠 - 判斷是否在圓內:
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))
}連結成 Program
Vertex Shader 和 Fragment Shader 必須配成一對,連結成 Program 才能使用。
const program = gl.createProgram()
gl.attachShader(program, vertexShader)
gl.attachShader(program, fragmentShader)
gl.linkProgram(program)就像寫 TypeScript 一樣,原始碼要先「編譯」才能執行。一個 Program 就是一組完整的 GPU 小程式。
Uniform:CPU 傳資料給 GPU
上面範例中的 uRadius 就是一個 Uniform。JavaScript 每幀把滑桿的值傳給 GPU,所有像素共用同一個半徑值。
const radiusLocation = gl.getUniformLocation(program, 'uRadius')
gl.uniform1f(radiusLocation, radius)Uniform 是「每幀全部像素共用」的常數。
滑鼠位置、散開半徑、摩擦力這些值對所有粒子都一樣,所以用 Uniform 傳入。
那每個粒子各自的位置和速度都不一樣怎麼辦呢?塞進 Texture 吧。( ´ ▽ ` )ノ
Step 13:用浮點紋理儲存粒子
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 個通道剛好存下位置和速度。
為什麼需要 RGBA32F?
預設紋理每通道只有 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 用它來算回歸力(知道「家」在哪)。
texelFetch:讀取紋理
ivec2 coord = ivec2(gl_FragCoord.xy);
vec4 state = texelFetch(uState, coord, 0);texelFetch 用整數座標讀取紋理,不做任何插值。每個粒子的 Shader 用自己的座標去讀自己的資料,互不干擾。ლ(´∀`ლ)
Step 14:Ping-Pong 與 Framebuffer
知道怎麼「讀」紋理後,接下來要能「寫」紋理,才能每幀更新粒子狀態。
GPU 的限制:不能同時讀寫
GPU 有個重要限制:不能同時讀取和寫入同一張紋理。
這很合理。想像有 10,000 個粒子同時在跑,如果大家同時讀寫同一塊記憶體,結果會完全不可預測。
Ping-Pong:準備兩張紋理
解法是準備兩張狀態紋理 A 和 B,交替使用:
第 1 幀:讀 A → 算物理 → 寫到 B
第 2 幀:讀 B → 算物理 → 寫到 A
第 3 幀:讀 A → 算物理 → 寫到 B
...這就叫 Ping-Pong,在 GPU 粒子系統中相當常見的技巧。
Framebuffer:讓 Shader 寫入紋理
正常情況下,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 就會寫入對應的紋理而不是螢幕。
完整 Ping-Pong 流程
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,把「剛寫完的」變成「下一幀要讀的」。兩張紋理像乒乓球一樣來回,就永遠不會同時讀寫同一張。
Step 15:GPU 物理 Shader
準備工作做完了,現在來寫真正算物理的 Fragment Shader。
完整的 Physics 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 是什麼?
ivec2 coord = ivec2(gl_FragCoord.xy);ivec2 是整數版的二維向量(integer vector 2)。gl_FragCoord.xy 本身是 float,但 texelFetch 需要整數座標才能精確取到紋理中的某一格,所以先轉成 ivec2。
類似的型別還有:
| 型別 | 內容 | 例子 |
|---|---|---|
vec2 | 2 個 float | 位置、速度 |
vec4 | 4 個 float | 顏色 RGBA、狀態封裝 |
ivec2 | 2 個 int | 紋理座標、像素索引 |
Uniform 怎麼來的?
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 | 型別 | 來源 | 用途 |
|---|---|---|---|
uState | sampler2D | 上一幀的狀態紋理 | 讀取每個粒子的位置 xy 和速度 zw |
uOrigin | sampler2D | 初始位置紋理 | 粒子的「家」,回歸用 |
uMouse | vec2 | useMouseInElement | 滑鼠在畫布上的座標 |
uMouseVelocity | vec2 | 每幀座標差值 | 計算風力方向和強度 |
uMouseInside | int | isOutside 取反 | 滑鼠不在畫布內就不算推力 |
uScatterRadius | float | props | 推力的影響範圍(像素) |
uScatterForce | float | props | 推力的強度倍率 |
uReturnSpeed | float | props | mix 的插值比例,越大回越快 |
uFriction | float | props | 每幀速度乘上這個值,< 1 就會減速 |
如 Step 12 提到的概念,Uniform 是「每幀全部像素共用」的常數。
滑鼠位置、物理參數對所有粒子都一樣,所以用 Uniform;而每個粒子各自不同的位置和速度,則存在紋理裡用 texelFetch 讀取。ԅ(´∀` ԅ)
跟 CPU 版的對比
注意到了嗎?邏輯跟 Step 4~8 幾乎一模一樣:
| 概念 | CPU 版 | GPU 版 |
|---|---|---|
| 摩擦力 | velocity *= friction | vel *= uFriction |
| 回歸 | x += (origin - x) * speed | pos = mix(pos, originPos, speed) |
| 距離 | Math.sqrt(dx*dx + dy*dy) | length(toParticle) |
| 衰減 | 1 - dist / radius | smoothstep(radius, 0, dist) |
GLSL 的 mix 就是 lerp,length 就是向量長度,smoothstep 是更平滑的衰減曲線。差別只在語法和「誰來算」。
smoothstep vs 線性衰減
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
注意徑向推力用了 influence * influence(平方),不是線性。平方衰減讓邊緣更柔,中心更強,推出來的「空洞」更有層次。
Step 16:gl.POINTS 與 Vertex Shader
物理算完了,粒子位置存在紋理裡,最後只剩畫到螢幕上了!ヾ(◍'౪`◍)ノ゙
不過在動手之前,先退一步看看整體流程。
到目前為止我們寫了 Step 15 的 Physics Shader,接下來還要寫渲染用的 Vertex Shader 和 Fragment Shader,Shader 越來越多,容易搞混誰是誰。
整個粒子系統的 Shader 可以分成兩大類:
- 模擬用 Shader:負責算物理,更新粒子的位置和速度
- 渲染用 Shader:負責把粒子畫出來,決定位置、大小、形狀和顏色
這兩類 Shader 各司其職,資料透過紋理傳遞。
兩類 Shader 的關係
模擬階段的 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>gl.POINTS
WebGL 有個很方便的繪製模式叫 gl.POINTS,每個頂點畫成一個正方形的「點」。
gl.drawArrays(gl.POINTS, 0, particleCount)一行就畫完所有粒子。大小由 Vertex Shader 裡的 gl_PointSize 控制。
Vertex Shader:從紋理讀取位置
除了前面見過的 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 的妙用
gl_VertexID 是 WebGL2 的內建變數,代表「第幾個頂點」。我們不需要傳入任何頂點資料,只要知道 ID 就能從紋理中查到位置。
傳統做法需要建立頂點緩衝區(VBO)存座標,每幀從 CPU 上傳新資料。紋理查表的話,資料全程留在 GPU 上,零傳輸成本。ヽ(●`∀´●)ノ
Clip Space 座標轉換
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 軸。
Varying 傳遞
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,方便一眼辨認資料來源:
| 字首 | 意思 | 例子 |
|---|---|---|
a | Attribute(來自 JavaScript 的頂點資料) | aPosition |
u | Uniform(來自 JavaScript 的全域常數) | uMouse |
v | Varying(來自 Vertex Shader) | vDisplacement |
Step 17:圓形粒子與 Alpha Blending
Vertex Shader 決定了粒子的「位置」和「大小」,Fragment Shader 決定它「長什麼樣」。
查看範例原始碼
<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 是什麼?
discard 是 GLSL 的關鍵字,意思是「這個像素不要了,什麼都不畫」。被 discard 的像素不會影響 Framebuffer,就像從來沒存在過一樣。
柔和邊緣
smoothstep(1.0, 0.4, dist) 讓粒子邊緣不是硬切的。距離從 0.4 到 1.0 之間會平滑過渡到透明,看起來像微微發光的小點,而不是銳利的圓形。
Alpha Blending
粒子互相重疊時需要混色:
gl.enable(gl.BLEND)
gl.blendFuncSeparate(
gl.SRC_ALPHA,
gl.ONE_MINUS_SRC_ALPHA,
gl.ONE,
gl.ONE_MINUS_SRC_ALPHA
)這是標準的 alpha 混合模式。多個半透明粒子疊在一起,顏色自然地混合,不會互相蓋掉。
模擬與繪製邏輯都完成了!✧⁑。٩(ˊᗜˋ*)و✧⁕。
最後讓我們加入 Curl Noise 和顏色變化,讓粒子更有層次,動作更像蟲群 ◝( •ω• )◟
Step 18:Curl Noise 擾動
目前粒子被推開後直線回來,少了自然界那種混沌又有秩序的美感。(´・ω・`)
Curl Noise 會讓粒子沿著漩渦走,看起來就像被風捲著跑。
查看範例原始碼
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
const canvasRef = ref<HTMLCanvasElement | null>(null)
let glContext: WebGL2RenderingContext | null = null
let physicsProgram: WebGLProgram | null = null
let renderProgram: WebGLProgram | null = null
let noiseProgram: WebGLProgram | null = null
let animationFrameId = 0
const stateTextureList: [WebGLTexture | null, WebGLTexture | null] = [null, null]
const framebufferList: [WebGLFramebuffer | null, WebGLFramebuffer | null] = [null, null]
let originTexture: WebGLTexture | null = null
let noiseTexture: WebGLTexture | null = null
let noiseFramebuffer: WebGLFramebuffer | null = null
let quadVertexBuffer: WebGLBuffer | null = null
let quadVertexArray: WebGLVertexArrayObject | null = null
let particleVertexArray: WebGLVertexArrayObject | null = null
let particleCount = 0
let textureSize = 0
let pingPongIndex = 0
let startTime = 0
let logicalWidth = 0
let logicalHeight = 0
let mouseX = 0
let mouseY = 0
let previousMouseX = -9999
let previousMouseY = -9999
let mouseVelocityX = 0
let mouseVelocityY = 0
let isMouseInside = false
const DISPLAY_TEXT = 'SWARM'
const NOISE_SIZE = 256
// ----- Noise generation shader -----
const noiseVertexSource = `#version 300 es
in vec2 aPosition;
void main() {
gl_Position = vec4(aPosition, 0.0, 1.0);
}
`
const noiseFragmentSource = `#version 300 es
precision highp float;
out vec4 fragColor;
float hash(vec2 p) {
vec3 p3 = fract(vec3(p.xyx) * 0.13);
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
float noise(vec2 x) {
vec2 i = floor(x); vec2 f = fract(x);
float a = hash(i); float b = hash(i + vec2(1.0, 0.0));
float c = hash(i + vec2(0.0, 1.0)); float d = hash(i + vec2(1.0, 1.0));
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y;
}
float fbm(vec2 p) {
float v = 0.0; float a = 0.5;
for (int i = 0; i < 4; i++) { v += a * noise(p); p *= 2.0; a *= 0.5; }
return v;
}
void main() {
vec2 uv = gl_FragCoord.xy / 256.0;
vec2 p = uv * 6.0;
const float e = 0.1;
float a = (fbm(p + vec2(0.0, e)) - fbm(p - vec2(0.0, e))) / (2.0 * e);
float b = (fbm(p + vec2(e, 0.0)) - fbm(p - vec2(e, 0.0))) / (2.0 * e);
fragColor = vec4(a, -b, 0.0, 1.0);
}
`
// ----- Physics pass shaders -----
const physicsVertexSource = `#version 300 es
in vec2 aPosition;
void main() {
gl_Position = vec4(aPosition, 0.0, 1.0);
}
`
const physicsFragmentSource = `#version 300 es
precision highp float;
uniform sampler2D uState;
uniform sampler2D uOrigin;
uniform sampler2D uNoise;
uniform vec2 uMouse;
uniform vec2 uMouseVelocity;
uniform int uMouseInside;
uniform float uScatterRadius;
uniform float uScatterForce;
uniform float uReturnSpeed;
uniform float uFriction;
uniform float uTime;
out vec4 fragColor;
float hash(vec2 p) {
vec3 p3 = fract(vec3(p.xyx) * 0.13);
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
void main() {
ivec2 coord = ivec2(gl_FragCoord.xy);
vec4 state = texelFetch(uState, coord, 0);
vec4 origin = texelFetch(uOrigin, coord, 0);
vec2 pos = state.xy;
vec2 vel = state.zw;
vec2 originPos = origin.xy;
if (originPos.x < -9999.0) {
fragColor = state;
return;
}
float pRand = hash(vec2(float(coord.x), float(coord.y)));
float forceScale = 0.5 + pRand * 1.0;
if (uMouseInside == 1) {
vec2 toParticle = pos - uMouse;
float dist = length(toParticle);
if (dist < uScatterRadius) {
float influence = smoothstep(uScatterRadius, 0.0, dist);
float mouseSpeed = length(uMouseVelocity);
if (mouseSpeed > 0.5) {
vec2 windDir = uMouseVelocity / mouseSpeed;
vel += windDir * mouseSpeed * influence * uScatterForce * 0.025 * forceScale;
}
vec2 noiseUV = pos * 0.005 + uTime * 0.15 + pRand * 10.0;
vec2 curl = texture(uNoise, noiseUV).xy;
float turbStrength = (1.5 + mouseSpeed * 0.4) * influence * uScatterForce * 0.12 * forceScale;
vel += curl * turbStrength;
if (dist > 0.01) {
vel += (toParticle / dist) * influence * influence * uScatterForce * 0.5 * forceScale;
}
}
}
vec2 toOrigin = originPos - pos;
float distFromOrigin = length(toOrigin);
if (distFromOrigin > 2.0) {
float displacement = smoothstep(2.0, 40.0, distFromOrigin);
vec2 driftUV = pos * 0.002 + uTime * 0.08 + pRand * 0.3;
vec2 drift = texture(uNoise, driftUV).xy;
vel += drift * displacement * 1.5 * forceScale;
}
float particleFriction = uFriction - pRand * 0.04;
vel *= particleFriction;
pos += vel;
float returnRate = uReturnSpeed * (0.6 + pRand * 0.8);
pos = mix(pos, originPos, returnRate);
fragColor = vec4(pos, vel);
}
`
// ----- Render pass shaders -----
const renderVertexSource = `#version 300 es
precision highp float;
uniform sampler2D uState;
uniform sampler2D uOrigin;
uniform int uTextureSize;
uniform vec2 uResolution;
uniform float uPointScale;
uniform float uTime;
out float vOpacity;
out float vDisplacement;
out float vSpeed;
void main() {
int index = gl_VertexID;
int texelX = index % uTextureSize;
int texelY = index / uTextureSize;
vec4 state = texelFetch(uState, ivec2(texelX, texelY), 0);
vec4 origin = texelFetch(uOrigin, ivec2(texelX, texelY), 0);
vec2 pos = state.xy;
vec2 vel = state.zw;
vec2 originPos = origin.xy;
float size = origin.z;
float opacity = origin.w;
if (originPos.x < -9999.0) {
gl_Position = vec4(-2.0, -2.0, 0.0, 1.0);
gl_PointSize = 0.0;
vOpacity = 0.0;
vDisplacement = 0.0;
vSpeed = 0.0;
return;
}
float distFromOrigin = length(pos - originPos);
float speed = length(vel);
float displacementNear = smoothstep(0.0, 8.0, distFromOrigin);
float displacementMid = smoothstep(8.0, 30.0, distFromOrigin);
float displacementFar = smoothstep(30.0, 80.0, distFromOrigin);
float displacement = displacementNear * 0.3 + displacementMid * 0.4 + displacementFar * 0.3;
float speedFactor = smoothstep(0.0, 8.0, speed);
float phase = float(index) * 1.618033988;
pos.x += sin(phase + uTime * 0.001) * 0.2;
pos.y += cos(phase * 1.3 + uTime * 0.0008) * 0.2;
vec2 clipPosition = (pos / uResolution) * 2.0 - 1.0;
clipPosition.y *= -1.0;
gl_Position = vec4(clipPosition, 0.0, 1.0);
gl_PointSize = size * 2.0 * uPointScale;
vOpacity = opacity;
vDisplacement = displacement;
vSpeed = speedFactor;
}
`
const renderFragmentSource = `#version 300 es
precision highp float;
in float vOpacity;
in float vDisplacement;
in float vSpeed;
out vec4 fragColor;
void main() {
float dist = length(gl_PointCoord - 0.5) * 2.0;
if (dist > 1.0) discard;
float alpha = smoothstep(1.0, 0.4, dist) * vOpacity;
fragColor = vec4(0.53, 0.53, 0.53, alpha);
}
`
// ----- Helper functions -----
function compileShader(
gl: WebGL2RenderingContext,
type: number,
source: string,
): WebGLShader | null {
const shader = gl.createShader(type)
if (!shader)
return null
gl.shaderSource(shader, source)
gl.compileShader(shader)
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error(gl.getShaderInfoLog(shader))
gl.deleteShader(shader)
return null
}
return shader
}
function linkProgram(
gl: WebGL2RenderingContext,
vertSource: string,
fragSource: string,
): WebGLProgram | null {
const vert = compileShader(gl, gl.VERTEX_SHADER, vertSource)
const frag = compileShader(gl, gl.FRAGMENT_SHADER, fragSource)
if (!vert || !frag)
return null
const program = gl.createProgram()
if (!program)
return null
gl.attachShader(program, vert)
gl.attachShader(program, frag)
gl.linkProgram(program)
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error(gl.getProgramInfoLog(program))
gl.deleteProgram(program)
return null
}
gl.deleteShader(vert)
gl.deleteShader(frag)
return program
}
function createFloatTexture(
gl: WebGL2RenderingContext,
size: number,
data: Float32Array,
): WebGLTexture | null {
const texture = gl.createTexture()
if (!texture)
return null
gl.bindTexture(gl.TEXTURE_2D, texture)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, size, size, 0, gl.RGBA, gl.FLOAT, data)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
return texture
}
// ----- Particle sampling from text -----
interface SampledParticle {
x: number;
y: number;
size: number;
opacity: number;
}
function sampleParticleListFromText(
text: string,
canvasWidth: number,
canvasHeight: number,
): SampledParticle[] {
const offscreen = document.createElement('canvas')
offscreen.width = canvasWidth
offscreen.height = canvasHeight
const context = offscreen.getContext('2d')
if (!context)
return []
const fontSize = Math.min(canvasWidth / (text.length * 0.7), canvasHeight * 0.6)
context.fillStyle = 'white'
context.font = `bold ${fontSize}px Arial, sans-serif`
context.textAlign = 'center'
context.textBaseline = 'middle'
context.fillText(text, canvasWidth / 2, canvasHeight / 2)
const imageData = context.getImageData(0, 0, canvasWidth, canvasHeight)
const pixelDataList = imageData.data
const gap = 1
const resultList: SampledParticle[] = []
for (let y = 0; y < canvasHeight; y += gap) {
for (let x = 0; x < canvasWidth; x += gap) {
const pixelX = Math.floor(x)
const pixelY = Math.floor(y)
const index = (pixelY * canvasWidth + pixelX) * 4
const alpha = (pixelDataList[index + 3] ?? 0) / 255
if (alpha > 0.15) {
resultList.push({
x,
y,
size: 1.5 * (0.4 + alpha * 0.6),
opacity: alpha * (0.8 + Math.random() * 0.2),
})
}
}
}
return resultList
}
// ----- Setup textures and framebuffers -----
function setupParticleTextures(
gl: WebGL2RenderingContext,
sampledParticleList: SampledParticle[],
): void {
particleCount = sampledParticleList.length
textureSize = Math.ceil(Math.sqrt(particleCount))
const totalPixelCount = textureSize * textureSize
const stateData = new Float32Array(totalPixelCount * 4)
const originData = new Float32Array(totalPixelCount * 4)
for (let i = 0; i < totalPixelCount; i++) {
if (i < particleCount) {
const particle = sampledParticleList[i]
stateData[i * 4 + 0] = particle?.x ?? 0
stateData[i * 4 + 1] = particle?.y ?? 0
stateData[i * 4 + 2] = 0
stateData[i * 4 + 3] = 0
originData[i * 4 + 0] = particle?.x ?? 0
originData[i * 4 + 1] = particle?.y ?? 0
originData[i * 4 + 2] = particle?.size ?? 0
originData[i * 4 + 3] = particle?.opacity ?? 0
}
else {
stateData[i * 4 + 0] = -99999
stateData[i * 4 + 1] = -99999
stateData[i * 4 + 2] = 0
stateData[i * 4 + 3] = 0
originData[i * 4 + 0] = -99999
originData[i * 4 + 1] = -99999
originData[i * 4 + 2] = 0
originData[i * 4 + 3] = 0
}
}
stateTextureList[0] = createFloatTexture(gl, textureSize, stateData)
stateTextureList[1] = createFloatTexture(gl, textureSize, new Float32Array(stateData))
originTexture = createFloatTexture(gl, textureSize, originData)
for (let i = 0; i < 2; i++) {
framebufferList[i] = gl.createFramebuffer()
gl.bindFramebuffer(gl.FRAMEBUFFER, framebufferList[i])
gl.framebufferTexture2D(
gl.FRAMEBUFFER,
gl.COLOR_ATTACHMENT0,
gl.TEXTURE_2D,
stateTextureList[i],
0,
)
}
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
}
function generateNoiseTexture(gl: WebGL2RenderingContext): void {
noiseProgram = linkProgram(gl, noiseVertexSource, noiseFragmentSource)
if (!noiseProgram)
return
noiseTexture = gl.createTexture()
gl.bindTexture(gl.TEXTURE_2D, noiseTexture)
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA32F,
NOISE_SIZE,
NOISE_SIZE,
0,
gl.RGBA,
gl.FLOAT,
null,
)
const hasFloatLinear = !!gl.getExtension('OES_texture_float_linear')
const noiseFilter = hasFloatLinear ? gl.LINEAR : gl.NEAREST
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, noiseFilter)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, noiseFilter)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT)
noiseFramebuffer = gl.createFramebuffer()
gl.bindFramebuffer(gl.FRAMEBUFFER, noiseFramebuffer)
gl.framebufferTexture2D(
gl.FRAMEBUFFER,
gl.COLOR_ATTACHMENT0,
gl.TEXTURE_2D,
noiseTexture,
0,
)
gl.viewport(0, 0, NOISE_SIZE, NOISE_SIZE)
gl.useProgram(noiseProgram)
gl.bindVertexArray(quadVertexArray)
gl.drawArrays(gl.TRIANGLES, 0, 6)
gl.bindVertexArray(null)
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
gl.deleteProgram(noiseProgram)
noiseProgram = null
}
function setupBuffers(gl: WebGL2RenderingContext): void {
const positionList = new Float32Array([
-1,
-1,
1,
-1,
-1,
1,
-1,
1,
1,
-1,
1,
1,
])
quadVertexArray = gl.createVertexArray()
gl.bindVertexArray(quadVertexArray)
quadVertexBuffer = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, quadVertexBuffer)
gl.bufferData(gl.ARRAY_BUFFER, positionList, gl.STATIC_DRAW)
const positionLocation = 0
gl.enableVertexAttribArray(positionLocation)
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0)
gl.bindVertexArray(null)
particleVertexArray = gl.createVertexArray()
}
// ----- Pointer events -----
function handlePointerDown(event: PointerEvent): void {
const target = event.currentTarget
if (target instanceof Element)
target.setPointerCapture(event.pointerId)
handlePointerMove(event)
}
function handlePointerMove(event: PointerEvent): void {
const newX = event.offsetX
const newY = event.offsetY
if (previousMouseX > -9000 && previousMouseY > -9000) {
mouseVelocityX = newX - previousMouseX
mouseVelocityY = newY - previousMouseY
}
previousMouseX = newX
previousMouseY = newY
mouseX = newX
mouseY = newY
isMouseInside = true
}
function handlePointerLeave(): void {
isMouseInside = false
previousMouseX = -9999
previousMouseY = -9999
}
// ----- Init and render -----
function initWebGL(): void {
const canvas = canvasRef.value
if (!canvas)
return
const dpr = window.devicePixelRatio || 1
logicalWidth = canvas.clientWidth
logicalHeight = canvas.clientHeight
canvas.width = logicalWidth * dpr
canvas.height = logicalHeight * dpr
glContext = canvas.getContext('webgl2', {
premultipliedAlpha: false,
alpha: true,
})
if (!glContext) {
console.error('WebGL2 not supported')
return
}
const extColorFloat = glContext.getExtension('EXT_color_buffer_float')
if (!extColorFloat) {
console.error('EXT_color_buffer_float not supported')
return
}
physicsProgram = linkProgram(glContext, physicsVertexSource, physicsFragmentSource)
renderProgram = linkProgram(glContext, renderVertexSource, renderFragmentSource)
if (!physicsProgram || !renderProgram)
return
setupBuffers(glContext)
generateNoiseTexture(glContext)
const sampledParticleList = sampleParticleListFromText(
DISPLAY_TEXT,
logicalWidth,
logicalHeight,
)
setupParticleTextures(glContext, sampledParticleList)
glContext.enable(glContext.BLEND)
glContext.blendFuncSeparate(
glContext.SRC_ALPHA,
glContext.ONE_MINUS_SRC_ALPHA,
glContext.ONE,
glContext.ONE_MINUS_SRC_ALPHA,
)
previousMouseX = -9999
previousMouseY = -9999
mouseVelocityX = 0
mouseVelocityY = 0
startTime = performance.now()
canvas.addEventListener('pointerdown', handlePointerDown)
canvas.addEventListener('pointermove', handlePointerMove)
canvas.addEventListener('pointerup', handlePointerLeave)
canvas.addEventListener('pointerleave', handlePointerLeave)
}
function render(): void {
if (!glContext || !physicsProgram || !renderProgram)
return
const canvas = canvasRef.value
if (!canvas)
return
const gl = glContext
const currentTime = (performance.now() - startTime) * 0.001
mouseVelocityX *= 0.92
mouseVelocityY *= 0.92
const readIndex = pingPongIndex
const writeIndex = 1 - pingPongIndex
gl.disable(gl.BLEND)
gl.bindFramebuffer(gl.FRAMEBUFFER, framebufferList[writeIndex])
gl.viewport(0, 0, textureSize, textureSize)
gl.useProgram(physicsProgram)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, stateTextureList[readIndex])
gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uState'), 0)
gl.activeTexture(gl.TEXTURE1)
gl.bindTexture(gl.TEXTURE_2D, originTexture)
gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uOrigin'), 1)
gl.activeTexture(gl.TEXTURE2)
gl.bindTexture(gl.TEXTURE_2D, noiseTexture)
gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uNoise'), 2)
gl.uniform2f(gl.getUniformLocation(physicsProgram, 'uMouse'), mouseX, mouseY)
gl.uniform2f(gl.getUniformLocation(physicsProgram, 'uMouseVelocity'), mouseVelocityX, mouseVelocityY)
gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uMouseInside'), isMouseInside ? 1 : 0)
gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uScatterRadius'), 40.0)
gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uScatterForce'), 40.0)
gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uReturnSpeed'), 0.1)
gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uFriction'), 0.92)
gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uTime'), currentTime)
gl.bindVertexArray(quadVertexArray)
gl.drawArrays(gl.TRIANGLES, 0, 6)
gl.bindVertexArray(null)
pingPongIndex = writeIndex
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
gl.viewport(0, 0, canvas.width, canvas.height)
gl.clearColor(0, 0, 0, 0)
gl.clear(gl.COLOR_BUFFER_BIT)
gl.enable(gl.BLEND)
gl.blendFuncSeparate(
gl.SRC_ALPHA,
gl.ONE_MINUS_SRC_ALPHA,
gl.ONE,
gl.ONE_MINUS_SRC_ALPHA,
)
gl.useProgram(renderProgram)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, stateTextureList[writeIndex])
gl.uniform1i(gl.getUniformLocation(renderProgram, 'uState'), 0)
gl.activeTexture(gl.TEXTURE1)
gl.bindTexture(gl.TEXTURE_2D, originTexture)
gl.uniform1i(gl.getUniformLocation(renderProgram, 'uOrigin'), 1)
const dpr = window.devicePixelRatio || 1
gl.uniform1i(gl.getUniformLocation(renderProgram, 'uTextureSize'), textureSize)
gl.uniform2f(
gl.getUniformLocation(renderProgram, 'uResolution'),
logicalWidth,
logicalHeight,
)
gl.uniform1f(gl.getUniformLocation(renderProgram, 'uPointScale'), dpr)
gl.uniform1f(gl.getUniformLocation(renderProgram, 'uTime'), performance.now())
gl.bindVertexArray(particleVertexArray)
gl.drawArrays(gl.POINTS, 0, particleCount)
gl.bindVertexArray(null)
animationFrameId = requestAnimationFrame(render)
}
onMounted(() => {
initWebGL()
animationFrameId = requestAnimationFrame(render)
})
onBeforeUnmount(() => {
cancelAnimationFrame(animationFrameId)
const canvas = canvasRef.value
if (canvas) {
canvas.removeEventListener('pointerdown', handlePointerDown)
canvas.removeEventListener('pointermove', handlePointerMove)
canvas.removeEventListener('pointerup', handlePointerLeave)
canvas.removeEventListener('pointerleave', handlePointerLeave)
}
if (glContext) {
if (physicsProgram)
glContext.deleteProgram(physicsProgram)
if (renderProgram)
glContext.deleteProgram(renderProgram)
for (const texture of stateTextureList) {
if (texture)
glContext.deleteTexture(texture)
}
if (originTexture)
glContext.deleteTexture(originTexture)
if (noiseTexture)
glContext.deleteTexture(noiseTexture)
for (const framebuffer of framebufferList) {
if (framebuffer)
glContext.deleteFramebuffer(framebuffer)
}
if (noiseFramebuffer)
glContext.deleteFramebuffer(noiseFramebuffer)
if (quadVertexBuffer)
glContext.deleteBuffer(quadVertexBuffer)
if (quadVertexArray)
glContext.deleteVertexArray(quadVertexArray)
if (particleVertexArray)
glContext.deleteVertexArray(particleVertexArray)
}
})
</script>
<template>
<div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6">
<span class="text-sm text-gray-600">
回補原始版的渲染層次:多段位移、時間性微飄動、位移混色與完整指標慣性,讓回歸時更有渦流感。
</span>
<canvas
ref="canvasRef"
class="w-full rounded-lg"
style="height: 320px;"
/>
</div>
</template>普通亂數 vs Curl Noise
一般的 Math.random() 會讓粒子亂跳,像喝醉一樣毫無方向感。
Curl Noise 不一樣,它產生的是旋轉場。鄰近的點會有相似的方向,粒子沿著它走會形成漩渦般的軌跡。
查看範例原始碼
<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(Fractal Brownian Motion)
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;
}低頻的大漩渦 + 高頻的小擾動,看起來就很像自然界的紊流。
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 時自動循環,等於無限大的噪聲場。
在物理 Shader 中加入擾動
這段程式碼用到三個新變數:
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
Step 19:多層發光效果
粒子飄起來了!但看起來都是一樣亮度的白點,讓我們加入一點顏色變化提高視覺層次感。✧⁑。٩(ˊᗜˋ*)و✧⁕。
路人:「蟲蟲會變色?(́⊙◞౪◟⊙‵)」
鱈魚:「我說會就會ヽ(́◕◞౪◟◕‵)ノ」
查看範例原始碼
<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>亮度取決於兩個因子
- 離家多遠(displacement):被推得越遠越亮
- 移動多快(speed):速度越快越亮
這兩個值在 Vertex Shader(Step 16)已經算好,透過 Varying 傳到 Fragment Shader。
三層 displacement
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) 只在粒子正中央才亮。
效果是剛被推開的粒子中心特別亮,有種「衝擊波」的感覺。離中心遠的部分維持原本的暖色調。
柔化邊緣與 displacement 的連動
float coreEdge = mix(0.4, 0.25, vDisplacement);
float alpha = smoothstep(1.0, coreEdge, dist) * vOpacity;粒子被推得越遠,coreEdge 越小,光暈越散越柔。靜止時邊緣清晰(0.4),飛散時邊緣模糊(0.25)。
完成!🎉
恭喜各位走完了所有步驟!讓我們回顧一下整趟旅程:
Canvas 2D 篇
| 步驟 | 概念 | 關鍵技術 |
|---|---|---|
| 1 | Canvas 基礎 | getContext('2d')、fillText、DPR |
| 2 | 像素取樣 | getImageData、alpha 通道判斷 |
| 3 | 粒子渲染 | requestAnimationFrame、fillRect |
| 4 | 速度與摩擦力 | velocity *= friction |
| 5 | 回歸原位 | lerp(線性內插) |
| 6 | 追蹤滑鼠 | Pointer Events、座標轉換 |
| 7 | 徑向推力 | 單位方向向量 × 衰減力道 |
| 8 | 風力 | 滑鼠速度追蹤、方向性推力 |
| 9 | 飄盪 | 隨機飄移、displacement 閾值 |
| 10 | 效能瓶頸 | 繪圖呼叫開銷分析 |
WebGL2 Shader 篇
| 步驟 | 概念 | 關鍵技術 |
|---|---|---|
| 11 | GPU 思維 | 平行運算、Shader 類型 |
| 12 | Shader 編譯 | Program、Uniform |
| 13 | 浮點紋理 | RGBA32F、texelFetch |
| 14 | Ping-Pong | 雙紋理交替、Framebuffer |
| 15 | GPU 物理 | smoothstep、mix、GLSL 物理模擬 |
| 16 | Vertex Shader | gl_VertexID、clip space、Varying |
| 17 | 圓形粒子 | gl_PointCoord、Alpha Blending |
| 18 | Curl Noise | FBM、預計算噪聲紋理 |
| 19 | 多層發光 | displacement 亮度、暖白漸變 |
從最簡單的 Canvas 2D 一路到 WebGL2 Shader,核心物理邏輯其實沒甚麼變,變的是「誰來算」。
從 CPU 一個一個慢慢來,改成 GPU 小精靈大軍一起算。∠( ᐛ 」∠)_
完整元件請見:蟲群文字 🐟
以上如有任何錯誤,還請各位大大多多指教。
感謝您讀到這裡,如果您覺得有收穫,歡迎分享出去。◝( •ω• )◟