Skip to example

vgpu vectors

A pointer-driven WebGPU refraction lens built with spring-animated vec2 uniforms and vgpuEffect in Motion.

JavaScript

Source code

<div class="stage">
  <canvas aria-label="A refractive lens moving over a photograph"></canvas>
</div>

<script type="module">
  import { frame, motionValue, springValue, transformValue } from "motion"
  import { vgpuEffect } from "motion/vgpu"
  import { effect, frame as vgpuFrame, init, sampler, surface } from "vgpu"

  const PHOTO_URL = "/photos/japan/4.jpg"
  const stage = document.querySelector(".stage")
  const canvas = document.querySelector("canvas")

  function runLensMotion(gpu, canvasSurface, lens, imageAspect) {
    const mouseFollowSpring = { stiffness: 120, damping: 20 }
    const x = motionValue(0.5)
    const y = motionValue(0.46)
    const aspect = motionValue(canvasSurface.size[0] / canvasSurface.size[1])
    const springX = springValue(x, mouseFollowSpring)
    const springY = springValue(y, mouseFollowSpring)

    vgpuEffect(lens, {
      "params.lens": transformValue(() => springX.get() + " " + springY.get()),
      "params.aspect": aspect,
      "params.imageAspect": motionValue(imageAspect),
    })

    canvasSurface.onResize(({ width, height }) => {
      aspect.set(width / height)
    })

    let isReady = false

    function render() {
      vgpuFrame(gpu, (currentFrame) => {
        currentFrame.pass(canvasSurface, lens)
      })

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

    frame.render(render, true)

    canvas.addEventListener("pointermove", (event) => {
      const rect = canvas.getBoundingClientRect()

      x.set((event.clientX - rect.left) / rect.width)
      y.set((event.clientY - rect.top) / rect.height)
    })
  }

  const shader = `
        struct Params {
            lens: vec2f,
            aspect: f32,
            imageAspect: f32,
        }

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

        fn sampleScene(canvasUv: vec2f) -> vec3f {
            var imageSize = vec2f(
                min(params.aspect * 0.72, 0.96),
                0.0
            );
            imageSize.y = imageSize.x / params.imageAspect;

            if (imageSize.y > 0.72) {
                imageSize = vec2f(0.72 * params.imageAspect, 0.72);
            }

            let imageSizeUv = imageSize / vec2f(params.aspect, 1.0);
            let imageCenter = vec2f(0.5, 0.46);
            let imageMin = imageCenter - imageSizeUv * 0.5;
            let uv = (canvasUv - imageMin) / imageSizeUv;
            let onPhoto =
                uv.x >= 0.0 && uv.x <= 1.0 &&
                uv.y >= 0.0 && uv.y <= 1.0;
            let texel = textureSampleLevel(
                photo,
                samp,
                clamp(uv, vec2f(0.0), vec2f(1.0)),
                0.0
            ).rgb;
            return select(vec3f(1.0), texel, onPhoto);
        }

        fn refractedUv(
            canvasUv: vec2f,
            local: vec2f,
            ior: f32
        ) -> vec2f {
            let r2 = dot(local, local);
            let angle = atan2(local.y, local.x);
            let distortion =
                sin(angle * 6.0 + r2 * 8.0) * 0.65;
            let distorted = local * (1.0 + distortion * r2 * 0.16);
            let normal = normalize(vec3f(
                distorted,
                sqrt(max(0.0, 1.0 - r2))
            ));
            let ray = refract(vec3f(0.0, 0.0, -1.0), normal, 1.0 / ior);
            let edgeMagnification =
                mix(0.75, 2.4, smoothstep(0.2, 1.0, r2));
            return canvasUv +
                ray.xy * 0.075 * edgeMagnification /
                vec2f(params.aspect, 1.0);
        }

        @fragment
        fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
            let scale = vec2f(params.aspect, 1.0);
            let local = (uv - params.lens) * scale / 0.14;
            let r2 = dot(local, local);
            let radius = sqrt(r2);
            let mask = 1.0 - smoothstep(0.985, 1.0, radius);
            let base = sampleScene(uv);

            let chromaticAberration = 0.65;
            let spread = chromaticAberration * 0.08;
            let red = sampleScene(refractedUv(uv, local, 1.14 - spread));
            let green = sampleScene(refractedUv(uv, local, 1.14));
            let blue = sampleScene(refractedUv(uv, local, 1.14 + spread));
            var glass = vec3f(red.r, green.g, blue.b);

            let normalZ = sqrt(max(0.0, 1.0 - r2));
            let fresnel = pow(1.0 - normalZ, 3.0);
            let highlight = pow(max(
                dot(
                    normalize(vec3f(local, normalZ)),
                    normalize(vec3f(-0.45, -0.65, 1.0))
                ),
                0.0
            ), 24.0);
            glass += vec3f(fresnel * 0.1 + highlight * 0.12);

            let color = mix(base, glass, mask);

            return vec4f(pow(color, vec3f(1.0 / 2.2)), 1.0);
        }
    `

  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 aspect = bitmap.width / bitmap.height
    bitmap.close()
    return {
      texture,
      aspect,
    }
  }

  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 photo = await loadPhoto(gpu, PHOTO_URL)
    const lens = effect(gpu, shader, {
      set: {
        photo: photo.texture,
        samp: sampler(gpu, {
          minFilter: "linear",
          magFilter: "linear",
          addressModeU: "clamp-to-edge",
          addressModeV: "clamp-to-edge",
        }),
      },
    })

    runLensMotion(gpu, canvasSurface, lens, photo.aspect)
  }

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

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

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

  .stage {
    position: relative;
    flex: 1;
    min-height: 0;
    overflow: hidden;
    background: #fff;
  }

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

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

  .hint {
    position: absolute;
    bottom: clamp(24px, 4vw, 44px);
    left: 0;
    right: 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(--black);
  }
</style>