蟲群文字 text
從零開始,一步步打造文字化為蟲群粒子,滑鼠靠近時散開、離開後聚集的互動效果。◝( •ω• )◟
完成品請見 蟲群文字元件。
前言
讓我們來開發文字會散開的粒子效果?滑鼠一靠近就像蟲子受到驚嚇四散奔逃,離開後又慢慢聚回來排成原本的文字。
其實這種效果不難,難在如何讓上萬個粒子跑得又順又好看。
路人:「數大便是美的概念?」
鱈魚:「哈哈大便!ᕕ( ゚ ∀。)ᕗ 」
路人:「你小學生喔?...(。-`ω´-)」
讓我們從最基本的 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) {
if (isPointerInside) {
const deltaX = particle.x - pointerX
const deltaY = particle.y - pointerY
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY)
// Radial push force
if (distance < SCATTER_RADIUS && distance > 0) {
const influence = 1 - distance / SCATTER_RADIUS
const directionX = deltaX / distance
const directionY = deltaY / distance
const force = influence * 2
particle.velocityX += directionX * force
particle.velocityY += directionY * force
}
// Wind force based on mouse velocity
if (distance < SCATTER_RADIUS && currentMouseSpeed > 0.5) {
const influence = 1 - distance / SCATTER_RADIUS
particle.velocityX += pointerVelocityX * influence * 0.3
particle.velocityY += pointerVelocityY * influence * 0.3
}
}
// Random drift for displaced particles
const displacementX = particle.x - particle.originX
const displacementY = particle.y - particle.originY
const displacement = Math.sqrt(
displacementX * displacementX + displacementY * displacementY,
)
if (displacement > 1) {
const driftScale = Math.min(displacement / 50, 1)
const driftAngle = Math.sin(time * 0.001 + particle.randomSeed)
const driftForce = driftScale * 0.3
particle.velocityX += Math.cos(driftAngle) * driftForce
particle.velocityY += Math.sin(driftAngle) * driftForce
}
// Return to origin
const distanceX = particle.originX - particle.x
const distanceY = particle.originY - particle.y
particle.velocityX += distanceX * RETURN_SPEED
particle.velocityY += distanceY * RETURN_SPEED
particle.velocityX *= FRICTION
particle.velocityY *= FRICTION
particle.x += particle.velocityX
particle.y += particle.velocityY
}
}
function render() {
const canvas = canvasRef.value
if (!canvas)
return
const devicePixelRatio = window.devicePixelRatio || 1
const context = canvas.getContext('2d')
if (!context)
return
context.save()
context.scale(devicePixelRatio, devicePixelRatio)
context.fillStyle = '#111827'
context.fillRect(0, 0, canvasWidth, CANVAS_HEIGHT)
context.fillStyle = '#9ca3af'
for (const particle of particleList) {
context.beginPath()
context.arc(particle.x, particle.y, 1, 0, Math.PI * 2)
context.fill()
}
context.restore()
}
function animationLoop() {
updateParticleList()
render()
// FPS calculation
frameCount++
const now = performance.now()
const elapsed = now - lastFpsTime
if (elapsed >= 1000) {
currentFps.value = Math.round((frameCount * 1000) / elapsed)
frameCount = 0
lastFpsTime = now
}
animationFrameId = requestAnimationFrame(animationLoop)
}
function handlePointerMove(event: PointerEvent) {
const canvas = canvasRef.value
if (!canvas)
return
const rect = canvas.getBoundingClientRect()
const currentX = event.clientX - rect.left
const currentY = event.clientY - rect.top
if (isPointerInside) {
pointerVelocityX = currentX - previousPointerX
pointerVelocityY = currentY - previousPointerY
}
previousPointerX = currentX
previousPointerY = currentY
pointerX = currentX
pointerY = currentY
isPointerInside = true
}
function handlePointerLeave() {
isPointerInside = false
}
function handleResize() {
initParticleList()
}
watch(sampleGap, () => {
initParticleList()
})
onMounted(() => {
initParticleList()
animationFrameId = requestAnimationFrame(animationLoop)
window.addEventListener('resize', handleResize)
})
onBeforeUnmount(() => {
cancelAnimationFrame(animationFrameId)
window.removeEventListener('resize', handleResize)
})
</script>
<template>
<div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6">
<div class="flex items-center gap-4 text-sm text-gray-500">
<label class="flex items-center gap-2">
粒子間距 (gap)
<input
v-model.number="sampleGap"
type="range"
min="0.5"
max="8"
step="0.5"
class="w-32"
>
<span class="w-4 text-center">{{ sampleGap }}</span>
</label>
</div>
<p class="text-sm text-gray-500">
粒子數量:{{ particleCount }}|FPS:{{ currentFps }}
</p>
<canvas
ref="canvasRef"
class="w-full"
@pointermove="handlePointerMove"
@pointerleave="handlePointerLeave"
/>
</div>
</template>每幀 CPU 要做多少事?
假設有 5,000 個粒子,每幀的工作量:
for 5000 個粒子:
1. 計算滑鼠距離 → 2 次減法 + 1 次開根號
2. 風力計算 → 若干乘法
3. 摩擦力 → 2 次乘法
4. 回歸力 → 4 次加減乘
5. 更新位置 → 2 次加法
6. fillRect → 1 次 Canvas API 呼叫每個粒子約 15~20 個數學運算 + 1 次繪圖呼叫。5,000 個粒子就是 10 萬次運算 + 5,000 次 fillRect。
60fps 代表每幀只有 16.6ms 的時間預算。Canvas 2D 的 fillRect 開銷不小(每次都要走一趟渲染管線),5,000 次呼叫可能就吃掉大半時間。
瓶頸在哪裡?
其實數學運算不是問題,CPU 做浮點運算很快,十萬次也只要幾毫秒。
真正的瓶頸是 Canvas 2D API 的繪圖呼叫。
每次 fillRect 都是一次獨立的繪圖指令,沒辦法批次處理。
粒子越多,繪圖呼叫越多,瀏覽器越吃力。
從這裡開始搬上 GPU
接下來的步驟我們會把物理計算和渲染全部搬到 GPU 上。CPU 只負責傳滑鼠座標等少量資料,其他全交給 Shader。
粒子數量從 5,000 變成 100,000+,幀率反而更高。這就是 GPU 平行運算的威力。
Step 11:WebGL2 基礎,GPU 思維
開始寫 Shader 之前,我們需要先了解 GPU 的思考方式。不用怕,其實核心概念不複雜。
下面這個範例同時用 Canvas 2D(CPU)和 WebGL2(GPU)渲染相同數量的粒子。拉高粒子數量,感受一下差異:
兩邊都是純渲染(無物理),比的是 N 次 fillRect vs 1 次 drawArrays
查看範例原始碼
<template>
<div class="flex flex-col gap-3 border border-gray-200 rounded-xl p-6">
<div class="flex items-center gap-4 text-sm text-gray-500">
<label class="flex items-center gap-2">
粒子數量
<input
v-model.number="particleCountLevel"
type="range"
min="0"
max="3"
step="1"
class="w-24"
>
<span class="w-20">{{ particleCountLabel }}</span>
</label>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1">
<div class="text-xs font-bold text-gray-400">
Canvas 2D(CPU)
</div>
<canvas ref="cpuCanvasRef" class="w-full rounded" />
<div class="text-xs text-gray-400">
FPS: {{ cpuFps }}|渲染: {{ cpuRenderTime }}ms
</div>
</div>
<div class="flex flex-col gap-1">
<div class="text-xs font-bold text-gray-400">
WebGL2(GPU)
</div>
<canvas ref="gpuCanvasRef" class="w-full rounded" />
<div class="text-xs text-gray-400">
FPS: {{ gpuFps }}|渲染: {{ gpuRenderTime }}ms
</div>
</div>
</div>
<p class="text-xs text-gray-400">
兩邊都是純渲染(無物理),比的是 N 次 fillRect vs 1 次 drawArrays
</p>
</div>
</template>
<script setup lang="ts">
import {
computed,
onBeforeUnmount,
onMounted,
ref,
watch,
} from 'vue'
const cpuCanvasRef = ref<HTMLCanvasElement | null>(null)
const gpuCanvasRef = ref<HTMLCanvasElement | null>(null)
const CANVAS_HEIGHT = 150
const PARTICLE_COUNT_LIST = [5000, 20000, 100000, 500000]
const particleCountLevel = ref(1)
const particleCountLabel = computed(
() => `${PARTICLE_COUNT_LIST[particleCountLevel.value]?.toLocaleString() ?? 0} 個`,
)
const cpuFps = ref(0)
const gpuFps = ref(0)
const cpuRenderTime = ref('0.0')
const gpuRenderTime = ref('0.0')
let gpuAnimationId = 0
let canvasWidth = 0
// 共用的靜態粒子位置(兩邊畫同一份資料)
let positionList: Float32Array = new Float32Array(0)
// GPU
let gl: WebGL2RenderingContext | null = null
let gpuProgram: WebGLProgram | null = null
let gpuPositionBuffer: WebGLBuffer | null = null
let smoothGpuTime = 0
let gpuFrameCount = 0
let gpuLastFpsTime = 0
// Worker
let cpuWorker: Worker | null = null
// ====== Worker(純渲染,無物理) ======
const workerSource = `
let canvas = null
let ctx = null
let positionList = new Float32Array(0)
let canvasWidth = 0
let canvasHeight = 0
let running = false
let frameCount = 0
let lastFpsTime = 0
let smoothRenderTime = 0
let particleCount = 0
function animate() {
if (!running || !ctx) return
const start = performance.now()
ctx.fillStyle = '#111827'
ctx.fillRect(0, 0, canvasWidth, canvasHeight)
ctx.fillStyle = '#9ca3af'
for (let i = 0; i < particleCount; i++) {
ctx.beginPath()
ctx.arc(positionList[i * 2], positionList[i * 2 + 1], 1, 0, Math.PI * 2)
ctx.fill()
}
const renderTime = performance.now() - start
smoothRenderTime += (renderTime - smoothRenderTime) * 0.1
frameCount++
const now = performance.now()
if (now - lastFpsTime >= 1000) {
const elapsed = (now - lastFpsTime) / 1000
self.postMessage({
type: 'stats',
fps: Math.round(frameCount / elapsed),
renderTime: smoothRenderTime.toFixed(1),
})
frameCount = 0
lastFpsTime = now
}
setTimeout(animate, 16)
}
self.onmessage = function(event) {
const msg = event.data
if (msg.type === 'init') {
canvas = msg.canvas
canvasWidth = msg.width
canvasHeight = msg.height
canvas.width = canvasWidth
canvas.height = canvasHeight
ctx = canvas.getContext('2d')
}
if (msg.type === 'rebuild') {
running = false
positionList = msg.positionList
particleCount = msg.count
canvasWidth = msg.width
canvasHeight = msg.height
if (canvas) {
canvas.width = canvasWidth
canvas.height = canvasHeight
ctx = canvas.getContext('2d')
}
frameCount = 0
lastFpsTime = performance.now()
smoothRenderTime = 0
running = true
animate()
}
if (msg.type === 'stop') {
running = false
}
}
`
// ====== GPU Shader ======
const GPU_VERT = `#version 300 es
in vec2 aPosition;
uniform vec2 uResolution;
void main() {
vec2 clip = (aPosition / uResolution) * 2.0 - 1.0;
clip.y = -clip.y;
gl_Position = vec4(clip, 0.0, 1.0);
gl_PointSize = 1.5;
}
`
const GPU_FRAG = `#version 300 es
precision highp float;
out vec4 fragColor;
void main() {
fragColor = vec4(0.61, 0.64, 0.69, 1.0);
}
`
function compileShader(
glCtx: WebGL2RenderingContext,
type: number,
source: string,
): WebGLShader | null {
const shader = glCtx.createShader(type)
if (!shader)
return null
glCtx.shaderSource(shader, source)
glCtx.compileShader(shader)
if (!glCtx.getShaderParameter(shader, glCtx.COMPILE_STATUS)) {
console.error(glCtx.getShaderInfoLog(shader))
glCtx.deleteShader(shader)
return null
}
return shader
}
function generatePositionList(count: number): Float32Array {
const list = new Float32Array(count * 2)
for (let i = 0; i < count; i++) {
list[i * 2] = Math.random() * canvasWidth
list[i * 2 + 1] = Math.random() * CANVAS_HEIGHT
}
return list
}
function initGpu() {
const canvas = gpuCanvasRef.value
if (!canvas)
return
gl = canvas.getContext('webgl2', { alpha: true })
if (!gl)
return
const vert = compileShader(gl, gl.VERTEX_SHADER, GPU_VERT)
const frag = compileShader(gl, gl.FRAGMENT_SHADER, GPU_FRAG)
if (!vert || !frag)
return
gpuProgram = gl.createProgram()
if (!gpuProgram)
return
gl.attachShader(gpuProgram, vert)
gl.attachShader(gpuProgram, frag)
gl.linkProgram(gpuProgram)
gl.deleteShader(vert)
gl.deleteShader(frag)
gpuPositionBuffer = gl.createBuffer()
}
// GPU 每幀:只做 drawArrays,位置不變
function gpuAnimate() {
if (!gl || !gpuProgram || !gpuPositionBuffer)
return
const canvas = gpuCanvasRef.value
if (!canvas)
return
const count = positionList.length / 2
const start = performance.now()
gl.viewport(0, 0, canvas.width, canvas.height)
gl.clearColor(0.067, 0.094, 0.153, 1.0)
gl.clear(gl.COLOR_BUFFER_BIT)
gl.useProgram(gpuProgram)
const resLoc = gl.getUniformLocation(gpuProgram, 'uResolution')
gl.uniform2f(resLoc, canvasWidth, CANVAS_HEIGHT)
// 位置不變,不需要每幀重新 bufferData
gl.bindBuffer(gl.ARRAY_BUFFER, gpuPositionBuffer)
const posLoc = gl.getAttribLocation(gpuProgram, 'aPosition')
gl.enableVertexAttribArray(posLoc)
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0)
gl.drawArrays(gl.POINTS, 0, count)
const renderTime = performance.now() - start
smoothGpuTime += (renderTime - smoothGpuTime) * 0.1
gpuRenderTime.value = smoothGpuTime.toFixed(1)
gpuFrameCount++
const now = performance.now()
if (now - gpuLastFpsTime >= 1000) {
const elapsed = (now - gpuLastFpsTime) / 1000
gpuFps.value = Math.round(gpuFrameCount / elapsed)
gpuFrameCount = 0
gpuLastFpsTime = now
}
gpuAnimationId = requestAnimationFrame(gpuAnimate)
}
// ====== 初始化 ======
function setupCanvas(canvas: HTMLCanvasElement) {
canvasWidth = canvas.clientWidth
const dpr = window.devicePixelRatio || 1
canvas.width = canvasWidth * dpr
canvas.height = CANVAS_HEIGHT * dpr
canvas.style.height = `${CANVAS_HEIGHT}px`
}
function rebuild() {
cancelAnimationFrame(gpuAnimationId)
const count = PARTICLE_COUNT_LIST[particleCountLevel.value] ?? 1000
positionList = generatePositionList(count)
// GPU:上傳一次位置到 buffer
if (gl && gpuPositionBuffer) {
gl.bindBuffer(gl.ARRAY_BUFFER, gpuPositionBuffer)
gl.bufferData(gl.ARRAY_BUFFER, positionList, gl.STATIC_DRAW)
}
smoothGpuTime = 0
gpuFrameCount = 0
gpuLastFpsTime = performance.now()
gpuAnimationId = requestAnimationFrame(gpuAnimate)
// CPU Worker:傳一份 copy 過去
const positionCopy = new Float32Array(positionList)
cpuWorker?.postMessage(
{
type: 'rebuild',
positionList: positionCopy.buffer,
count,
width: canvasWidth,
height: CANVAS_HEIGHT,
},
[positionCopy.buffer],
)
}
watch(particleCountLevel, rebuild)
onMounted(() => {
const cpuCanvas = cpuCanvasRef.value
const gpuCanvas = gpuCanvasRef.value
if (!cpuCanvas || !gpuCanvas)
return
setupCanvas(gpuCanvas)
canvasWidth = cpuCanvas.clientWidth
cpuCanvas.style.height = `${CANVAS_HEIGHT}px`
// Worker + OffscreenCanvas
const blob = new Blob([workerSource], { type: 'application/javascript' })
const workerUrl = URL.createObjectURL(blob)
cpuWorker = new Worker(workerUrl)
URL.revokeObjectURL(workerUrl)
const offscreen = cpuCanvas.transferControlToOffscreen()
cpuWorker.postMessage(
{
type: 'init',
canvas: offscreen,
width: canvasWidth,
height: CANVAS_HEIGHT,
},
[offscreen],
)
cpuWorker.onmessage = (event) => {
const msg = event.data
if (msg.type === 'stats') {
cpuFps.value = msg.fps
cpuRenderTime.value = msg.renderTime
}
}
initGpu()
rebuild()
})
onBeforeUnmount(() => {
cancelAnimationFrame(gpuAnimationId)
cpuWorker?.postMessage({ type: 'stop' })
cpuWorker?.terminate()
if (gl) {
if (gpuProgram)
gl.deleteProgram(gpuProgram)
if (gpuPositionBuffer)
gl.deleteBuffer(gpuPositionBuffer)
}
})
</script>CPU vs GPU
Canvas 2D(CPU)的做法是:
for 每個粒子:
計算新位置
畫出來一個一個來,慢慢做。CPU 很擅長複雜的邏輯,但不擅長同時做很多簡單的事。
GPU 的做法是:
同時對所有粒子:
每個粒子各自計算新位置
每個粒子各自畫出來GPU 有成千上萬個小核心,每個核心做的事很簡單,但大家同時做。一萬個粒子對 CPU 是一萬次迴圈,對 GPU 只是一瞬間。(⌐■_■)✧
什麼是 Shader?
Shader 是跑在 GPU 上的小程式,用 GLSL 寫。WebGL2 用的是 GLSL ES 3.0(#version 300 es)。
蟲群文字用到兩種 Shader:
| 類型 | 用途 | 執行頻率 |
|---|---|---|
| Vertex Shader | 決定每個頂點的位置 | 每個頂點跑一次 |
| Fragment Shader | 決定每個像素的顏色 | 每個像素跑一次 |
取得 WebGL2 Context
const gl = canvas.getContext('webgl2', {
premultipliedAlpha: false,
alpha: true,
})跟 getContext('2d') 一樣簡單。premultipliedAlpha: false 避免 alpha 預乘導致的混色問題,alpha: true 讓 canvas 支援透明背景。
一個重要的心理轉換
寫 Shader 最大的不同是:你不能迴圈遍歷所有粒子,你只能控制「自己這一個」。
沒有 for 迴圈跑全部粒子。每個粒子的 Shader 同時執行,各自只知道自己的資料。粒子之間沒辦法直接溝通。
路人:「那怎麼知道滑鼠在哪?」 鱈魚:「用 Uniform,所有粒子共用同一份資料 (●'◡'●)」
Step 12:Shader 編譯與 Uniform
WebGL 的 Shader 是純文字的 GLSL 程式碼,必須在執行時動態編譯。
查看範例原始碼
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
const canvasRef = ref<HTMLCanvasElement | null>(null)
const scatterRadius = ref(40)
const force = ref(50)
let glContext: WebGL2RenderingContext | null = null
let program: WebGLProgram | null = null
let animationFrameId = 0
const vertexShaderSource = `#version 300 es
in vec2 aPosition;
void main() {
gl_Position = vec4(aPosition, 0.0, 1.0);
}
`
const fragmentShaderSource = `#version 300 es
precision highp float;
uniform vec2 uResolution;
uniform float uScatterRadius;
uniform float uForce;
out vec4 fragColor;
void main() {
vec2 uv = gl_FragCoord.xy / uResolution;
vec2 center = vec2(0.5, 0.5);
float normalizedRadius = uScatterRadius / 100.0;
float normalizedForce = uForce / 100.0;
float distance = length(uv - center);
float circle = smoothstep(normalizedRadius + 0.02, normalizedRadius - 0.02, distance);
vec3 circleColor = vec3(
0.2 + 0.8 * normalizedForce,
0.4 * (1.0 - normalizedForce),
0.8 * (1.0 - normalizedForce)
) * circle;
float ring = smoothstep(normalizedRadius + 0.01, normalizedRadius, distance)
- smoothstep(normalizedRadius, normalizedRadius - 0.01, distance);
vec3 ringColor = vec3(1.0, 1.0, 1.0) * ring * 0.5;
float backgroundGradient = 0.05 + 0.05 * uv.y;
vec3 background = vec3(backgroundGradient) * (1.0 - circle);
fragColor = vec4(circleColor + ringColor + background, 1.0);
}
`
function compileShader(
glContext: WebGL2RenderingContext,
type: number,
source: string
): WebGLShader | null {
const shader = glContext.createShader(type)
if (!shader) return null
glContext.shaderSource(shader, source)
glContext.compileShader(shader)
if (!glContext.getShaderParameter(shader, glContext.COMPILE_STATUS)) {
console.error(glContext.getShaderInfoLog(shader))
glContext.deleteShader(shader)
return null
}
return shader
}
function linkProgram(
glContext: WebGL2RenderingContext,
vertSource: string,
fragSource: string
): WebGLProgram | null {
const vert = compileShader(glContext, glContext.VERTEX_SHADER, vertSource)
const frag = compileShader(glContext, glContext.FRAGMENT_SHADER, fragSource)
if (!vert || !frag) return null
const program = glContext.createProgram()
if (!program) return null
glContext.attachShader(program, vert)
glContext.attachShader(program, frag)
glContext.linkProgram(program)
if (!glContext.getProgramParameter(program, glContext.LINK_STATUS)) {
console.error(glContext.getProgramInfoLog(program))
glContext.deleteProgram(program)
return null
}
glContext.deleteShader(vert)
glContext.deleteShader(frag)
return program
}
function initWebGL(): void {
const canvas = canvasRef.value
if (!canvas) return
canvas.width = canvas.clientWidth
canvas.height = canvas.clientHeight
glContext = canvas.getContext('webgl2')
if (!glContext) {
console.error('WebGL2 not supported')
return
}
program = linkProgram(glContext, vertexShaderSource, fragmentShaderSource)
if (!program) return
const positionList = new Float32Array([
-1, -1,
1, -1,
-1, 1,
-1, 1,
1, -1,
1, 1,
])
const vertexBuffer = glContext.createBuffer()
glContext.bindBuffer(glContext.ARRAY_BUFFER, vertexBuffer)
glContext.bufferData(glContext.ARRAY_BUFFER, positionList, glContext.STATIC_DRAW)
const positionLocation = glContext.getAttribLocation(program, 'aPosition')
glContext.enableVertexAttribArray(positionLocation)
glContext.vertexAttribPointer(positionLocation, 2, glContext.FLOAT, false, 0, 0)
}
function render(): void {
if (!glContext || !program) return
const canvas = canvasRef.value
if (!canvas) return
canvas.width = canvas.clientWidth
canvas.height = canvas.clientHeight
glContext.viewport(0, 0, canvas.width, canvas.height)
glContext.useProgram(program)
const resolutionLocation = glContext.getUniformLocation(program, 'uResolution')
const scatterRadiusLocation = glContext.getUniformLocation(program, 'uScatterRadius')
const forceLocation = glContext.getUniformLocation(program, 'uForce')
glContext.uniform2f(resolutionLocation, canvas.width, canvas.height)
glContext.uniform1f(scatterRadiusLocation, scatterRadius.value)
glContext.uniform1f(forceLocation, force.value)
glContext.drawArrays(glContext.TRIANGLES, 0, 6)
animationFrameId = requestAnimationFrame(render)
}
onMounted(() => {
initWebGL()
animationFrameId = requestAnimationFrame(render)
})
onBeforeUnmount(() => {
cancelAnimationFrame(animationFrameId)
if (glContext && program) {
glContext.deleteProgram(program)
}
})
</script>
<template>
<div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6">
<div class="flex flex-col gap-3">
<label class="flex items-center gap-2 text-sm text-gray-700">
散開半徑:{{ scatterRadius }}
<input
v-model.number="scatterRadius"
type="range"
min="10"
max="100"
step="1"
class="flex-1"
>
</label>
<label class="flex items-center gap-2 text-sm text-gray-700">
力道:{{ force }}
<input
v-model.number="force"
type="range"
min="1"
max="100"
step="1"
class="flex-1"
>
</label>
</div>
<canvas
ref="canvasRef"
class="w-full rounded-lg"
style="height: 320px;"
/>
</div>
</template>編譯流程
// 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
const mouseLocation = gl.getUniformLocation(program, 'uMouse')
gl.uniform2f(mouseLocation, pointerX, pointerY)Uniform 是「每幀全部粒子共用」的常數。滑鼠位置、散開半徑、摩擦力這些值對所有粒子都一樣,所以用 Uniform 傳入。
蟲群文字用到的 Uniform:
| Uniform | 類型 | 用途 |
|---|---|---|
uMouse | vec2 | 滑鼠位置 |
uMouseVelocity | vec2 | 滑鼠速度 |
uMouseInside | int | 滑鼠是否在元素內 |
uScatterRadius | float | 散開半徑 |
uScatterForce | float | 散開力道 |
uReturnSpeed | float | 回歸速度 |
uFriction | float | 摩擦力 |
uTime | float | 時間(用於噪聲動畫) |
路人:「那每個粒子各自的位置和速度呢?那些都不一樣啊」 鱈魚:「好問題,那些要塞進 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>紋理不只是圖片
路人:「紋理不是拿來貼圖片的嗎?」 鱈魚:「表面上是啦,但其實紋理就是一個 2D 陣列,每個像素可以存 4 個值(RGBA)」
假設有 10,000 個粒子。我們建立一張 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
知道怎麼「讀」紋理後,接下來要能「寫」紋理,才能每幀更新粒子狀態。
點擊畫布新增亮點(Ping-Pong 擴散模擬)
查看範例原始碼
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
const canvasRef = ref<HTMLCanvasElement | null>(null)
let glContext: WebGL2RenderingContext | null = null
let physicsProgram: WebGLProgram | null = null
let renderProgram: WebGLProgram | null = null
let textureList: Array<WebGLTexture | null> = [null, null]
let framebufferList: Array<WebGLFramebuffer | null> = [null, null]
let currentTextureIndex = 0
let animationFrameId = 0
const SIMULATION_WIDTH = 256
const SIMULATION_HEIGHT = 256
const fullscreenQuadVertexSource = `#version 300 es
in vec2 aPosition;
out vec2 vUv;
void main() {
vUv = aPosition * 0.5 + 0.5;
gl_Position = vec4(aPosition, 0.0, 1.0);
}
`
const physicsFragmentSource = `#version 300 es
precision highp float;
uniform sampler2D uStateTexture;
uniform vec2 uTexelSize;
uniform vec2 uClickPosition;
uniform float uClickActive;
in vec2 vUv;
out vec4 fragColor;
void main() {
vec4 center = texture(uStateTexture, vUv);
vec4 left = texture(uStateTexture, vUv + vec2(-uTexelSize.x, 0.0));
vec4 right = texture(uStateTexture, vUv + vec2(uTexelSize.x, 0.0));
vec4 up = texture(uStateTexture, vUv + vec2(0.0, uTexelSize.y));
vec4 down = texture(uStateTexture, vUv + vec2(0.0, -uTexelSize.y));
vec4 average = (center + left + right + up + down) / 5.0;
vec4 result = average * 0.995;
if (uClickActive > 0.5) {
float distance = length(vUv - uClickPosition);
float spot = exp(-distance * distance * 800.0);
result += vec4(spot * 2.0, spot * 1.5, spot * 0.8, 0.0);
}
fragColor = result;
}
`
const renderFragmentSource = `#version 300 es
precision highp float;
uniform sampler2D uStateTexture;
in vec2 vUv;
out vec4 fragColor;
void main() {
vec4 state = texture(uStateTexture, vUv);
float brightness = length(state.rgb);
vec3 color = vec3(
state.r * 0.8 + brightness * 0.2,
state.g * 0.6 + brightness * 0.15,
state.b * 0.9 + brightness * 0.1
);
color = pow(color, vec3(0.8));
fragColor = vec4(color, 1.0);
}
`
let pendingClickPosition: { x: number; y: number } | null = null
function compileShader(
glContext: WebGL2RenderingContext,
type: number,
source: string
): WebGLShader | null {
const shader = glContext.createShader(type)
if (!shader) return null
glContext.shaderSource(shader, source)
glContext.compileShader(shader)
if (!glContext.getShaderParameter(shader, glContext.COMPILE_STATUS)) {
console.error(glContext.getShaderInfoLog(shader))
glContext.deleteShader(shader)
return null
}
return shader
}
function linkProgram(
glContext: WebGL2RenderingContext,
vertSource: string,
fragSource: string
): WebGLProgram | null {
const vert = compileShader(glContext, glContext.VERTEX_SHADER, vertSource)
const frag = compileShader(glContext, glContext.FRAGMENT_SHADER, fragSource)
if (!vert || !frag) return null
const program = glContext.createProgram()
if (!program) return null
glContext.attachShader(program, vert)
glContext.attachShader(program, frag)
glContext.linkProgram(program)
if (!glContext.getProgramParameter(program, glContext.LINK_STATUS)) {
console.error(glContext.getProgramInfoLog(program))
glContext.deleteProgram(program)
return null
}
glContext.deleteShader(vert)
glContext.deleteShader(frag)
return program
}
function createFloatTexture(
context: WebGL2RenderingContext,
width: number,
height: number,
data: Float32Array | null
): WebGLTexture | null {
const texture = context.createTexture()
if (!texture) return null
context.bindTexture(context.TEXTURE_2D, texture)
context.texImage2D(
context.TEXTURE_2D,
0,
context.RGBA32F,
width,
height,
0,
context.RGBA,
context.FLOAT,
data
)
context.texParameteri(context.TEXTURE_2D, context.TEXTURE_MIN_FILTER, context.LINEAR)
context.texParameteri(context.TEXTURE_2D, context.TEXTURE_MAG_FILTER, context.LINEAR)
context.texParameteri(context.TEXTURE_2D, context.TEXTURE_WRAP_S, context.CLAMP_TO_EDGE)
context.texParameteri(context.TEXTURE_2D, context.TEXTURE_WRAP_T, context.CLAMP_TO_EDGE)
return texture
}
function createInitialData(): Float32Array {
const data = new Float32Array(SIMULATION_WIDTH * SIMULATION_HEIGHT * 4)
const spotPositionList = [
{ x: 0.3, y: 0.3 },
{ x: 0.7, y: 0.5 },
{ x: 0.5, y: 0.7 },
{ x: 0.2, y: 0.6 },
{ x: 0.8, y: 0.2 },
]
for (let row = 0; row < SIMULATION_HEIGHT; row++) {
for (let column = 0; column < SIMULATION_WIDTH; column++) {
const index = (row * SIMULATION_WIDTH + column) * 4
const normalizedX = column / SIMULATION_WIDTH
const normalizedY = row / SIMULATION_HEIGHT
let value = 0
for (const spot of spotPositionList) {
const distanceX = normalizedX - spot.x
const distanceY = normalizedY - spot.y
const distanceSquared = distanceX * distanceX + distanceY * distanceY
value += Math.exp(-distanceSquared * 200)
}
data[index + 0] = value * 2.0
data[index + 1] = value * 1.5
data[index + 2] = value * 0.8
data[index + 3] = 0
}
}
return data
}
function initWebGL(): void {
const canvas = canvasRef.value
if (!canvas) return
canvas.width = canvas.clientWidth
canvas.height = canvas.clientHeight
glContext = canvas.getContext('webgl2')
if (!glContext) {
console.error('WebGL2 not supported')
return
}
const extensionColorBufferFloat = glContext.getExtension('EXT_color_buffer_float')
if (!extensionColorBufferFloat) {
console.error('EXT_color_buffer_float not supported')
return
}
physicsProgram = linkProgram(glContext, fullscreenQuadVertexSource, physicsFragmentSource)
renderProgram = linkProgram(glContext, fullscreenQuadVertexSource, renderFragmentSource)
if (!physicsProgram || !renderProgram) return
const positionList = new Float32Array([
-1, -1,
1, -1,
-1, 1,
-1, 1,
1, -1,
1, 1,
])
const vertexBuffer = glContext.createBuffer()
glContext.bindBuffer(glContext.ARRAY_BUFFER, vertexBuffer)
glContext.bufferData(glContext.ARRAY_BUFFER, positionList, glContext.STATIC_DRAW)
const physicsPositionLocation = glContext.getAttribLocation(physicsProgram, 'aPosition')
const renderPositionLocation = glContext.getAttribLocation(renderProgram, 'aPosition')
glContext.enableVertexAttribArray(physicsPositionLocation)
glContext.vertexAttribPointer(physicsPositionLocation, 2, glContext.FLOAT, false, 0, 0)
if (renderPositionLocation !== physicsPositionLocation) {
glContext.enableVertexAttribArray(renderPositionLocation)
glContext.vertexAttribPointer(renderPositionLocation, 2, glContext.FLOAT, false, 0, 0)
}
const initialData = createInitialData()
for (let index = 0; index < 2; index++) {
textureList[index] = createFloatTexture(
glContext,
SIMULATION_WIDTH,
SIMULATION_HEIGHT,
initialData
)
framebufferList[index] = glContext.createFramebuffer()
glContext.bindFramebuffer(glContext.FRAMEBUFFER, framebufferList[index])
glContext.framebufferTexture2D(
glContext.FRAMEBUFFER,
glContext.COLOR_ATTACHMENT0,
glContext.TEXTURE_2D,
textureList[index],
0
)
}
glContext.bindFramebuffer(glContext.FRAMEBUFFER, null)
}
function render(): void {
if (!glContext || !physicsProgram || !renderProgram) return
const canvas = canvasRef.value
if (!canvas) return
const readIndex = currentTextureIndex
const writeIndex = 1 - currentTextureIndex
glContext.useProgram(physicsProgram)
glContext.bindFramebuffer(glContext.FRAMEBUFFER, framebufferList[writeIndex])
glContext.viewport(0, 0, SIMULATION_WIDTH, SIMULATION_HEIGHT)
glContext.activeTexture(glContext.TEXTURE0)
glContext.bindTexture(glContext.TEXTURE_2D, textureList[readIndex])
const stateTextureLocation = glContext.getUniformLocation(physicsProgram, 'uStateTexture')
const texelSizeLocation = glContext.getUniformLocation(physicsProgram, 'uTexelSize')
const clickPositionLocation = glContext.getUniformLocation(physicsProgram, 'uClickPosition')
const clickActiveLocation = glContext.getUniformLocation(physicsProgram, 'uClickActive')
glContext.uniform1i(stateTextureLocation, 0)
glContext.uniform2f(texelSizeLocation, 1.0 / SIMULATION_WIDTH, 1.0 / SIMULATION_HEIGHT)
if (pendingClickPosition) {
glContext.uniform2f(clickPositionLocation, pendingClickPosition.x, pendingClickPosition.y)
glContext.uniform1f(clickActiveLocation, 1.0)
pendingClickPosition = null
} else {
glContext.uniform2f(clickPositionLocation, 0.0, 0.0)
glContext.uniform1f(clickActiveLocation, 0.0)
}
glContext.drawArrays(glContext.TRIANGLES, 0, 6)
canvas.width = canvas.clientWidth
canvas.height = canvas.clientHeight
glContext.useProgram(renderProgram)
glContext.bindFramebuffer(glContext.FRAMEBUFFER, null)
glContext.viewport(0, 0, canvas.width, canvas.height)
glContext.activeTexture(glContext.TEXTURE0)
glContext.bindTexture(glContext.TEXTURE_2D, textureList[writeIndex])
const renderStateLocation = glContext.getUniformLocation(renderProgram, 'uStateTexture')
glContext.uniform1i(renderStateLocation, 0)
glContext.drawArrays(glContext.TRIANGLES, 0, 6)
currentTextureIndex = writeIndex
animationFrameId = requestAnimationFrame(render)
}
function handleCanvasClick(event: MouseEvent): void {
const canvas = canvasRef.value
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const normalizedX = (event.clientX - rect.left) / rect.width
const normalizedY = 1.0 - (event.clientY - rect.top) / rect.height
pendingClickPosition = { x: normalizedX, y: normalizedY }
}
onMounted(() => {
initWebGL()
animationFrameId = requestAnimationFrame(render)
})
onBeforeUnmount(() => {
cancelAnimationFrame(animationFrameId)
if (glContext) {
if (physicsProgram) glContext.deleteProgram(physicsProgram)
if (renderProgram) glContext.deleteProgram(renderProgram)
for (const texture of textureList) {
if (texture) glContext.deleteTexture(texture)
}
for (const framebuffer of framebufferList) {
if (framebuffer) glContext.deleteFramebuffer(framebuffer)
}
}
})
</script>
<template>
<div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6">
<p class="text-sm text-gray-600">
點擊畫布新增亮點(Ping-Pong 擴散模擬)
</p>
<canvas
ref="canvasRef"
class="w-full cursor-crosshair rounded-lg"
style="height: 400px;"
@click="handleCanvasClick"
/>
</div>
</template>GPU 的限制:不能同時讀寫
GPU 有個重要限制:不能同時讀取和寫入同一張紋理。
這很合理。想像有 10,000 個粒子同時在跑,如果大家同時讀寫同一塊記憶體,結果會完全不可預測。
Ping-Pong:準備兩張紋理
解法是準備兩張狀態紋理 A 和 B,交替使用:
第 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 流程
const readIndex = currentStateIndex
const writeIndex = 1 - currentStateIndex
// 綁定「讀」的紋理
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, stateTextureList[readIndex])
// 綁定「寫」的 Framebuffer
gl.bindFramebuffer(
gl.FRAMEBUFFER,
framebufferList[writeIndex]
)
// 全螢幕四邊形,觸發每個像素的 Fragment Shader
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
// 交換索引
currentStateIndex = writeIndex路人:「全螢幕四邊形?不是在算粒子嗎,為什麼要畫四邊形?」 鱈魚:「因為 Fragment Shader 是『每個像素跑一次』。畫一個跟紋理一樣大的四邊形,就能觸發每個像素(每個粒子)的計算 (・∀・)9」
Step 15:GPU 物理 Shader
準備工作做完了,現在來寫真正算物理的 Fragment Shader。
GPU 物理模擬:滑鼠推開粒子,鬆開後回歸原位
查看範例原始碼
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
const canvasRef = ref<HTMLCanvasElement | null>(null)
let glContext: WebGL2RenderingContext | null = null
let physicsProgram: WebGLProgram | null = null
let renderProgram: WebGLProgram | null = null
let animationFrameId = 0
let stateTextureList: [WebGLTexture | null, WebGLTexture | null] = [null, null]
let framebufferList: [WebGLFramebuffer | null, WebGLFramebuffer | null] = [null, null]
let originTexture: WebGLTexture | null = null
let quadVertexBuffer: WebGLBuffer | null = null
let quadVertexArray: WebGLVertexArrayObject | null = null
let particleCount = 0
let textureSize = 0
let pingPongIndex = 0
let mouseX = 0
let mouseY = 0
let isMouseInside = false
const DISPLAY_TEXT = 'SWARM'
// ----- Physics pass shaders -----
const physicsVertexSource = `#version 300 es
in vec2 aPosition;
void main() {
gl_Position = vec4(aPosition, 0.0, 1.0);
}
`
const physicsFragmentSource = `#version 300 es
precision highp float;
uniform sampler2D uState;
uniform sampler2D uOrigin;
uniform vec2 uMouse;
uniform int uMouseInside;
uniform float uScatterRadius;
uniform float uScatterForce;
uniform float uReturnSpeed;
uniform float uFriction;
out vec4 fragColor;
void main() {
ivec2 coord = ivec2(gl_FragCoord.xy);
vec4 state = texelFetch(uState, coord, 0);
vec4 origin = texelFetch(uOrigin, coord, 0);
float x = state.x;
float y = state.y;
float velocityX = state.z;
float velocityY = state.w;
float originX = origin.x;
float originY = origin.y;
if (originX < -9999.0) {
fragColor = state;
return;
}
// Mouse radial push
if (uMouseInside == 1) {
vec2 delta = vec2(x, y) - uMouse;
float distance = length(delta);
float influence = smoothstep(uScatterRadius, 0.0, distance);
if (distance > 0.001) {
vec2 direction = delta / distance;
velocityX += direction.x * influence * uScatterForce;
velocityY += direction.y * influence * uScatterForce;
}
}
// Return to origin
velocityX += (originX - x) * uReturnSpeed;
velocityY += (originY - y) * uReturnSpeed;
// Friction
velocityX *= uFriction;
velocityY *= uFriction;
// Update position
x += velocityX;
y += velocityY;
fragColor = vec4(x, y, velocityX, velocityY);
}
`
// ----- Render pass shaders -----
const renderVertexSource = `#version 300 es
precision highp float;
uniform sampler2D uState;
uniform int uTextureSize;
uniform vec2 uResolution;
void main() {
int texelX = gl_VertexID % uTextureSize;
int texelY = gl_VertexID / uTextureSize;
vec4 state = texelFetch(uState, ivec2(texelX, texelY), 0);
float x = state.x;
float y = state.y;
if (x < -9999.0) {
gl_Position = vec4(-9999.0, -9999.0, 0.0, 1.0);
gl_PointSize = 0.0;
return;
}
vec2 clipPosition = (vec2(x, y) / uResolution) * 2.0 - 1.0;
clipPosition.y *= -1.0;
gl_Position = vec4(clipPosition, 0.0, 1.0);
gl_PointSize = 2.0;
}
`
const renderFragmentSource = `#version 300 es
precision highp float;
out vec4 fragColor;
void main() {
fragColor = vec4(0.53, 0.53, 0.53, 0.8);
}
`
// ----- Helper functions -----
function compileShader(
gl: WebGL2RenderingContext,
type: number,
source: string
): WebGLShader | null {
const shader = gl.createShader(type)
if (!shader) return null
gl.shaderSource(shader, source)
gl.compileShader(shader)
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error(gl.getShaderInfoLog(shader))
gl.deleteShader(shader)
return null
}
return shader
}
function linkProgram(
gl: WebGL2RenderingContext,
vertSource: string,
fragSource: string
): WebGLProgram | null {
const vert = compileShader(gl, gl.VERTEX_SHADER, vertSource)
const frag = compileShader(gl, gl.FRAGMENT_SHADER, fragSource)
if (!vert || !frag) return null
const program = gl.createProgram()
if (!program) return null
gl.attachShader(program, vert)
gl.attachShader(program, frag)
gl.linkProgram(program)
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error(gl.getProgramInfoLog(program))
gl.deleteProgram(program)
return null
}
gl.deleteShader(vert)
gl.deleteShader(frag)
return program
}
function createFloatTexture(
gl: WebGL2RenderingContext,
size: number,
data: Float32Array
): WebGLTexture | null {
const texture = gl.createTexture()
if (!texture) return null
gl.bindTexture(gl.TEXTURE_2D, texture)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, size, size, 0, gl.RGBA, gl.FLOAT, data)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
return texture
}
// ----- Particle sampling from text -----
interface SampledParticle {
x: number
y: number
size: number
opacity: number
}
function sampleParticleListFromText(
text: string,
canvasWidth: number,
canvasHeight: number
): SampledParticle[] {
const offscreen = document.createElement('canvas')
offscreen.width = canvasWidth
offscreen.height = canvasHeight
const context = offscreen.getContext('2d')
if (!context) return []
const fontSize = Math.min(canvasWidth / (text.length * 0.7), canvasHeight * 0.6)
context.fillStyle = 'white'
context.font = `bold ${fontSize}px Arial, sans-serif`
context.textAlign = 'center'
context.textBaseline = 'middle'
context.fillText(text, canvasWidth / 2, canvasHeight / 2)
const imageData = context.getImageData(0, 0, canvasWidth, canvasHeight)
const pixelDataList = imageData.data
const gap = 1
const resultList: SampledParticle[] = []
for (let y = 0; y < canvasHeight; y += gap) {
for (let x = 0; x < canvasWidth; x += gap) {
const index = (y * canvasWidth + x) * 4
const alpha = pixelDataList[index + 3]
if (alpha > 128) {
resultList.push({
x,
y,
size: 2.0,
opacity: alpha / 255,
})
}
}
}
return resultList
}
// ----- Setup textures and framebuffers -----
function setupParticleTextures(
gl: WebGL2RenderingContext,
sampledParticleList: SampledParticle[]
): void {
particleCount = sampledParticleList.length
textureSize = Math.ceil(Math.sqrt(particleCount))
const totalPixelCount = textureSize * textureSize
const stateData = new Float32Array(totalPixelCount * 4)
const originData = new Float32Array(totalPixelCount * 4)
for (let i = 0; i < totalPixelCount; i++) {
if (i < particleCount) {
const particle = sampledParticleList[i]
// State: x, y, vx, vy
stateData[i * 4 + 0] = particle.x
stateData[i * 4 + 1] = particle.y
stateData[i * 4 + 2] = 0
stateData[i * 4 + 3] = 0
// Origin: originX, originY, size, opacity
originData[i * 4 + 0] = particle.x
originData[i * 4 + 1] = particle.y
originData[i * 4 + 2] = particle.size
originData[i * 4 + 3] = particle.opacity
}
else {
stateData[i * 4 + 0] = -99999
stateData[i * 4 + 1] = -99999
stateData[i * 4 + 2] = 0
stateData[i * 4 + 3] = 0
originData[i * 4 + 0] = -99999
originData[i * 4 + 1] = -99999
originData[i * 4 + 2] = 0
originData[i * 4 + 3] = 0
}
}
stateTextureList[0] = createFloatTexture(gl, textureSize, stateData)
stateTextureList[1] = createFloatTexture(gl, textureSize, new Float32Array(stateData))
originTexture = createFloatTexture(gl, textureSize, originData)
for (let i = 0; i < 2; i++) {
framebufferList[i] = gl.createFramebuffer()
gl.bindFramebuffer(gl.FRAMEBUFFER, framebufferList[i])
gl.framebufferTexture2D(
gl.FRAMEBUFFER,
gl.COLOR_ATTACHMENT0,
gl.TEXTURE_2D,
stateTextureList[i],
0
)
}
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
}
function setupQuadBuffer(gl: WebGL2RenderingContext): void {
const positionList = new Float32Array([
-1, -1,
1, -1,
-1, 1,
-1, 1,
1, -1,
1, 1,
])
quadVertexArray = gl.createVertexArray()
gl.bindVertexArray(quadVertexArray)
quadVertexBuffer = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, quadVertexBuffer)
gl.bufferData(gl.ARRAY_BUFFER, positionList, gl.STATIC_DRAW)
gl.enableVertexAttribArray(0)
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0)
gl.bindVertexArray(null)
}
// ----- Pointer events -----
function handlePointerMove(event: PointerEvent): void {
const canvas = canvasRef.value
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const devicePixelRatio = window.devicePixelRatio || 1
mouseX = (event.clientX - rect.left) * devicePixelRatio
mouseY = (event.clientY - rect.top) * devicePixelRatio
isMouseInside = true
}
function handlePointerLeave(): void {
isMouseInside = false
}
// ----- Init and render -----
function initWebGL(): void {
const canvas = canvasRef.value
if (!canvas) return
const devicePixelRatio = window.devicePixelRatio || 1
const displayWidth = canvas.clientWidth
const displayHeight = canvas.clientHeight
canvas.width = displayWidth * devicePixelRatio
canvas.height = displayHeight * devicePixelRatio
glContext = canvas.getContext('webgl2')
if (!glContext) {
console.error('WebGL2 not supported')
return
}
const extColorFloat = glContext.getExtension('EXT_color_buffer_float')
if (!extColorFloat) {
console.error('EXT_color_buffer_float not supported')
return
}
physicsProgram = linkProgram(glContext, physicsVertexSource, physicsFragmentSource)
renderProgram = linkProgram(glContext, renderVertexSource, renderFragmentSource)
if (!physicsProgram || !renderProgram) return
setupQuadBuffer(glContext)
const sampledParticleList = sampleParticleListFromText(
DISPLAY_TEXT,
canvas.width,
canvas.height
)
setupParticleTextures(glContext, sampledParticleList)
canvas.addEventListener('pointermove', handlePointerMove)
canvas.addEventListener('pointerleave', handlePointerLeave)
}
function render(): void {
if (!glContext || !physicsProgram || !renderProgram) return
const canvas = canvasRef.value
if (!canvas) return
const gl = glContext
const readIndex = pingPongIndex
const writeIndex = 1 - pingPongIndex
// ----- Physics pass -----
gl.bindFramebuffer(gl.FRAMEBUFFER, framebufferList[writeIndex])
gl.viewport(0, 0, textureSize, textureSize)
gl.useProgram(physicsProgram)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, stateTextureList[readIndex])
gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uState'), 0)
gl.activeTexture(gl.TEXTURE1)
gl.bindTexture(gl.TEXTURE_2D, originTexture)
gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uOrigin'), 1)
gl.uniform2f(gl.getUniformLocation(physicsProgram, 'uMouse'), mouseX, mouseY)
gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uMouseInside'), isMouseInside ? 1 : 0)
gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uScatterRadius'), 60.0)
gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uScatterForce'), 40.0)
gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uReturnSpeed'), 0.1)
gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uFriction'), 0.92)
gl.bindVertexArray(quadVertexArray)
gl.drawArrays(gl.TRIANGLES, 0, 6)
gl.bindVertexArray(null)
pingPongIndex = writeIndex
// ----- Render pass -----
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
gl.viewport(0, 0, canvas.width, canvas.height)
gl.clearColor(0, 0, 0, 1)
gl.clear(gl.COLOR_BUFFER_BIT)
gl.useProgram(renderProgram)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, stateTextureList[writeIndex])
gl.uniform1i(gl.getUniformLocation(renderProgram, 'uState'), 0)
gl.uniform1i(gl.getUniformLocation(renderProgram, 'uTextureSize'), textureSize)
gl.uniform2f(
gl.getUniformLocation(renderProgram, 'uResolution'),
canvas.width,
canvas.height
)
gl.drawArrays(gl.POINTS, 0, textureSize * textureSize)
animationFrameId = requestAnimationFrame(render)
}
onMounted(() => {
initWebGL()
animationFrameId = requestAnimationFrame(render)
})
onBeforeUnmount(() => {
cancelAnimationFrame(animationFrameId)
const canvas = canvasRef.value
if (canvas) {
canvas.removeEventListener('pointermove', handlePointerMove)
canvas.removeEventListener('pointerleave', handlePointerLeave)
}
if (glContext) {
if (physicsProgram) glContext.deleteProgram(physicsProgram)
if (renderProgram) glContext.deleteProgram(renderProgram)
for (const texture of stateTextureList) {
if (texture) glContext.deleteTexture(texture)
}
if (originTexture) glContext.deleteTexture(originTexture)
for (const framebuffer of framebufferList) {
if (framebuffer) glContext.deleteFramebuffer(framebuffer)
}
if (quadVertexBuffer) glContext.deleteBuffer(quadVertexBuffer)
if (quadVertexArray) glContext.deleteVertexArray(quadVertexArray)
}
})
</script>
<template>
<div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6">
<p class="text-sm text-gray-600">
GPU 物理模擬:滑鼠推開粒子,鬆開後回歸原位
</p>
<canvas
ref="canvasRef"
class="w-full rounded-lg"
style="height: 320px;"
/>
</div>
</template>完整的 Physics Shader
#version 300 es
precision highp float;
uniform sampler2D uState;
uniform sampler2D uOrigin;
uniform vec2 uMouse;
uniform vec2 uMouseVelocity;
uniform int uMouseInside;
uniform float uScatterRadius;
uniform float uScatterForce;
uniform float uReturnSpeed;
uniform float uFriction;
out vec4 outState;
void main() {
ivec2 coord = ivec2(gl_FragCoord.xy);
vec4 state = texelFetch(uState, coord, 0);
vec4 origin = texelFetch(uOrigin, coord, 0);
vec2 pos = state.xy;
vec2 vel = state.zw;
vec2 originPos = origin.xy;
// 滑鼠推力
if (uMouseInside != 0) {
vec2 toParticle = pos - uMouse;
float dist = length(toParticle);
if (dist < uScatterRadius) {
float influence = smoothstep(
uScatterRadius, 0.0, dist
);
// 風力
float mouseSpeed = length(uMouseVelocity);
if (mouseSpeed > 0.5) {
vec2 windDir = uMouseVelocity / mouseSpeed;
vel += windDir * mouseSpeed
* influence * uScatterForce * 0.025;
}
// 徑向推力
vel += normalize(toParticle)
* influence * influence
* uScatterForce * 0.5;
}
}
// 摩擦力
vel *= uFriction;
// 更新位置
pos += vel;
// 回歸
pos = mix(pos, originPos, uReturnSpeed);
outState = vec4(pos, vel);
}跟 CPU 版的對比
注意到了嗎?邏輯跟 Step 4~8 幾乎一模一樣:
| 概念 | CPU 版 | GPU 版 |
|---|---|---|
| 摩擦力 | velocity *= 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
物理算完了,粒子位置存在紋理裡。接下來要把它們畫到螢幕上。
改良頂點著色器:每個粒子有獨立的大小、透明度、位移量與速度
查看範例原始碼
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
const canvasRef = ref<HTMLCanvasElement | null>(null)
let glContext: WebGL2RenderingContext | null = null
let physicsProgram: WebGLProgram | null = null
let renderProgram: WebGLProgram | null = null
let animationFrameId = 0
let stateTextureList: [WebGLTexture | null, WebGLTexture | null] = [null, null]
let framebufferList: [WebGLFramebuffer | null, WebGLFramebuffer | null] = [null, null]
let originTexture: WebGLTexture | null = null
let quadVertexBuffer: WebGLBuffer | null = null
let quadVertexArray: WebGLVertexArrayObject | null = null
let particleCount = 0
let textureSize = 0
let pingPongIndex = 0
let mouseX = 0
let mouseY = 0
let isMouseInside = false
const DISPLAY_TEXT = 'SWARM'
// ----- Physics pass shaders -----
const physicsVertexSource = `#version 300 es
in vec2 aPosition;
void main() {
gl_Position = vec4(aPosition, 0.0, 1.0);
}
`
const physicsFragmentSource = `#version 300 es
precision highp float;
uniform sampler2D uState;
uniform sampler2D uOrigin;
uniform vec2 uMouse;
uniform int uMouseInside;
uniform float uScatterRadius;
uniform float uScatterForce;
uniform float uReturnSpeed;
uniform float uFriction;
out vec4 fragColor;
void main() {
ivec2 coord = ivec2(gl_FragCoord.xy);
vec4 state = texelFetch(uState, coord, 0);
vec4 origin = texelFetch(uOrigin, coord, 0);
float x = state.x;
float y = state.y;
float velocityX = state.z;
float velocityY = state.w;
float originX = origin.x;
float originY = origin.y;
if (originX < -9999.0) {
fragColor = state;
return;
}
// Mouse radial push
if (uMouseInside == 1) {
vec2 delta = vec2(x, y) - uMouse;
float distance = length(delta);
float influence = smoothstep(uScatterRadius, 0.0, distance);
if (distance > 0.001) {
vec2 direction = delta / distance;
velocityX += direction.x * influence * uScatterForce;
velocityY += direction.y * influence * uScatterForce;
}
}
// Return to origin
velocityX += (originX - x) * uReturnSpeed;
velocityY += (originY - y) * uReturnSpeed;
// Friction
velocityX *= uFriction;
velocityY *= uFriction;
// Update position
x += velocityX;
y += velocityY;
fragColor = vec4(x, y, velocityX, velocityY);
}
`
// ----- Render pass shaders -----
const renderVertexSource = `#version 300 es
precision highp float;
uniform sampler2D uState;
uniform sampler2D uOrigin;
uniform int uTextureSize;
uniform vec2 uResolution;
out float vOpacity;
out float vDisplacement;
out float vSpeed;
void main() {
int texelX = gl_VertexID % uTextureSize;
int texelY = gl_VertexID / uTextureSize;
vec4 state = texelFetch(uState, ivec2(texelX, texelY), 0);
vec4 origin = texelFetch(uOrigin, ivec2(texelX, texelY), 0);
float x = state.x;
float y = state.y;
float velocityX = state.z;
float velocityY = state.w;
float originX = origin.x;
float originY = origin.y;
float size = origin.z;
float opacity = origin.w;
if (originX < -9999.0) {
gl_Position = vec4(-9999.0, -9999.0, 0.0, 1.0);
gl_PointSize = 0.0;
vOpacity = 0.0;
vDisplacement = 0.0;
vSpeed = 0.0;
return;
}
vec2 clipPosition = (vec2(x, y) / uResolution) * 2.0 - 1.0;
clipPosition.y *= -1.0;
gl_Position = vec4(clipPosition, 0.0, 1.0);
gl_PointSize = size;
vOpacity = opacity;
float distanceFromOrigin = length(vec2(x, y) - vec2(originX, originY));
vDisplacement = smoothstep(0.0, 40.0, distanceFromOrigin);
float speed = length(vec2(velocityX, velocityY));
vSpeed = smoothstep(0.0, 8.0, speed);
}
`
const renderFragmentSource = `#version 300 es
precision highp float;
in float vOpacity;
in float vDisplacement;
in float vSpeed;
out vec4 fragColor;
void main() {
fragColor = vec4(0.53, 0.53, 0.53, vOpacity);
}
`
// ----- Helper functions -----
function compileShader(
gl: WebGL2RenderingContext,
type: number,
source: string
): WebGLShader | null {
const shader = gl.createShader(type)
if (!shader) return null
gl.shaderSource(shader, source)
gl.compileShader(shader)
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error(gl.getShaderInfoLog(shader))
gl.deleteShader(shader)
return null
}
return shader
}
function linkProgram(
gl: WebGL2RenderingContext,
vertSource: string,
fragSource: string
): WebGLProgram | null {
const vert = compileShader(gl, gl.VERTEX_SHADER, vertSource)
const frag = compileShader(gl, gl.FRAGMENT_SHADER, fragSource)
if (!vert || !frag) return null
const program = gl.createProgram()
if (!program) return null
gl.attachShader(program, vert)
gl.attachShader(program, frag)
gl.linkProgram(program)
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error(gl.getProgramInfoLog(program))
gl.deleteProgram(program)
return null
}
gl.deleteShader(vert)
gl.deleteShader(frag)
return program
}
function createFloatTexture(
gl: WebGL2RenderingContext,
size: number,
data: Float32Array
): WebGLTexture | null {
const texture = gl.createTexture()
if (!texture) return null
gl.bindTexture(gl.TEXTURE_2D, texture)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, size, size, 0, gl.RGBA, gl.FLOAT, data)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
return texture
}
// ----- Particle sampling from text -----
interface SampledParticle {
x: number
y: number
size: number
opacity: number
}
function sampleParticleListFromText(
text: string,
canvasWidth: number,
canvasHeight: number
): SampledParticle[] {
const offscreen = document.createElement('canvas')
offscreen.width = canvasWidth
offscreen.height = canvasHeight
const context = offscreen.getContext('2d')
if (!context) return []
const fontSize = Math.min(canvasWidth / (text.length * 0.7), canvasHeight * 0.6)
context.fillStyle = 'white'
context.font = `bold ${fontSize}px Arial, sans-serif`
context.textAlign = 'center'
context.textBaseline = 'middle'
context.fillText(text, canvasWidth / 2, canvasHeight / 2)
const imageData = context.getImageData(0, 0, canvasWidth, canvasHeight)
const pixelDataList = imageData.data
const gap = 1
const resultList: SampledParticle[] = []
for (let y = 0; y < canvasHeight; y += gap) {
for (let x = 0; x < canvasWidth; x += gap) {
const index = (y * canvasWidth + x) * 4
const alpha = pixelDataList[index + 3]
if (alpha > 128) {
resultList.push({
x,
y,
size: 2.0,
opacity: alpha / 255,
})
}
}
}
return resultList
}
// ----- Setup textures and framebuffers -----
function setupParticleTextures(
gl: WebGL2RenderingContext,
sampledParticleList: SampledParticle[]
): void {
particleCount = sampledParticleList.length
textureSize = Math.ceil(Math.sqrt(particleCount))
const totalPixelCount = textureSize * textureSize
const stateData = new Float32Array(totalPixelCount * 4)
const originData = new Float32Array(totalPixelCount * 4)
for (let i = 0; i < totalPixelCount; i++) {
if (i < particleCount) {
const particle = sampledParticleList[i]
stateData[i * 4 + 0] = particle.x
stateData[i * 4 + 1] = particle.y
stateData[i * 4 + 2] = 0
stateData[i * 4 + 3] = 0
originData[i * 4 + 0] = particle.x
originData[i * 4 + 1] = particle.y
originData[i * 4 + 2] = particle.size
originData[i * 4 + 3] = particle.opacity
}
else {
stateData[i * 4 + 0] = -99999
stateData[i * 4 + 1] = -99999
stateData[i * 4 + 2] = 0
stateData[i * 4 + 3] = 0
originData[i * 4 + 0] = -99999
originData[i * 4 + 1] = -99999
originData[i * 4 + 2] = 0
originData[i * 4 + 3] = 0
}
}
stateTextureList[0] = createFloatTexture(gl, textureSize, stateData)
stateTextureList[1] = createFloatTexture(gl, textureSize, new Float32Array(stateData))
originTexture = createFloatTexture(gl, textureSize, originData)
for (let i = 0; i < 2; i++) {
framebufferList[i] = gl.createFramebuffer()
gl.bindFramebuffer(gl.FRAMEBUFFER, framebufferList[i])
gl.framebufferTexture2D(
gl.FRAMEBUFFER,
gl.COLOR_ATTACHMENT0,
gl.TEXTURE_2D,
stateTextureList[i],
0
)
}
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
}
function setupQuadBuffer(gl: WebGL2RenderingContext): void {
const positionList = new Float32Array([
-1, -1,
1, -1,
-1, 1,
-1, 1,
1, -1,
1, 1,
])
quadVertexArray = gl.createVertexArray()
gl.bindVertexArray(quadVertexArray)
quadVertexBuffer = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, quadVertexBuffer)
gl.bufferData(gl.ARRAY_BUFFER, positionList, gl.STATIC_DRAW)
gl.enableVertexAttribArray(0)
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0)
gl.bindVertexArray(null)
}
// ----- Pointer events -----
function handlePointerMove(event: PointerEvent): void {
const canvas = canvasRef.value
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const devicePixelRatio = window.devicePixelRatio || 1
mouseX = (event.clientX - rect.left) * devicePixelRatio
mouseY = (event.clientY - rect.top) * devicePixelRatio
isMouseInside = true
}
function handlePointerLeave(): void {
isMouseInside = false
}
// ----- Init and render -----
function initWebGL(): void {
const canvas = canvasRef.value
if (!canvas) return
const devicePixelRatio = window.devicePixelRatio || 1
const displayWidth = canvas.clientWidth
const displayHeight = canvas.clientHeight
canvas.width = displayWidth * devicePixelRatio
canvas.height = displayHeight * devicePixelRatio
glContext = canvas.getContext('webgl2')
if (!glContext) {
console.error('WebGL2 not supported')
return
}
const extColorFloat = glContext.getExtension('EXT_color_buffer_float')
if (!extColorFloat) {
console.error('EXT_color_buffer_float not supported')
return
}
physicsProgram = linkProgram(glContext, physicsVertexSource, physicsFragmentSource)
renderProgram = linkProgram(glContext, renderVertexSource, renderFragmentSource)
if (!physicsProgram || !renderProgram) return
setupQuadBuffer(glContext)
const sampledParticleList = sampleParticleListFromText(
DISPLAY_TEXT,
canvas.width,
canvas.height
)
setupParticleTextures(glContext, sampledParticleList)
canvas.addEventListener('pointermove', handlePointerMove)
canvas.addEventListener('pointerleave', handlePointerLeave)
}
function render(): void {
if (!glContext || !physicsProgram || !renderProgram) return
const canvas = canvasRef.value
if (!canvas) return
const gl = glContext
const readIndex = pingPongIndex
const writeIndex = 1 - pingPongIndex
// ----- Physics pass -----
gl.bindFramebuffer(gl.FRAMEBUFFER, framebufferList[writeIndex])
gl.viewport(0, 0, textureSize, textureSize)
gl.useProgram(physicsProgram)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, stateTextureList[readIndex])
gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uState'), 0)
gl.activeTexture(gl.TEXTURE1)
gl.bindTexture(gl.TEXTURE_2D, originTexture)
gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uOrigin'), 1)
gl.uniform2f(gl.getUniformLocation(physicsProgram, 'uMouse'), mouseX, mouseY)
gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uMouseInside'), isMouseInside ? 1 : 0)
gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uScatterRadius'), 60.0)
gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uScatterForce'), 40.0)
gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uReturnSpeed'), 0.1)
gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uFriction'), 0.92)
gl.bindVertexArray(quadVertexArray)
gl.drawArrays(gl.TRIANGLES, 0, 6)
gl.bindVertexArray(null)
pingPongIndex = writeIndex
// ----- Render pass -----
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
gl.viewport(0, 0, canvas.width, canvas.height)
gl.clearColor(0, 0, 0, 1)
gl.clear(gl.COLOR_BUFFER_BIT)
gl.useProgram(renderProgram)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, stateTextureList[writeIndex])
gl.uniform1i(gl.getUniformLocation(renderProgram, 'uState'), 0)
gl.activeTexture(gl.TEXTURE1)
gl.bindTexture(gl.TEXTURE_2D, originTexture)
gl.uniform1i(gl.getUniformLocation(renderProgram, 'uOrigin'), 1)
gl.uniform1i(gl.getUniformLocation(renderProgram, 'uTextureSize'), textureSize)
gl.uniform2f(
gl.getUniformLocation(renderProgram, 'uResolution'),
canvas.width,
canvas.height
)
gl.drawArrays(gl.POINTS, 0, textureSize * textureSize)
animationFrameId = requestAnimationFrame(render)
}
onMounted(() => {
initWebGL()
animationFrameId = requestAnimationFrame(render)
})
onBeforeUnmount(() => {
cancelAnimationFrame(animationFrameId)
const canvas = canvasRef.value
if (canvas) {
canvas.removeEventListener('pointermove', handlePointerMove)
canvas.removeEventListener('pointerleave', handlePointerLeave)
}
if (glContext) {
if (physicsProgram) glContext.deleteProgram(physicsProgram)
if (renderProgram) glContext.deleteProgram(renderProgram)
for (const texture of stateTextureList) {
if (texture) glContext.deleteTexture(texture)
}
if (originTexture) glContext.deleteTexture(originTexture)
for (const framebuffer of framebufferList) {
if (framebuffer) glContext.deleteFramebuffer(framebuffer)
}
if (quadVertexBuffer) glContext.deleteBuffer(quadVertexBuffer)
if (quadVertexArray) glContext.deleteVertexArray(quadVertexArray)
}
})
</script>
<template>
<div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6">
<p class="text-sm text-gray-600">
改良頂點著色器:每個粒子有獨立的大小、透明度、位移量與速度
</p>
<canvas
ref="canvasRef"
class="w-full rounded-lg"
style="height: 320px;"
/>
</div>
</template>gl.POINTS
WebGL 有個很方便的繪製模式叫 gl.POINTS,每個頂點畫成一個正方形的「點」。
gl.drawArrays(gl.POINTS, 0, particleCount)一行就畫完所有粒子。大小由 Vertex Shader 裡的 gl_PointSize 控制。
Vertex Shader:從紋理讀取位置
#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 傳遞
out float vDisplacement 和 out float vSpeed 會從 Vertex Shader 傳到 Fragment Shader。v 字首是慣例,代表 Varying。
Step 17:圓形粒子與 Alpha Blending
Vertex Shader 決定了粒子的「位置」和「大小」,Fragment Shader 決定它「長什麼樣」。
圓形粒子 + Alpha 混合:柔和邊緣與透明度
查看範例原始碼
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
const canvasRef = ref<HTMLCanvasElement | null>(null)
let glContext: WebGL2RenderingContext | null = null
let physicsProgram: WebGLProgram | null = null
let renderProgram: WebGLProgram | null = null
let animationFrameId = 0
let stateTextureList: [WebGLTexture | null, WebGLTexture | null] = [null, null]
let framebufferList: [WebGLFramebuffer | null, WebGLFramebuffer | null] = [null, null]
let originTexture: WebGLTexture | null = null
let quadVertexBuffer: WebGLBuffer | null = null
let quadVertexArray: WebGLVertexArrayObject | null = null
let particleCount = 0
let textureSize = 0
let pingPongIndex = 0
let mouseX = 0
let mouseY = 0
let isMouseInside = false
const DISPLAY_TEXT = 'SWARM'
// ----- Physics pass shaders -----
const physicsVertexSource = `#version 300 es
in vec2 aPosition;
void main() {
gl_Position = vec4(aPosition, 0.0, 1.0);
}
`
const physicsFragmentSource = `#version 300 es
precision highp float;
uniform sampler2D uState;
uniform sampler2D uOrigin;
uniform vec2 uMouse;
uniform int uMouseInside;
uniform float uScatterRadius;
uniform float uScatterForce;
uniform float uReturnSpeed;
uniform float uFriction;
out vec4 fragColor;
void main() {
ivec2 coord = ivec2(gl_FragCoord.xy);
vec4 state = texelFetch(uState, coord, 0);
vec4 origin = texelFetch(uOrigin, coord, 0);
float x = state.x;
float y = state.y;
float velocityX = state.z;
float velocityY = state.w;
float originX = origin.x;
float originY = origin.y;
if (originX < -9999.0) {
fragColor = state;
return;
}
// Mouse radial push
if (uMouseInside == 1) {
vec2 delta = vec2(x, y) - uMouse;
float distance = length(delta);
float influence = smoothstep(uScatterRadius, 0.0, distance);
if (distance > 0.001) {
vec2 direction = delta / distance;
velocityX += direction.x * influence * uScatterForce;
velocityY += direction.y * influence * uScatterForce;
}
}
// Return to origin
velocityX += (originX - x) * uReturnSpeed;
velocityY += (originY - y) * uReturnSpeed;
// Friction
velocityX *= uFriction;
velocityY *= uFriction;
// Update position
x += velocityX;
y += velocityY;
fragColor = vec4(x, y, velocityX, velocityY);
}
`
// ----- Render pass shaders -----
const renderVertexSource = `#version 300 es
precision highp float;
uniform sampler2D uState;
uniform sampler2D uOrigin;
uniform int uTextureSize;
uniform vec2 uResolution;
out float vOpacity;
out float vDisplacement;
out float vSpeed;
void main() {
int texelX = gl_VertexID % uTextureSize;
int texelY = gl_VertexID / uTextureSize;
vec4 state = texelFetch(uState, ivec2(texelX, texelY), 0);
vec4 origin = texelFetch(uOrigin, ivec2(texelX, texelY), 0);
float x = state.x;
float y = state.y;
float velocityX = state.z;
float velocityY = state.w;
float originX = origin.x;
float originY = origin.y;
float size = origin.z;
float opacity = origin.w;
if (originX < -9999.0) {
gl_Position = vec4(-9999.0, -9999.0, 0.0, 1.0);
gl_PointSize = 0.0;
vOpacity = 0.0;
vDisplacement = 0.0;
vSpeed = 0.0;
return;
}
vec2 clipPosition = (vec2(x, y) / uResolution) * 2.0 - 1.0;
clipPosition.y *= -1.0;
gl_Position = vec4(clipPosition, 0.0, 1.0);
gl_PointSize = size;
vOpacity = opacity;
float distanceFromOrigin = length(vec2(x, y) - vec2(originX, originY));
vDisplacement = smoothstep(0.0, 40.0, distanceFromOrigin);
float speed = length(vec2(velocityX, velocityY));
vSpeed = smoothstep(0.0, 8.0, speed);
}
`
const renderFragmentSource = `#version 300 es
precision highp float;
in float vOpacity;
in float vDisplacement;
in float vSpeed;
out vec4 fragColor;
void main() {
// Round particle shape using gl_PointCoord
vec2 center = gl_PointCoord - vec2(0.5);
float distance = length(center) * 2.0;
if (distance > 1.0) {
discard;
}
// Soft edges
float alpha = smoothstep(1.0, 0.4, distance);
fragColor = vec4(0.53, 0.53, 0.53, vOpacity * alpha);
}
`
// ----- Helper functions -----
function compileShader(
gl: WebGL2RenderingContext,
type: number,
source: string
): WebGLShader | null {
const shader = gl.createShader(type)
if (!shader) return null
gl.shaderSource(shader, source)
gl.compileShader(shader)
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error(gl.getShaderInfoLog(shader))
gl.deleteShader(shader)
return null
}
return shader
}
function linkProgram(
gl: WebGL2RenderingContext,
vertSource: string,
fragSource: string
): WebGLProgram | null {
const vert = compileShader(gl, gl.VERTEX_SHADER, vertSource)
const frag = compileShader(gl, gl.FRAGMENT_SHADER, fragSource)
if (!vert || !frag) return null
const program = gl.createProgram()
if (!program) return null
gl.attachShader(program, vert)
gl.attachShader(program, frag)
gl.linkProgram(program)
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error(gl.getProgramInfoLog(program))
gl.deleteProgram(program)
return null
}
gl.deleteShader(vert)
gl.deleteShader(frag)
return program
}
function createFloatTexture(
gl: WebGL2RenderingContext,
size: number,
data: Float32Array
): WebGLTexture | null {
const texture = gl.createTexture()
if (!texture) return null
gl.bindTexture(gl.TEXTURE_2D, texture)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, size, size, 0, gl.RGBA, gl.FLOAT, data)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
return texture
}
// ----- Particle sampling from text -----
interface SampledParticle {
x: number
y: number
size: number
opacity: number
}
function sampleParticleListFromText(
text: string,
canvasWidth: number,
canvasHeight: number
): SampledParticle[] {
const offscreen = document.createElement('canvas')
offscreen.width = canvasWidth
offscreen.height = canvasHeight
const context = offscreen.getContext('2d')
if (!context) return []
const fontSize = Math.min(canvasWidth / (text.length * 0.7), canvasHeight * 0.6)
context.fillStyle = 'white'
context.font = `bold ${fontSize}px Arial, sans-serif`
context.textAlign = 'center'
context.textBaseline = 'middle'
context.fillText(text, canvasWidth / 2, canvasHeight / 2)
const imageData = context.getImageData(0, 0, canvasWidth, canvasHeight)
const pixelDataList = imageData.data
const gap = 1
const resultList: SampledParticle[] = []
for (let y = 0; y < canvasHeight; y += gap) {
for (let x = 0; x < canvasWidth; x += gap) {
const index = (y * canvasWidth + x) * 4
const alpha = pixelDataList[index + 3]
if (alpha > 128) {
resultList.push({
x,
y,
size: 2.0,
opacity: alpha / 255,
})
}
}
}
return resultList
}
// ----- Setup textures and framebuffers -----
function setupParticleTextures(
gl: WebGL2RenderingContext,
sampledParticleList: SampledParticle[]
): void {
particleCount = sampledParticleList.length
textureSize = Math.ceil(Math.sqrt(particleCount))
const totalPixelCount = textureSize * textureSize
const stateData = new Float32Array(totalPixelCount * 4)
const originData = new Float32Array(totalPixelCount * 4)
for (let i = 0; i < totalPixelCount; i++) {
if (i < particleCount) {
const particle = sampledParticleList[i]
stateData[i * 4 + 0] = particle.x
stateData[i * 4 + 1] = particle.y
stateData[i * 4 + 2] = 0
stateData[i * 4 + 3] = 0
originData[i * 4 + 0] = particle.x
originData[i * 4 + 1] = particle.y
originData[i * 4 + 2] = particle.size
originData[i * 4 + 3] = particle.opacity
}
else {
stateData[i * 4 + 0] = -99999
stateData[i * 4 + 1] = -99999
stateData[i * 4 + 2] = 0
stateData[i * 4 + 3] = 0
originData[i * 4 + 0] = -99999
originData[i * 4 + 1] = -99999
originData[i * 4 + 2] = 0
originData[i * 4 + 3] = 0
}
}
stateTextureList[0] = createFloatTexture(gl, textureSize, stateData)
stateTextureList[1] = createFloatTexture(gl, textureSize, new Float32Array(stateData))
originTexture = createFloatTexture(gl, textureSize, originData)
for (let i = 0; i < 2; i++) {
framebufferList[i] = gl.createFramebuffer()
gl.bindFramebuffer(gl.FRAMEBUFFER, framebufferList[i])
gl.framebufferTexture2D(
gl.FRAMEBUFFER,
gl.COLOR_ATTACHMENT0,
gl.TEXTURE_2D,
stateTextureList[i],
0
)
}
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
}
function setupQuadBuffer(gl: WebGL2RenderingContext): void {
const positionList = new Float32Array([
-1, -1,
1, -1,
-1, 1,
-1, 1,
1, -1,
1, 1,
])
quadVertexArray = gl.createVertexArray()
gl.bindVertexArray(quadVertexArray)
quadVertexBuffer = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, quadVertexBuffer)
gl.bufferData(gl.ARRAY_BUFFER, positionList, gl.STATIC_DRAW)
gl.enableVertexAttribArray(0)
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0)
gl.bindVertexArray(null)
}
// ----- Pointer events -----
function handlePointerMove(event: PointerEvent): void {
const canvas = canvasRef.value
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const devicePixelRatio = window.devicePixelRatio || 1
mouseX = (event.clientX - rect.left) * devicePixelRatio
mouseY = (event.clientY - rect.top) * devicePixelRatio
isMouseInside = true
}
function handlePointerLeave(): void {
isMouseInside = false
}
// ----- Init and render -----
function initWebGL(): void {
const canvas = canvasRef.value
if (!canvas) return
const devicePixelRatio = window.devicePixelRatio || 1
const displayWidth = canvas.clientWidth
const displayHeight = canvas.clientHeight
canvas.width = displayWidth * devicePixelRatio
canvas.height = displayHeight * devicePixelRatio
glContext = canvas.getContext('webgl2')
if (!glContext) {
console.error('WebGL2 not supported')
return
}
const extColorFloat = glContext.getExtension('EXT_color_buffer_float')
if (!extColorFloat) {
console.error('EXT_color_buffer_float not supported')
return
}
physicsProgram = linkProgram(glContext, physicsVertexSource, physicsFragmentSource)
renderProgram = linkProgram(glContext, renderVertexSource, renderFragmentSource)
if (!physicsProgram || !renderProgram) return
setupQuadBuffer(glContext)
const sampledParticleList = sampleParticleListFromText(
DISPLAY_TEXT,
canvas.width,
canvas.height
)
setupParticleTextures(glContext, sampledParticleList)
// Enable alpha blending
glContext.enable(glContext.BLEND)
glContext.blendFuncSeparate(
glContext.SRC_ALPHA,
glContext.ONE_MINUS_SRC_ALPHA,
glContext.ONE,
glContext.ONE_MINUS_SRC_ALPHA
)
canvas.addEventListener('pointermove', handlePointerMove)
canvas.addEventListener('pointerleave', handlePointerLeave)
}
function render(): void {
if (!glContext || !physicsProgram || !renderProgram) return
const canvas = canvasRef.value
if (!canvas) return
const gl = glContext
const readIndex = pingPongIndex
const writeIndex = 1 - pingPongIndex
// ----- Physics pass -----
gl.disable(gl.BLEND)
gl.bindFramebuffer(gl.FRAMEBUFFER, framebufferList[writeIndex])
gl.viewport(0, 0, textureSize, textureSize)
gl.useProgram(physicsProgram)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, stateTextureList[readIndex])
gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uState'), 0)
gl.activeTexture(gl.TEXTURE1)
gl.bindTexture(gl.TEXTURE_2D, originTexture)
gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uOrigin'), 1)
gl.uniform2f(gl.getUniformLocation(physicsProgram, 'uMouse'), mouseX, mouseY)
gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uMouseInside'), isMouseInside ? 1 : 0)
gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uScatterRadius'), 60.0)
gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uScatterForce'), 40.0)
gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uReturnSpeed'), 0.1)
gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uFriction'), 0.92)
gl.bindVertexArray(quadVertexArray)
gl.drawArrays(gl.TRIANGLES, 0, 6)
gl.bindVertexArray(null)
pingPongIndex = writeIndex
// ----- Render pass -----
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
gl.viewport(0, 0, canvas.width, canvas.height)
gl.clearColor(0, 0, 0, 1)
gl.clear(gl.COLOR_BUFFER_BIT)
gl.enable(gl.BLEND)
gl.blendFuncSeparate(
gl.SRC_ALPHA,
gl.ONE_MINUS_SRC_ALPHA,
gl.ONE,
gl.ONE_MINUS_SRC_ALPHA
)
gl.useProgram(renderProgram)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, stateTextureList[writeIndex])
gl.uniform1i(gl.getUniformLocation(renderProgram, 'uState'), 0)
gl.activeTexture(gl.TEXTURE1)
gl.bindTexture(gl.TEXTURE_2D, originTexture)
gl.uniform1i(gl.getUniformLocation(renderProgram, 'uOrigin'), 1)
gl.uniform1i(gl.getUniformLocation(renderProgram, 'uTextureSize'), textureSize)
gl.uniform2f(
gl.getUniformLocation(renderProgram, 'uResolution'),
canvas.width,
canvas.height
)
gl.drawArrays(gl.POINTS, 0, textureSize * textureSize)
animationFrameId = requestAnimationFrame(render)
}
onMounted(() => {
initWebGL()
animationFrameId = requestAnimationFrame(render)
})
onBeforeUnmount(() => {
cancelAnimationFrame(animationFrameId)
const canvas = canvasRef.value
if (canvas) {
canvas.removeEventListener('pointermove', handlePointerMove)
canvas.removeEventListener('pointerleave', handlePointerLeave)
}
if (glContext) {
if (physicsProgram) glContext.deleteProgram(physicsProgram)
if (renderProgram) glContext.deleteProgram(renderProgram)
for (const texture of stateTextureList) {
if (texture) glContext.deleteTexture(texture)
}
if (originTexture) glContext.deleteTexture(originTexture)
for (const framebuffer of framebufferList) {
if (framebuffer) glContext.deleteFramebuffer(framebuffer)
}
if (quadVertexBuffer) glContext.deleteBuffer(quadVertexBuffer)
if (quadVertexArray) glContext.deleteVertexArray(quadVertexArray)
}
})
</script>
<template>
<div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6">
<p class="text-sm text-gray-600">
圓形粒子 + Alpha 混合:柔和邊緣與透明度
</p>
<canvas
ref="canvasRef"
class="w-full rounded-lg"
style="height: 320px;"
/>
</div>
</template>正方形變圓形
gl.POINTS 畫出來的是正方形。gl_PointCoord 提供正方形內的 UV 座標(0~1):
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 會讓粒子沿著漩渦走,看起來就像被風捲著跑。
Curl Noise 擾動:滑鼠附近產生渦流效果,遠離原點的粒子自然飄動
查看範例原始碼
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
const canvasRef = ref<HTMLCanvasElement | null>(null)
let glContext: WebGL2RenderingContext | null = null
let physicsProgram: WebGLProgram | null = null
let renderProgram: WebGLProgram | null = null
let noiseProgram: WebGLProgram | null = null
let animationFrameId = 0
let stateTextureList: [WebGLTexture | null, WebGLTexture | null] = [null, null]
let framebufferList: [WebGLFramebuffer | null, WebGLFramebuffer | null] = [null, null]
let originTexture: WebGLTexture | null = null
let noiseTexture: WebGLTexture | null = null
let noiseFramebuffer: WebGLFramebuffer | null = null
let quadVertexBuffer: WebGLBuffer | null = null
let quadVertexArray: WebGLVertexArrayObject | null = null
let particleCount = 0
let textureSize = 0
let pingPongIndex = 0
let startTime = 0
let mouseX = 0
let mouseY = 0
let isMouseInside = false
const DISPLAY_TEXT = 'SWARM'
const NOISE_SIZE = 256
// ----- Noise generation shader -----
const noiseVertexSource = `#version 300 es
in vec2 aPosition;
void main() {
gl_Position = vec4(aPosition, 0.0, 1.0);
}
`
const noiseFragmentSource = `#version 300 es
precision highp float;
out vec4 fragColor;
float hash(vec2 p) {
vec3 p3 = fract(vec3(p.xyx) * 0.13);
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
float noise(vec2 x) {
vec2 i = floor(x); vec2 f = fract(x);
float a = hash(i); float b = hash(i + vec2(1,0));
float c = hash(i + vec2(0,1)); float d = hash(i + vec2(1,1));
vec2 u = f*f*(3.0-2.0*f);
return mix(a,b,u.x)+(c-a)*u.y*(1.0-u.x)+(d-b)*u.x*u.y;
}
float fbm(vec2 p) {
float v = 0.0; float a = 0.5;
for(int i=0;i<4;i++){v+=a*noise(p);p*=2.0;a*=0.5;}
return v;
}
void main() {
vec2 uv = gl_FragCoord.xy / 256.0;
vec2 p = uv * 6.0;
const float e = 0.1;
float a = (fbm(p+vec2(0,e))-fbm(p-vec2(0,e)))/(2.0*e);
float b = (fbm(p+vec2(e,0))-fbm(p-vec2(e,0)))/(2.0*e);
fragColor = vec4(a, -b, 0.0, 1.0);
}
`
// ----- Physics pass shaders -----
const physicsVertexSource = `#version 300 es
in vec2 aPosition;
void main() {
gl_Position = vec4(aPosition, 0.0, 1.0);
}
`
const physicsFragmentSource = `#version 300 es
precision highp float;
uniform sampler2D uState;
uniform sampler2D uOrigin;
uniform sampler2D uNoise;
uniform vec2 uMouse;
uniform int uMouseInside;
uniform float uScatterRadius;
uniform float uScatterForce;
uniform float uReturnSpeed;
uniform float uFriction;
uniform float uTime;
uniform vec2 uCanvasSize;
out vec4 fragColor;
void main() {
ivec2 coord = ivec2(gl_FragCoord.xy);
vec4 state = texelFetch(uState, coord, 0);
vec4 origin = texelFetch(uOrigin, coord, 0);
float x = state.x;
float y = state.y;
float velocityX = state.z;
float velocityY = state.w;
float originX = origin.x;
float originY = origin.y;
if (originX < -9999.0) {
fragColor = state;
return;
}
float distanceFromOrigin = length(vec2(x, y) - vec2(originX, originY));
// Mouse radial push
if (uMouseInside == 1) {
vec2 delta = vec2(x, y) - uMouse;
float distance = length(delta);
float influence = smoothstep(uScatterRadius, 0.0, distance);
if (distance > 0.001) {
vec2 direction = delta / distance;
velocityX += direction.x * influence * uScatterForce;
velocityY += direction.y * influence * uScatterForce;
}
// Curl noise turbulence near mouse
if (distance < uScatterRadius * 1.5) {
vec2 noiseCoord = vec2(x, y) / uCanvasSize + vec2(uTime * 0.05, uTime * 0.03);
noiseCoord = fract(noiseCoord);
vec2 curlForce = texture(uNoise, noiseCoord).xy;
float turbulenceStrength = influence * 15.0;
velocityX += curlForce.x * turbulenceStrength;
velocityY += curlForce.y * turbulenceStrength;
}
}
// Drift noise for particles far from origin
if (distanceFromOrigin > 5.0) {
vec2 driftCoord = vec2(x * 0.003, y * 0.003) + vec2(uTime * 0.02);
driftCoord = fract(driftCoord);
vec2 driftForce = texture(uNoise, driftCoord).xy;
float driftStrength = smoothstep(5.0, 30.0, distanceFromOrigin) * 2.0;
velocityX += driftForce.x * driftStrength;
velocityY += driftForce.y * driftStrength;
}
// Return to origin
velocityX += (originX - x) * uReturnSpeed;
velocityY += (originY - y) * uReturnSpeed;
// Friction
velocityX *= uFriction;
velocityY *= uFriction;
// Update position
x += velocityX;
y += velocityY;
fragColor = vec4(x, y, velocityX, velocityY);
}
`
// ----- Render pass shaders -----
const renderVertexSource = `#version 300 es
precision highp float;
uniform sampler2D uState;
uniform sampler2D uOrigin;
uniform int uTextureSize;
uniform vec2 uResolution;
out float vOpacity;
out float vDisplacement;
out float vSpeed;
void main() {
int texelX = gl_VertexID % uTextureSize;
int texelY = gl_VertexID / uTextureSize;
vec4 state = texelFetch(uState, ivec2(texelX, texelY), 0);
vec4 origin = texelFetch(uOrigin, ivec2(texelX, texelY), 0);
float x = state.x;
float y = state.y;
float velocityX = state.z;
float velocityY = state.w;
float originX = origin.x;
float originY = origin.y;
float size = origin.z;
float opacity = origin.w;
if (originX < -9999.0) {
gl_Position = vec4(-9999.0, -9999.0, 0.0, 1.0);
gl_PointSize = 0.0;
vOpacity = 0.0;
vDisplacement = 0.0;
vSpeed = 0.0;
return;
}
vec2 clipPosition = (vec2(x, y) / uResolution) * 2.0 - 1.0;
clipPosition.y *= -1.0;
gl_Position = vec4(clipPosition, 0.0, 1.0);
gl_PointSize = size;
vOpacity = opacity;
float distanceFromOrigin = length(vec2(x, y) - vec2(originX, originY));
vDisplacement = smoothstep(0.0, 40.0, distanceFromOrigin);
float speed = length(vec2(velocityX, velocityY));
vSpeed = smoothstep(0.0, 8.0, speed);
}
`
const renderFragmentSource = `#version 300 es
precision highp float;
in float vOpacity;
in float vDisplacement;
in float vSpeed;
out vec4 fragColor;
void main() {
// Round particle shape using gl_PointCoord
vec2 center = gl_PointCoord - vec2(0.5);
float distance = length(center) * 2.0;
if (distance > 1.0) {
discard;
}
// Soft edges
float alpha = smoothstep(1.0, 0.4, distance);
fragColor = vec4(0.53, 0.53, 0.53, vOpacity * alpha);
}
`
// ----- Helper functions -----
function compileShader(
gl: WebGL2RenderingContext,
type: number,
source: string
): WebGLShader | null {
const shader = gl.createShader(type)
if (!shader) return null
gl.shaderSource(shader, source)
gl.compileShader(shader)
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error(gl.getShaderInfoLog(shader))
gl.deleteShader(shader)
return null
}
return shader
}
function linkProgram(
gl: WebGL2RenderingContext,
vertSource: string,
fragSource: string
): WebGLProgram | null {
const vert = compileShader(gl, gl.VERTEX_SHADER, vertSource)
const frag = compileShader(gl, gl.FRAGMENT_SHADER, fragSource)
if (!vert || !frag) return null
const program = gl.createProgram()
if (!program) return null
gl.attachShader(program, vert)
gl.attachShader(program, frag)
gl.linkProgram(program)
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error(gl.getProgramInfoLog(program))
gl.deleteProgram(program)
return null
}
gl.deleteShader(vert)
gl.deleteShader(frag)
return program
}
function createFloatTexture(
gl: WebGL2RenderingContext,
size: number,
data: Float32Array
): WebGLTexture | null {
const texture = gl.createTexture()
if (!texture) return null
gl.bindTexture(gl.TEXTURE_2D, texture)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, size, size, 0, gl.RGBA, gl.FLOAT, data)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
return texture
}
// ----- Particle sampling from text -----
interface SampledParticle {
x: number
y: number
size: number
opacity: number
}
function sampleParticleListFromText(
text: string,
canvasWidth: number,
canvasHeight: number
): SampledParticle[] {
const offscreen = document.createElement('canvas')
offscreen.width = canvasWidth
offscreen.height = canvasHeight
const context = offscreen.getContext('2d')
if (!context) return []
const fontSize = Math.min(canvasWidth / (text.length * 0.7), canvasHeight * 0.6)
context.fillStyle = 'white'
context.font = `bold ${fontSize}px Arial, sans-serif`
context.textAlign = 'center'
context.textBaseline = 'middle'
context.fillText(text, canvasWidth / 2, canvasHeight / 2)
const imageData = context.getImageData(0, 0, canvasWidth, canvasHeight)
const pixelDataList = imageData.data
const gap = 1
const resultList: SampledParticle[] = []
for (let y = 0; y < canvasHeight; y += gap) {
for (let x = 0; x < canvasWidth; x += gap) {
const index = (y * canvasWidth + x) * 4
const alpha = pixelDataList[index + 3]
if (alpha > 128) {
resultList.push({
x,
y,
size: 2.0,
opacity: alpha / 255,
})
}
}
}
return resultList
}
// ----- Setup textures and framebuffers -----
function setupParticleTextures(
gl: WebGL2RenderingContext,
sampledParticleList: SampledParticle[]
): void {
particleCount = sampledParticleList.length
textureSize = Math.ceil(Math.sqrt(particleCount))
const totalPixelCount = textureSize * textureSize
const stateData = new Float32Array(totalPixelCount * 4)
const originData = new Float32Array(totalPixelCount * 4)
for (let i = 0; i < totalPixelCount; i++) {
if (i < particleCount) {
const particle = sampledParticleList[i]
stateData[i * 4 + 0] = particle.x
stateData[i * 4 + 1] = particle.y
stateData[i * 4 + 2] = 0
stateData[i * 4 + 3] = 0
originData[i * 4 + 0] = particle.x
originData[i * 4 + 1] = particle.y
originData[i * 4 + 2] = particle.size
originData[i * 4 + 3] = particle.opacity
}
else {
stateData[i * 4 + 0] = -99999
stateData[i * 4 + 1] = -99999
stateData[i * 4 + 2] = 0
stateData[i * 4 + 3] = 0
originData[i * 4 + 0] = -99999
originData[i * 4 + 1] = -99999
originData[i * 4 + 2] = 0
originData[i * 4 + 3] = 0
}
}
stateTextureList[0] = createFloatTexture(gl, textureSize, stateData)
stateTextureList[1] = createFloatTexture(gl, textureSize, new Float32Array(stateData))
originTexture = createFloatTexture(gl, textureSize, originData)
for (let i = 0; i < 2; i++) {
framebufferList[i] = gl.createFramebuffer()
gl.bindFramebuffer(gl.FRAMEBUFFER, framebufferList[i])
gl.framebufferTexture2D(
gl.FRAMEBUFFER,
gl.COLOR_ATTACHMENT0,
gl.TEXTURE_2D,
stateTextureList[i],
0
)
}
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
}
function generateNoiseTexture(gl: WebGL2RenderingContext): void {
noiseProgram = linkProgram(gl, noiseVertexSource, noiseFragmentSource)
if (!noiseProgram) return
// Create noise texture and framebuffer
noiseTexture = gl.createTexture()
gl.bindTexture(gl.TEXTURE_2D, noiseTexture)
gl.texImage2D(
gl.TEXTURE_2D, 0, gl.RGBA32F,
NOISE_SIZE, NOISE_SIZE, 0,
gl.RGBA, gl.FLOAT, null
)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT)
noiseFramebuffer = gl.createFramebuffer()
gl.bindFramebuffer(gl.FRAMEBUFFER, noiseFramebuffer)
gl.framebufferTexture2D(
gl.FRAMEBUFFER,
gl.COLOR_ATTACHMENT0,
gl.TEXTURE_2D,
noiseTexture,
0
)
// Render noise
gl.viewport(0, 0, NOISE_SIZE, NOISE_SIZE)
gl.useProgram(noiseProgram)
gl.bindVertexArray(quadVertexArray)
gl.drawArrays(gl.TRIANGLES, 0, 6)
gl.bindVertexArray(null)
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
gl.deleteProgram(noiseProgram)
noiseProgram = null
}
function setupQuadBuffer(gl: WebGL2RenderingContext): void {
const positionList = new Float32Array([
-1, -1,
1, -1,
-1, 1,
-1, 1,
1, -1,
1, 1,
])
quadVertexArray = gl.createVertexArray()
gl.bindVertexArray(quadVertexArray)
quadVertexBuffer = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, quadVertexBuffer)
gl.bufferData(gl.ARRAY_BUFFER, positionList, gl.STATIC_DRAW)
gl.enableVertexAttribArray(0)
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0)
gl.bindVertexArray(null)
}
// ----- Pointer events -----
function handlePointerMove(event: PointerEvent): void {
const canvas = canvasRef.value
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const devicePixelRatio = window.devicePixelRatio || 1
mouseX = (event.clientX - rect.left) * devicePixelRatio
mouseY = (event.clientY - rect.top) * devicePixelRatio
isMouseInside = true
}
function handlePointerLeave(): void {
isMouseInside = false
}
// ----- Init and render -----
function initWebGL(): void {
const canvas = canvasRef.value
if (!canvas) return
const devicePixelRatio = window.devicePixelRatio || 1
const displayWidth = canvas.clientWidth
const displayHeight = canvas.clientHeight
canvas.width = displayWidth * devicePixelRatio
canvas.height = displayHeight * devicePixelRatio
glContext = canvas.getContext('webgl2')
if (!glContext) {
console.error('WebGL2 not supported')
return
}
const extColorFloat = glContext.getExtension('EXT_color_buffer_float')
if (!extColorFloat) {
console.error('EXT_color_buffer_float not supported')
return
}
physicsProgram = linkProgram(glContext, physicsVertexSource, physicsFragmentSource)
renderProgram = linkProgram(glContext, renderVertexSource, renderFragmentSource)
if (!physicsProgram || !renderProgram) return
setupQuadBuffer(glContext)
generateNoiseTexture(glContext)
const sampledParticleList = sampleParticleListFromText(
DISPLAY_TEXT,
canvas.width,
canvas.height
)
setupParticleTextures(glContext, sampledParticleList)
glContext.enable(glContext.BLEND)
glContext.blendFuncSeparate(
glContext.SRC_ALPHA,
glContext.ONE_MINUS_SRC_ALPHA,
glContext.ONE,
glContext.ONE_MINUS_SRC_ALPHA
)
startTime = performance.now()
canvas.addEventListener('pointermove', handlePointerMove)
canvas.addEventListener('pointerleave', handlePointerLeave)
}
function render(): void {
if (!glContext || !physicsProgram || !renderProgram) return
const canvas = canvasRef.value
if (!canvas) return
const gl = glContext
const currentTime = (performance.now() - startTime) / 1000
const readIndex = pingPongIndex
const writeIndex = 1 - pingPongIndex
// ----- Physics pass -----
gl.disable(gl.BLEND)
gl.bindFramebuffer(gl.FRAMEBUFFER, framebufferList[writeIndex])
gl.viewport(0, 0, textureSize, textureSize)
gl.useProgram(physicsProgram)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, stateTextureList[readIndex])
gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uState'), 0)
gl.activeTexture(gl.TEXTURE1)
gl.bindTexture(gl.TEXTURE_2D, originTexture)
gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uOrigin'), 1)
gl.activeTexture(gl.TEXTURE2)
gl.bindTexture(gl.TEXTURE_2D, noiseTexture)
gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uNoise'), 2)
gl.uniform2f(gl.getUniformLocation(physicsProgram, 'uMouse'), mouseX, mouseY)
gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uMouseInside'), isMouseInside ? 1 : 0)
gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uScatterRadius'), 60.0)
gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uScatterForce'), 40.0)
gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uReturnSpeed'), 0.1)
gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uFriction'), 0.92)
gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uTime'), currentTime)
gl.uniform2f(
gl.getUniformLocation(physicsProgram, 'uCanvasSize'),
canvas.width,
canvas.height
)
gl.bindVertexArray(quadVertexArray)
gl.drawArrays(gl.TRIANGLES, 0, 6)
gl.bindVertexArray(null)
pingPongIndex = writeIndex
// ----- Render pass -----
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
gl.viewport(0, 0, canvas.width, canvas.height)
gl.clearColor(0, 0, 0, 1)
gl.clear(gl.COLOR_BUFFER_BIT)
gl.enable(gl.BLEND)
gl.blendFuncSeparate(
gl.SRC_ALPHA,
gl.ONE_MINUS_SRC_ALPHA,
gl.ONE,
gl.ONE_MINUS_SRC_ALPHA
)
gl.useProgram(renderProgram)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, stateTextureList[writeIndex])
gl.uniform1i(gl.getUniformLocation(renderProgram, 'uState'), 0)
gl.activeTexture(gl.TEXTURE1)
gl.bindTexture(gl.TEXTURE_2D, originTexture)
gl.uniform1i(gl.getUniformLocation(renderProgram, 'uOrigin'), 1)
gl.uniform1i(gl.getUniformLocation(renderProgram, 'uTextureSize'), textureSize)
gl.uniform2f(
gl.getUniformLocation(renderProgram, 'uResolution'),
canvas.width,
canvas.height
)
gl.drawArrays(gl.POINTS, 0, textureSize * textureSize)
animationFrameId = requestAnimationFrame(render)
}
onMounted(() => {
initWebGL()
animationFrameId = requestAnimationFrame(render)
})
onBeforeUnmount(() => {
cancelAnimationFrame(animationFrameId)
const canvas = canvasRef.value
if (canvas) {
canvas.removeEventListener('pointermove', handlePointerMove)
canvas.removeEventListener('pointerleave', handlePointerLeave)
}
if (glContext) {
if (physicsProgram) glContext.deleteProgram(physicsProgram)
if (renderProgram) glContext.deleteProgram(renderProgram)
for (const texture of stateTextureList) {
if (texture) glContext.deleteTexture(texture)
}
if (originTexture) glContext.deleteTexture(originTexture)
if (noiseTexture) glContext.deleteTexture(noiseTexture)
for (const framebuffer of framebufferList) {
if (framebuffer) glContext.deleteFramebuffer(framebuffer)
}
if (noiseFramebuffer) glContext.deleteFramebuffer(noiseFramebuffer)
if (quadVertexBuffer) glContext.deleteBuffer(quadVertexBuffer)
if (quadVertexArray) glContext.deleteVertexArray(quadVertexArray)
}
})
</script>
<template>
<div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6">
<p class="text-sm text-gray-600">
Curl Noise 擾動:滑鼠附近產生渦流效果,遠離原點的粒子自然飄動
</p>
<canvas
ref="canvasRef"
class="w-full rounded-lg"
style="height: 320px;"
/>
</div>
</template>普通亂數 vs Curl Noise
一般的 Math.random() 會讓粒子亂跳,像喝醉一樣毫無方向感。
Curl Noise 不一樣,它產生的是旋轉場。鄰近的點會有相似的方向,粒子沿著它走會形成漩渦般的軌跡。
原理
對一個 2D 噪聲場取偏導數,然後旋轉 90 度:
float a = (fbm(p + vec2(0.0, e))
- fbm(p - vec2(0.0, e))) / (2.0 * e);
float b = (fbm(p + vec2(e, 0.0))
- fbm(p - vec2(e, 0.0))) / (2.0 * e);
curl = vec2(a, -b);路人:「偏導數?微積分?我頭好痛 (╥ω╥`)」 鱈魚:「其實不用真的懂數學啦,記住結果就好:Curl Noise 讓粒子沿著漩渦走 ♪( ◜ω◝و(و」
FBM(Fractal Brownian Motion)
fbm 是分形布朗運動,把多個頻率的噪聲疊在一起:
float fbm(vec2 p) {
float value = 0.0;
float amplitude = 0.5;
for (int i = 0; i < 4; i++) {
value += amplitude * noise(p);
p *= 2.0;
amplitude *= 0.5;
}
return value;
}低頻的大漩渦 + 高頻的小擾動,看起來就很像自然界的紊流。
預計算噪聲紋理
每幀在 Shader 裡即時算 FBM 太貴了(4 層巢狀噪聲)。所以我們在初始化時用一個專門的 Shader 把整張噪聲場算好,烘焙成 256×256 的紋理:
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 中加入擾動
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 { ref, onMounted, onBeforeUnmount } from 'vue'
const canvasRef = ref<HTMLCanvasElement | null>(null)
let glContext: WebGL2RenderingContext | null = null
let physicsProgram: WebGLProgram | null = null
let renderProgram: WebGLProgram | null = null
let noiseProgram: WebGLProgram | null = null
let animationFrameId = 0
let stateTextureList: [WebGLTexture | null, WebGLTexture | null] = [null, null]
let framebufferList: [WebGLFramebuffer | null, WebGLFramebuffer | null] = [null, null]
let originTexture: WebGLTexture | null = null
let noiseTexture: WebGLTexture | null = null
let noiseFramebuffer: WebGLFramebuffer | null = null
let quadVertexBuffer: WebGLBuffer | null = null
let quadVertexArray: WebGLVertexArrayObject | null = null
let particleCount = 0
let textureSize = 0
let pingPongIndex = 0
let startTime = 0
let mouseX = 0
let mouseY = 0
let isMouseInside = false
const DISPLAY_TEXT = 'SWARM'
const NOISE_SIZE = 256
// ----- Noise generation shader -----
const noiseVertexSource = `#version 300 es
in vec2 aPosition;
void main() {
gl_Position = vec4(aPosition, 0.0, 1.0);
}
`
const noiseFragmentSource = `#version 300 es
precision highp float;
out vec4 fragColor;
float hash(vec2 p) {
vec3 p3 = fract(vec3(p.xyx) * 0.13);
p3 += dot(p3, p3.yzx + 33.33);
return fract((p3.x + p3.y) * p3.z);
}
float noise(vec2 x) {
vec2 i = floor(x); vec2 f = fract(x);
float a = hash(i); float b = hash(i + vec2(1,0));
float c = hash(i + vec2(0,1)); float d = hash(i + vec2(1,1));
vec2 u = f*f*(3.0-2.0*f);
return mix(a,b,u.x)+(c-a)*u.y*(1.0-u.x)+(d-b)*u.x*u.y;
}
float fbm(vec2 p) {
float v = 0.0; float a = 0.5;
for(int i=0;i<4;i++){v+=a*noise(p);p*=2.0;a*=0.5;}
return v;
}
void main() {
vec2 uv = gl_FragCoord.xy / 256.0;
vec2 p = uv * 6.0;
const float e = 0.1;
float a = (fbm(p+vec2(0,e))-fbm(p-vec2(0,e)))/(2.0*e);
float b = (fbm(p+vec2(e,0))-fbm(p-vec2(e,0)))/(2.0*e);
fragColor = vec4(a, -b, 0.0, 1.0);
}
`
// ----- Physics pass shaders -----
const physicsVertexSource = `#version 300 es
in vec2 aPosition;
void main() {
gl_Position = vec4(aPosition, 0.0, 1.0);
}
`
const physicsFragmentSource = `#version 300 es
precision highp float;
uniform sampler2D uState;
uniform sampler2D uOrigin;
uniform sampler2D uNoise;
uniform vec2 uMouse;
uniform int uMouseInside;
uniform float uScatterRadius;
uniform float uScatterForce;
uniform float uReturnSpeed;
uniform float uFriction;
uniform float uTime;
uniform vec2 uCanvasSize;
out vec4 fragColor;
void main() {
ivec2 coord = ivec2(gl_FragCoord.xy);
vec4 state = texelFetch(uState, coord, 0);
vec4 origin = texelFetch(uOrigin, coord, 0);
float x = state.x;
float y = state.y;
float velocityX = state.z;
float velocityY = state.w;
float originX = origin.x;
float originY = origin.y;
if (originX < -9999.0) {
fragColor = state;
return;
}
float distanceFromOrigin = length(vec2(x, y) - vec2(originX, originY));
// Mouse radial push
if (uMouseInside == 1) {
vec2 delta = vec2(x, y) - uMouse;
float distance = length(delta);
float influence = smoothstep(uScatterRadius, 0.0, distance);
if (distance > 0.001) {
vec2 direction = delta / distance;
velocityX += direction.x * influence * uScatterForce;
velocityY += direction.y * influence * uScatterForce;
}
// Curl noise turbulence near mouse
if (distance < uScatterRadius * 1.5) {
vec2 noiseCoord = vec2(x, y) / uCanvasSize + vec2(uTime * 0.05, uTime * 0.03);
noiseCoord = fract(noiseCoord);
vec2 curlForce = texture(uNoise, noiseCoord).xy;
float turbulenceStrength = influence * 15.0;
velocityX += curlForce.x * turbulenceStrength;
velocityY += curlForce.y * turbulenceStrength;
}
}
// Drift noise for particles far from origin
if (distanceFromOrigin > 5.0) {
vec2 driftCoord = vec2(x * 0.003, y * 0.003) + vec2(uTime * 0.02);
driftCoord = fract(driftCoord);
vec2 driftForce = texture(uNoise, driftCoord).xy;
float driftStrength = smoothstep(5.0, 30.0, distanceFromOrigin) * 2.0;
velocityX += driftForce.x * driftStrength;
velocityY += driftForce.y * driftStrength;
}
// Return to origin
velocityX += (originX - x) * uReturnSpeed;
velocityY += (originY - y) * uReturnSpeed;
// Friction
velocityX *= uFriction;
velocityY *= uFriction;
// Update position
x += velocityX;
y += velocityY;
fragColor = vec4(x, y, velocityX, velocityY);
}
`
// ----- Render pass shaders -----
const renderVertexSource = `#version 300 es
precision highp float;
uniform sampler2D uState;
uniform sampler2D uOrigin;
uniform int uTextureSize;
uniform vec2 uResolution;
uniform float uTime;
out float vOpacity;
out float vDisplacement;
out float vSpeed;
void main() {
int texelX = gl_VertexID % uTextureSize;
int texelY = gl_VertexID / uTextureSize;
vec4 state = texelFetch(uState, ivec2(texelX, texelY), 0);
vec4 origin = texelFetch(uOrigin, ivec2(texelX, texelY), 0);
float x = state.x;
float y = state.y;
float velocityX = state.z;
float velocityY = state.w;
float originX = origin.x;
float originY = origin.y;
float size = origin.z;
float opacity = origin.w;
if (originX < -9999.0) {
gl_Position = vec4(-9999.0, -9999.0, 0.0, 1.0);
gl_PointSize = 0.0;
vOpacity = 0.0;
vDisplacement = 0.0;
vSpeed = 0.0;
return;
}
float distanceFromOrigin = length(vec2(x, y) - vec2(originX, originY));
float displacement = smoothstep(0.0, 40.0, distanceFromOrigin);
float speed = length(vec2(velocityX, velocityY));
float speedNormalized = smoothstep(0.0, 8.0, speed);
// Per-particle slight position oscillation
float phase = float(gl_VertexID) * 0.1;
float oscillationX = sin(uTime * 2.0 + phase) * displacement * 0.5;
float oscillationY = cos(uTime * 1.7 + phase * 1.3) * displacement * 0.5;
vec2 finalPosition = vec2(x + oscillationX, y + oscillationY);
vec2 clipPosition = (finalPosition / uResolution) * 2.0 - 1.0;
clipPosition.y *= -1.0;
gl_Position = vec4(clipPosition, 0.0, 1.0);
gl_PointSize = size;
vOpacity = opacity;
vDisplacement = displacement;
vSpeed = speedNormalized;
}
`
const renderFragmentSource = `#version 300 es
precision highp float;
in float vOpacity;
in float vDisplacement;
in float vSpeed;
uniform vec3 uColor;
out vec4 fragColor;
void main() {
// Round particle shape using gl_PointCoord
vec2 center = gl_PointCoord - vec2(0.5);
float distance = length(center) * 2.0;
if (distance > 1.0) {
discard;
}
// Three-layer displacement classification
float nearDisplacement = smoothstep(0.0, 0.1, vDisplacement); // 0-8 range mapped
float midDisplacement = smoothstep(0.1, 0.375, vDisplacement); // 8-30 range mapped
float farDisplacement = smoothstep(0.375, 1.0, vDisplacement); // 30-80 range mapped
float displacement = nearDisplacement * 0.3 + midDisplacement * 0.4 + farDisplacement * 0.3;
// Warm white gradient based on displacement
vec3 warmColor = mix(uColor, uColor + vec3(0.15, 0.1, 0.05), displacement);
// Speed-based brightness
float speedBrightness = 1.0 + vSpeed * 0.5;
warmColor *= speedBrightness;
// Core edge varies with displacement
float coreEdge = mix(0.4, 0.25, vDisplacement);
// Core bright for fast particles
float coreBright = smoothstep(coreEdge, 0.0, distance);
float coreFactor = 1.0 + coreBright * vSpeed * 0.8;
// Soft edges
float alpha = smoothstep(1.0, coreEdge, distance);
// Glow layers
float innerGlow = smoothstep(0.8, 0.0, distance) * 0.3;
float outerGlow = smoothstep(1.0, 0.3, distance) * 0.15;
float glowTotal = innerGlow + outerGlow;
vec3 finalColor = warmColor * coreFactor + vec3(glowTotal);
float finalAlpha = vOpacity * alpha;
fragColor = vec4(finalColor, finalAlpha);
}
`
// ----- Helper functions -----
function compileShader(
gl: WebGL2RenderingContext,
type: number,
source: string
): WebGLShader | null {
const shader = gl.createShader(type)
if (!shader) return null
gl.shaderSource(shader, source)
gl.compileShader(shader)
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error(gl.getShaderInfoLog(shader))
gl.deleteShader(shader)
return null
}
return shader
}
function linkProgram(
gl: WebGL2RenderingContext,
vertSource: string,
fragSource: string
): WebGLProgram | null {
const vert = compileShader(gl, gl.VERTEX_SHADER, vertSource)
const frag = compileShader(gl, gl.FRAGMENT_SHADER, fragSource)
if (!vert || !frag) return null
const program = gl.createProgram()
if (!program) return null
gl.attachShader(program, vert)
gl.attachShader(program, frag)
gl.linkProgram(program)
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error(gl.getProgramInfoLog(program))
gl.deleteProgram(program)
return null
}
gl.deleteShader(vert)
gl.deleteShader(frag)
return program
}
function createFloatTexture(
gl: WebGL2RenderingContext,
size: number,
data: Float32Array
): WebGLTexture | null {
const texture = gl.createTexture()
if (!texture) return null
gl.bindTexture(gl.TEXTURE_2D, texture)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, size, size, 0, gl.RGBA, gl.FLOAT, data)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
return texture
}
// ----- Particle sampling from text -----
interface SampledParticle {
x: number
y: number
size: number
opacity: number
}
function sampleParticleListFromText(
text: string,
canvasWidth: number,
canvasHeight: number
): SampledParticle[] {
const offscreen = document.createElement('canvas')
offscreen.width = canvasWidth
offscreen.height = canvasHeight
const context = offscreen.getContext('2d')
if (!context) return []
const fontSize = Math.min(canvasWidth / (text.length * 0.7), canvasHeight * 0.6)
context.fillStyle = 'white'
context.font = `bold ${fontSize}px Arial, sans-serif`
context.textAlign = 'center'
context.textBaseline = 'middle'
context.fillText(text, canvasWidth / 2, canvasHeight / 2)
const imageData = context.getImageData(0, 0, canvasWidth, canvasHeight)
const pixelDataList = imageData.data
const gap = 1
const resultList: SampledParticle[] = []
for (let y = 0; y < canvasHeight; y += gap) {
for (let x = 0; x < canvasWidth; x += gap) {
const index = (y * canvasWidth + x) * 4
const alpha = pixelDataList[index + 3]
if (alpha > 128) {
resultList.push({
x,
y,
size: 2.0,
opacity: alpha / 255,
})
}
}
}
return resultList
}
// ----- Setup textures and framebuffers -----
function setupParticleTextures(
gl: WebGL2RenderingContext,
sampledParticleList: SampledParticle[]
): void {
particleCount = sampledParticleList.length
textureSize = Math.ceil(Math.sqrt(particleCount))
const totalPixelCount = textureSize * textureSize
const stateData = new Float32Array(totalPixelCount * 4)
const originData = new Float32Array(totalPixelCount * 4)
for (let i = 0; i < totalPixelCount; i++) {
if (i < particleCount) {
const particle = sampledParticleList[i]
stateData[i * 4 + 0] = particle.x
stateData[i * 4 + 1] = particle.y
stateData[i * 4 + 2] = 0
stateData[i * 4 + 3] = 0
originData[i * 4 + 0] = particle.x
originData[i * 4 + 1] = particle.y
originData[i * 4 + 2] = particle.size
originData[i * 4 + 3] = particle.opacity
}
else {
stateData[i * 4 + 0] = -99999
stateData[i * 4 + 1] = -99999
stateData[i * 4 + 2] = 0
stateData[i * 4 + 3] = 0
originData[i * 4 + 0] = -99999
originData[i * 4 + 1] = -99999
originData[i * 4 + 2] = 0
originData[i * 4 + 3] = 0
}
}
stateTextureList[0] = createFloatTexture(gl, textureSize, stateData)
stateTextureList[1] = createFloatTexture(gl, textureSize, new Float32Array(stateData))
originTexture = createFloatTexture(gl, textureSize, originData)
for (let i = 0; i < 2; i++) {
framebufferList[i] = gl.createFramebuffer()
gl.bindFramebuffer(gl.FRAMEBUFFER, framebufferList[i])
gl.framebufferTexture2D(
gl.FRAMEBUFFER,
gl.COLOR_ATTACHMENT0,
gl.TEXTURE_2D,
stateTextureList[i],
0
)
}
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
}
function generateNoiseTexture(gl: WebGL2RenderingContext): void {
noiseProgram = linkProgram(gl, noiseVertexSource, noiseFragmentSource)
if (!noiseProgram) return
noiseTexture = gl.createTexture()
gl.bindTexture(gl.TEXTURE_2D, noiseTexture)
gl.texImage2D(
gl.TEXTURE_2D, 0, gl.RGBA32F,
NOISE_SIZE, NOISE_SIZE, 0,
gl.RGBA, gl.FLOAT, null
)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT)
noiseFramebuffer = gl.createFramebuffer()
gl.bindFramebuffer(gl.FRAMEBUFFER, noiseFramebuffer)
gl.framebufferTexture2D(
gl.FRAMEBUFFER,
gl.COLOR_ATTACHMENT0,
gl.TEXTURE_2D,
noiseTexture,
0
)
gl.viewport(0, 0, NOISE_SIZE, NOISE_SIZE)
gl.useProgram(noiseProgram)
gl.bindVertexArray(quadVertexArray)
gl.drawArrays(gl.TRIANGLES, 0, 6)
gl.bindVertexArray(null)
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
gl.deleteProgram(noiseProgram)
noiseProgram = null
}
function setupQuadBuffer(gl: WebGL2RenderingContext): void {
const positionList = new Float32Array([
-1, -1,
1, -1,
-1, 1,
-1, 1,
1, -1,
1, 1,
])
quadVertexArray = gl.createVertexArray()
gl.bindVertexArray(quadVertexArray)
quadVertexBuffer = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, quadVertexBuffer)
gl.bufferData(gl.ARRAY_BUFFER, positionList, gl.STATIC_DRAW)
gl.enableVertexAttribArray(0)
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0)
gl.bindVertexArray(null)
}
// ----- Pointer events -----
function handlePointerMove(event: PointerEvent): void {
const canvas = canvasRef.value
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const devicePixelRatio = window.devicePixelRatio || 1
mouseX = (event.clientX - rect.left) * devicePixelRatio
mouseY = (event.clientY - rect.top) * devicePixelRatio
isMouseInside = true
}
function handlePointerLeave(): void {
isMouseInside = false
}
// ----- Init and render -----
function initWebGL(): void {
const canvas = canvasRef.value
if (!canvas) return
const devicePixelRatio = window.devicePixelRatio || 1
const displayWidth = canvas.clientWidth
const displayHeight = canvas.clientHeight
canvas.width = displayWidth * devicePixelRatio
canvas.height = displayHeight * devicePixelRatio
glContext = canvas.getContext('webgl2')
if (!glContext) {
console.error('WebGL2 not supported')
return
}
const extColorFloat = glContext.getExtension('EXT_color_buffer_float')
if (!extColorFloat) {
console.error('EXT_color_buffer_float not supported')
return
}
physicsProgram = linkProgram(glContext, physicsVertexSource, physicsFragmentSource)
renderProgram = linkProgram(glContext, renderVertexSource, renderFragmentSource)
if (!physicsProgram || !renderProgram) return
setupQuadBuffer(glContext)
generateNoiseTexture(glContext)
const sampledParticleList = sampleParticleListFromText(
DISPLAY_TEXT,
canvas.width,
canvas.height
)
setupParticleTextures(glContext, sampledParticleList)
glContext.enable(glContext.BLEND)
glContext.blendFuncSeparate(
glContext.SRC_ALPHA,
glContext.ONE_MINUS_SRC_ALPHA,
glContext.ONE,
glContext.ONE_MINUS_SRC_ALPHA
)
startTime = performance.now()
canvas.addEventListener('pointermove', handlePointerMove)
canvas.addEventListener('pointerleave', handlePointerLeave)
}
function render(): void {
if (!glContext || !physicsProgram || !renderProgram) return
const canvas = canvasRef.value
if (!canvas) return
const gl = glContext
const currentTime = (performance.now() - startTime) / 1000
const readIndex = pingPongIndex
const writeIndex = 1 - pingPongIndex
// ----- Physics pass -----
gl.disable(gl.BLEND)
gl.bindFramebuffer(gl.FRAMEBUFFER, framebufferList[writeIndex])
gl.viewport(0, 0, textureSize, textureSize)
gl.useProgram(physicsProgram)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, stateTextureList[readIndex])
gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uState'), 0)
gl.activeTexture(gl.TEXTURE1)
gl.bindTexture(gl.TEXTURE_2D, originTexture)
gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uOrigin'), 1)
gl.activeTexture(gl.TEXTURE2)
gl.bindTexture(gl.TEXTURE_2D, noiseTexture)
gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uNoise'), 2)
gl.uniform2f(gl.getUniformLocation(physicsProgram, 'uMouse'), mouseX, mouseY)
gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uMouseInside'), isMouseInside ? 1 : 0)
gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uScatterRadius'), 60.0)
gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uScatterForce'), 40.0)
gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uReturnSpeed'), 0.1)
gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uFriction'), 0.92)
gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uTime'), currentTime)
gl.uniform2f(
gl.getUniformLocation(physicsProgram, 'uCanvasSize'),
canvas.width,
canvas.height
)
gl.bindVertexArray(quadVertexArray)
gl.drawArrays(gl.TRIANGLES, 0, 6)
gl.bindVertexArray(null)
pingPongIndex = writeIndex
// ----- Render pass -----
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
gl.viewport(0, 0, canvas.width, canvas.height)
gl.clearColor(0, 0, 0, 1)
gl.clear(gl.COLOR_BUFFER_BIT)
gl.enable(gl.BLEND)
gl.blendFuncSeparate(
gl.SRC_ALPHA,
gl.ONE_MINUS_SRC_ALPHA,
gl.ONE,
gl.ONE_MINUS_SRC_ALPHA
)
gl.useProgram(renderProgram)
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, stateTextureList[writeIndex])
gl.uniform1i(gl.getUniformLocation(renderProgram, 'uState'), 0)
gl.activeTexture(gl.TEXTURE1)
gl.bindTexture(gl.TEXTURE_2D, originTexture)
gl.uniform1i(gl.getUniformLocation(renderProgram, 'uOrigin'), 1)
gl.uniform1i(gl.getUniformLocation(renderProgram, 'uTextureSize'), textureSize)
gl.uniform2f(
gl.getUniformLocation(renderProgram, 'uResolution'),
canvas.width,
canvas.height
)
gl.uniform1f(gl.getUniformLocation(renderProgram, 'uTime'), currentTime)
gl.uniform3f(gl.getUniformLocation(renderProgram, 'uColor'), 0.53, 0.53, 0.53)
gl.drawArrays(gl.POINTS, 0, textureSize * textureSize)
animationFrameId = requestAnimationFrame(render)
}
onMounted(() => {
initWebGL()
animationFrameId = requestAnimationFrame(render)
})
onBeforeUnmount(() => {
cancelAnimationFrame(animationFrameId)
const canvas = canvasRef.value
if (canvas) {
canvas.removeEventListener('pointermove', handlePointerMove)
canvas.removeEventListener('pointerleave', handlePointerLeave)
}
if (glContext) {
if (physicsProgram) glContext.deleteProgram(physicsProgram)
if (renderProgram) glContext.deleteProgram(renderProgram)
for (const texture of stateTextureList) {
if (texture) glContext.deleteTexture(texture)
}
if (originTexture) glContext.deleteTexture(originTexture)
if (noiseTexture) glContext.deleteTexture(noiseTexture)
for (const framebuffer of framebufferList) {
if (framebuffer) glContext.deleteFramebuffer(framebuffer)
}
if (noiseFramebuffer) glContext.deleteFramebuffer(noiseFramebuffer)
if (quadVertexBuffer) glContext.deleteBuffer(quadVertexBuffer)
if (quadVertexArray) glContext.deleteVertexArray(quadVertexArray)
}
})
</script>
<template>
<div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6">
<p class="text-sm text-gray-600">
多層發光效果:位移漸層、速度亮度、核心高光與柔和光暈
</p>
<canvas
ref="canvasRef"
class="w-full rounded-lg"
style="height: 320px;"
/>
</div>
</template>亮度取決於兩個因子
- 離家多遠(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 的漸變太平均了。三層分別控制不同距離帶的亮度曲線,近距微微亮、中距明顯亮、遠距爆亮,層次更豐富。
暖白漸變
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 物理 | Fragment Shader 計算 |
| 16 | Vertex Shader | gl_VertexID、clip space |
| 17 | 圓形粒子 | gl_PointCoord、Alpha Blending |
| 18 | Curl Noise | FBM、預計算噪聲紋理 |
| 19 | 多層發光 | displacement 亮度、暖白漸變 |
從最簡單的 Canvas 2D 一路到 WebGL2 Shader,核心物理邏輯其實沒變太多,變的是「誰來算」。CPU 一個一個慢慢來,GPU 大家一起衝,就這樣而已。
完整元件請見:蟲群文字 🐟
以上如有任何錯誤,還請各位大大多多指教。
感謝您讀到這裡,如果您覺得有收穫,歡迎分享出去。◝( •ω• )◟