每條游來游去的小魚,都是正在瀏覽這一頁的訪客,有一條是你自己 ( ´ ▽ ` )ノ 覺得干擾閱讀的話,可以把小魚關掉。
蟲群文字
step-by-step完成品請見 蟲群文字元件。
文字化為蟲群,滑鼠靠近時會散開,離開後重新聚集。
路人:「數大便是美的概念?(ゝ∀・)b」
鱈魚:「哈哈大便!ᕕ( ゚ ∀。)ᕗ 」
路人:「你小學生喔?...(。-`ω´-)」
讓我們從最基本的 Canvas 2D 開始,先在 CPU 把整個效果做出來,然後再一步步搬到 WebGL2 Shader 上。
每一步只加入一個新概念,讓我們一步一腳印地理解。
Step 1:在 Canvas 上畫文字
Section titled “Step 1:在 Canvas 上畫文字”萬事起頭難,先從一塊空白 Canvas 和一行文字開始吧。( ´ ▽ ` )ノ
<script setup lang="ts">import { ref, onMounted, onBeforeUnmount } from 'vue'
const canvasRef = ref<HTMLCanvasElement | null>(null)
const CANVAS_HEIGHT = 200const 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
Section titled “Canvas 2D Context”Canvas 是 HTML5 提供的畫布元素,可以用 JavaScript 在上面畫任何東西。第一步是取得 2D 繪圖上下文:
const canvas = document.querySelector('canvas')const ctx = canvas.getContext('2d')ctx 就是我們的畫布,所有繪圖操作都在它身上進行。
fillText 繪製文字
Section titled “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 匹配
Section titled “DPR 匹配”在高解析度螢幕(如 Retina)上,Canvas 預設會模糊。
解法是把實際像素放大:
const dpr = window.devicePixelRatio || 1canvas.width = logicalWidth * dprcanvas.height = logicalHeight * dprcanvas.style.width = `${logicalWidth}px`canvas.style.height = `${logicalHeight}px`canvas.width 是實際像素大小,canvas.style.width 是 CSS 顯示大小。兩者不同就能在高 DPR 螢幕上保持清晰。
左邊沒有匹配 DPR,右邊有,若 DPR 更高差異會更明顯:
Step 2:像素取樣,從文字生出粒子
Section titled “Step 2:像素取樣,從文字生出粒子”上一步畫了文字,但我們的目標不是顯示文字,而是把文字「打碎」成一堆粒子。
這一步的核心概念很簡單,掃描整個 canvas 每個像素,有顏色的地方就生成一個粒子。
粒子數量:0
<script setup lang="ts">import { ref, onMounted, onBeforeUnmount } from 'vue'
interface Particle { x: number y: number}
const canvasRef = ref<HTMLCanvasElement | null>(null)const particleCount = ref(0)
const CANVAS_HEIGHT = 200const 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:拿到所有像素
Section titled “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 = 3const 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 的取捨
Section titled “gap 的取捨”gap 決定了粒子密度。gap 越小,粒子越多,效果越細緻,但效能壓力也越大。
| gap | 粒子數量(概估) | 效果 |
|---|---|---|
| 1 | 超多 | 幾乎還原原始文字,可能超卡 |
| 3 | 中等 | 看得出文字形狀,效能 OK |
| 6 | 較少 | 有點稀疏但跑起來很順 |
Step 3:Canvas 2D 粒子渲染
Section titled “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 = 200const 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 動畫循環
Section titled “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?
Section titled “為什麼用 requestAnimationFrame 而不是 setInterval?”requestAnimationFrame | setInterval(fn, 16) | |
|---|---|---|
| 觸發時機 | 瀏覽器準備重繪時 | 固定間隔,不管瀏覽器忙不忙 |
| 頁面切到背景 | 自動暫停,省 CPU | 繼續跑,白白浪費資源 |
| 螢幕刷新率適配 | 自動配合(60Hz、120Hz、144Hz) | 永遠 16ms,高刷螢幕浪費、低刷螢幕卡頓 |
| 動畫流暢度 | 跟 vsync 同步,不會撕裂 | 可能跟重繪錯開,造成畫面抖動 |
requestAnimationFrame 會跟瀏覽器的重繪週期同步。
setInterval 不管瀏覽器有沒有準備好就硬塞,可能重繪前算了兩次(浪費),也可能兩次重繪之間都沒算到(掉幀)。
實際開發動畫使用 setInterval 幾乎沒有好處。乁( ◔ ௰◔)「
fillRect vs arc
Section titled “fillRect vs arc”畫粒子有兩種常見做法:
fillRect(x, y, size, size):畫方形,效能好arc(x, y, radius, 0, Math.PI * 2)+fill():畫圓形,好看但每次都要beginPath
粒子很小的時候其實方形和圓形看起來差不多,所以 Canvas 2D 版本用 fillRect 就夠了。◝( •ω• )◟
路人:「所以畫面怎麼不會動?」
鱈魚:「請付費解...ヽ(́◕◞౪◟◕‵)ノ」
路人:(抽出生魚片刀)
鱈魚:「因為還沒加入靈魂 ヽ(́⊙◞౪◟⊙‵)ノ」
Step 4:速度與摩擦力
Section titled “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 = 200const TEXT_CONTENT = 'SWARM'const SAMPLE_GAP = 3const FRICTION = 0.92const 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>粒子資料結構
Section titled “粒子資料結構”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 實際衰減多快?
Section titled “摩擦力 0.92 實際衰減多快?”| 幀數 | 剩餘速度 |
|---|---|
| 0 | 100% |
| 10 | 43% |
| 30 | 8% |
| 60 | 0.6% |
大概半秒(30 幀)速度就只剩不到 10%,視覺上已經感覺快停了。不過還保留一點殘餘速度,讓收尾有飄飄的感覺。
Step 5:回歸原位
Section titled “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 = 200const TEXT_CONTENT = 'SWARM'const SAMPLE_GAP = 3const FRICTION = 0.92const SCATTER_FORCE = 15const 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(線性內插)
Section titled “Lerp(線性內插)”particle.x += (particle.originX - particle.x) * returnSpeedparticle.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:追蹤滑鼠位置
Section titled “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 = 200const TEXT_CONTENT = 'SWARM'const SAMPLE_GAP = 3const FRICTION = 0.92const RETURN_SPEED = 0.1const SCATTER_RADIUS = 60
let particleList: Particle[] = []let animationFrameId = 0let 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
Section titled “Pointer Events”let pointerX = -9999let 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?
Section titled “為什麼用 -9999 而不是 null?”離開時把座標設成 -9999 而不是 null,好處是後續計算不需要做 null check。-9999 離任何粒子都超過 scatterRadius,自然不會觸發任何推力。省一個 if 少一個分支。
pointerX = event.clientX - rect.leftpointerY = event.clientY - rect.topevent.clientX 是滑鼠相對於瀏覽器視窗的座標,減掉 Canvas 左上角的偏移 rect.left,就得到滑鼠在 Canvas 內部的座標。
Step 7:徑向推力,吹散粒子
Section titled “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 = 200const TEXT_CONTENT = 'SWARM'const SAMPLE_GAP = 3const FRICTION = 0.92const RETURN_SPEED = 0.1const SCATTER_RADIUS = 60
let particleList: Particle[] = []let animationFrameId = 0let 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 - pointerXconst dy = particle.y - pointerYconst 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:風力,讓推力有方向感
Section titled “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 = 150const TEXT_CONTENT = 'SWARM'const SAMPLE_GAP = 3const FRICTION = 0.92const RETURN_SPEED = 0.1const SCATTER_RADIUS = 50const SCATTER_FORCE = 2
let leftParticleList: Particle[] = []let rightParticleList: Particle[] = []let animationFrameId = 0let canvasWidth = 0
// 左邊(只有徑向)let leftPointerX = -9999let leftPointerY = -9999
// 右邊(徑向 + 風力)let rightPointerX = -9999let rightPointerY = -9999let rightPrevPointerX = -9999let rightPrevPointerY = -9999let rightPointerVelocityX = 0let 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>快速水平劃過兩邊的文字,左邊粒子只會四散,右邊粒子會順著滑鼠方向飛走。
滑鼠速度追蹤
Section titled “滑鼠速度追蹤”let previousPointerX = -9999let previousPointerY = -9999let pointerVelocityX = 0let 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成正比:滑越快風越大
風力與徑向推力的差別
Section titled “風力與徑向推力的差別”| 力 | 方向 | 效果 |
|---|---|---|
| 徑向推力 | 從滑鼠向外 | 粒子四散 |
| 風力 | 順著滑鼠移動方向 | 粒子被「吹」走 |
兩者加在一起,滑鼠慢慢靠近時粒子往外推開,快速劃過時粒子順著風向飛走。=͟͟͞͞( •̀д•́)
pointerVelocityX *= 0.92pointerVelocityY *= 0.92滑鼠速度也要加摩擦力。不加的話,滑鼠停下來後速度會殘留,粒子還在被吹動。
Step 9:飄盪回歸
Section titled “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 = 150const TEXT_CONTENT = 'SWARM'const SAMPLE_GAP = 3const FRICTION = 0.92const RETURN_SPEED = 0.1
let leftParticleList: Particle[] = []let rightParticleList: Particle[] = []let animationFrameId = 0let 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.PIconst driftForce = 0.3
particle.velocityX += Math.cos(driftAngle) * driftForceparticle.velocityY += Math.sin(driftAngle) * driftForce每個粒子都有獨立的 randomSeed(初始化時隨機生成),讓它們的飄移方向不同步。time 讓方向隨時間變化,sin 確保方向是平滑旋轉而不是亂跳。
離家近的粒子不該飄
Section titled “離家近的粒子不該飄”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,飄移力才完全發揮。
這個細節很重要。不加這個判斷的話,粒子在原位也會不停抖動,文字永遠看不清楚。
每個粒子的個性
Section titled “每個粒子的個性”最終元件裡,每個粒子的摩擦力和回歸速度都有些微差異:
const particleFriction = friction - Math.random() * 0.04const returnRate = returnSpeed * (0.6 + Math.random() * 0.8)這讓粒子群不會整齊劃一地動作,看起來更像一群各有性格的小蟲子,而不是同步的機器人。ヾ(◍'౪`◍)ノ゙
Step 10:CPU 的效能瓶頸
Section titled “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 = 200const TEXT_CONTENT = 'SWARM'const FRICTION = 0.92const RETURN_SPEED = 0.1const SCATTER_RADIUS = 60
const sampleGap = ref(3)const particleCount = ref(0)const currentFps = ref(0)
let particleList: Particle[] = []let animationFrameId = 0let canvasWidth = 0
let pointerX = 0let pointerY = 0let isPointerInside = false
let previousPointerX = 0let previousPointerY = 0let pointerVelocityX = 0let pointerVelocityY = 0
let frameCount = 0let lastFpsTime = performance.now()
function sampleTextParticleList( width: number, height: number, gap: number,): Particle[] { const offscreenCanvas = document.createElement('canvas') offscreenCanvas.width = width offscreenCanvas.height = height
const offscreenContext = offscreenCanvas.getContext('2d') if (!offscreenContext) return []
offscreenContext.fillStyle = '#ffffff' offscreenContext.font = 'bold 80px sans-serif' offscreenContext.textAlign = 'center' offscreenContext.textBaseline = 'middle' offscreenContext.fillText(TEXT_CONTENT, width / 2, height / 2)
const imageData = offscreenContext.getImageData(0, 0, width, height) const pixelDataList = imageData.data
const result: Particle[] = []
for (let y = 0; y < height; y += gap) { for (let x = 0; x < width; x += gap) { const pixelX = Math.floor(x) const pixelY = Math.floor(y) const index = (pixelY * width + pixelX) * 4 const alpha = pixelDataList[index + 3] if (alpha !== undefined && alpha > 128) { result.push({ x, y, originX: x, originY: y, velocityX: 0, velocityY: 0, randomSeed: Math.random() * Math.PI * 2, }) } } }
return result}
function initParticleList() { const canvas = canvasRef.value if (!canvas) return
const devicePixelRatio = window.devicePixelRatio || 1 canvasWidth = canvas.clientWidth
canvas.style.height = `${CANVAS_HEIGHT}px` canvas.width = canvasWidth * devicePixelRatio canvas.height = CANVAS_HEIGHT * devicePixelRatio
particleList = sampleTextParticleList( canvasWidth, CANVAS_HEIGHT, sampleGap.value, ) particleCount.value = particleList.length}
function updateParticleList() { const time = performance.now()
// Decay pointer velocity each frame pointerVelocityX *= 0.92 pointerVelocityY *= 0.92
const currentMouseSpeed = Math.sqrt( pointerVelocityX * pointerVelocityX + pointerVelocityY * pointerVelocityY, )
for (const particle of particleList) { // 每粒子隨機力道係數(參考 GPU shader 的 forceScale) const forceScale = 0.5 + (Math.sin(particle.randomSeed * 100) * 0.5 + 0.5)
if (isPointerInside) { const deltaX = particle.x - pointerX const deltaY = particle.y - pointerY const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY)
if (distance < SCATTER_RADIUS && distance > 0) { // smoothstep 風格衰減(參考 GPU shader) const ratio = distance / SCATTER_RADIUS const influence = 1 - ratio * ratio * (3 - 2 * ratio)
const directionX = deltaX / distance const directionY = deltaY / distance
// 徑向推力(參考 GPU: influence² × scatterForce × 0.5) const radialForce = influence * influence * 8 * forceScale particle.velocityX += directionX * radialForce particle.velocityY += directionY * radialForce
// 風力(參考 GPU: mouseSpeed × influence × scatterForce × 0.025) if (currentMouseSpeed > 0.5) { particle.velocityX += pointerVelocityX * influence * 0.8 * forceScale particle.velocityY += pointerVelocityY * influence * 0.8 * forceScale } } }
// 飄盪效果(參考 step 9) const displacementX = particle.x - particle.originX const displacementY = particle.y - particle.originY const displacement = Math.sqrt( displacementX * displacementX + displacementY * displacementY, )
if (displacement > 1) { const driftScale = Math.min(displacement / 40, 1) const driftAngle = Math.sin( time * 0.001 + particle.randomSeed, ) * Math.PI particle.velocityX += Math.cos(driftAngle) * driftScale * 1.2 * forceScale particle.velocityY += Math.sin(driftAngle) * driftScale * 1.2 * forceScale }
// 回歸原位 const distanceX = particle.originX - particle.x const distanceY = particle.originY - particle.y
particle.velocityX += distanceX * RETURN_SPEED particle.velocityY += distanceY * RETURN_SPEED
particle.velocityX *= FRICTION particle.velocityY *= FRICTION
particle.x += particle.velocityX particle.y += particle.velocityY }}
function render() { const canvas = canvasRef.value if (!canvas) return
const devicePixelRatio = window.devicePixelRatio || 1 const context = canvas.getContext('2d') if (!context) return
context.save() context.scale(devicePixelRatio, devicePixelRatio)
context.fillStyle = '#111827' context.fillRect(0, 0, canvasWidth, CANVAS_HEIGHT)
context.fillStyle = '#9ca3af' for (const particle of particleList) { context.beginPath() context.arc(particle.x, particle.y, 1, 0, Math.PI * 2) context.fill() }
context.restore()}
function animationLoop() { updateParticleList() render()
// FPS calculation frameCount++ const now = performance.now() const elapsed = now - lastFpsTime
if (elapsed >= 1000) { currentFps.value = Math.round((frameCount * 1000) / elapsed) frameCount = 0 lastFpsTime = now }
animationFrameId = requestAnimationFrame(animationLoop)}
function handlePointerMove(event: PointerEvent) { const canvas = canvasRef.value if (!canvas) return
const rect = canvas.getBoundingClientRect() const currentX = event.clientX - rect.left const currentY = event.clientY - rect.top
if (isPointerInside) { pointerVelocityX = currentX - previousPointerX pointerVelocityY = currentY - previousPointerY }
previousPointerX = currentX previousPointerY = currentY pointerX = currentX pointerY = currentY isPointerInside = true}
function handlePointerLeave() { isPointerInside = false}
function handleResize() { initParticleList()}
watch(sampleGap, () => { initParticleList()})
onMounted(() => { initParticleList() animationFrameId = requestAnimationFrame(animationLoop) window.addEventListener('resize', handleResize)})
onBeforeUnmount(() => { cancelAnimationFrame(animationFrameId) window.removeEventListener('resize', handleResize)})</script>
<template> <div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6"> <div class="flex items-center gap-4 text-sm text-gray-500"> <label class="flex items-center gap-2"> 粒子間距 (gap) <input v-model.number="sampleGap" type="range" min="0.5" max="8" step="0.5" class="w-32" > <span class="w-4 text-center">{{ sampleGap }}</span> </label> </div>
<p class="text-sm text-gray-500"> 粒子數量:{{ particleCount }}|FPS:{{ currentFps }} </p>
<canvas ref="canvasRef" class="w-full" @pointermove="handlePointerMove" @pointerleave="handlePointerLeave" /> </div></template>每幀 CPU 要做多少事?
Section titled “每幀 CPU 要做多少事?”假設有 5000 個粒子,每幀的工作量:
for 5000 個粒子: 1. 計算滑鼠距離 → 2 次減法 + 1 次開根號 2. 風力計算 → 若干乘法 3. 摩擦力 → 2 次乘法 4. 回歸力 → 4 次加減乘 5. 更新位置 → 2 次加法 6. fillRect → 1 次 Canvas API 呼叫每個粒子約 15~20 個數學運算 + 1 次繪圖呼叫。
5000 個粒子就是 10 萬次運算 + 5000 次 fillRect。
60fps 代表每幀只有 16.6ms 的時間預算。Canvas 2D 的 fillRect 開銷不小(每次都要走一趟渲染管線),5000 次呼叫可能就吃掉大半時間。
瓶頸在哪裡?
Section titled “瓶頸在哪裡?”其實數學運算不是問題,CPU 做浮點運算很快,十萬次也只要幾毫秒。
真正的瓶頸是 Canvas 2D API 的繪圖呼叫。
每次 fillRect 都是一次獨立的繪圖指令,沒辦法批次處理。
粒子越多,繪圖呼叫越多,瀏覽器越吃力。
接下來讓我們會把物理計算和渲染全部搬到 GPU 上。
CPU 只負責傳滑鼠座標等少量資料,其他全交給 Shader。
來體驗一下 GPU 平行運算的威力。( •̀ ω •́ )✧
Step 11:WebGL2 基礎,GPU 思維
Section titled “Step 11:WebGL2 基礎,GPU 思維”開始寫 Shader 之前,我們需要先了解 GPU 的思考方式。不用怕,其實核心概念不複雜。
下面這個範例讓你親手比較 Canvas 2D(CPU)和 WebGL2(GPU)的渲染耗時。
選好粒子數量後按下測試,感受一下差異:
點擊開始後,兩邊各連續渲染 120 幀相同粒子,CPU 在 Worker 中執行不阻塞頁面。
<template> <div class="benchmark-root"> <!-- 控制列 --> <div class="benchmark-controls"> <label class="benchmark-slider-group"> <span class="benchmark-label">粒子數量</span> <input v-model.number="particleCountLevel" type="range" :min="0" :max="PARTICLE_COUNT_LIST.length - 1" step="1" class="benchmark-slider" :disabled="phase !== 'idle'" > <span class="benchmark-count"> {{ particleCountLabel }} </span> </label>
<button class="benchmark-button" :class="{ 'is-running': phase !== 'idle' }" :disabled="phase !== 'idle'" @click="runBenchmark" > <template v-if="phase === 'idle'"> ▶ 開始測試 </template> <template v-else-if="phase === 'cpu'"> CPU 測試中⋯ </template> <template v-else> GPU 測試中⋯ </template> </button> </div>
<!-- Canvas 區 --> <div class="benchmark-canvas-row"> <div class="benchmark-canvas-box"> <div class="benchmark-canvas-label"> <span class="benchmark-dot dot-cpu" /> Canvas 2D(CPU) </div> <canvas ref="cpuCanvasRef" class="benchmark-canvas" /> </div> <div class="benchmark-canvas-box"> <div class="benchmark-canvas-label"> <span class="benchmark-dot dot-gpu" /> WebGL2(GPU) </div> <canvas ref="gpuCanvasRef" class="benchmark-canvas" /> </div> </div>
<!-- 結果 --> <div class="benchmark-result"> <div class="benchmark-bars"> <div class="benchmark-bar-row"> <span class="benchmark-bar-label">CPU</span> <div class="benchmark-bar-track"> <div class="benchmark-bar-fill bar-cpu" :style="{ width: cpuBarWidth }" /> </div> <span class="benchmark-bar-value"> {{ displayResult.cpuAverage }} ms </span> </div> <div class="benchmark-bar-row"> <span class="benchmark-bar-label">GPU</span> <div class="benchmark-bar-track"> <div class="benchmark-bar-fill bar-gpu" :style="{ width: gpuBarWidth }" /> </div> <span class="benchmark-bar-value"> {{ displayResult.gpuAverage }} ms </span> </div> </div>
<div class="benchmark-speedup"> GPU 快了約 <strong>{{ displayResult.speedup }}×</strong> </div> </div>
<p class="benchmark-hint"> 點擊開始後,兩邊各連續渲染 {{ BENCHMARK_FRAME_COUNT }} 幀相同粒子,CPU 在 Worker 中執行不阻塞頁面。 </p> </div></template>
<script setup lang="ts">import { computed, onBeforeUnmount, onMounted, ref, shallowRef,} from 'vue'
// ====== 常數 ======const CANVAS_HEIGHT = 150const PARTICLE_COUNT_LIST = [100, 1_000, 20_000]const BENCHMARK_FRAME_COUNT = 120
// ====== Refs ======const cpuCanvasRef = ref<HTMLCanvasElement | null>(null)const gpuCanvasRef = ref<HTMLCanvasElement | null>(null)
const particleCountLevel = ref(1)const particleCountLabel = computed( () => `${PARTICLE_COUNT_LIST[particleCountLevel.value]?.toLocaleString() ?? 0} 個`,)
type BenchmarkPhase = 'idle' | 'cpu' | 'gpu'const phase = ref<BenchmarkPhase>('idle')
interface BenchmarkResult { cpuAverage: string; gpuAverage: string; speedup: string; /** 0~1,CPU 佔比 */ cpuRatio: number; /** 0~1,GPU 佔比 */ gpuRatio: number;}const benchmarkResult = shallowRef<BenchmarkResult | null>(null)
const DEFAULT_RESULT: BenchmarkResult = { cpuAverage: '-', gpuAverage: '-', speedup: '-', cpuRatio: 0, gpuRatio: 0,}
const displayResult = computed( () => benchmarkResult.value ?? DEFAULT_RESULT,)
const cpuBarWidth = computed( () => `${(displayResult.value.cpuRatio * 100).toFixed(1)}%`,)const gpuBarWidth = computed( () => `${(displayResult.value.gpuRatio * 100).toFixed(1)}%`,)
// ====== 內部狀態 ======let canvasWidth = 0let aborted = false
// GPU 資源let gl: WebGL2RenderingContext | null = nulllet gpuProgram: WebGLProgram | null = nulllet gpuPositionBuffer: WebGLBuffer | null = nulllet gpuResolutionLoc: WebGLUniformLocation | null = nulllet gpuPositionLoc = -1
// Workerlet cpuWorker: Worker | null = null
// ====== Worker 原始碼 ======const WORKER_SOURCE = /* js */ `let canvas = nulllet ctx = null
self.onmessage = function (event) { const msg = event.data
if (msg.type === 'init') { canvas = msg.canvas canvas.width = msg.width canvas.height = msg.height ctx = canvas.getContext('2d') return }
if (msg.type === 'resize') { if (!canvas) return canvas.width = msg.width canvas.height = msg.height ctx = canvas.getContext('2d') return }
if (msg.type === 'benchmark') { if (!ctx || !canvas) { self.postMessage({ type: 'result', totalTime: -1 }) return }
const positionList = new Float32Array(msg.positionBuffer) const count = msg.count const frameCount = msg.frameCount const width = canvas.width const height = canvas.height
// 暖機 2 幀 for (let w = 0; w < 2; w++) { ctx.fillStyle = '#111827' ctx.fillRect(0, 0, width, height) ctx.fillStyle = '#9ca3af' for (let i = 0; i < count; i++) { ctx.fillRect(positionList[i * 2], positionList[i * 2 + 1], 2, 2) } }
// 正式測試 const start = performance.now() for (let f = 0; f < frameCount; f++) { ctx.fillStyle = '#111827' ctx.fillRect(0, 0, width, height) ctx.fillStyle = '#9ca3af' for (let i = 0; i < count; i++) { ctx.fillRect(positionList[i * 2], positionList[i * 2 + 1], 2, 2) } } const totalTime = performance.now() - start
self.postMessage({ type: 'result', totalTime }) }}`
// ====== Shader ======const VERT_SOURCE = `#version 300 esin vec2 aPosition;uniform vec2 uResolution;
void main() { vec2 clip = (aPosition / uResolution) * 2.0 - 1.0; clip.y = -clip.y; gl_Position = vec4(clip, 0.0, 1.0); gl_PointSize = 2.0;}`
const FRAG_SOURCE = `#version 300 esprecision highp float;out vec4 fragColor;
void main() { fragColor = vec4(0.61, 0.64, 0.69, 1.0);}`
// ====== 工具函式 ======function compileShader( glCtx: WebGL2RenderingContext, type: number, source: string,): WebGLShader | null { const shader = glCtx.createShader(type) if (!shader) return null glCtx.shaderSource(shader, source) glCtx.compileShader(shader) if (!glCtx.getShaderParameter(shader, glCtx.COMPILE_STATUS)) { console.error(glCtx.getShaderInfoLog(shader)) glCtx.deleteShader(shader) return null } return shader}
function generatePositionList(count: number): Float32Array { const list = new Float32Array(count * 2) for (let i = 0; i < count; i++) { list[i * 2] = Math.random() * canvasWidth list[i * 2 + 1] = Math.random() * CANVAS_HEIGHT } return list}
// ====== GPU 渲染 ======function renderGpuFrame(count: number) { if (!gl || !gpuProgram || !gpuPositionBuffer) return const canvas = gpuCanvasRef.value if (!canvas) return
gl.viewport(0, 0, canvas.width, canvas.height) gl.clearColor(0.067, 0.094, 0.153, 1.0) gl.clear(gl.COLOR_BUFFER_BIT)
gl.useProgram(gpuProgram) gl.uniform2f(gpuResolutionLoc, canvasWidth, CANVAS_HEIGHT)
gl.bindBuffer(gl.ARRAY_BUFFER, gpuPositionBuffer) gl.enableVertexAttribArray(gpuPositionLoc) gl.vertexAttribPointer(gpuPositionLoc, 2, gl.FLOAT, false, 0, 0)
gl.drawArrays(gl.POINTS, 0, count) gl.finish()}
// ====== 初始化 ======function initGpu() { const canvas = gpuCanvasRef.value if (!canvas) return
gl = canvas.getContext('webgl2', { alpha: false }) if (!gl) return
const vert = compileShader(gl, gl.VERTEX_SHADER, VERT_SOURCE) const frag = compileShader(gl, gl.FRAGMENT_SHADER, FRAG_SOURCE) if (!vert || !frag) return
gpuProgram = gl.createProgram() if (!gpuProgram) return
gl.attachShader(gpuProgram, vert) gl.attachShader(gpuProgram, frag) gl.linkProgram(gpuProgram) gl.deleteShader(vert) gl.deleteShader(frag)
gpuPositionBuffer = gl.createBuffer() gl.useProgram(gpuProgram) gpuResolutionLoc = gl.getUniformLocation(gpuProgram, 'uResolution') gpuPositionLoc = gl.getAttribLocation(gpuProgram, 'aPosition')}
function initWorker() { const cpuCanvas = cpuCanvasRef.value if (!cpuCanvas) return
const blob = new Blob([WORKER_SOURCE], { type: 'application/javascript' }) const url = URL.createObjectURL(blob) cpuWorker = new Worker(url) URL.revokeObjectURL(url)
const offscreen = cpuCanvas.transferControlToOffscreen() cpuWorker.postMessage( { type: 'init', canvas: offscreen, width: canvasWidth, height: CANVAS_HEIGHT }, [offscreen], )}
function setupCanvas(canvas: HTMLCanvasElement) { canvasWidth = canvas.clientWidth canvas.width = canvasWidth canvas.height = CANVAS_HEIGHT canvas.style.height = `${CANVAS_HEIGHT}px`}
// ====== CPU 基準測試(透過 Worker) ======function runCpuBenchmark( positionList: Float32Array, count: number,): Promise<number> { return new Promise((resolve) => { if (!cpuWorker) { resolve(-1) return }
const handler = (event: MessageEvent) => { if (event.data.type === 'result') { cpuWorker?.removeEventListener('message', handler) resolve(event.data.totalTime as number) } } cpuWorker.addEventListener('message', handler)
const buffer = positionList.buffer.slice(0) cpuWorker.postMessage( { type: 'benchmark', positionBuffer: buffer, count, frameCount: BENCHMARK_FRAME_COUNT, }, [buffer], ) })}
// ====== GPU 基準測試(每幀透過 rAF 分散,不阻塞主執行緒) ======function runGpuBenchmark(count: number): Promise<number> { return new Promise((resolve) => { if (!gl) { resolve(-1) return }
// 暖機 2 幀 renderGpuFrame(count) renderGpuFrame(count)
let frameIndex = 0 let totalTime = 0
function tick() { if (aborted) { resolve(-1) return }
const start = performance.now() renderGpuFrame(count) totalTime += performance.now() - start
frameIndex++ if (frameIndex < BENCHMARK_FRAME_COUNT) { requestAnimationFrame(tick) } else { resolve(totalTime) } }
requestAnimationFrame(tick) })}
// ====== 執行測試 ======async function runBenchmark() { if (!gl || !cpuWorker) return
phase.value = 'cpu' benchmarkResult.value = null aborted = false
const count = PARTICLE_COUNT_LIST[particleCountLevel.value] ?? 5_000 const positionList = generatePositionList(count)
// 上傳到 GPU buffer gl.bindBuffer(gl.ARRAY_BUFFER, gpuPositionBuffer) gl.bufferData(gl.ARRAY_BUFFER, positionList, gl.STATIC_DRAW)
// 等一幀讓 UI 更新 await new Promise((resolve) => requestAnimationFrame(resolve)) if (aborted) return
// --- CPU(Worker) --- const cpuTotal = await runCpuBenchmark(positionList, count) if (aborted) return
// --- GPU --- phase.value = 'gpu' await new Promise((resolve) => requestAnimationFrame(resolve)) if (aborted) return
const gpuTotal = await runGpuBenchmark(count) if (aborted || gpuTotal < 0) return
// --- 結果 --- const cpuAverage = cpuTotal / BENCHMARK_FRAME_COUNT const gpuAverage = gpuTotal / BENCHMARK_FRAME_COUNT const maxTime = Math.max(cpuAverage, gpuAverage) const speedup = gpuAverage > 0 ? cpuAverage / gpuAverage : 0
benchmarkResult.value = { cpuAverage: cpuAverage.toFixed(2), gpuAverage: gpuAverage.toFixed(2), speedup: speedup.toFixed(1), cpuRatio: maxTime > 0 ? cpuAverage / maxTime : 0, gpuRatio: maxTime > 0 ? gpuAverage / maxTime : 0, }
phase.value = 'idle'}
// ====== 生命週期 ======onMounted(() => { const cpuCanvas = cpuCanvasRef.value const gpuCanvas = gpuCanvasRef.value if (!cpuCanvas || !gpuCanvas) return
// GPU canvas 先 setup(取得 canvasWidth) setupCanvas(gpuCanvas) // CPU canvas 只設 style,實際 size 由 Worker 控制 cpuCanvas.style.height = `${CANVAS_HEIGHT}px`
initGpu() initWorker()})
onBeforeUnmount(() => { aborted = true cpuWorker?.terminate()
if (gl) { if (gpuProgram) gl.deleteProgram(gpuProgram) if (gpuPositionBuffer) gl.deleteBuffer(gpuPositionBuffer) }})</script>
<style scoped>.benchmark-root { --bar-cpu: #f59e0b; --bar-gpu: #22d3ee; --surface: #f8fafc; --surface-dark: #111827; --border: #e2e8f0; --text-1: #1e293b; --text-2: #64748b; --text-3: #94a3b8;
display: flex; flex-direction: column; gap: 16px; border: 1px solid var(--border); border-radius: 14px; padding: 24px; font-family: 'PingFang TC', 'Microsoft JhengHei', system-ui, sans-serif;}
/* ---- 控制列 ---- */.benchmark-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 12px;}
.benchmark-slider-group { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text-2);}
.benchmark-label { white-space: nowrap;}
.benchmark-slider { width: 96px; accent-color: var(--text-2);}
.benchmark-count { min-width: 72px; font-variant-numeric: tabular-nums; text-align: right;}
.benchmark-button { padding: 6px 16px; border: none; border-radius: 8px; font-size: 13px; font-weight: 600; cursor: pointer; background: var(--surface-dark); color: #fff; transition: background 0.15s, opacity 0.15s; line-height: 1.5;}.benchmark-button:hover:not(:disabled) { background: #374151;}.benchmark-button.is-running { background: var(--text-3); cursor: wait; animation: benchmark-pulse 1.6s ease-in-out infinite;}
@keyframes benchmark-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.6; }}
/* ---- Canvas ---- */.benchmark-canvas-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px;}
.benchmark-canvas-box { display: flex; flex-direction: column; gap: 6px;}
.benchmark-canvas-label { display: flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.03em;}
.benchmark-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%;}.dot-cpu { background: var(--bar-cpu); }.dot-gpu { background: var(--bar-gpu); }
.benchmark-canvas { width: 100%; border-radius: 8px; background: var(--surface-dark);}
/* ---- 結果 ---- */.benchmark-result { display: flex; flex-direction: column; gap: 12px; padding: 16px; background: var(--surface); border-radius: 10px;}
.benchmark-bars { display: flex; flex-direction: column; gap: 10px;}
.benchmark-bar-row { display: grid; grid-template-columns: 36px 1fr auto; align-items: center; gap: 10px;}
.benchmark-bar-label { font-size: 12px; font-weight: 700; color: var(--text-2); text-align: right;}
.benchmark-bar-track { height: 22px; border-radius: 6px; background: #e2e8f0; overflow: hidden;}
.benchmark-bar-fill { height: 100%; border-radius: 6px; transition: width 0.6s cubic-bezier(0.16, 1, 0.3, 1);}
.bar-cpu { background: linear-gradient(90deg, #f59e0b, #fbbf24);}.bar-gpu { background: linear-gradient(90deg, #06b6d4, #22d3ee);}
.benchmark-bar-value { min-width: 80px; text-align: right; font-size: 13px; font-weight: 600; font-variant-numeric: tabular-nums; color: var(--text-1);}
.benchmark-speedup { text-align: center; font-size: 13px; color: var(--text-2);}.benchmark-speedup strong { font-size: 18px; color: #0891b2;}
/* ---- 提示 ---- */.benchmark-hint { margin: 0; font-size: 12px; color: var(--text-3);}
/* ---- Dark mode(html.dark) ---- */.dark .benchmark-root { --surface: #1e293b; --border: #334155; --text-1: #f1f5f9; --text-2: #94a3b8; --text-3: #64748b;}.dark .benchmark-bar-track { background: #334155;}.dark .benchmark-speedup strong { color: #22d3ee;}</style>CPU vs GPU
Section titled “CPU vs GPU”CPU 就像一個很強的資優生,運算能力很強,但是只能一個一個慢慢來。
GPU 就像一群幫手小精靈,單體能力不強,不過大家一起上,效率超高。(*´ω`)人(´ω`*)
什麼是 Shader?
Section titled “什麼是 Shader?”Shader 是跑在 GPU 上的小程式,用 GLSL 寫。WebGL2 用的是 GLSL ES 3.0(#version 300 es)。
WebGL2 只有兩種 Shader:
| 類型 | 用途 | 執行頻率 |
|---|---|---|
| Vertex Shader | 決定每個頂點的位置 | 每個頂點跑一次 |
| Fragment Shader | 決定每個像素的顏色 | 每個像素跑一次 |
WebGPU / OpenGL 4.x+ / Vulkan / DirectX 還有其他 Shader 類型:
| 類型 | 用途 |
|---|---|
| Geometry Shader | 可以新增或刪除頂點,例如把一個點變成一個三角形 |
| Tessellation Shader | 把粗糙的網格細分成更多三角形,用於地形、曲面等 |
| Compute Shader | 通用 GPU 運算,不一定和圖形渲染有關(物理模擬、粒子系統等) |
| Mesh Shader | 較新的概念,取代 Vertex + Geometry 的組合,效能更好 |
蟲群文字用 GL_POINTS,每個粒子 = 一個頂點。gl_PointSize 決定每個頂點渲染成多大的方塊。所以:
- Vertex Shader 跑「粒子數量」次(決定每個粒子的位置)
- Fragment Shader 跑「粒子數量 × pointSize²」次(決定每個像素的顏色)
下面的圖解用 9 個粒子示範這個過程:
9 個粒子 → 跑 9 次
每個粒子 4×4 = 16 像素,共 144 次
gl_PointSize = 4.0 代表每個頂點渲染成 4×4 像素的方塊。 Vertex Shader 決定方塊的位置,Fragment Shader 決定方塊內每個像素的顏色。 取得 WebGL2 Context
Section titled “取得 WebGL2 Context”const gl = canvas.getContext('webgl2', { premultipliedAlpha: false, alpha: true,})跟 getContext('2d') 相同概念,不過多了一些設定:
premultipliedAlpha: false避免 alpha 預乘導致的混色問題alpha: true讓 canvas 支援透明背景。
Shader 與 Canvas 2D 最大的不同在於不能迴圈遍歷所有粒子,只能控制「自己這一個」。
沒有 for 迴圈跑全部粒子。每個粒子的 Shader 同時執行,各自只知道自己的資料,粒子之間沒辦法直接溝通。
那怎麼知道滑鼠在哪?用 Uniform 讓所有粒子共用同一份資料。(●'◡'●)
Step 12:Shader 編譯與 Uniform
Section titled “Step 12:Shader 編譯與 Uniform”WebGL 的 Shader 是純文字的 GLSL 程式碼,必須在執行時動態編譯。
先來看最簡單的例子:
畫一個圓,拖動滑桿可以改變半徑,半徑透過 Uniform 從 CPU 傳給 GPU。
<script setup lang="ts">import { ref, onMounted, onBeforeUnmount } from 'vue'
const canvasRef = ref<HTMLCanvasElement | null>(null)const radius = ref(30)
let glContext: WebGL2RenderingContext | null = nulllet program: WebGLProgram | null = nulllet vertexBuffer: WebGLBuffer | null = nulllet animationFrameId = 0
/** Vertex Shader:把全螢幕三角形的頂點直接傳到裁剪空間 */const vertexShaderSource = `#version 300 esin vec2 aPosition;
void main() { gl_Position = vec4(aPosition, 0.0, 1.0);}`
/** * Fragment Shader:每個像素各自執行一次 * 用 uResolution 把像素座標換算成 0~1 的 UV,再判斷是否在圓內 */const fragmentShaderSource = `#version 300 esprecision highp float;
uniform vec2 uResolution;uniform float uRadius;
out vec4 fragColor;
void main() { // 將像素座標正規化為 0~1 vec2 uv = gl_FragCoord.xy / uResolution;
// 圓心在畫面正中央 vec2 center = vec2(0.5, 0.5);
// 計算此像素到圓心的距離 float dist = length(uv - center);
// 半徑從百分比轉為 0~1 範圍 float r = uRadius / 100.0;
// smoothstep 讓邊緣柔和,避免鋸齒 float circle = smoothstep(r + 0.01, r - 0.01, dist);
// 圓的顏色 vec3 color = vec3(0.26, 0.52, 0.96) * circle;
fragColor = vec4(color, 1.0);}`
function compileShader( glContext: WebGL2RenderingContext, type: number, source: string): WebGLShader | null { const shader = glContext.createShader(type) if (!shader) return null glContext.shaderSource(shader, source) glContext.compileShader(shader) if (!glContext.getShaderParameter(shader, glContext.COMPILE_STATUS)) { console.error(glContext.getShaderInfoLog(shader)) glContext.deleteShader(shader) return null } return shader}
function linkProgram( glContext: WebGL2RenderingContext, vertSource: string, fragSource: string): WebGLProgram | null { const vert = compileShader(glContext, glContext.VERTEX_SHADER, vertSource) const frag = compileShader(glContext, glContext.FRAGMENT_SHADER, fragSource) if (!vert || !frag) return null const program = glContext.createProgram() if (!program) return null glContext.attachShader(program, vert) glContext.attachShader(program, frag) glContext.linkProgram(program) if (!glContext.getProgramParameter(program, glContext.LINK_STATUS)) { console.error(glContext.getProgramInfoLog(program)) glContext.deleteProgram(program) return null } glContext.deleteShader(vert) glContext.deleteShader(frag) return program}
function initWebGL(): void { const canvas = canvasRef.value if (!canvas) return
canvas.width = canvas.clientWidth canvas.height = canvas.clientHeight
glContext = canvas.getContext('webgl2') if (!glContext) { console.error('WebGL2 not supported') return }
program = linkProgram(glContext, vertexShaderSource, fragmentShaderSource) if (!program) return
// 全螢幕四邊形(兩個三角形) const positionList = new Float32Array([ -1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1, ])
vertexBuffer = glContext.createBuffer() glContext.bindBuffer(glContext.ARRAY_BUFFER, vertexBuffer) glContext.bufferData(glContext.ARRAY_BUFFER, positionList, glContext.STATIC_DRAW)
const positionLocation = glContext.getAttribLocation(program, 'aPosition') glContext.enableVertexAttribArray(positionLocation) glContext.vertexAttribPointer(positionLocation, 2, glContext.FLOAT, false, 0, 0)}
function render(): void { if (!glContext || !program) return
const canvas = canvasRef.value if (!canvas) return
canvas.width = canvas.clientWidth canvas.height = canvas.clientHeight glContext.viewport(0, 0, canvas.width, canvas.height)
glContext.useProgram(program)
const resolutionLocation = glContext.getUniformLocation(program, 'uResolution') const radiusLocation = glContext.getUniformLocation(program, 'uRadius')
glContext.uniform2f(resolutionLocation, canvas.width, canvas.height) glContext.uniform1f(radiusLocation, radius.value)
glContext.drawArrays(glContext.TRIANGLES, 0, 6)
animationFrameId = requestAnimationFrame(render)}
onMounted(() => { initWebGL() animationFrameId = requestAnimationFrame(render)})
onBeforeUnmount(() => { cancelAnimationFrame(animationFrameId)
if (glContext) { if (program) glContext.deleteProgram(program) if (vertexBuffer) glContext.deleteBuffer(vertexBuffer) glContext.getExtension('WEBGL_lose_context')?.loseContext() }})</script>
<template> <div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6"> <label class="flex items-center gap-2 text-sm text-gray-700"> 半徑:{{ radius }}% <input v-model.number="radius" type="range" min="5" max="50" step="1" class="flex-1" > </label>
<canvas ref="canvasRef" class="w-full rounded-lg" style="height: 320px;" /> </div></template>上一步提到 Vertex Shader 決定「頂點在哪」,Fragment Shader 決定「像素什麼顏色」。現在來看這兩段程式碼實際長什麼樣子。
不過先小小提醒一下,Step 11 說的是「每個粒子 = 一個頂點」,用 GL_POINTS 畫點。但這個範例的目的不是畫粒子,而是先學會 GLSL 語法。
這裡用頂點拼出一個覆蓋畫面的四邊形,讓 Fragment Shader 能在每個像素上執行,用數學畫出一個圓。
等到後面真正做粒子系統時,才會回到「一個頂點 = 一個粒子」的做法。
Vertex Shader:決定頂點位置
Section titled “Vertex Shader:決定頂點位置”#version 300 esin vec2 aPosition;
void main() { gl_Position = vec4(aPosition, 0.0, 1.0);}aPosition 是從 JavaScript 傳進來的頂點座標(Attribute),每個頂點跑一次。
同樣的頂點資料,drawArrays 的第一個參數決定 GPU 怎麼連接這些頂點。
WebGL 提供 7 種繪圖模式:
gl.POINTS:每個頂點畫一個點,大小由 gl_PointSize 決定。
gl.LINES:每 2 個頂點連一條線,多餘的丟掉。
gl.LINE_STRIP:依序連成一條折線。
gl.LINE_LOOP:跟 LINE_STRIP 一樣,但首尾會相連。
gl.TRIANGLES:每 3 個頂點組一個獨立三角形,最通用。需要 6 個頂點才能拼出四邊形。
gl.TRIANGLE_STRIP:每個新頂點和前兩個組成三角形,頂點共用。4 個頂點就能拼出四邊形。
gl.TRIANGLE_FAN:所有三角形共用第一個頂點,像扇子展開。適合畫扇形、圓形。
這個範例用 gl.TRIANGLES,所以要拼出一個覆蓋整個畫面的四邊形,得拆成兩個三角形,共 6 個頂點:
對應到 JavaScript 就是:
const positionList = new Float32Array([ -1, -1, 1, -1, -1, 1, -1, 1, // 三角形 1(左下) -1, 1, 1, -1, 1, 1, // 三角形 2(右上)])有了這個全螢幕四邊形,Fragment Shader 就能在畫面上的每個像素執行,用數學算出圓形。
gl_Position 是 GLSL 的內建變數,用來告訴 GPU「這個頂點放在哪」。vec4 的四個值分別是 x、y、z、w,2D 的情況下 z 固定 0.0,w 固定 1.0 就好。
Fragment Shader:決定像素顏色
Section titled “Fragment Shader:決定像素顏色”Vertex Shader 決定了頂點圍出的區域後,GPU 會自動把那個區域內的每個像素都交給 Fragment Shader 處理。
不同的繪圖模式,Fragment Shader 執行的範圍也不同:
POINTS:每個頂點周圍gl_PointSize × gl_PointSize的正方形區域LINES:每條線段涵蓋的像素(通常只有 1px 寬)TRIANGLES:三角形內部的所有像素
這個範例用兩個三角形拼成全螢幕四邊形,所以 Fragment Shader 會在畫面上的每一個像素都跑一次。
每個像素執行時,可以透過 gl_FragCoord.xy 知道「我是畫面上哪一個像素」。
這是 GLSL 的內建變數,單位是像素座標(例如 320.5, 240.5)。
有了位置,就能用數學決定這個像素該是什麼顏色。
WebGL 必須手動宣告的浮點精度,桌面 OpenGL 預設就是高精度,但 WebGL 繼承自行動端的 OpenGL ES,Fragment Shader 不會預設精度,不寫就會編譯錯誤。
有三種可以選:
| 精度 | 用途 |
|---|---|
lowp | 省電,但容易出現色階斷層 |
mediump | 夠用於大部分 2D 效果 |
highp | 精準計算距離、座標時必須用 |
畫圓需要精準的距離計算,所以這裡用 highp。
這個 Shader 有兩個 uniform,都是 JavaScript 在每幀傳進來的:
uResolution:畫布的寬高(像素),用來把像素座標正規化成 0~1uRadius:圓的半徑(百分比),來自範例中的滑桿數值
#version 300 esprecision highp float;
uniform vec2 uResolution;uniform float uRadius;
out vec4 fragColor;
void main() { // 將像素座標正規化為 0~1 vec2 uv = gl_FragCoord.xy / uResolution;
// 圓心在畫面正中央 vec2 center = vec2(0.5, 0.5);
// 計算此像素到圓心的距離 float dist = length(uv - center);
// 半徑從百分比轉為 0~1 範圍 float r = uRadius / 100.0;
// smoothstep:圓內 → 1.0,圓外 → 0.0,邊緣柔和過渡 float circle = smoothstep(r + 0.01, r - 0.01, dist);
vec3 color = vec3(0.26, 0.52, 0.96) * circle; fragColor = vec4(color, 1.0);}核心邏輯只有三步:
- 取得位置:
gl_FragCoord.xy除以uResolution正規化成 0~1 的 UV - 算距離:
length(uv - center)算出此像素到圓心有多遠 - 判斷是否在圓內:
smoothstep讓邊緣柔和過渡,避免鋸齒
最後把 circle 乘上顏色,圓內是藍色,圓外是黑色。fragColor 就是這個像素最終的顏色輸出。
// 1. 建立 shader 物件const shader = gl.createShader(gl.FRAGMENT_SHADER)
// 2. 設定原始碼gl.shaderSource(shader, sourceCode)
// 3. 編譯gl.compileShader(shader)
// 4. 檢查有沒有錯if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { console.error(gl.getShaderInfoLog(shader))}連結成 Program
Section titled “連結成 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
Section titled “Uniform:CPU 傳資料給 GPU”上面範例中的 uRadius 就是一個 Uniform。JavaScript 每幀把滑桿的值傳給 GPU,所有像素共用同一個半徑值。
const radiusLocation = gl.getUniformLocation(program, 'uRadius')gl.uniform1f(radiusLocation, radius)Uniform 是「每幀全部像素共用」的常數。
滑鼠位置、散開半徑、摩擦力這些值對所有粒子都一樣,所以用 Uniform 傳入。
那每個粒子各自的位置和速度都不一樣怎麼辦呢?塞進 Texture 吧。( ´ ▽ ` )ノ
Step 13:用浮點紋理儲存粒子
Section titled “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 = 8const TOTAL_PARTICLES = GRID_SIZE * GRID_SIZE
let glContext: WebGL2RenderingContext | null = nulllet floatTexture: WebGLTexture | null = nulllet canvasContext: CanvasRenderingContext2D | null = nulllet 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>紋理不只是圖片
Section titled “紋理不只是圖片”假設有 10000 個粒子。我們建立一張 100×100 的紋理(ceil(sqrt(10000))),每個像素代表一個粒子:
像素 RGBA = (positionX, positionY, velocityX, velocityY)一個像素 4 個通道剛好存下位置和速度。
為什麼需要 RGBA32F?
Section titled “為什麼需要 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 支援讀寫浮點紋理,幾乎所有現代裝置都支援。
兩張紋理:狀態與原始位置
Section titled “兩張紋理:狀態與原始位置”蟲群文字用了兩種紋理:
| 紋理 | 內容 | 會變嗎? |
|---|---|---|
| 狀態紋理 | (x, y, velocityX, velocityY) | 每幀更新 |
| 原始位置紋理 | (originX, originY, size, opacity) | 永遠不變 |
原始位置紋理只建一次,之後 Shader 用它來算回歸力(知道「家」在哪)。
texelFetch:讀取紋理
Section titled “texelFetch:讀取紋理”ivec2 coord = ivec2(gl_FragCoord.xy);vec4 state = texelFetch(uState, coord, 0);texelFetch 用整數座標讀取紋理,不做任何插值。每個粒子的 Shader 用自己的座標去讀自己的資料,互不干擾。ლ(´∀`ლ)
Step 14:Ping-Pong 與 Framebuffer
Section titled “Step 14:Ping-Pong 與 Framebuffer”知道怎麼「讀」紋理後,接下來要能「寫」紋理,才能每幀更新粒子狀態。
GPU 的限制:不能同時讀寫
Section titled “GPU 的限制:不能同時讀寫”GPU 有個重要限制:不能同時讀取和寫入同一張紋理。
這很合理。想像有 10,000 個粒子同時在跑,如果大家同時讀寫同一塊記憶體,結果會完全不可預測。
Ping-Pong:準備兩張紋理
Section titled “Ping-Pong:準備兩張紋理”解法是準備兩張狀態紋理 A 和 B,交替使用:
第 1 幀:讀 A → 算物理 → 寫到 B第 2 幀:讀 B → 算物理 → 寫到 A第 3 幀:讀 A → 算物理 → 寫到 B...這就叫 Ping-Pong,在 GPU 粒子系統中相當常見的技巧。
Framebuffer:讓 Shader 寫入紋理
Section titled “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 流程
Section titled “完整 Ping-Pong 流程”currentStateIndex 記錄目前要「讀」的是哪張紋理,初始值為 0(代表紋理 A):
const currentStateIndex = 0每幀的 Ping-Pong 邏輯:
const readIndex = currentStateIndexconst writeIndex = 1 - currentStateIndex
// 綁定「讀」的紋理gl.activeTexture(gl.TEXTURE0)gl.bindTexture(gl.TEXTURE_2D, stateTextureList[readIndex])
// 綁定「寫」的 Framebuffergl.bindFramebuffer( gl.FRAMEBUFFER, framebufferList[writeIndex])
// 全螢幕四邊形,觸發每個像素的 Fragment Shadergl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)
// 交換索引currentStateIndex = writeIndex逐幀展開來看:
第 1 幀:currentStateIndex = 0 readIndex = 0, writeIndex = 1 讀紋理 A → Shader 計算 → 寫入紋理 B currentStateIndex = 1
第 2 幀:currentStateIndex = 1 readIndex = 1, writeIndex = 0 讀紋理 B → Shader 計算 → 寫入紋理 A currentStateIndex = 0
第 3 幀:又回到讀 A → 寫 B,不斷交替…每一幀結束時 currentStateIndex = writeIndex,把「剛寫完的」變成「下一幀要讀的」。兩張紋理像乒乓球一樣來回,就永遠不會同時讀寫同一張。
Step 15:GPU 物理 Shader
Section titled “Step 15:GPU 物理 Shader”準備工作做完了,現在來寫真正算物理的 Fragment Shader。
完整的 Physics Shader
Section titled “完整的 Physics Shader”#version 300 esprecision highp float;
uniform sampler2D uState;uniform sampler2D uOrigin;uniform vec2 uMouse;uniform vec2 uMouseVelocity;uniform int uMouseInside;uniform float uScatterRadius;uniform float uScatterForce;uniform float uReturnSpeed;uniform float uFriction;
out vec4 outState;
void main() { ivec2 coord = ivec2(gl_FragCoord.xy); vec4 state = texelFetch(uState, coord, 0); vec4 origin = texelFetch(uOrigin, coord, 0);
vec2 pos = state.xy; vec2 vel = state.zw; vec2 originPos = origin.xy;
// 滑鼠推力 if (uMouseInside != 0) { vec2 toParticle = pos - uMouse; float dist = length(toParticle);
if (dist < uScatterRadius) { float influence = smoothstep( uScatterRadius, 0.0, dist );
// 風力 float mouseSpeed = length(uMouseVelocity); if (mouseSpeed > 0.5) { vec2 windDir = uMouseVelocity / mouseSpeed; vel += windDir * mouseSpeed * influence * uScatterForce * 0.025; }
// 徑向推力 vel += normalize(toParticle) * influence * influence * uScatterForce * 0.5; } }
// 摩擦力 vel *= uFriction;
// 更新位置 pos += vel;
// 回歸 pos = mix(pos, originPos, uReturnSpeed);
outState = vec4(pos, vel);}ivec2 是什麼?
Section titled “ivec2 是什麼?”ivec2 coord = ivec2(gl_FragCoord.xy);ivec2 是整數版的二維向量(integer vector 2)。gl_FragCoord.xy 本身是 float,但 texelFetch 需要整數座標才能精確取到紋理中的某一格,所以先轉成 ivec2。
類似的型別還有:
| 型別 | 內容 | 例子 |
|---|---|---|
vec2 | 2 個 float | 位置、速度 |
vec4 | 4 個 float | 顏色 RGBA、狀態封裝 |
ivec2 | 2 個 int | 紋理座標、像素索引 |
Uniform 怎麼來的?
Section titled “Uniform 怎麼來的?”Shader 裡這些 uniform 變數不是憑空出現的,每一個都是 JavaScript 在每幀透過 gl.uniform* 傳進來的:
// 紋理:把粒子狀態和原始位置以紋理方式綁定gl.activeTexture(gl.TEXTURE0)gl.bindTexture(gl.TEXTURE_2D, stateTexture) // → uStategl.activeTexture(gl.TEXTURE1)gl.bindTexture(gl.TEXTURE_2D, originTexture) // → uOrigin
// 滑鼠相關gl.uniform2f(loc.uMouse, mouseX, mouseY) // 滑鼠座標gl.uniform2f(loc.uMouseVelocity, velX, velY) // 滑鼠移動速度gl.uniform1i(loc.uMouseInside, isInside ? 1 : 0) // 滑鼠是否在畫布內
// 物理參數(來自元件 props)gl.uniform1f(loc.uScatterRadius, props.scatterRadius) // 散開半徑gl.uniform1f(loc.uScatterForce, props.scatterForce) // 推力強度gl.uniform1f(loc.uReturnSpeed, props.returnSpeed) // 回歸速度gl.uniform1f(loc.uFriction, props.friction) // 摩擦力對照表:
| Uniform | 型別 | 來源 | 用途 |
|---|---|---|---|
uState | sampler2D | 上一幀的狀態紋理 | 讀取每個粒子的位置 xy 和速度 zw |
uOrigin | sampler2D | 初始位置紋理 | 粒子的「家」,回歸用 |
uMouse | vec2 | useMouseInElement | 滑鼠在畫布上的座標 |
uMouseVelocity | vec2 | 每幀座標差值 | 計算風力方向和強度 |
uMouseInside | int | isOutside 取反 | 滑鼠不在畫布內就不算推力 |
uScatterRadius | float | props | 推力的影響範圍(像素) |
uScatterForce | float | props | 推力的強度倍率 |
uReturnSpeed | float | props | mix 的插值比例,越大回越快 |
uFriction | float | props | 每幀速度乘上這個值,< 1 就會減速 |
如 Step 12 提到的概念,Uniform 是「每幀全部像素共用」的常數。
滑鼠位置、物理參數對所有粒子都一樣,所以用 Uniform;而每個粒子各自不同的位置和速度,則存在紋理裡用 texelFetch 讀取。ԅ(´∀` ԅ)
跟 CPU 版的對比
Section titled “跟 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 線性衰減
Section titled “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
Section titled “influence * influence”注意徑向推力用了 influence * influence(平方),不是線性。平方衰減讓邊緣更柔,中心更強,推出來的「空洞」更有層次。
Step 16:gl.POINTS 與 Vertex Shader
Section titled “Step 16:gl.POINTS 與 Vertex Shader”物理算完了,粒子位置存在紋理裡,最後只剩畫到螢幕上了!ヾ(◍'౪`◍)ノ゙
不過在動手之前,先退一步看看整體流程。
到目前為止我們寫了 Step 15 的 Physics Shader,接下來還要寫渲染用的 Vertex Shader 和 Fragment Shader,Shader 越來越多,容易搞混誰是誰。
整個粒子系統的 Shader 可以分成兩大類:
- 模擬用 Shader:負責算物理,更新粒子的位置和速度
- 渲染用 Shader:負責把粒子畫出來,決定位置、大小、形狀和顏色
這兩類 Shader 各司其職,資料透過紋理傳遞。
兩類 Shader 的關係
Section titled “兩類 Shader 的關係”模擬階段的 Fragment Shader 把計算結果寫入紋理,渲染階段的 Vertex Shader 再從同一張紋理讀取位置。
兩個階段透過紋理「接力」,完全不需要 CPU 介入搬資料。( ‧ω‧)ノ╰(‧ω‧ )
每個像素對應一個粒子,Fragment Shader 就變成了平行計算引擎。(≖‿ゝ≖)✧
這也是為什麼模擬用的 Shader 需要全螢幕四邊形(gl.TRIANGLE_STRIP)觸發,而渲染用的 Shader 用 gl.POINTS 觸發。
觸發方式不同,但都是 GPU 在跑,不會經過 CPU,性能好棒棒。ヾ(◍'౪`◍)ノ゙
<script setup lang="ts">import { onBeforeUnmount, onMounted, ref } from 'vue'
const canvasRef = ref<HTMLCanvasElement | null>(null)
let glContext: WebGL2RenderingContext | null = nulllet physicsProgram: WebGLProgram | null = nulllet renderProgram: WebGLProgram | null = nulllet animationFrameId = 0
const stateTextureList: [WebGLTexture | null, WebGLTexture | null] = [null, null]const framebufferList: [WebGLFramebuffer | null, WebGLFramebuffer | null] = [null, null]let originTexture: WebGLTexture | null = nulllet quadVertexBuffer: WebGLBuffer | null = nulllet quadVertexArray: WebGLVertexArrayObject | null = null
let particleCount = 0let textureSize = 0let pingPongIndex = 0
let mouseX = 0let mouseY = 0let isMouseInside = false
const DISPLAY_TEXT = 'SWARM'
// ----- Physics pass shaders -----const physicsVertexSource = `#version 300 esin vec2 aPosition;void main() { gl_Position = vec4(aPosition, 0.0, 1.0);}`
const physicsFragmentSource = `#version 300 esprecision 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 esprecision 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 esprecision highp float;
in float vOpacity;in float vDisplacement;in float vSpeed;
out vec4 fragColor;
void main() { fragColor = vec4(0.53, 0.53, 0.53, vOpacity);}`
// ----- Helper functions -----function compileShader( gl: WebGL2RenderingContext, type: number, source: string,): WebGLShader | null { const shader = gl.createShader(type) if (!shader) return null gl.shaderSource(shader, source) gl.compileShader(shader) if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { console.error(gl.getShaderInfoLog(shader)) gl.deleteShader(shader) return null } return shader}
function linkProgram( gl: WebGL2RenderingContext, vertSource: string, fragSource: string,): WebGLProgram | null { const vert = compileShader(gl, gl.VERTEX_SHADER, vertSource) const frag = compileShader(gl, gl.FRAGMENT_SHADER, fragSource) if (!vert || !frag) return null const program = gl.createProgram() if (!program) return null gl.attachShader(program, vert) gl.attachShader(program, frag) gl.linkProgram(program) if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { console.error(gl.getProgramInfoLog(program)) gl.deleteProgram(program) return null } gl.deleteShader(vert) gl.deleteShader(frag) return program}
function createFloatTexture( gl: WebGL2RenderingContext, size: number, data: Float32Array,): WebGLTexture | null { const texture = gl.createTexture() if (!texture) return null gl.bindTexture(gl.TEXTURE_2D, texture) gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, size, size, 0, gl.RGBA, gl.FLOAT, data) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) return texture}
// ----- Particle sampling from text -----interface SampledParticle { x: number; y: number; size: number; opacity: number;}
function sampleParticleListFromText( text: string, canvasWidth: number, canvasHeight: number,): SampledParticle[] { const offscreen = document.createElement('canvas') offscreen.width = canvasWidth offscreen.height = canvasHeight const context = offscreen.getContext('2d') if (!context) return []
const fontSize = Math.min(canvasWidth / (text.length * 0.7), canvasHeight * 0.6) context.fillStyle = 'white' context.font = `bold ${fontSize}px Arial, sans-serif` context.textAlign = 'center' context.textBaseline = 'middle' context.fillText(text, canvasWidth / 2, canvasHeight / 2)
const imageData = context.getImageData(0, 0, canvasWidth, canvasHeight) const pixelDataList = imageData.data const gap = 1 const resultList: SampledParticle[] = []
for (let y = 0; y < canvasHeight; y += gap) { for (let x = 0; x < canvasWidth; x += gap) { const index = (y * canvasWidth + x) * 4 const alpha = pixelDataList[index + 3] if (alpha > 128) { resultList.push({ x, y, size: 2.0, opacity: alpha / 255, }) } } }
return resultList}
// ----- Setup textures and framebuffers -----function setupParticleTextures( gl: WebGL2RenderingContext, sampledParticleList: SampledParticle[],): void { particleCount = sampledParticleList.length textureSize = Math.ceil(Math.sqrt(particleCount)) const totalPixelCount = textureSize * textureSize
const stateData = new Float32Array(totalPixelCount * 4) const originData = new Float32Array(totalPixelCount * 4)
for (let i = 0; i < totalPixelCount; i++) { if (i < particleCount) { const particle = sampledParticleList[i] stateData[i * 4 + 0] = particle.x stateData[i * 4 + 1] = particle.y stateData[i * 4 + 2] = 0 stateData[i * 4 + 3] = 0 originData[i * 4 + 0] = particle.x originData[i * 4 + 1] = particle.y originData[i * 4 + 2] = particle.size originData[i * 4 + 3] = particle.opacity } else { stateData[i * 4 + 0] = -99999 stateData[i * 4 + 1] = -99999 stateData[i * 4 + 2] = 0 stateData[i * 4 + 3] = 0 originData[i * 4 + 0] = -99999 originData[i * 4 + 1] = -99999 originData[i * 4 + 2] = 0 originData[i * 4 + 3] = 0 } }
stateTextureList[0] = createFloatTexture(gl, textureSize, stateData) stateTextureList[1] = createFloatTexture(gl, textureSize, new Float32Array(stateData)) originTexture = createFloatTexture(gl, textureSize, originData)
for (let i = 0; i < 2; i++) { framebufferList[i] = gl.createFramebuffer() gl.bindFramebuffer(gl.FRAMEBUFFER, framebufferList[i]) gl.framebufferTexture2D( gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, stateTextureList[i], 0, ) } gl.bindFramebuffer(gl.FRAMEBUFFER, null)}
function setupQuadBuffer(gl: WebGL2RenderingContext): void { const positionList = new Float32Array([ -1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1, ])
quadVertexArray = gl.createVertexArray() gl.bindVertexArray(quadVertexArray)
quadVertexBuffer = gl.createBuffer() gl.bindBuffer(gl.ARRAY_BUFFER, quadVertexBuffer) gl.bufferData(gl.ARRAY_BUFFER, positionList, gl.STATIC_DRAW)
gl.enableVertexAttribArray(0) gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0)
gl.bindVertexArray(null)}
// ----- Pointer events -----function handlePointerMove(event: PointerEvent): void { const canvas = canvasRef.value if (!canvas) return const rect = canvas.getBoundingClientRect() const devicePixelRatio = window.devicePixelRatio || 1 mouseX = (event.clientX - rect.left) * devicePixelRatio mouseY = (event.clientY - rect.top) * devicePixelRatio isMouseInside = true}
function handlePointerLeave(): void { isMouseInside = false}
// ----- Init and render -----function initWebGL(): void { const canvas = canvasRef.value if (!canvas) return
const devicePixelRatio = window.devicePixelRatio || 1 const displayWidth = canvas.clientWidth const displayHeight = canvas.clientHeight canvas.width = displayWidth * devicePixelRatio canvas.height = displayHeight * devicePixelRatio
glContext = canvas.getContext('webgl2') if (!glContext) { console.error('WebGL2 not supported') return }
const extColorFloat = glContext.getExtension('EXT_color_buffer_float') if (!extColorFloat) { console.error('EXT_color_buffer_float not supported') return }
physicsProgram = linkProgram(glContext, physicsVertexSource, physicsFragmentSource) renderProgram = linkProgram(glContext, renderVertexSource, renderFragmentSource) if (!physicsProgram || !renderProgram) return
setupQuadBuffer(glContext)
const sampledParticleList = sampleParticleListFromText( DISPLAY_TEXT, canvas.width, canvas.height, ) setupParticleTextures(glContext, sampledParticleList)
canvas.addEventListener('pointermove', handlePointerMove) canvas.addEventListener('pointerleave', handlePointerLeave)}
function render(): void { if (!glContext || !physicsProgram || !renderProgram) return
const canvas = canvasRef.value if (!canvas) return
const gl = glContext const readIndex = pingPongIndex const writeIndex = 1 - pingPongIndex
// ----- Physics pass ----- gl.bindFramebuffer(gl.FRAMEBUFFER, framebufferList[writeIndex]) gl.viewport(0, 0, textureSize, textureSize)
gl.useProgram(physicsProgram)
gl.activeTexture(gl.TEXTURE0) gl.bindTexture(gl.TEXTURE_2D, stateTextureList[readIndex]) gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uState'), 0)
gl.activeTexture(gl.TEXTURE1) gl.bindTexture(gl.TEXTURE_2D, originTexture) gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uOrigin'), 1)
gl.uniform2f(gl.getUniformLocation(physicsProgram, 'uMouse'), mouseX, mouseY) gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uMouseInside'), isMouseInside ? 1 : 0) gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uScatterRadius'), 60.0) gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uScatterForce'), 40.0) gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uReturnSpeed'), 0.1) gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uFriction'), 0.92)
gl.bindVertexArray(quadVertexArray) gl.drawArrays(gl.TRIANGLES, 0, 6) gl.bindVertexArray(null)
pingPongIndex = writeIndex
// ----- Render pass ----- gl.bindFramebuffer(gl.FRAMEBUFFER, null) gl.viewport(0, 0, canvas.width, canvas.height) gl.clearColor(0, 0, 0, 1) gl.clear(gl.COLOR_BUFFER_BIT)
gl.useProgram(renderProgram)
gl.activeTexture(gl.TEXTURE0) gl.bindTexture(gl.TEXTURE_2D, stateTextureList[writeIndex]) gl.uniform1i(gl.getUniformLocation(renderProgram, 'uState'), 0)
gl.activeTexture(gl.TEXTURE1) gl.bindTexture(gl.TEXTURE_2D, originTexture) gl.uniform1i(gl.getUniformLocation(renderProgram, 'uOrigin'), 1)
gl.uniform1i(gl.getUniformLocation(renderProgram, 'uTextureSize'), textureSize) gl.uniform2f( gl.getUniformLocation(renderProgram, 'uResolution'), canvas.width, canvas.height, )
gl.drawArrays(gl.POINTS, 0, textureSize * textureSize)
animationFrameId = requestAnimationFrame(render)}
onMounted(() => { initWebGL() animationFrameId = requestAnimationFrame(render)})
onBeforeUnmount(() => { cancelAnimationFrame(animationFrameId)
const canvas = canvasRef.value if (canvas) { canvas.removeEventListener('pointermove', handlePointerMove) canvas.removeEventListener('pointerleave', handlePointerLeave) }
if (glContext) { if (physicsProgram) glContext.deleteProgram(physicsProgram) if (renderProgram) glContext.deleteProgram(renderProgram) for (const texture of stateTextureList) { if (texture) glContext.deleteTexture(texture) } if (originTexture) glContext.deleteTexture(originTexture) for (const framebuffer of framebufferList) { if (framebuffer) glContext.deleteFramebuffer(framebuffer) } if (quadVertexBuffer) glContext.deleteBuffer(quadVertexBuffer) if (quadVertexArray) glContext.deleteVertexArray(quadVertexArray) }})</script>
<template> <div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6"> <span class="text-sm text-gray-600"> 改良頂點著色器:每個粒子有獨立的大小、透明度、位移量與速度 </span>
<canvas ref="canvasRef" class="w-full rounded-lg" style="height: 320px;" /> </div></template>gl.POINTS
Section titled “gl.POINTS”WebGL 有個很方便的繪製模式叫 gl.POINTS,每個頂點畫成一個正方形的「點」。
gl.drawArrays(gl.POINTS, 0, particleCount)一行就畫完所有粒子。大小由 Vertex Shader 裡的 gl_PointSize 控制。
Vertex Shader:從紋理讀取位置
Section titled “Vertex Shader:從紋理讀取位置”除了前面見過的 uState、uOrigin、uResolution,這裡多了兩個 uniform:
uTextureSize:狀態紋理的邊長(例如 128),用來把一維的頂點 ID 轉換成二維的紋理座標uPointScale:粒子大小的縮放倍率,來自元件 props,讓外部能控制粒子的視覺大小
#version 300 esprecision 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 的妙用
Section titled “gl_VertexID 的妙用”gl_VertexID 是 WebGL2 的內建變數,代表「第幾個頂點」。我們不需要傳入任何頂點資料,只要知道 ID 就能從紋理中查到位置。
傳統做法需要建立頂點緩衝區(VBO)存座標,每幀從 CPU 上傳新資料。紋理查表的話,資料全程留在 GPU 上,零傳輸成本。ヽ(●`∀´●)ノ
Clip Space 座標轉換
Section titled “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 傳遞
Section titled “Varying 傳遞”Vertex Shader 和 Fragment Shader 是兩個獨立的程式,不能共用變數。但 Fragment Shader 常常需要用到 Vertex Shader 算好的值,例如粒子離家多遠、跑多快。
Varying 就是這條橋樑:Vertex Shader 用 out 送出,Fragment Shader 用 in 接收,變數名稱必須完全相同。
// Vertex Shaderout float vDisplacement; // 送出去
// Fragment Shaderin float vDisplacement; // 接收(名稱必須一致)在三角形模式中,Varying 會自動對三角形內部的像素做重心插值(barycentric interpolation),讓顏色、光照能平滑過渡。
不過蟲群文字用的是 gl.POINTS,每個粒子就是一個點,不存在「三個頂點之間」的插值,所以 Fragment Shader 收到的就是 Vertex Shader 設定的原始值。
命名慣例上,v 字首代表 Varying,方便一眼辨認資料來源:
| 字首 | 意思 | 例子 |
|---|---|---|
a | Attribute(來自 JavaScript 的頂點資料) | aPosition |
u | Uniform(來自 JavaScript 的全域常數) | uMouse |
v | Varying(來自 Vertex Shader) | vDisplacement |
Step 17:圓形粒子與 Alpha Blending
Section titled “Step 17:圓形粒子與 Alpha Blending”Vertex Shader 決定了粒子的「位置」和「大小」,Fragment Shader 決定它「長什麼樣」。
<script setup lang="ts">import { onBeforeUnmount, onMounted, ref } from 'vue'
const canvasListRef = ref<HTMLCanvasElement[]>([])
interface GlResource { gl: WebGL2RenderingContext; program: WebGLProgram; vao: WebGLVertexArrayObject; buffer: WebGLBuffer;}
const resourceList: GlResource[] = []
const PARTICLE_LIST = [ { x: 0.3, y: 0.5, size: 80.0, opacity: 0.7 }, { x: 0.5, y: 0.5, size: 80.0, opacity: 0.7 }, { x: 0.7, y: 0.5, size: 80.0, opacity: 0.7 }, { x: 0.4, y: 0.35, size: 60.0, opacity: 0.5 }, { x: 0.6, y: 0.35, size: 60.0, opacity: 0.5 }, { x: 0.5, y: 0.65, size: 60.0, opacity: 0.5 },]
const VERTEX_SOURCE = `#version 300 esprecision highp float;in vec2 aPosition;in float aSize;in float aOpacity;out float vOpacity;
void main() { vec2 clip = aPosition * 2.0 - 1.0; clip.y *= -1.0; gl_Position = vec4(clip, 0.0, 1.0); gl_PointSize = aSize; vOpacity = aOpacity;}`
// 1) 原始正方形:不做任何處理const FRAG_SQUARE = `#version 300 esprecision highp float;in float vOpacity;out vec4 fragColor;
void main() { fragColor = vec4(0.53, 0.53, 0.53, 1.0);}`
// 2) discard 裁圓,硬邊const FRAG_CIRCLE_HARD = `#version 300 esprecision highp float;in float vOpacity;out vec4 fragColor;
void main() { float dist = length(gl_PointCoord - 0.5) * 2.0; if (dist > 1.0) discard; fragColor = vec4(0.53, 0.53, 0.53, 1.0);}`
// 3) smoothstep 柔邊 + opacity + alpha blendingconst FRAG_SOFT_BLEND = `#version 300 esprecision highp float;in float vOpacity;out vec4 fragColor;
void main() { float dist = length(gl_PointCoord - 0.5) * 2.0; if (dist > 1.0) discard; float alpha = smoothstep(1.0, 0.4, dist); fragColor = vec4(0.53, 0.53, 0.53, vOpacity * alpha);}`
const FRAGMENT_SOURCE_LIST = [FRAG_SQUARE, FRAG_CIRCLE_HARD, FRAG_SOFT_BLEND]
function compileShader( gl: WebGL2RenderingContext, type: number, source: string,): WebGLShader | null { const shader = gl.createShader(type) if (!shader) return null gl.shaderSource(shader, source) gl.compileShader(shader) if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { console.error(gl.getShaderInfoLog(shader)) gl.deleteShader(shader) return null } return shader}
function linkProgram( gl: WebGL2RenderingContext, vertSource: string, fragSource: string,): WebGLProgram | null { const vert = compileShader(gl, gl.VERTEX_SHADER, vertSource) const frag = compileShader(gl, gl.FRAGMENT_SHADER, fragSource) if (!vert || !frag) return null const program = gl.createProgram() if (!program) return null gl.attachShader(program, vert) gl.attachShader(program, frag) gl.linkProgram(program) if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { console.error(gl.getProgramInfoLog(program)) gl.deleteProgram(program) return null } gl.deleteShader(vert) gl.deleteShader(frag) return program}
function initCanvas(canvas: HTMLCanvasElement, fragSource: string, enableBlend: boolean): void { const dpr = window.devicePixelRatio || 1 const logicalWidth = canvas.clientWidth const logicalHeight = canvas.clientHeight canvas.width = logicalWidth * dpr canvas.height = logicalHeight * dpr
const gl = canvas.getContext('webgl2', { premultipliedAlpha: false, alpha: true, }) if (!gl) return
const program = linkProgram(gl, VERTEX_SOURCE, fragSource) if (!program) return
const vertexData = new Float32Array(PARTICLE_LIST.length * 4) for (let i = 0; i < PARTICLE_LIST.length; i++) { const particle = PARTICLE_LIST[i]! vertexData[i * 4 + 0] = particle.x vertexData[i * 4 + 1] = particle.y vertexData[i * 4 + 2] = particle.size * dpr vertexData[i * 4 + 3] = particle.opacity }
const vao = gl.createVertexArray()! gl.bindVertexArray(vao)
const buffer = gl.createBuffer()! gl.bindBuffer(gl.ARRAY_BUFFER, buffer) gl.bufferData(gl.ARRAY_BUFFER, vertexData, gl.STATIC_DRAW)
const stride = 4 * 4 const posLoc = gl.getAttribLocation(program, 'aPosition') gl.enableVertexAttribArray(posLoc) gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, stride, 0)
const sizeLoc = gl.getAttribLocation(program, 'aSize') gl.enableVertexAttribArray(sizeLoc) gl.vertexAttribPointer(sizeLoc, 1, gl.FLOAT, false, stride, 8)
const opacityLoc = gl.getAttribLocation(program, 'aOpacity') gl.enableVertexAttribArray(opacityLoc) gl.vertexAttribPointer(opacityLoc, 1, gl.FLOAT, false, stride, 12)
gl.bindVertexArray(null)
resourceList.push({ gl, program, vao, buffer })
// Draw gl.viewport(0, 0, canvas.width, canvas.height) gl.clearColor(0.07, 0.07, 0.07, 1) gl.clear(gl.COLOR_BUFFER_BIT)
if (enableBlend) { gl.enable(gl.BLEND) gl.blendFuncSeparate( gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA, ) }
gl.useProgram(program) gl.bindVertexArray(vao) gl.drawArrays(gl.POINTS, 0, PARTICLE_LIST.length) gl.bindVertexArray(null)}
onMounted(() => { const canvasList = canvasListRef.value if (canvasList.length < 3) return
initCanvas(canvasList[0]!, FRAGMENT_SOURCE_LIST[0]!, false) initCanvas(canvasList[1]!, FRAGMENT_SOURCE_LIST[1]!, false) initCanvas(canvasList[2]!, FRAGMENT_SOURCE_LIST[2]!, true)})
onBeforeUnmount(() => { for (const resource of resourceList) { resource.gl.deleteProgram(resource.program) resource.gl.deleteBuffer(resource.buffer) resource.gl.deleteVertexArray(resource.vao) } resourceList.length = 0})</script>
<template> <div class="flex flex-col gap-3 border border-gray-200 rounded-xl p-6"> <div class="grid grid-cols-3 gap-3"> <div class="flex flex-col items-center gap-1"> <canvas :ref="(el: any) => { if (el) canvasListRef[0] = el }" class="w-full rounded-lg" style="height: 180px;" /> <span class="text-xs text-gray-500"> 原始正方形 </span> </div>
<div class="flex flex-col items-center gap-1"> <canvas :ref="(el: any) => { if (el) canvasListRef[1] = el }" class="w-full rounded-lg" style="height: 180px;" /> <span class="text-xs text-gray-500"> discard 裁圓 </span> </div>
<div class="flex flex-col items-center gap-1"> <canvas :ref="(el: any) => { if (el) canvasListRef[2] = el }" class="w-full rounded-lg" style="height: 180px;" /> <span class="text-xs text-gray-500"> 柔邊 + Alpha Blending </span> </div> </div> </div></template>正方形變圓形
Section titled “正方形變圓形”gl.POINTS 畫出來的是正方形。gl_PointCoord 提供正方形內的 UV 座標(0~1)。
uColor 是粒子的顏色,由 JavaScript 將元件的 particleColor prop 轉成 vec3(RGB 0~1)後傳入。
void main() { float dist = length(gl_PointCoord - 0.5) * 2.0; if (dist > 1.0) discard;
float alpha = smoothstep(1.0, 0.4, dist) * vOpacity; fragColor = vec4(uColor, alpha);}算出到中心的距離,超過 1 的用 discard 丟掉,正方形就變成圓形了。
discard 是什麼?
Section titled “discard 是什麼?”discard 是 GLSL 的關鍵字,意思是「這個像素不要了,什麼都不畫」。被 discard 的像素不會影響 Framebuffer,就像從來沒存在過一樣。
smoothstep(1.0, 0.4, dist) 讓粒子邊緣不是硬切的。距離從 0.4 到 1.0 之間會平滑過渡到透明,看起來像微微發光的小點,而不是銳利的圓形。
Alpha Blending
Section titled “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 擾動
Section titled “Step 18:Curl Noise 擾動”目前粒子被推開後直線回來,少了自然界那種混沌又有秩序的美感。(´・ω・`)
Curl Noise 會讓粒子沿著漩渦走,看起來就像被風捲著跑。
<script setup lang="ts">import { onBeforeUnmount, onMounted, ref } from 'vue'
const canvasRef = ref<HTMLCanvasElement | null>(null)
let glContext: WebGL2RenderingContext | null = nulllet physicsProgram: WebGLProgram | null = nulllet renderProgram: WebGLProgram | null = nulllet noiseProgram: WebGLProgram | null = nulllet animationFrameId = 0
const stateTextureList: [WebGLTexture | null, WebGLTexture | null] = [null, null]const framebufferList: [WebGLFramebuffer | null, WebGLFramebuffer | null] = [null, null]let originTexture: WebGLTexture | null = nulllet noiseTexture: WebGLTexture | null = nulllet noiseFramebuffer: WebGLFramebuffer | null = nulllet quadVertexBuffer: WebGLBuffer | null = nulllet quadVertexArray: WebGLVertexArrayObject | null = nulllet particleVertexArray: WebGLVertexArrayObject | null = null
let particleCount = 0let textureSize = 0let pingPongIndex = 0let startTime = 0let logicalWidth = 0let logicalHeight = 0
let mouseX = 0let mouseY = 0let previousMouseX = -9999let previousMouseY = -9999let mouseVelocityX = 0let mouseVelocityY = 0let isMouseInside = false
const DISPLAY_TEXT = 'SWARM'const NOISE_SIZE = 256
// ----- Noise generation shader -----const noiseVertexSource = `#version 300 esin vec2 aPosition;void main() { gl_Position = vec4(aPosition, 0.0, 1.0);}`
const noiseFragmentSource = `#version 300 esprecision highp float;out vec4 fragColor;
float hash(vec2 p) { vec3 p3 = fract(vec3(p.xyx) * 0.13); p3 += dot(p3, p3.yzx + 33.33); return fract((p3.x + p3.y) * p3.z);}
float noise(vec2 x) { vec2 i = floor(x); vec2 f = fract(x); float a = hash(i); float b = hash(i + vec2(1.0, 0.0)); float c = hash(i + vec2(0.0, 1.0)); float d = hash(i + vec2(1.0, 1.0)); vec2 u = f * f * (3.0 - 2.0 * f); return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y;}
float fbm(vec2 p) { float v = 0.0; float a = 0.5; for (int i = 0; i < 4; i++) { v += a * noise(p); p *= 2.0; a *= 0.5; } return v;}
void main() { vec2 uv = gl_FragCoord.xy / 256.0; vec2 p = uv * 6.0; const float e = 0.1; float a = (fbm(p + vec2(0.0, e)) - fbm(p - vec2(0.0, e))) / (2.0 * e); float b = (fbm(p + vec2(e, 0.0)) - fbm(p - vec2(e, 0.0))) / (2.0 * e); fragColor = vec4(a, -b, 0.0, 1.0);}`
// ----- Physics pass shaders -----const physicsVertexSource = `#version 300 esin vec2 aPosition;void main() { gl_Position = vec4(aPosition, 0.0, 1.0);}`
const physicsFragmentSource = `#version 300 esprecision highp float;
uniform sampler2D uState;uniform sampler2D uOrigin;uniform sampler2D uNoise;uniform vec2 uMouse;uniform vec2 uMouseVelocity;uniform int uMouseInside;uniform float uScatterRadius;uniform float uScatterForce;uniform float uReturnSpeed;uniform float uFriction;uniform float uTime;
out vec4 fragColor;
float hash(vec2 p) { vec3 p3 = fract(vec3(p.xyx) * 0.13); p3 += dot(p3, p3.yzx + 33.33); return fract((p3.x + p3.y) * p3.z);}
void main() { ivec2 coord = ivec2(gl_FragCoord.xy); vec4 state = texelFetch(uState, coord, 0); vec4 origin = texelFetch(uOrigin, coord, 0);
vec2 pos = state.xy; vec2 vel = state.zw; vec2 originPos = origin.xy;
if (originPos.x < -9999.0) { fragColor = state; return; }
float pRand = hash(vec2(float(coord.x), float(coord.y))); float forceScale = 0.5 + pRand * 1.0;
if (uMouseInside == 1) { vec2 toParticle = pos - uMouse; float dist = length(toParticle);
if (dist < uScatterRadius) { float influence = smoothstep(uScatterRadius, 0.0, dist); float mouseSpeed = length(uMouseVelocity);
if (mouseSpeed > 0.5) { vec2 windDir = uMouseVelocity / mouseSpeed; vel += windDir * mouseSpeed * influence * uScatterForce * 0.025 * forceScale; }
vec2 noiseUV = pos * 0.005 + uTime * 0.15 + pRand * 10.0; vec2 curl = texture(uNoise, noiseUV).xy; float turbStrength = (1.5 + mouseSpeed * 0.4) * influence * uScatterForce * 0.12 * forceScale; vel += curl * turbStrength;
if (dist > 0.01) { vel += (toParticle / dist) * influence * influence * uScatterForce * 0.5 * forceScale; } } }
vec2 toOrigin = originPos - pos; float distFromOrigin = length(toOrigin);
if (distFromOrigin > 2.0) { float displacement = smoothstep(2.0, 40.0, distFromOrigin); vec2 driftUV = pos * 0.002 + uTime * 0.08 + pRand * 0.3; vec2 drift = texture(uNoise, driftUV).xy; vel += drift * displacement * 1.5 * forceScale; }
float particleFriction = uFriction - pRand * 0.04; vel *= particleFriction;
pos += vel;
float returnRate = uReturnSpeed * (0.6 + pRand * 0.8); pos = mix(pos, originPos, returnRate);
fragColor = vec4(pos, vel);}`
// ----- Render pass shaders -----const renderVertexSource = `#version 300 esprecision highp float;
uniform sampler2D uState;uniform sampler2D uOrigin;uniform int uTextureSize;uniform vec2 uResolution;uniform float uPointScale;uniform float uTime;
out float vOpacity;out float vDisplacement;out float vSpeed;
void main() { int index = gl_VertexID; int texelX = index % uTextureSize; int texelY = index / uTextureSize;
vec4 state = texelFetch(uState, ivec2(texelX, texelY), 0); vec4 origin = texelFetch(uOrigin, ivec2(texelX, texelY), 0);
vec2 pos = state.xy; vec2 vel = state.zw; vec2 originPos = origin.xy; float size = origin.z; float opacity = origin.w;
if (originPos.x < -9999.0) { gl_Position = vec4(-2.0, -2.0, 0.0, 1.0); gl_PointSize = 0.0; vOpacity = 0.0; vDisplacement = 0.0; vSpeed = 0.0; return; }
float distFromOrigin = length(pos - originPos); float speed = length(vel);
float displacementNear = smoothstep(0.0, 8.0, distFromOrigin); float displacementMid = smoothstep(8.0, 30.0, distFromOrigin); float displacementFar = smoothstep(30.0, 80.0, distFromOrigin); float displacement = displacementNear * 0.3 + displacementMid * 0.4 + displacementFar * 0.3;
float speedFactor = smoothstep(0.0, 8.0, speed);
float phase = float(index) * 1.618033988; pos.x += sin(phase + uTime * 0.001) * 0.2; pos.y += cos(phase * 1.3 + uTime * 0.0008) * 0.2;
vec2 clipPosition = (pos / uResolution) * 2.0 - 1.0; clipPosition.y *= -1.0;
gl_Position = vec4(clipPosition, 0.0, 1.0); gl_PointSize = size * 2.0 * uPointScale;
vOpacity = opacity; vDisplacement = displacement; vSpeed = speedFactor;}`
const renderFragmentSource = `#version 300 esprecision highp float;
in float vOpacity;in float vDisplacement;in float vSpeed;
out vec4 fragColor;
void main() { float dist = length(gl_PointCoord - 0.5) * 2.0; if (dist > 1.0) discard;
float alpha = smoothstep(1.0, 0.4, dist) * vOpacity;
fragColor = vec4(0.53, 0.53, 0.53, alpha);}`
// ----- Helper functions -----function compileShader( gl: WebGL2RenderingContext, type: number, source: string,): WebGLShader | null { const shader = gl.createShader(type) if (!shader) return null gl.shaderSource(shader, source) gl.compileShader(shader) if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { console.error(gl.getShaderInfoLog(shader)) gl.deleteShader(shader) return null } return shader}
function linkProgram( gl: WebGL2RenderingContext, vertSource: string, fragSource: string,): WebGLProgram | null { const vert = compileShader(gl, gl.VERTEX_SHADER, vertSource) const frag = compileShader(gl, gl.FRAGMENT_SHADER, fragSource) if (!vert || !frag) return null const program = gl.createProgram() if (!program) return null gl.attachShader(program, vert) gl.attachShader(program, frag) gl.linkProgram(program) if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { console.error(gl.getProgramInfoLog(program)) gl.deleteProgram(program) return null } gl.deleteShader(vert) gl.deleteShader(frag) return program}
function createFloatTexture( gl: WebGL2RenderingContext, size: number, data: Float32Array,): WebGLTexture | null { const texture = gl.createTexture() if (!texture) return null gl.bindTexture(gl.TEXTURE_2D, texture) gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, size, size, 0, gl.RGBA, gl.FLOAT, data) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) return texture}
// ----- Particle sampling from text -----interface SampledParticle { x: number; y: number; size: number; opacity: number;}
function sampleParticleListFromText( text: string, canvasWidth: number, canvasHeight: number,): SampledParticle[] { const offscreen = document.createElement('canvas') offscreen.width = canvasWidth offscreen.height = canvasHeight const context = offscreen.getContext('2d') if (!context) return []
const fontSize = Math.min(canvasWidth / (text.length * 0.7), canvasHeight * 0.6) context.fillStyle = 'white' context.font = `bold ${fontSize}px Arial, sans-serif` context.textAlign = 'center' context.textBaseline = 'middle' context.fillText(text, canvasWidth / 2, canvasHeight / 2)
const imageData = context.getImageData(0, 0, canvasWidth, canvasHeight) const pixelDataList = imageData.data const gap = 1 const resultList: SampledParticle[] = []
for (let y = 0; y < canvasHeight; y += gap) { for (let x = 0; x < canvasWidth; x += gap) { const pixelX = Math.floor(x) const pixelY = Math.floor(y) const index = (pixelY * canvasWidth + pixelX) * 4 const alpha = (pixelDataList[index + 3] ?? 0) / 255 if (alpha > 0.15) { resultList.push({ x, y, size: 1.5 * (0.4 + alpha * 0.6), opacity: alpha * (0.8 + Math.random() * 0.2), }) } } }
return resultList}
// ----- Setup textures and framebuffers -----function setupParticleTextures( gl: WebGL2RenderingContext, sampledParticleList: SampledParticle[],): void { particleCount = sampledParticleList.length textureSize = Math.ceil(Math.sqrt(particleCount)) const totalPixelCount = textureSize * textureSize
const stateData = new Float32Array(totalPixelCount * 4) const originData = new Float32Array(totalPixelCount * 4)
for (let i = 0; i < totalPixelCount; i++) { if (i < particleCount) { const particle = sampledParticleList[i] stateData[i * 4 + 0] = particle?.x ?? 0 stateData[i * 4 + 1] = particle?.y ?? 0 stateData[i * 4 + 2] = 0 stateData[i * 4 + 3] = 0 originData[i * 4 + 0] = particle?.x ?? 0 originData[i * 4 + 1] = particle?.y ?? 0 originData[i * 4 + 2] = particle?.size ?? 0 originData[i * 4 + 3] = particle?.opacity ?? 0 } else { stateData[i * 4 + 0] = -99999 stateData[i * 4 + 1] = -99999 stateData[i * 4 + 2] = 0 stateData[i * 4 + 3] = 0 originData[i * 4 + 0] = -99999 originData[i * 4 + 1] = -99999 originData[i * 4 + 2] = 0 originData[i * 4 + 3] = 0 } }
stateTextureList[0] = createFloatTexture(gl, textureSize, stateData) stateTextureList[1] = createFloatTexture(gl, textureSize, new Float32Array(stateData)) originTexture = createFloatTexture(gl, textureSize, originData)
for (let i = 0; i < 2; i++) { framebufferList[i] = gl.createFramebuffer() gl.bindFramebuffer(gl.FRAMEBUFFER, framebufferList[i]) gl.framebufferTexture2D( gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, stateTextureList[i], 0, ) } gl.bindFramebuffer(gl.FRAMEBUFFER, null)}
function generateNoiseTexture(gl: WebGL2RenderingContext): void { noiseProgram = linkProgram(gl, noiseVertexSource, noiseFragmentSource) if (!noiseProgram) return
noiseTexture = gl.createTexture() gl.bindTexture(gl.TEXTURE_2D, noiseTexture) gl.texImage2D( gl.TEXTURE_2D, 0, gl.RGBA32F, NOISE_SIZE, NOISE_SIZE, 0, gl.RGBA, gl.FLOAT, null, )
const hasFloatLinear = !!gl.getExtension('OES_texture_float_linear') const noiseFilter = hasFloatLinear ? gl.LINEAR : gl.NEAREST gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, noiseFilter) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, noiseFilter) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT)
noiseFramebuffer = gl.createFramebuffer() gl.bindFramebuffer(gl.FRAMEBUFFER, noiseFramebuffer) gl.framebufferTexture2D( gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, noiseTexture, 0, )
gl.viewport(0, 0, NOISE_SIZE, NOISE_SIZE) gl.useProgram(noiseProgram) gl.bindVertexArray(quadVertexArray) gl.drawArrays(gl.TRIANGLES, 0, 6) gl.bindVertexArray(null)
gl.bindFramebuffer(gl.FRAMEBUFFER, null) gl.deleteProgram(noiseProgram) noiseProgram = null}
function setupBuffers(gl: WebGL2RenderingContext): void { const positionList = new Float32Array([ -1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1, ])
quadVertexArray = gl.createVertexArray() gl.bindVertexArray(quadVertexArray)
quadVertexBuffer = gl.createBuffer() gl.bindBuffer(gl.ARRAY_BUFFER, quadVertexBuffer) gl.bufferData(gl.ARRAY_BUFFER, positionList, gl.STATIC_DRAW)
const positionLocation = 0 gl.enableVertexAttribArray(positionLocation) gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0) gl.bindVertexArray(null)
particleVertexArray = gl.createVertexArray()}
// ----- Pointer events -----function handlePointerDown(event: PointerEvent): void { const target = event.currentTarget if (target instanceof Element) target.setPointerCapture(event.pointerId) handlePointerMove(event)}
function handlePointerMove(event: PointerEvent): void { const newX = event.offsetX const newY = event.offsetY
if (previousMouseX > -9000 && previousMouseY > -9000) { mouseVelocityX = newX - previousMouseX mouseVelocityY = newY - previousMouseY }
previousMouseX = newX previousMouseY = newY mouseX = newX mouseY = newY isMouseInside = true}
function handlePointerLeave(): void { isMouseInside = false previousMouseX = -9999 previousMouseY = -9999}
// ----- Init and render -----function initWebGL(): void { const canvas = canvasRef.value if (!canvas) return
const dpr = window.devicePixelRatio || 1 logicalWidth = canvas.clientWidth logicalHeight = canvas.clientHeight canvas.width = logicalWidth * dpr canvas.height = logicalHeight * dpr
glContext = canvas.getContext('webgl2', { premultipliedAlpha: false, alpha: true, }) if (!glContext) { console.error('WebGL2 not supported') return }
const extColorFloat = glContext.getExtension('EXT_color_buffer_float') if (!extColorFloat) { console.error('EXT_color_buffer_float not supported') return }
physicsProgram = linkProgram(glContext, physicsVertexSource, physicsFragmentSource) renderProgram = linkProgram(glContext, renderVertexSource, renderFragmentSource) if (!physicsProgram || !renderProgram) return
setupBuffers(glContext) generateNoiseTexture(glContext)
const sampledParticleList = sampleParticleListFromText( DISPLAY_TEXT, logicalWidth, logicalHeight, ) setupParticleTextures(glContext, sampledParticleList)
glContext.enable(glContext.BLEND) glContext.blendFuncSeparate( glContext.SRC_ALPHA, glContext.ONE_MINUS_SRC_ALPHA, glContext.ONE, glContext.ONE_MINUS_SRC_ALPHA, )
previousMouseX = -9999 previousMouseY = -9999 mouseVelocityX = 0 mouseVelocityY = 0 startTime = performance.now()
canvas.addEventListener('pointerdown', handlePointerDown) canvas.addEventListener('pointermove', handlePointerMove) canvas.addEventListener('pointerup', handlePointerLeave) canvas.addEventListener('pointerleave', handlePointerLeave)}
function render(): void { if (!glContext || !physicsProgram || !renderProgram) return
const canvas = canvasRef.value if (!canvas) return
const gl = glContext const currentTime = (performance.now() - startTime) * 0.001
mouseVelocityX *= 0.92 mouseVelocityY *= 0.92
const readIndex = pingPongIndex const writeIndex = 1 - pingPongIndex
gl.disable(gl.BLEND) gl.bindFramebuffer(gl.FRAMEBUFFER, framebufferList[writeIndex]) gl.viewport(0, 0, textureSize, textureSize)
gl.useProgram(physicsProgram)
gl.activeTexture(gl.TEXTURE0) gl.bindTexture(gl.TEXTURE_2D, stateTextureList[readIndex]) gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uState'), 0)
gl.activeTexture(gl.TEXTURE1) gl.bindTexture(gl.TEXTURE_2D, originTexture) gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uOrigin'), 1)
gl.activeTexture(gl.TEXTURE2) gl.bindTexture(gl.TEXTURE_2D, noiseTexture) gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uNoise'), 2)
gl.uniform2f(gl.getUniformLocation(physicsProgram, 'uMouse'), mouseX, mouseY) gl.uniform2f(gl.getUniformLocation(physicsProgram, 'uMouseVelocity'), mouseVelocityX, mouseVelocityY) gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uMouseInside'), isMouseInside ? 1 : 0) gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uScatterRadius'), 40.0) gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uScatterForce'), 40.0) gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uReturnSpeed'), 0.1) gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uFriction'), 0.92) gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uTime'), currentTime)
gl.bindVertexArray(quadVertexArray) gl.drawArrays(gl.TRIANGLES, 0, 6) gl.bindVertexArray(null)
pingPongIndex = writeIndex
gl.bindFramebuffer(gl.FRAMEBUFFER, null) gl.viewport(0, 0, canvas.width, canvas.height) gl.clearColor(0, 0, 0, 0) gl.clear(gl.COLOR_BUFFER_BIT)
gl.enable(gl.BLEND) gl.blendFuncSeparate( gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA, )
gl.useProgram(renderProgram)
gl.activeTexture(gl.TEXTURE0) gl.bindTexture(gl.TEXTURE_2D, stateTextureList[writeIndex]) gl.uniform1i(gl.getUniformLocation(renderProgram, 'uState'), 0)
gl.activeTexture(gl.TEXTURE1) gl.bindTexture(gl.TEXTURE_2D, originTexture) gl.uniform1i(gl.getUniformLocation(renderProgram, 'uOrigin'), 1)
const dpr = window.devicePixelRatio || 1 gl.uniform1i(gl.getUniformLocation(renderProgram, 'uTextureSize'), textureSize) gl.uniform2f( gl.getUniformLocation(renderProgram, 'uResolution'), logicalWidth, logicalHeight, ) gl.uniform1f(gl.getUniformLocation(renderProgram, 'uPointScale'), dpr) gl.uniform1f(gl.getUniformLocation(renderProgram, 'uTime'), performance.now())
gl.bindVertexArray(particleVertexArray) gl.drawArrays(gl.POINTS, 0, particleCount) gl.bindVertexArray(null)
animationFrameId = requestAnimationFrame(render)}
onMounted(() => { initWebGL() animationFrameId = requestAnimationFrame(render)})
onBeforeUnmount(() => { cancelAnimationFrame(animationFrameId)
const canvas = canvasRef.value if (canvas) { canvas.removeEventListener('pointerdown', handlePointerDown) canvas.removeEventListener('pointermove', handlePointerMove) canvas.removeEventListener('pointerup', handlePointerLeave) canvas.removeEventListener('pointerleave', handlePointerLeave) }
if (glContext) { if (physicsProgram) glContext.deleteProgram(physicsProgram) if (renderProgram) glContext.deleteProgram(renderProgram) for (const texture of stateTextureList) { if (texture) glContext.deleteTexture(texture) } if (originTexture) glContext.deleteTexture(originTexture) if (noiseTexture) glContext.deleteTexture(noiseTexture) for (const framebuffer of framebufferList) { if (framebuffer) glContext.deleteFramebuffer(framebuffer) } if (noiseFramebuffer) glContext.deleteFramebuffer(noiseFramebuffer) if (quadVertexBuffer) glContext.deleteBuffer(quadVertexBuffer) if (quadVertexArray) glContext.deleteVertexArray(quadVertexArray) if (particleVertexArray) glContext.deleteVertexArray(particleVertexArray) }})</script>
<template> <div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6"> <span class="text-sm text-gray-600"> 回補原始版的渲染層次:多段位移、時間性微飄動、位移混色與完整指標慣性,讓回歸時更有渦流感。 </span>
<canvas ref="canvasRef" class="w-full rounded-lg" style="height: 320px;" /> </div></template>普通亂數 vs Curl Noise
Section titled “普通亂數 vs Curl Noise”一般的 Math.random() 會讓粒子亂跳,像喝醉一樣毫無方向感。
Curl Noise 不一樣,它產生的是旋轉場。鄰近的點會有相似的方向,粒子沿著它走會形成漩渦般的軌跡。
<script setup lang="ts">import { onBeforeUnmount, onMounted, ref } from 'vue'
const containerRef = ref<HTMLElement | null>(null)const randomCanvasRef = ref<HTMLCanvasElement | null>(null)const curlCanvasRef = ref<HTMLCanvasElement | null>(null)
const PARTICLE_COUNT = 600const CANVAS_SIZE = 280const TRAIL_ALPHA = 0.06
let animationFrameId = 0
interface Particle { x: number; y: number; velocityX: number; velocityY: number;}
// ---- Curl Noise utilities ----function hash(x: number, y: number): number { const p3x = ((x * 0.13) % 1 + 1) % 1 const p3y = ((y * 0.13) % 1 + 1) % 1 const p3z = ((x * 0.13) % 1 + 1) % 1 const dot = p3x * (p3y + 33.33) + p3y * (p3z + 33.33) + p3z * (p3x + 33.33) return ((dot * dot) % 1 + 1) % 1}
function smoothNoise(x: number, y: number): number { const ix = Math.floor(x) const iy = Math.floor(y) const fx = x - ix const fy = y - iy
const a = hash(ix, iy) const b = hash(ix + 1, iy) const c = hash(ix, iy + 1) const d = hash(ix + 1, iy + 1)
const ux = fx * fx * (3 - 2 * fx) const uy = fy * fy * (3 - 2 * fy)
return a + (b - a) * ux + (c - a) * uy * (1 - ux) + (d - b) * ux * uy}
function fbm(x: number, y: number): number { let value = 0 let amplitude = 0.5 let px = x let py = y
for (let i = 0; i < 4; i++) { value += amplitude * smoothNoise(px, py) px *= 2 py *= 2 amplitude *= 0.5 }
return value}
function curlNoise(x: number, y: number): { x: number; y: number } { const epsilon = 0.1 const a = (fbm(x, y + epsilon) - fbm(x, y - epsilon)) / (2 * epsilon) const b = (fbm(x + epsilon, y) - fbm(x - epsilon, y)) / (2 * epsilon) return { x: a, y: -b }}
// ---- Particle creation ----function createParticleList(): Particle[] { const list: Particle[] = [] for (let i = 0; i < PARTICLE_COUNT; i++) { list.push({ x: CANVAS_SIZE / 2 + (Math.random() - 0.5) * 40, y: CANVAS_SIZE / 2 + (Math.random() - 0.5) * 40, velocityX: 0, velocityY: 0, }) } return list}
let randomParticleList: Particle[] = []let curlParticleList: Particle[] = []let time = 0
function updateRandomParticleList(): void { for (const particle of randomParticleList) { const angle = Math.random() * Math.PI * 2 const strength = 1.2 particle.velocityX += Math.cos(angle) * strength particle.velocityY += Math.sin(angle) * strength
particle.velocityX *= 0.92 particle.velocityY *= 0.92
particle.x += particle.velocityX particle.y += particle.velocityY
// 邊界回彈 if (particle.x < 0 || particle.x > CANVAS_SIZE) { particle.velocityX *= -0.5 particle.x = Math.max(0, Math.min(CANVAS_SIZE, particle.x)) } if (particle.y < 0 || particle.y > CANVAS_SIZE) { particle.velocityY *= -0.5 particle.y = Math.max(0, Math.min(CANVAS_SIZE, particle.y)) } }}
function updateCurlParticleList(): void { time += 0.01
for (const particle of curlParticleList) { const scale = 0.012 const curl = curlNoise( particle.x * scale + time * 0.8, particle.y * scale + time * 0.5, ) const strength = 1.2 particle.velocityX += curl.x * strength particle.velocityY += curl.y * strength
particle.velocityX *= 0.92 particle.velocityY *= 0.92
particle.x += particle.velocityX particle.y += particle.velocityY
// 邊界回彈 if (particle.x < 0 || particle.x > CANVAS_SIZE) { particle.velocityX *= -0.5 particle.x = Math.max(0, Math.min(CANVAS_SIZE, particle.x)) } if (particle.y < 0 || particle.y > CANVAS_SIZE) { particle.velocityY *= -0.5 particle.y = Math.max(0, Math.min(CANVAS_SIZE, particle.y)) } }}
function drawParticleList( context: CanvasRenderingContext2D, particleList: Particle[], color: string, dpr: number,): void { // 半透明背景覆蓋產生拖尾 context.fillStyle = `rgba(24, 24, 27, ${TRAIL_ALPHA})` context.fillRect(0, 0, CANVAS_SIZE * dpr, CANVAS_SIZE * dpr)
context.fillStyle = color for (const particle of particleList) { const speed = Math.sqrt( particle.velocityX * particle.velocityX + particle.velocityY * particle.velocityY, ) const radius = Math.min(1 + speed * 0.15, 2.5) * dpr
context.beginPath() context.arc(particle.x * dpr, particle.y * dpr, radius, 0, Math.PI * 2) context.fill() }}
function animate(): void { const randomCanvas = randomCanvasRef.value const curlCanvas = curlCanvasRef.value if (!randomCanvas || !curlCanvas) return
const dpr = window.devicePixelRatio || 1 const randomContext = randomCanvas.getContext('2d') const curlContext = curlCanvas.getContext('2d') if (!randomContext || !curlContext) return
updateRandomParticleList() updateCurlParticleList()
drawParticleList(randomContext, randomParticleList, 'rgba(160, 160, 170, 0.8)', dpr) drawParticleList(curlContext, curlParticleList, 'rgba(130, 180, 255, 0.8)', dpr)
animationFrameId = requestAnimationFrame(animate)}
function initCanvas(): void { const randomCanvas = randomCanvasRef.value const curlCanvas = curlCanvasRef.value if (!randomCanvas || !curlCanvas) return
const dpr = window.devicePixelRatio || 1 randomCanvas.width = CANVAS_SIZE * dpr randomCanvas.height = CANVAS_SIZE * dpr curlCanvas.width = CANVAS_SIZE * dpr curlCanvas.height = CANVAS_SIZE * dpr
// 初始填充背景 const randomContext = randomCanvas.getContext('2d') const curlContext = curlCanvas.getContext('2d') if (randomContext) { randomContext.fillStyle = 'rgb(24, 24, 27)' randomContext.fillRect(0, 0, CANVAS_SIZE * dpr, CANVAS_SIZE * dpr) } if (curlContext) { curlContext.fillStyle = 'rgb(24, 24, 27)' curlContext.fillRect(0, 0, CANVAS_SIZE * dpr, CANVAS_SIZE * dpr) }
randomParticleList = createParticleList() curlParticleList = createParticleList() time = 0}
onMounted(() => { initCanvas() animationFrameId = requestAnimationFrame(animate)})
onBeforeUnmount(() => { cancelAnimationFrame(animationFrameId)})</script>
<template> <div ref="containerRef" class="flex flex-col gap-4 border border-gray-200 rounded-xl p-6" > <div class="grid grid-cols-2 gap-4"> <div class="flex flex-col items-center gap-2"> <span class="text-sm text-gray-500 font-bold"> Math.random() </span> <canvas ref="randomCanvasRef" class="w-full rounded-lg" :style="{ aspectRatio: '1 / 1' }" /> </div>
<div class="flex flex-col items-center gap-2"> <span class="text-sm text-blue-400 font-bold"> Curl Noise </span> <canvas ref="curlCanvasRef" class="w-full rounded-lg" :style="{ aspectRatio: '1 / 1' }" /> </div> </div> </div></template>對一個 2D 噪聲場取偏導數,然後旋轉 90 度。e 是微小的取樣間距(例如 0.01),用來做數值微分:
float e = 0.01;float a = (fbm(p + vec2(0.0, e)) - fbm(p - vec2(0.0, e))) / (2.0 * e);float b = (fbm(p + vec2(e, 0.0)) - fbm(p - vec2(e, 0.0))) / (2.0 * e);curl = vec2(a, -b);FBM(Fractal Brownian Motion)
Section titled “FBM(Fractal Brownian Motion)”fbm 是分形布朗運動,只要把多個頻率的噪聲疊在一起。裡面的 noise() 是自己實作的 2D 雜訊函式(例如 Simplex Noise 或 Value Noise),輸入一個座標,回傳 -1~1 的浮點數:
float fbm(vec2 p) { float value = 0.0; float amplitude = 0.5; for (int i = 0; i < 4; i++) { value += amplitude * noise(p); p *= 2.0; // 頻率加倍(看得更細) amplitude *= 0.5; // 振幅減半(影響力變小) } return value;}低頻的大漩渦 + 高頻的小擾動,看起來就很像自然界的紊流。
1 層:只有大塊色斑,像馬賽克
<script setup lang="ts">import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
const canvasRef = ref<HTMLCanvasElement | null>(null)const octaveCount = ref(1)
const CANVAS_WIDTH = 600const CANVAS_HEIGHT = 200const NOISE_SCALE = 0.02
// ---- 噪聲工具 ----function hash(x: number, y: number): number { const p3x = ((x * 0.1031) % 1 + 1) % 1 const p3y = ((y * 0.1030) % 1 + 1) % 1 const p3z = ((x * 0.0973) % 1 + 1) % 1 const dotValue = p3x * (p3y + 33.33) + p3y * (p3z + 33.33) + p3z * (p3x + 33.33) return ((dotValue * (dotValue + 23.45)) % 1 + 1) % 1}
function smoothNoise(x: number, y: number): number { const ix = Math.floor(x) const iy = Math.floor(y) const fx = x - ix const fy = y - iy
const a = hash(ix, iy) const b = hash(ix + 1, iy) const c = hash(ix, iy + 1) const d = hash(ix + 1, iy + 1)
// smoothstep 插值 const ux = fx * fx * (3 - 2 * fx) const uy = fy * fy * (3 - 2 * fy)
return a + (b - a) * ux + (c - a) * uy * (1 - ux) + (d - b) * ux * uy}
/** 指定層數的 FBM */function fbm(x: number, y: number, octaveMax: number): number { let value = 0 let amplitude = 0.5 let px = x let py = y
for (let i = 0; i < octaveMax; i++) { value += amplitude * smoothNoise(px, py) px *= 2 py *= 2 amplitude *= 0.5 }
return value}
function drawNoise(): void { const canvas = canvasRef.value if (!canvas) return
const dpr = window.devicePixelRatio || 1 const logicalWidth = canvas.clientWidth const logicalHeight = canvas.clientHeight canvas.width = logicalWidth * dpr canvas.height = logicalHeight * dpr
const context = canvas.getContext('2d') if (!context) return
const imageData = context.createImageData(canvas.width, canvas.height) const pixelDataList = imageData.data
const currentOctaveCount = octaveCount.value
for (let pixelY = 0; pixelY < canvas.height; pixelY++) { for (let pixelX = 0; pixelX < canvas.width; pixelX++) { const noiseX = (pixelX / dpr) * NOISE_SCALE const noiseY = (pixelY / dpr) * NOISE_SCALE
const noiseValue = fbm(noiseX, noiseY, currentOctaveCount) const brightness = Math.floor(noiseValue * 255)
const index = (pixelY * canvas.width + pixelX) * 4 pixelDataList[index] = brightness pixelDataList[index + 1] = brightness pixelDataList[index + 2] = brightness pixelDataList[index + 3] = 255 } }
context.putImageData(imageData, 0, 0)}
const octaveLabelList = [ '1 層:只有大塊色斑,像馬賽克', '2 層:加入中頻細節', '3 層:開始有自然紋理感', '4 層:接近真實紊流',]
let resizeObserver: ResizeObserver | null = null
onMounted(() => { drawNoise()
resizeObserver = new ResizeObserver(() => { drawNoise() }) if (canvasRef.value) { resizeObserver.observe(canvasRef.value) }})
onBeforeUnmount(() => { resizeObserver?.disconnect()})
watch(octaveCount, () => { drawNoise()})</script>
<template> <div class="flex flex-col gap-3 border border-gray-200 rounded-xl p-6"> <div class="flex flex-wrap items-center gap-2"> <span class="text-sm text-gray-500 shrink-0"> FBM 層數: </span> <div class="flex gap-1.5"> <button v-for="n in 4" :key="n" class="min-w-9 rounded-lg px-3 py-1.5 text-sm transition-colors" :class="octaveCount === n ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'" @click="octaveCount = n" > {{ n }} </button> </div> </div>
<canvas ref="canvasRef" class="w-full rounded-lg" :style="{ height: '200px' }" />
<p class="m-0 text-sm text-gray-400"> {{ octaveLabelList[octaveCount - 1] }} </p> </div></template>預計算噪聲紋理
Section titled “預計算噪聲紋理”每幀在 Shader 裡即時算 FBM 太耗時,(4 層巢狀噪聲)。
最簡單的方法是初始化時先將整張噪聲場算好,烘焙成 256×256 的紋理:
gl.viewport(0, 0, 256, 256)gl.useProgram(noiseProgram)gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4)之後物理 Shader 只要 texture(uNoise, uv) 查表就好,就是經典的用空間換時間策略。
紋理的 WRAP 設成 REPEAT,讓 UV 超出 0~1 時自動循環,等於無限大的噪聲場。
在物理 Shader 中加入擾動
Section titled “在物理 Shader 中加入擾動”這段程式碼用到三個新變數:
uNoise:上一節烘焙好的噪聲紋理,用texture()查表讀取(和texelFetch不同,texture用 0~1 的浮點 UV 座標,且會自動插值)uTime:JavaScript 每幀傳入的累計時間(秒),讓噪聲場隨時間漂移pRand:每個粒子的隨機種子,在初始化時存進原始位置紋理的空閒通道,Shader 裡用origin.z或origin.w讀出來
vec2 noiseUV = pos * 0.005 + uTime * 0.15 + pRand * 10.0;vec2 curl = texture(uNoise, noiseUV).xy;float turbStrength = influence * uScatterForce * 0.12;vel += curl * turbStrength;pos * 0.005:用粒子位置當 UV,鄰近粒子查到相似的值(群集感)uTime * 0.15:隨時間漂移,讓漩渦不斷流動pRand * 10.0:每個粒子加一點偏移,避免完全同步
飄盪回歸也用噪聲
Section titled “飄盪回歸也用噪聲”被吹散的粒子在回家路上也會查噪聲紋理,產生飄盪效果:
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:多層發光效果
Section titled “Step 19:多層發光效果”粒子飄起來了!但看起來都是一樣亮度的白點,讓我們加入一點顏色變化提高視覺層次感。✧⁑。٩(ˊᗜˋ*)و✧⁕。
路人:「蟲蟲會變色?(́⊙◞౪◟⊙‵)」
鱈魚:「我說會就會ヽ(́◕◞౪◟◕‵)ノ」
<script setup lang="ts">import { onBeforeUnmount, onMounted, ref } from 'vue'
const canvasRef = ref<HTMLCanvasElement | null>(null)
let glContext: WebGL2RenderingContext | null = nulllet physicsProgram: WebGLProgram | null = nulllet renderProgram: WebGLProgram | null = nulllet noiseProgram: WebGLProgram | null = nulllet animationFrameId = 0
const stateTextureList: [WebGLTexture | null, WebGLTexture | null] = [null, null]const framebufferList: [WebGLFramebuffer | null, WebGLFramebuffer | null] = [null, null]let originTexture: WebGLTexture | null = nulllet noiseTexture: WebGLTexture | null = nulllet noiseFramebuffer: WebGLFramebuffer | null = nulllet quadVertexBuffer: WebGLBuffer | null = nulllet quadVertexArray: WebGLVertexArrayObject | null = nulllet particleVertexArray: WebGLVertexArrayObject | null = null
let particleCount = 0let textureSize = 0let pingPongIndex = 0let startTime = 0let logicalWidth = 0let logicalHeight = 0
let mouseX = 0let mouseY = 0let previousMouseX = -9999let previousMouseY = -9999let mouseVelocityX = 0let mouseVelocityY = 0let isMouseInside = false
const DISPLAY_TEXT = 'SWARM'const NOISE_SIZE = 256
// ----- Noise generation shader -----const noiseVertexSource = `#version 300 esin vec2 aPosition;void main() { gl_Position = vec4(aPosition, 0.0, 1.0);}`
const noiseFragmentSource = `#version 300 esprecision highp float;out vec4 fragColor;
float hash(vec2 p) { vec3 p3 = fract(vec3(p.xyx) * 0.13); p3 += dot(p3, p3.yzx + 33.33); return fract((p3.x + p3.y) * p3.z);}
float noise(vec2 x) { vec2 i = floor(x); vec2 f = fract(x); float a = hash(i); float b = hash(i + vec2(1.0, 0.0)); float c = hash(i + vec2(0.0, 1.0)); float d = hash(i + vec2(1.0, 1.0)); vec2 u = f * f * (3.0 - 2.0 * f); return mix(a, b, u.x) + (c - a) * u.y * (1.0 - u.x) + (d - b) * u.x * u.y;}
float fbm(vec2 p) { float v = 0.0; float a = 0.5; for (int i = 0; i < 4; i++) { v += a * noise(p); p *= 2.0; a *= 0.5; } return v;}
void main() { vec2 uv = gl_FragCoord.xy / 256.0; vec2 p = uv * 6.0; const float e = 0.1; float a = (fbm(p + vec2(0.0, e)) - fbm(p - vec2(0.0, e))) / (2.0 * e); float b = (fbm(p + vec2(e, 0.0)) - fbm(p - vec2(e, 0.0))) / (2.0 * e); fragColor = vec4(a, -b, 0.0, 1.0);}`
// ----- Physics pass shaders -----const physicsVertexSource = `#version 300 esin vec2 aPosition;void main() { gl_Position = vec4(aPosition, 0.0, 1.0);}`
const physicsFragmentSource = `#version 300 esprecision highp float;
uniform sampler2D uState;uniform sampler2D uOrigin;uniform sampler2D uNoise;uniform vec2 uMouse;uniform vec2 uMouseVelocity;uniform int uMouseInside;uniform float uScatterRadius;uniform float uScatterForce;uniform float uReturnSpeed;uniform float uFriction;uniform float uTime;
out vec4 fragColor;
float hash(vec2 p) { vec3 p3 = fract(vec3(p.xyx) * 0.13); p3 += dot(p3, p3.yzx + 33.33); return fract((p3.x + p3.y) * p3.z);}
void main() { ivec2 coord = ivec2(gl_FragCoord.xy); vec4 state = texelFetch(uState, coord, 0); vec4 origin = texelFetch(uOrigin, coord, 0);
vec2 pos = state.xy; vec2 vel = state.zw; vec2 originPos = origin.xy;
if (originPos.x < -9999.0) { fragColor = state; return; }
float pRand = hash(vec2(float(coord.x), float(coord.y))); float forceScale = 0.5 + pRand * 1.0;
if (uMouseInside == 1) { vec2 toParticle = pos - uMouse; float dist = length(toParticle);
if (dist < uScatterRadius) { float influence = smoothstep(uScatterRadius, 0.0, dist); float mouseSpeed = length(uMouseVelocity);
if (mouseSpeed > 0.5) { vec2 windDir = uMouseVelocity / mouseSpeed; vel += windDir * mouseSpeed * influence * uScatterForce * 0.025 * forceScale; }
vec2 noiseUV = pos * 0.005 + uTime * 0.15 + pRand * 10.0; vec2 curl = texture(uNoise, noiseUV).xy; float turbStrength = (1.5 + mouseSpeed * 0.4) * influence * uScatterForce * 0.12 * forceScale; vel += curl * turbStrength;
if (dist > 0.01) { vel += (toParticle / dist) * influence * influence * uScatterForce * 0.5 * forceScale; } } }
vec2 toOrigin = originPos - pos; float distFromOrigin = length(toOrigin);
if (distFromOrigin > 2.0) { float displacement = smoothstep(2.0, 40.0, distFromOrigin); vec2 driftUV = pos * 0.002 + uTime * 0.08 + pRand * 0.3; vec2 drift = texture(uNoise, driftUV).xy; vel += drift * displacement * 1.5 * forceScale; }
float particleFriction = uFriction - pRand * 0.04; vel *= particleFriction;
pos += vel;
float returnRate = uReturnSpeed * (0.6 + pRand * 0.8); pos = mix(pos, originPos, returnRate);
fragColor = vec4(pos, vel);}`
// ----- Render pass shaders -----const renderVertexSource = `#version 300 esprecision highp float;
uniform sampler2D uState;uniform sampler2D uOrigin;uniform int uTextureSize;uniform vec2 uResolution;uniform float uPointScale;uniform float uTime;
out float vOpacity;out float vDisplacement;out float vSpeed;
void main() { int index = gl_VertexID; int texelX = index % uTextureSize; int texelY = index / uTextureSize;
vec4 state = texelFetch(uState, ivec2(texelX, texelY), 0); vec4 origin = texelFetch(uOrigin, ivec2(texelX, texelY), 0);
vec2 pos = state.xy; vec2 vel = state.zw; vec2 originPos = origin.xy; float size = origin.z; float opacity = origin.w;
if (originPos.x < -9999.0) { gl_Position = vec4(-2.0, -2.0, 0.0, 1.0); gl_PointSize = 0.0; vOpacity = 0.0; vDisplacement = 0.0; vSpeed = 0.0; return; }
float distFromOrigin = length(pos - originPos); float speed = length(vel);
float displacementNear = smoothstep(0.0, 8.0, distFromOrigin); float displacementMid = smoothstep(8.0, 30.0, distFromOrigin); float displacementFar = smoothstep(30.0, 80.0, distFromOrigin); float displacement = displacementNear * 0.3 + displacementMid * 0.4 + displacementFar * 0.3;
float speedFactor = smoothstep(0.0, 8.0, speed);
float phase = float(index) * 1.618033988; pos.x += sin(phase + uTime * 0.001) * 0.2; pos.y += cos(phase * 1.3 + uTime * 0.0008) * 0.2;
vec2 clipPosition = (pos / uResolution) * 2.0 - 1.0; clipPosition.y *= -1.0;
gl_Position = vec4(clipPosition, 0.0, 1.0); gl_PointSize = size * 2.0 * uPointScale;
vOpacity = opacity; vDisplacement = displacement; vSpeed = speedFactor;}`
const renderFragmentSource = `#version 300 esprecision highp float;
uniform vec3 uColor;
in float vOpacity;in float vDisplacement;in float vSpeed;
out vec4 fragColor;
void main() { float dist = length(gl_PointCoord - 0.5) * 2.0; if (dist > 1.0) discard;
// 邊緣柔化隨 displacement 變化 float coreEdge = mix(0.4, 0.25, vDisplacement); float alpha = smoothstep(1.0, coreEdge, dist) * vOpacity;
// 暖白漸變:位移越遠越亮 vec3 warmHighlight = mix( uColor, uColor + vec3(0.15, 0.1, 0.05), vDisplacement ); vec3 color = mix( warmHighlight, vec3(1.0), vDisplacement * 0.5 + vSpeed * 0.3 );
// 高速白芯 float coreBright = (1.0 - smoothstep(0.0, 0.5, dist)) * vSpeed * 0.4; color += vec3(coreBright);
fragColor = vec4(color, alpha);}`
// ----- Helper functions -----function compileShader( gl: WebGL2RenderingContext, type: number, source: string,): WebGLShader | null { const shader = gl.createShader(type) if (!shader) return null gl.shaderSource(shader, source) gl.compileShader(shader) if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { console.error(gl.getShaderInfoLog(shader)) gl.deleteShader(shader) return null } return shader}
function linkProgram( gl: WebGL2RenderingContext, vertSource: string, fragSource: string,): WebGLProgram | null { const vert = compileShader(gl, gl.VERTEX_SHADER, vertSource) const frag = compileShader(gl, gl.FRAGMENT_SHADER, fragSource) if (!vert || !frag) return null const program = gl.createProgram() if (!program) return null gl.attachShader(program, vert) gl.attachShader(program, frag) gl.linkProgram(program) if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { console.error(gl.getProgramInfoLog(program)) gl.deleteProgram(program) return null } gl.deleteShader(vert) gl.deleteShader(frag) return program}
function createFloatTexture( gl: WebGL2RenderingContext, size: number, data: Float32Array,): WebGLTexture | null { const texture = gl.createTexture() if (!texture) return null gl.bindTexture(gl.TEXTURE_2D, texture) gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, size, size, 0, gl.RGBA, gl.FLOAT, data) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) return texture}
// ----- Particle sampling from text -----interface SampledParticle { x: number; y: number; size: number; opacity: number;}
function sampleParticleListFromText( text: string, canvasWidth: number, canvasHeight: number,): SampledParticle[] { const offscreen = document.createElement('canvas') offscreen.width = canvasWidth offscreen.height = canvasHeight const context = offscreen.getContext('2d') if (!context) return []
const fontSize = Math.min(canvasWidth / (text.length * 0.7), canvasHeight * 0.6) context.fillStyle = 'white' context.font = `bold ${fontSize}px Arial, sans-serif` context.textAlign = 'center' context.textBaseline = 'middle' context.fillText(text, canvasWidth / 2, canvasHeight / 2)
const imageData = context.getImageData(0, 0, canvasWidth, canvasHeight) const pixelDataList = imageData.data const gap = 1 const resultList: SampledParticle[] = []
for (let y = 0; y < canvasHeight; y += gap) { for (let x = 0; x < canvasWidth; x += gap) { const pixelX = Math.floor(x) const pixelY = Math.floor(y) const index = (pixelY * canvasWidth + pixelX) * 4 const alpha = (pixelDataList[index + 3] ?? 0) / 255 if (alpha > 0.15) { resultList.push({ x, y, size: 1.5 * (0.4 + alpha * 0.6), opacity: alpha * (0.8 + Math.random() * 0.2), }) } } }
return resultList}
// ----- Setup textures and framebuffers -----function setupParticleTextures( gl: WebGL2RenderingContext, sampledParticleList: SampledParticle[],): void { particleCount = sampledParticleList.length textureSize = Math.ceil(Math.sqrt(particleCount)) const totalPixelCount = textureSize * textureSize
const stateData = new Float32Array(totalPixelCount * 4) const originData = new Float32Array(totalPixelCount * 4)
for (let i = 0; i < totalPixelCount; i++) { if (i < particleCount) { const particle = sampledParticleList[i] stateData[i * 4 + 0] = particle?.x ?? 0 stateData[i * 4 + 1] = particle?.y ?? 0 stateData[i * 4 + 2] = 0 stateData[i * 4 + 3] = 0 originData[i * 4 + 0] = particle?.x ?? 0 originData[i * 4 + 1] = particle?.y ?? 0 originData[i * 4 + 2] = particle?.size ?? 0 originData[i * 4 + 3] = particle?.opacity ?? 0 } else { stateData[i * 4 + 0] = -99999 stateData[i * 4 + 1] = -99999 stateData[i * 4 + 2] = 0 stateData[i * 4 + 3] = 0 originData[i * 4 + 0] = -99999 originData[i * 4 + 1] = -99999 originData[i * 4 + 2] = 0 originData[i * 4 + 3] = 0 } }
stateTextureList[0] = createFloatTexture(gl, textureSize, stateData) stateTextureList[1] = createFloatTexture(gl, textureSize, new Float32Array(stateData)) originTexture = createFloatTexture(gl, textureSize, originData)
for (let i = 0; i < 2; i++) { framebufferList[i] = gl.createFramebuffer() gl.bindFramebuffer(gl.FRAMEBUFFER, framebufferList[i]) gl.framebufferTexture2D( gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, stateTextureList[i], 0, ) } gl.bindFramebuffer(gl.FRAMEBUFFER, null)}
function generateNoiseTexture(gl: WebGL2RenderingContext): void { noiseProgram = linkProgram(gl, noiseVertexSource, noiseFragmentSource) if (!noiseProgram) return
noiseTexture = gl.createTexture() gl.bindTexture(gl.TEXTURE_2D, noiseTexture) gl.texImage2D( gl.TEXTURE_2D, 0, gl.RGBA32F, NOISE_SIZE, NOISE_SIZE, 0, gl.RGBA, gl.FLOAT, null, )
const hasFloatLinear = !!gl.getExtension('OES_texture_float_linear') const noiseFilter = hasFloatLinear ? gl.LINEAR : gl.NEAREST gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, noiseFilter) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, noiseFilter) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT) gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.REPEAT)
noiseFramebuffer = gl.createFramebuffer() gl.bindFramebuffer(gl.FRAMEBUFFER, noiseFramebuffer) gl.framebufferTexture2D( gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, noiseTexture, 0, )
gl.viewport(0, 0, NOISE_SIZE, NOISE_SIZE) gl.useProgram(noiseProgram) gl.bindVertexArray(quadVertexArray) gl.drawArrays(gl.TRIANGLES, 0, 6) gl.bindVertexArray(null)
gl.bindFramebuffer(gl.FRAMEBUFFER, null) gl.deleteProgram(noiseProgram) noiseProgram = null}
function setupBuffers(gl: WebGL2RenderingContext): void { const positionList = new Float32Array([ -1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1, ])
quadVertexArray = gl.createVertexArray() gl.bindVertexArray(quadVertexArray)
quadVertexBuffer = gl.createBuffer() gl.bindBuffer(gl.ARRAY_BUFFER, quadVertexBuffer) gl.bufferData(gl.ARRAY_BUFFER, positionList, gl.STATIC_DRAW)
const positionLocation = 0 gl.enableVertexAttribArray(positionLocation) gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0) gl.bindVertexArray(null)
particleVertexArray = gl.createVertexArray()}
// ----- Pointer events -----function handlePointerDown(event: PointerEvent): void { const target = event.currentTarget if (target instanceof Element) target.setPointerCapture(event.pointerId) handlePointerMove(event)}
function handlePointerMove(event: PointerEvent): void { const newX = event.offsetX const newY = event.offsetY
if (previousMouseX > -9000 && previousMouseY > -9000) { mouseVelocityX = newX - previousMouseX mouseVelocityY = newY - previousMouseY }
previousMouseX = newX previousMouseY = newY mouseX = newX mouseY = newY isMouseInside = true}
function handlePointerLeave(): void { isMouseInside = false previousMouseX = -9999 previousMouseY = -9999}
// ----- Init and render -----function initWebGL(): void { const canvas = canvasRef.value if (!canvas) return
const dpr = window.devicePixelRatio || 1 logicalWidth = canvas.clientWidth logicalHeight = canvas.clientHeight canvas.width = logicalWidth * dpr canvas.height = logicalHeight * dpr
glContext = canvas.getContext('webgl2', { premultipliedAlpha: false, alpha: true, }) if (!glContext) { console.error('WebGL2 not supported') return }
const extColorFloat = glContext.getExtension('EXT_color_buffer_float') if (!extColorFloat) { console.error('EXT_color_buffer_float not supported') return }
physicsProgram = linkProgram(glContext, physicsVertexSource, physicsFragmentSource) renderProgram = linkProgram(glContext, renderVertexSource, renderFragmentSource) if (!physicsProgram || !renderProgram) return
setupBuffers(glContext) generateNoiseTexture(glContext)
const sampledParticleList = sampleParticleListFromText( DISPLAY_TEXT, logicalWidth, logicalHeight, ) setupParticleTextures(glContext, sampledParticleList)
glContext.enable(glContext.BLEND) glContext.blendFuncSeparate( glContext.SRC_ALPHA, glContext.ONE_MINUS_SRC_ALPHA, glContext.ONE, glContext.ONE_MINUS_SRC_ALPHA, )
previousMouseX = -9999 previousMouseY = -9999 mouseVelocityX = 0 mouseVelocityY = 0 startTime = performance.now()
canvas.addEventListener('pointerdown', handlePointerDown) canvas.addEventListener('pointermove', handlePointerMove) canvas.addEventListener('pointerup', handlePointerLeave) canvas.addEventListener('pointerleave', handlePointerLeave)}
function render(): void { if (!glContext || !physicsProgram || !renderProgram) return
const canvas = canvasRef.value if (!canvas) return
const gl = glContext const currentTime = (performance.now() - startTime) * 0.001
mouseVelocityX *= 0.92 mouseVelocityY *= 0.92
const readIndex = pingPongIndex const writeIndex = 1 - pingPongIndex
gl.disable(gl.BLEND) gl.bindFramebuffer(gl.FRAMEBUFFER, framebufferList[writeIndex]) gl.viewport(0, 0, textureSize, textureSize)
gl.useProgram(physicsProgram)
gl.activeTexture(gl.TEXTURE0) gl.bindTexture(gl.TEXTURE_2D, stateTextureList[readIndex]) gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uState'), 0)
gl.activeTexture(gl.TEXTURE1) gl.bindTexture(gl.TEXTURE_2D, originTexture) gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uOrigin'), 1)
gl.activeTexture(gl.TEXTURE2) gl.bindTexture(gl.TEXTURE_2D, noiseTexture) gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uNoise'), 2)
gl.uniform2f(gl.getUniformLocation(physicsProgram, 'uMouse'), mouseX, mouseY) gl.uniform2f(gl.getUniformLocation(physicsProgram, 'uMouseVelocity'), mouseVelocityX, mouseVelocityY) gl.uniform1i(gl.getUniformLocation(physicsProgram, 'uMouseInside'), isMouseInside ? 1 : 0) gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uScatterRadius'), 40.0) gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uScatterForce'), 40.0) gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uReturnSpeed'), 0.1) gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uFriction'), 0.92) gl.uniform1f(gl.getUniformLocation(physicsProgram, 'uTime'), currentTime)
gl.bindVertexArray(quadVertexArray) gl.drawArrays(gl.TRIANGLES, 0, 6) gl.bindVertexArray(null)
pingPongIndex = writeIndex
gl.bindFramebuffer(gl.FRAMEBUFFER, null) gl.viewport(0, 0, canvas.width, canvas.height) gl.clearColor(0, 0, 0, 0) gl.clear(gl.COLOR_BUFFER_BIT)
gl.enable(gl.BLEND) gl.blendFuncSeparate( gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA, )
gl.useProgram(renderProgram)
gl.activeTexture(gl.TEXTURE0) gl.bindTexture(gl.TEXTURE_2D, stateTextureList[writeIndex]) gl.uniform1i(gl.getUniformLocation(renderProgram, 'uState'), 0)
gl.activeTexture(gl.TEXTURE1) gl.bindTexture(gl.TEXTURE_2D, originTexture) gl.uniform1i(gl.getUniformLocation(renderProgram, 'uOrigin'), 1)
const dpr = window.devicePixelRatio || 1 gl.uniform1i(gl.getUniformLocation(renderProgram, 'uTextureSize'), textureSize) gl.uniform2f( gl.getUniformLocation(renderProgram, 'uResolution'), logicalWidth, logicalHeight, ) gl.uniform1f(gl.getUniformLocation(renderProgram, 'uPointScale'), dpr) gl.uniform1f(gl.getUniformLocation(renderProgram, 'uTime'), performance.now()) gl.uniform3f(gl.getUniformLocation(renderProgram, 'uColor'), 0.53, 0.53, 0.53)
gl.bindVertexArray(particleVertexArray) gl.drawArrays(gl.POINTS, 0, particleCount) gl.bindVertexArray(null)
animationFrameId = requestAnimationFrame(render)}
onMounted(() => { initWebGL() animationFrameId = requestAnimationFrame(render)})
onBeforeUnmount(() => { cancelAnimationFrame(animationFrameId)
const canvas = canvasRef.value if (canvas) { canvas.removeEventListener('pointerdown', handlePointerDown) canvas.removeEventListener('pointermove', handlePointerMove) canvas.removeEventListener('pointerup', handlePointerLeave) canvas.removeEventListener('pointerleave', handlePointerLeave) }
if (glContext) { if (physicsProgram) glContext.deleteProgram(physicsProgram) if (renderProgram) glContext.deleteProgram(renderProgram) for (const texture of stateTextureList) { if (texture) glContext.deleteTexture(texture) } if (originTexture) glContext.deleteTexture(originTexture) if (noiseTexture) glContext.deleteTexture(noiseTexture) for (const framebuffer of framebufferList) { if (framebuffer) glContext.deleteFramebuffer(framebuffer) } if (noiseFramebuffer) glContext.deleteFramebuffer(noiseFramebuffer) if (quadVertexBuffer) glContext.deleteBuffer(quadVertexBuffer) if (quadVertexArray) glContext.deleteVertexArray(quadVertexArray) if (particleVertexArray) glContext.deleteVertexArray(particleVertexArray) }})</script>
<template> <div class="flex flex-col gap-2 border border-gray-200 rounded-xl p-6"> <span class="text-sm text-gray-600"> 多層發光效果:位移越遠顏色越亮、邊緣隨位移柔化 </span>
<canvas ref="canvasRef" class="w-full rounded-lg" style="height: 320px;" /> </div></template>亮度取決於兩個因子
Section titled “亮度取決於兩個因子”- 離家多遠(displacement):被推得越遠越亮
- 移動多快(speed):速度越快越亮
這兩個值在 Vertex Shader(Step 16)已經算好,透過 Varying 傳到 Fragment Shader。
三層 displacement
Section titled “三層 displacement”float displacementNear = smoothstep(0.0, 8.0, distFromOrigin);float displacementMid = smoothstep(8.0, 30.0, distFromOrigin);float displacementFar = smoothstep(30.0, 80.0, distFromOrigin);float displacement = displacementNear * 0.3 + displacementMid * 0.4 + displacementFar * 0.3;| 層 | 距離 | 效果 |
|---|---|---|
| 近距微擾 | 0~8px | 微微亮起,暗示被輕輕推了一下 |
| 中距飄散 | 8~30px | 明顯變亮 |
| 遠距爆開 | 30px+ | 接近全白 |
為什麼要分三層而不是一個 smoothstep 搞定?因為單一 smoothstep 的漸變太平均了。三層分別控制不同距離帶的亮度曲線,近距微微亮、中距明顯亮、遠距爆亮,層次更豐富。
speedFactor 是從 Vertex Shader 傳來的 vSpeed(Step 16 算好的速度強度 0~1),代表粒子目前跑多快。和 displacement 搭配,讓「離家遠」和「移動快」的粒子都會變亮。
vec3 warmHighlight = mix( uColor, uColor + vec3(0.15, 0.1, 0.05), displacement);vec3 color = mix( warmHighlight, vec3(1.0), displacement * 0.5 + speedFactor * 0.3);粒子不是直接從原色變成白色,中間會經過一個暖色調(偏橘白)。vec3(0.15, 0.1, 0.05) 讓紅色多加一點、綠色少一點、藍色更少,就是暖色偏移。
float coreBright = (1.0 - smoothstep(0.0, 0.5, dist)) * speedFactor * 0.4;color += vec3(coreBright);快速移動的粒子中心額外疊加一層白芯。dist 是到粒子中心的距離,1.0 - smoothstep(0.0, 0.5, dist) 只在粒子正中央才亮。
效果是剛被推開的粒子中心特別亮,有種「衝擊波」的感覺。離中心遠的部分維持原本的暖色調。
柔化邊緣與 displacement 的連動
Section titled “柔化邊緣與 displacement 的連動”float coreEdge = mix(0.4, 0.25, vDisplacement);float alpha = smoothstep(1.0, coreEdge, dist) * vOpacity;粒子被推得越遠,coreEdge 越小,光暈越散越柔。靜止時邊緣清晰(0.4),飛散時邊緣模糊(0.25)。
恭喜各位走完了所有步驟!讓我們回顧一下整趟旅程:
Canvas 2D 篇
Section titled “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 篇
Section titled “WebGL2 Shader 篇”| 步驟 | 概念 | 關鍵技術 |
|---|---|---|
| 11 | GPU 思維 | 平行運算、Shader 類型 |
| 12 | Shader 編譯 | Program、Uniform |
| 13 | 浮點紋理 | RGBA32F、texelFetch |
| 14 | Ping-Pong | 雙紋理交替、Framebuffer |
| 15 | GPU 物理 | smoothstep、mix、GLSL 物理模擬 |
| 16 | Vertex Shader | gl_VertexID、clip space、Varying |
| 17 | 圓形粒子 | gl_PointCoord、Alpha Blending |
| 18 | Curl Noise | FBM、預計算噪聲紋理 |
| 19 | 多層發光 | displacement 亮度、暖白漸變 |
從最簡單的 Canvas 2D 一路到 WebGL2 Shader,核心物理邏輯其實沒甚麼變,變的是「誰來算」。
從 CPU 一個一個慢慢來,改成 GPU 小精靈大軍一起算。∠( ᐛ 」∠)_
完整元件請見:蟲群文字 🐟
以上如有任何錯誤,還請各位大大多多指教。
感謝您讀到這裡,如果您覺得有收穫,歡迎分享出去。◝( •ω• )◟