ToolKitToolKit工具箱
加载特效…
查看源码 / Source点击展开
ParticleFlowRegl.vue
<!--
  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>