Skip to example

vgpu effect bindings

Particles from a random Japan photograph swirl as a 3D globe, reconstruct the full-resolution image at rest and return to the globe while pressed.

JavaScript

Source code

<div class="stage">
  <canvas
    role="button"
    tabindex="0"
    aria-label="Hold to swirl the image particles into a globe"
  ></canvas>
</div>

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

  animate.addEffect(vgpuEffect)

  const JAPAN_PHOTOS = Array.from(
    { length: 13 },
    (_, index) => `/photos/japan/${index + 1}.jpg`
  )
  const previousPhoto = sessionStorage.getItem("vgpu-effect-photo")
  const photoChoices = JAPAN_PHOTOS.filter(
    (photo) => photo !== previousPhoto
  )
  const PHOTO_URL =
    photoChoices[Math.floor(Math.random() * photoChoices.length)]
  sessionStorage.setItem("vgpu-effect-photo", PHOTO_URL)
  const COLUMNS = 200
  const stage = document.querySelector(".stage")
  const canvas = document.querySelector("canvas")

  function runParticleMotion(
    gpu,
    canvasSurface,
    sceneTarget,
    params,
    particles,
    present
  ) {
    const globe = motionValue(1)
    let elapsed = 0

    vgpuEffect(params, { globe })

    animate(globe, 0, {
      type: "spring",
      stiffness: 55,
      damping: 16,
      mass: 1,
      delay: 0.8,
    })

    press(canvas, () => {
      animate(globe, 1, {
        type: "spring",
        stiffness: 120,
        damping: 18,
        mass: 0.9,
      })

      return () => {
        animate(globe, 0, {
          type: "spring",
          stiffness: 70,
          damping: 16,
          mass: 1,
        })
      }
    })

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

    function render({ delta }) {
      elapsed += Math.min(delta, 40) / 1000
      params.set({ time: elapsed })

      vgpuFrame(gpu, (currentFrame) => {
        currentFrame.pass(
          {
            target: sceneTarget,
            clear: [1, 1, 1, 1],
            clearDepth: 1,
          },
          (pass) => pass.draw(particles)
        )
        currentFrame.pass(canvasSurface, present)
      })
    }

    frame.render(render, true)
  }

  const particleShader = `
    struct Params {
      time: f32,
      globe: f32,
      imageAspect: f32,
      resolution: vec2f,
      grid: vec2f,
    }

    @group(0) @binding(0) var<uniform> params: Params;
    @group(0) @binding(1) var photo: texture_2d<f32>;
    @group(0) @binding(2) var photoSampler: sampler;

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

    struct VertexOut {
      @builtin(position) position: vec4f,
      @location(0) colour: vec3f,
      @location(1) light: 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);
      let count = params.grid.x * params.grid.y;
      let column = f32(instanceIndex % u32(params.grid.x));
      let row = floor(id / params.grid.x);
      let imageUv = (vec2f(column, row) + vec2f(0.5)) / params.grid;
      let y = 1.0 - (id / max(count - 1.0, 1.0)) * 2.0;
      let ringRadius = sqrt(max(0.0, 1.0 - y * y));
      let angle = id * 2.39996323;
      let sphere = vec3f(
        cos(angle) * ringRadius,
        y,
        sin(angle) * ringRadius
      );

      let randomA = hash11(id + 7.0);
      let randomB = hash11(id + 41.0);
      let localSpeed = 0.58 + (randomA - 0.5) * 0.24;
      let spin =
        params.time * localSpeed + (randomB - 0.5) * 0.24;
      let spun = vec3f(
        sphere.x * cos(spin) + sphere.z * sin(spin),
        sphere.y,
        -sphere.x * sin(spin) + sphere.z * cos(spin)
      );
      let reference = select(
        vec3f(0.0, 1.0, 0.0),
        vec3f(1.0, 0.0, 0.0),
        abs(spun.y) > 0.9
      );
      let tangent = normalize(cross(reference, spun));
      let bitangent = normalize(cross(spun, tangent));
      let turbulence =
        tangent *
          sin(params.time * (1.1 + randomA * 0.7) + randomB * 6.283185) *
          0.08 +
        bitangent *
          cos(params.time * (0.8 + randomB * 0.6) + randomA * 6.283185) *
          0.055;
      let turbulent = normalize(spun + turbulence);
      let tilt = -0.28;
      let rotated = vec3f(
        turbulent.x,
        turbulent.y * cos(tilt) - turbulent.z * sin(tilt),
        turbulent.y * sin(tilt) + turbulent.z * cos(tilt)
      );

      let aspect = params.resolution.x / params.resolution.y;
      let imageHalfHeight = min(
        0.72,
        0.86 * aspect / params.imageAspect
      );
      let imageHalfWidth =
        imageHalfHeight * params.imageAspect / aspect;
      let imageCenter = vec2f(
        (imageUv.x - 0.5) * imageHalfWidth * 2.0,
        (0.5 - imageUv.y) * imageHalfHeight * 2.0
      );
      let globeRadius = 0.52;
      let globeCenter = vec2f(
        rotated.x * globeRadius / aspect,
        rotated.y * globeRadius
      );
      let curvedTrajectory =
        sin(params.globe * 3.14159265) *
        vec2f(
          (randomA - 0.5) / aspect,
          randomB - 0.5
        ) *
        0.13;
      let center =
        mix(imageCenter, globeCenter, params.globe) +
        curvedTrajectory;
      let globeDepth = clamp(
        0.5 - rotated.z * 0.24,
        0.02,
        0.98
      );
      let particleHalfSize = vec2f(
        imageHalfWidth / params.grid.x,
        imageHalfHeight / params.grid.y
      );
      let corner = corners[vertexIndex];
      let depth = mix(0.5, globeDepth, params.globe);
      let near = clamp(0.5 + rotated.z * 0.5, 0.0, 1.0);
      let sampled = textureSampleLevel(
        photo,
        photoSampler,
        imageUv,
        0.0
      ).rgb;

      var out: VertexOut;
      out.position = vec4f(
        center + corner * particleHalfSize * 1.01,
        depth,
        1.0
      );
      out.colour = sampled;
      out.light = mix(1.0, mix(0.5, 1.45, near), params.globe);
      return out;
    }

    @fragment
    fn fs_main(
      @location(0) colour: vec3f,
      @location(1) light: f32
    ) -> @location(0) vec4f {
      return vec4f(colour * light, 1.0);
    }
  `

  const presentShader = `
    struct Params {
      time: f32,
      globe: f32,
      imageAspect: f32,
      resolution: vec2f,
      grid: vec2f,
    }

    @group(0) @binding(0) var scene: texture_2d<f32>;
    @group(0) @binding(1) var sceneSampler: sampler;
    @group(0) @binding(2) var<uniform> params: Params;
    @group(0) @binding(3) var photo: texture_2d<f32>;
    @group(0) @binding(4) var photoSampler: sampler;

    @fragment
    fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
      var colour = textureSampleLevel(scene, sceneSampler, uv, 0.0);
      let aspect = params.resolution.x / params.resolution.y;
      let imageHalfHeight = min(
        0.72,
        0.86 * aspect / params.imageAspect
      );
      let imageHalfWidth =
        imageHalfHeight * params.imageAspect / aspect;
      let ndc = vec2f(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
      let imageUv = vec2f(
        ndc.x / (imageHalfWidth * 2.0) + 0.5,
        0.5 - ndc.y / (imageHalfHeight * 2.0)
      );
      let inside =
        imageUv.x >= 0.0 && imageUv.x <= 1.0 &&
        imageUv.y >= 0.0 && imageUv.y <= 1.0;

      if (inside) {
        let highResolution = textureSampleLevel(
          photo,
          photoSampler,
          imageUv,
          0.0
        );
        let highResolutionMix =
          1.0 - smoothstep(0.005, 0.035, max(params.globe, 0.0));
        colour = mix(colour, highResolution, highResolutionMix);
      }

      return vec4f(
        pow(colour.rgb, vec3f(1.0 / 2.2)),
        colour.a
      );
    }
  `

  async function loadPhoto(gpu, url) {
    const response = await fetch(url)
    if (!response.ok) {
      throw new Error("Could not load the photograph")
    }

    const bitmap = await createImageBitmap(await response.blob())
    const texture = gpu.device.createTexture({
      size: [bitmap.width, bitmap.height],
      format: "rgba8unorm-srgb",
      usage: ["texture_binding", "copy_dst", "render_attachment"],
    })

    gpu.gpu.queue.copyExternalImageToTexture(
      { source: bitmap },
      { texture: texture.gpu },
      [bitmap.width, bitmap.height]
    )

    const imageAspect = bitmap.width / bitmap.height
    bitmap.close()
    return { texture, imageAspect }
  }

  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 sceneTarget = target(gpu, {
      size: canvasSurface.size,
      depth: true,
    })
    const { texture, imageAspect } = await loadPhoto(gpu, PHOTO_URL)
    const rows = Math.round(COLUMNS / imageAspect)
    const params = uniforms(gpu, {
      time: 0,
      globe: 1,
      imageAspect,
      resolution: canvasSurface.size,
      grid: [COLUMNS, rows],
    })
    const imageSampler = sampler(gpu, {
      minFilter: "linear",
      magFilter: "linear",
    })
    const particles = draw(gpu, {
      shader: particleShader,
      instances: COLUMNS * rows,
      vertices: 6,
      depth: { write: true, compare: "less" },
      set: {
        params,
        photo: texture,
        photoSampler: imageSampler,
      },
    })
    const present = effect(gpu, presentShader, {
      set: {
        scene: sceneTarget,
        sceneSampler: sampler(gpu, {
          minFilter: "linear",
          magFilter: "linear",
        }),
        params,
        photo: texture,
        photoSampler: imageSampler,
      },
    })

    runParticleMotion(
      gpu,
      canvasSurface,
      sceneTarget,
      params,
      particles,
      present
    )
  }

  start().catch((error) => {
    console.error(error)
    stage.textContent = error.message
  })
</script>

<style>
  .stage {
    position: fixed;
    inset: 0;
    width: 100vw;
    height: 100vh;
    background: var(--white);
  }

  canvas {
    display: block;
    width: 100%;
    height: 100%;
    cursor: crosshair;
    touch-action: none;
  }
</style>