Loading effect…
查看源码 / Source点击展开
<!--
ParticleFlowRegl.vue — GPGPU 流场粒子,纯 GPU 模拟。
原理:
- 把 N 个粒子的 (x,y,z) 位置存到一张 256x256 的浮点纹理里(每个像素一个粒子)
- 每帧一个 update pass:fragment shader 读旧位置 -> 算 curl noise 流场 -> 写新位置
- 双 FBO ping-pong,避免读写冲突
- 一个 render pass:vertex shader 用 attribute 拿到 uv,从位置纹理采样得到 (x,y,z)
- 不清屏,每帧画一层半透明黑色全屏 quad,自然形成 trail
迁移说明(Vite→Nuxt):
- regl 自管 canvas,父级须用 <ClientOnly> 包裹,不在服务端渲染。
- window.devicePixelRatio 的访问都在 init()(由 onMounted 调用)里,客户端安全。
- 自管 canvas 库必须在 style 里强制 canvas 铺满容器(见底部 :deep(canvas)),
否则 canvas 拿到错误高度导致画面全黑/竖条。
-->
<script setup lang="ts">
import { onMounted, onBeforeUnmount, useTemplateRef } from 'vue'
import createREGL from 'regl'
import type { Regl, Framebuffer2D, DrawCommand } from 'regl'
const containerRef = useTemplateRef<HTMLDivElement>('container')
const TEX_SIZE = 256 // 256*256 = 65536 粒子
const N = TEX_SIZE * TEX_SIZE
let regl: Regl | null = null
function init(container: HTMLElement) {
// 让 regl 自己创建 canvas 并自动处理 resize / pixelRatio
regl = createREGL({
container,
pixelRatio: Math.min(window.devicePixelRatio, 2),
attributes: { antialias: true, alpha: false, premultipliedAlpha: false },
extensions: ['OES_texture_float'],
optionalExtensions: ['OES_texture_half_float', 'WEBGL_color_buffer_float'],
})
// 初始位置:在球壳上随机分布
const initData = new Float32Array(N * 4)
for (let i = 0; i < N; i++) {
const u = Math.random() * 2 - 1
const t = Math.random() * Math.PI * 2
const r = Math.sqrt(1 - u * u)
const radius = 0.6 + Math.random() * 0.2
initData[i * 4] = r * Math.cos(t) * radius
initData[i * 4 + 1] = r * Math.sin(t) * radius
initData[i * 4 + 2] = u * radius
initData[i * 4 + 3] = Math.random() // 用作粒子寿命/相位
}
function makeFbo(): Framebuffer2D {
return regl!.framebuffer({
color: regl!.texture({
width: TEX_SIZE,
height: TEX_SIZE,
type: 'float',
format: 'rgba',
wrap: 'clamp',
min: 'nearest',
mag: 'nearest',
data: initData,
}),
depth: false,
})
}
let fboA = makeFbo()
let fboB = makeFbo()
// 给每个粒子的 uv(用来在纹理里查自己的位置)
const uvs = new Float32Array(N * 2)
for (let i = 0; i < N; i++) {
uvs[i * 2] = (i % TEX_SIZE) / TEX_SIZE
uvs[i * 2 + 1] = Math.floor(i / TEX_SIZE) / TEX_SIZE
}
// ===== Pass 1: update positions (full-screen quad into FBO) =====
const updateCmd = regl({
vert: `
precision highp float;
attribute vec2 position;
varying vec2 vUv;
void main() {
vUv = position * 0.5 + 0.5;
gl_Position = vec4(position, 0.0, 1.0);
}
`,
frag: `
precision highp float;
uniform sampler2D uPos;
uniform float uTime;
uniform float uDt;
varying vec2 vUv;
// 简化 simplex-like noise (够用,curl 不挑剔)
vec3 hash3(vec3 p) {
p = vec3(
dot(p, vec3(127.1, 311.7, 74.7)),
dot(p, vec3(269.5, 183.3, 246.1)),
dot(p, vec3(113.5, 271.9, 124.6))
);
return -1.0 + 2.0 * fract(sin(p) * 43758.5453123);
}
float noise3(vec3 p) {
vec3 i = floor(p);
vec3 f = fract(p);
vec3 u = f * f * (3.0 - 2.0 * f);
return mix(mix(mix(dot(hash3(i + vec3(0,0,0)), f - vec3(0,0,0)),
dot(hash3(i + vec3(1,0,0)), f - vec3(1,0,0)), u.x),
mix(dot(hash3(i + vec3(0,1,0)), f - vec3(0,1,0)),
dot(hash3(i + vec3(1,1,0)), f - vec3(1,1,0)), u.x), u.y),
mix(mix(dot(hash3(i + vec3(0,0,1)), f - vec3(0,0,1)),
dot(hash3(i + vec3(1,0,1)), f - vec3(1,0,1)), u.x),
mix(dot(hash3(i + vec3(0,1,1)), f - vec3(0,1,1)),
dot(hash3(i + vec3(1,1,1)), f - vec3(1,1,1)), u.x), u.y), u.z);
}
// Curl noise: 通过对 noise 取偏导数构造无散度向量场,粒子轨迹自然成涡旋
vec3 curlNoise(vec3 p) {
const float e = 0.1;
vec3 dx = vec3(e, 0.0, 0.0);
vec3 dy = vec3(0.0, e, 0.0);
vec3 dz = vec3(0.0, 0.0, e);
vec3 p_x0 = vec3(noise3(p - dx), noise3(p + 31.4 - dx), noise3(p - 78.2 - dx));
vec3 p_x1 = vec3(noise3(p + dx), noise3(p + 31.4 + dx), noise3(p - 78.2 + dx));
vec3 p_y0 = vec3(noise3(p - dy), noise3(p + 31.4 - dy), noise3(p - 78.2 - dy));
vec3 p_y1 = vec3(noise3(p + dy), noise3(p + 31.4 + dy), noise3(p - 78.2 + dy));
vec3 p_z0 = vec3(noise3(p - dz), noise3(p + 31.4 - dz), noise3(p - 78.2 - dz));
vec3 p_z1 = vec3(noise3(p + dz), noise3(p + 31.4 + dz), noise3(p - 78.2 + dz));
float x = (p_y1.z - p_y0.z) - (p_z1.y - p_z0.y);
float y = (p_z1.x - p_z0.x) - (p_x1.z - p_x0.z);
float z = (p_x1.y - p_x0.y) - (p_y1.x - p_y0.x);
return vec3(x, y, z) * (1.0 / (2.0 * e));
}
void main() {
vec4 prev = texture2D(uPos, vUv);
vec3 pos = prev.xyz;
float life = prev.w;
vec3 vel = curlNoise(pos * 1.4 + vec3(0.0, 0.0, uTime * 0.15));
pos += vel * uDt * 0.4;
life -= uDt * 0.3;
// 寿命到了就重生在球壳上(用 vUv + time 当随机种子)
if (life <= 0.0) {
float a = fract(sin(dot(vUv + uTime, vec2(12.9898, 78.233))) * 43758.5453);
float b = fract(sin(dot(vUv + uTime + 1.7, vec2(43.123, 19.6))) * 12345.6789);
float u = a * 2.0 - 1.0;
float t = b * 6.2831853;
float r = sqrt(1.0 - u * u);
pos = vec3(r * cos(t), r * sin(t), u) * 0.7;
life = 1.0;
}
gl_FragColor = vec4(pos, life);
}
`,
attributes: {
position: regl!.buffer([-1, -1, 1, -1, -1, 1, 1, 1]),
},
uniforms: {
uPos: regl!.prop<{ src: Framebuffer2D }, 'src'>('src'),
uTime: regl!.context('time'),
uDt: 1 / 60,
},
framebuffer: regl!.prop<{ dst: Framebuffer2D }, 'dst'>('dst'),
primitive: 'triangle strip',
count: 4,
depth: { enable: false },
}) as DrawCommand
// ===== Pass 2: render particles as POINTS =====
const renderCmd = regl({
vert: `
precision highp float;
attribute vec2 aUv;
uniform sampler2D uPos;
uniform mat4 uProj;
uniform mat4 uView;
uniform float uPointSize;
uniform float uPixelRatio;
varying float vSpeed;
varying float vLife;
void main() {
vec4 data = texture2D(uPos, aUv);
vec3 pos = data.xyz;
vLife = data.w;
// 用相邻 uv 采样粗略估计速度 -> 颜色
vec3 nbr = texture2D(uPos, aUv + vec2(1.0/256.0, 0.0)).xyz;
vSpeed = length(pos - nbr) * 30.0;
gl_Position = uProj * uView * vec4(pos, 1.0);
float atten = 4.0 / -(uView * vec4(pos, 1.0)).z;
gl_PointSize = uPointSize * uPixelRatio * atten * (0.4 + vLife);
}
`,
frag: `
precision highp float;
varying float vSpeed;
varying float vLife;
void main() {
vec2 uv = gl_PointCoord - 0.5;
float d = length(uv);
if (d > 0.5) discard;
float halo = pow(smoothstep(0.5, 0.0, d), 1.4);
vec3 cSlow = vec3(0.35, 0.18, 0.6); // 紫
vec3 cFast = vec3(1.0, 0.55, 0.85); // 粉
vec3 col = mix(cSlow, cFast, clamp(vSpeed, 0.0, 1.0));
gl_FragColor = vec4(col * halo * vLife, 1.0);
}
`,
attributes: {
aUv: regl!.buffer(uvs),
},
uniforms: {
uPos: regl!.prop<{ src: Framebuffer2D }, 'src'>('src'),
uProj: regl!.prop<{ proj: Float32Array }, 'proj'>('proj'),
uView: regl!.prop<{ view: Float32Array }, 'view'>('view'),
uPointSize: 2.2,
uPixelRatio: Math.min(window.devicePixelRatio, 2),
},
primitive: 'points',
count: N,
blend: {
enable: true,
func: { src: 'one', dst: 'one' }, // additive
equation: 'add',
},
depth: { enable: false },
}) as DrawCommand
// ===== Pass 3: trail fade —— 每帧画一层半透明黑覆盖,不清屏 =====
const fadeCmd = regl({
vert: `attribute vec2 position; void main() { gl_Position = vec4(position, 0.0, 1.0); }`,
frag: `precision highp float; void main() { gl_FragColor = vec4(0.04, 0.05, 0.08, 0.12); }`,
attributes: {
position: regl!.buffer([-1, -1, 1, -1, -1, 1, 1, 1]),
},
primitive: 'triangle strip',
count: 4,
blend: {
enable: true,
func: { src: 'src alpha', dst: 'one minus src alpha' },
equation: 'add',
},
depth: { enable: false },
}) as DrawCommand
// 简单透视矩阵(避免引入 gl-matrix)
function makeProj(aspect: number) {
const fov = 50 * Math.PI / 180
const near = 0.1
const far = 100
const f = 1 / Math.tan(fov / 2)
const nf = 1 / (near - far)
return new Float32Array([
f / aspect, 0, 0, 0,
0, f, 0, 0,
0, 0, (far + near) * nf, -1,
0, 0, 2 * far * near * nf, 0,
])
}
const view = new Float32Array([
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, -2.5, 1, // camera at z=2.5 looking at origin
])
let proj = makeProj(1)
let firstFrame = true
// ===== render loop —— 用 regl.frame 驱动,它会自动 poll canvas size 并更新 viewport =====
regl.frame(({ drawingBufferWidth, drawingBufferHeight }) => {
proj = makeProj(drawingBufferWidth / drawingBufferHeight)
if (firstFrame) {
regl!.clear({ color: [0.04, 0.05, 0.08, 1] })
firstFrame = false
}
// update: read fboA -> write fboB
updateCmd({ src: fboA, dst: fboB })
;[fboA, fboB] = [fboB, fboA]
// fade screen (creates trail)
fadeCmd({})
// render points
renderCmd({ src: fboA, proj, view })
})
}
onMounted(() => {
init(containerRef.value!)
})
onBeforeUnmount(() => {
regl?.destroy()
})
</script>
<template>
<div ref="container" class="ps-wrap">
<div class="ps-hint">regl · 65 536 GPGPU particles</div>
</div>
</template>
<style scoped>
.ps-wrap {
position: relative;
width: 100%;
height: 100%;
min-height: 480px;
background: radial-gradient(ellipse at center, #1a0a26 0%, #04060c 70%);
overflow: hidden;
border-radius: 12px;
}
/* regl 自己 append canvas, 没有内置铺满规则, 必须强制铺满容器,
否则 canvas 用错误的 clientHeight (grid 单元 auto 高 → 拉伸成竖条/全黑) */
.ps-wrap :deep(canvas) {
display: block;
width: 100% !important;
height: 100% !important;
}
.ps-hint {
position: absolute;
left: 16px;
bottom: 14px;
z-index: 10;
font-family: 'Consolas', monospace;
font-size: 11px;
letter-spacing: 3px;
color: rgba(255, 255, 255, 0.45);
pointer-events: none;
user-select: none;
}
</style>