Skip to example

vgpu shared uniforms

An instanced rainbow particle cylinder with shared uniforms for pointer-controlled speed, tilt, and press-to-tighten spread.

JavaScript

Source code

<div class="stage">
  <canvas aria-label="Hold to tighten the rainbow particle cloud"></canvas>
  <output class="speedometer" aria-label="Animation speed">1.00×</output>
</div>

<script type="module">
  import { animate, frame, motionValue, press, springValue } from "motion"
  import { vgpuEffect } from "motion/vgpu"
  import { draw, frame as vgpuFrame, init, surface, uniforms } from "vgpu"

  animate.addEffect(vgpuEffect)

  const stage = document.querySelector(".stage")
  const canvas = document.querySelector("canvas")
  const speedometer = document.querySelector(".speedometer")

  function runSharedUniforms(gpu, canvasSurface, globals, particles) {
    const speedTarget = motionValue(1)
    const speed = springValue(speedTarget, { stiffness: 80, damping: 20 })
    const tiltTarget = motionValue(0.88)
    const tilt = springValue(tiltTarget, { stiffness: 80, damping: 20 })
    const spreadTarget = motionValue(1)
    const spread = springValue(spreadTarget, { stiffness: 140, damping: 22 })
    let elapsed = 0
    let displayedSpeed = ""

    canvasSurface.onResize(({ width, height }) => {
      globals.set({ resolution: [width, height] })
    })

    let isReady = false

    function render({ delta }) {
      const currentSpeed = speed.get()
      elapsed += (Math.min(delta, 40) / 1000) * currentSpeed
      globals.set({
        time: elapsed,
        tilt: tilt.get(),
        spread: spread.get(),
      })

      const nextDisplayedSpeed = currentSpeed.toFixed(2) + "×"
      if (nextDisplayedSpeed !== displayedSpeed) {
        displayedSpeed = nextDisplayedSpeed
        speedometer.textContent = displayedSpeed
      }

      vgpuFrame(gpu, (currentFrame) => {
        currentFrame.pass(
          { target: canvasSurface, clear: [0, 0, 0, 1] },
          (pass) => {
            pass.draw(particles.dust)
            pass.draw(particles.core)
          },
        )
      })

      if (!isReady) {
        isReady = true
        canvas.classList.add("is-ready")
      }
    }

    frame.render(render, true)

    animate(
      globals,
      { intensity: [0.75, 1] },
      { duration: 1.8, ease: "easeOut" },
    )

    canvas.addEventListener("pointermove", (event) => {
      const rect = canvas.getBoundingClientRect()
      const xProgress = (event.clientX - rect.left) / rect.width
      const yProgress = (event.clientY - rect.top) / rect.height

      speedTarget.set(0.15 + xProgress * 2.85)
      tiltTarget.set(0.35 + yProgress * 1.05)
    })

    press(canvas, () => {
      spreadTarget.set(0.18)
      return () => spreadTarget.set(1)
    })
  }

  const globalsStruct = `
        struct Globals {
            time: f32,
            intensity: f32,
            resolution: vec2f,
            tilt: f32,
            spread: f32,
        }

        @group(0) @binding(0) var<uniform> globals: Globals;
    `

  const particleShader = `
        ${globalsStruct}

        override PARTICLE_SIZE: f32 = 1.0;
        override CLOUD_WIDTH: f32 = 1.0;
        override OPACITY: f32 = 1.0;
        override SEED_OFFSET: f32 = 0.0;

        fn palette(t: f32) -> vec3f {
            let phase = vec3f(0.08, 0.42, 0.76);
            return 0.5 + 0.5 * cos(6.283185 * (t + phase));
        }

        fn hash11(value: f32) -> f32 {
            return fract(sin(value * 12.9898) * 43758.5453);
        }

        fn centeredRandom(id: f32, offset: f32) -> f32 {
            return (
                hash11(id + offset) +
                hash11(id + offset + 19.19) +
                hash11(id + offset + 41.73) -
                1.5
            ) / 1.5;
        }

        struct VertexOut {
            @builtin(position) position: vec4f,
            @location(0) local: vec2f,
            @location(1) colour: vec3f,
            @location(2) alpha: f32,
        }

        @vertex
        fn vs_main(
            @builtin(vertex_index) vertexIndex: u32,
            @builtin(instance_index) instanceIndex: u32
        ) -> VertexOut {
            let corners = array<vec2f, 6>(
                vec2f(-1.0, -1.0),
                vec2f(1.0, -1.0),
                vec2f(-1.0, 1.0),
                vec2f(-1.0, 1.0),
                vec2f(1.0, -1.0),
                vec2f(1.0, 1.0)
            );

            let id = f32(instanceIndex) + SEED_OFFSET;
            let seed = hash11(id + 1.0);
            let speed = 0.24 + hash11(id + 2.0) * 0.18;
            let phase = seed * 6.283185 + globals.time * speed;
            let radialOffset = centeredRandom(id, 3.0);
            let axialOffset = centeredRandom(id, 7.0);
            let tiltOffset = centeredRandom(id, 11.0);
            let rotationOffset = centeredRandom(id, 15.0);
            let radius =
                0.72 +
                radialOffset * 0.16 * CLOUD_WIDTH * globals.spread;
            let axial =
                axialOffset * 0.2 * CLOUD_WIDTH * globals.spread;
            let angle = phase + centeredRandom(id, 17.0) * 0.04;
            let tilt = globals.tilt + tiltOffset * 0.18;
            let cylinder = vec3f(
                cos(angle) * radius,
                sin(angle) * radius,
                axial
            );
            let tilted = vec3f(
                cylinder.x,
                cylinder.y * cos(tilt) + cylinder.z * sin(tilt),
                -cylinder.y * sin(tilt) + cylinder.z * cos(tilt)
            );

            let rotation = -0.24 + rotationOffset * 0.16;
            let rotated = vec2f(
                tilted.x * cos(rotation) - tilted.y * sin(rotation),
                tilted.x * sin(rotation) + tilted.y * cos(rotation)
            );
            let near = clamp(0.5 + tilted.z * 0.68, 0.0, 1.0);
            let perspective = 1.0 / (1.0 - tilted.z * 0.48);
            let aspect = globals.resolution.x / globals.resolution.y;
            let center = vec2f(0.04, -0.01);
            let particleCenter =
                vec2f(rotated.x / aspect, rotated.y) * perspective + center;

            let depthScale = mix(0.65, 1.9, near);
            let viewportScale = clamp(
                min(globals.resolution.x, globals.resolution.y) / 700.0,
                0.8,
                2.75
            );
            let size =
                (0.6 + hash11(id + 5.0) * 0.8) *
                PARTICLE_SIZE *
                depthScale *
                viewportScale;
            let corner = corners[vertexIndex];
            let pixelSize = vec2f(
                2.0 / globals.resolution.x,
                2.0 / globals.resolution.y
            );

            var out: VertexOut;
            out.position = vec4f(
                particleCenter + corner * pixelSize * size,
                0.0,
                1.0
            );
            out.local = corner;
            out.colour = palette(
                phase / 6.283185 + seed * 0.08 + globals.time * 0.012
            );
            out.alpha =
                OPACITY * mix(0.38, 1.0, near) * globals.intensity;
            return out;
        }

        @fragment
        fn fs_main(
            @location(0) local: vec2f,
            @location(1) colour: vec3f,
            @location(2) alpha: f32
        ) -> @location(0) vec4f {
            let distance = length(local);
            if (distance > 1.0) {
                discard;
            }

            let point = 1.0 - smoothstep(0.35, 1.0, distance);
            let redDominance = smoothstep(
                0.05,
                0.55,
                colour.r - max(colour.g, colour.b)
            );
            let brightness = 1.16 + redDominance * 0.52;
            let brightColour =
                colour * brightness + vec3f(redDominance * 0.12, 0.0, 0.0);
            return vec4f(brightColour * point * alpha, point * alpha);
        }
    `

  async function start() {
    if (!navigator.gpu) {
      throw new Error("This example needs a browser with WebGPU")
    }

    const gpu = await init()
    const canvasSurface = surface(gpu, canvas, { dpr: [1, 2] })
    const globals = uniforms(gpu, {
      time: 0,
      intensity: 0.75,
      resolution: canvasSurface.size,
      tilt: 0.88,
      spread: 1,
    })

    const dust = draw(gpu, {
      shader: particleShader,
      instances: 2_500,
      vertices: 6,
      blend: "additive",
      set: { globals },
      constants: {
        PARTICLE_SIZE: 1.3,
        CLOUD_WIDTH: 1.5,
        OPACITY: 0.12,
        SEED_OFFSET: 20_000,
      },
    })
    const core = draw(gpu, {
      shader: particleShader,
      instances: 9_000,
      vertices: 6,
      blend: "additive",
      set: { globals },
    })

    runSharedUniforms(gpu, canvasSurface, globals, { dust, core })
  }

  start().catch((error) => {
    console.error(error)
    stage.querySelector(".hint").textContent = "WebGPU could not start"
  })
</script>

<style>
  #sandbox {
    align-items: stretch;
  }

  #example-container {
    display: flex;
    flex: 1;
    width: 100%;
    min-height: 0;
  }

  .stage {
    position: relative;
    flex: 1;
    min-height: 0;
    overflow: hidden;
    background: var(--black);
  }

  .stage canvas {
    position: absolute;
    inset: 0;
    display: block;
    width: 100%;
    height: 100%;
    opacity: 0;
    cursor: ew-resize;
    touch-action: none;
  }

  .stage canvas.is-ready {
    opacity: 1;
  }

  .speedometer {
    position: absolute;
    bottom: 24px;
    left: 24px;
    margin: 0;
    pointer-events: none;
    font-family: var(--font-mono);
    font-size: 15px;
    font-variant-numeric: tabular-nums;
    color: var(--white);
  }

  .hint {
    position: absolute;
    right: 0;
    bottom: 28px;
    left: 0;
    margin: 0;
    pointer-events: none;
    text-align: center;
    font-family: var(--font-mono);
    font-size: 11px;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    color: var(--white-feint);
  }
</style>