Skip to example

vgpu motion values

An example of binding motion values to a vgpu scene node and shader uniform with vgpuEffect in Motion.

JavaScript

Source code

<div class="stage">
  <canvas aria-label="A vgpu torus knot controlled by Motion values"></canvas>
</div>

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

  animate.addEffect(vgpuEffect)

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

  function runMotionValues(node, material) {
    /**
     * One motion value drives the node position. Two derived values fan it
     * out to the rotation and a shader uniform, so everything moves together.
     */
    const x = motionValue(0)
    const rotateY = motionValue(0)
    const progress = motionValue(0.5)

    x.on("change", (latest) => {
      rotateY.set(latest * 40)
      progress.set((latest + 1.5) / 3)
    })

    vgpuEffect(node, { x, rotateY })
    vgpuEffect(material, { progress })

    canvas.addEventListener("pointermove", (event) => {
      const rect = canvas.getBoundingClientRect()
      const target = ((event.clientX - rect.left) / rect.width - 0.5) * 3

      animate(x, target, {
        type: "spring",
        stiffness: 160,
        damping: 20,
      })
    })
  }

  function add(a, b) {
    return a.map((value, index) => value + b[index])
  }

  function subtract(a, b) {
    return a.map((value, index) => value - b[index])
  }

  function scale(vector, amount) {
    return vector.map((value) => value * amount)
  }

  function cross(a, b) {
    return [
      a[1] * b[2] - a[2] * b[1],
      a[2] * b[0] - a[0] * b[2],
      a[0] * b[1] - a[1] * b[0],
    ]
  }

  function normalize(vector) {
    const length = Math.hypot(...vector)
    return vector.map((value) => value / length)
  }

  function torusKnotPoint(u, radius = 0.8) {
    const p = 2
    const q = 3
    const contour = (2 + Math.cos((q / p) * u)) * 0.5

    return [
      radius * contour * Math.cos(u),
      radius * contour * Math.sin(u),
      radius * Math.sin((q / p) * u) * 0.5,
    ]
  }

  function torusKnotGeometry({
    tube = 0.26,
    tubularSegments = 160,
    radialSegments = 24,
  } = {}) {
    const vertices = []
    const indices = []

    for (let i = 0; i <= tubularSegments; i++) {
      const u = (i / tubularSegments) * Math.PI * 4
      const point = torusKnotPoint(u)
      const nextPoint = torusKnotPoint(u + 0.01)
      const tangent = subtract(nextPoint, point)
      let normal = add(nextPoint, point)
      const binormal = normalize(cross(tangent, normal))
      normal = normalize(cross(binormal, tangent))

      for (let j = 0; j <= radialSegments; j++) {
        const angle = (j / radialSegments) * Math.PI * 2
        const offset = add(
          scale(normal, -tube * Math.cos(angle)),
          scale(binormal, tube * Math.sin(angle))
        )
        const position = add(point, offset)
        vertices.push(...position, ...normalize(offset))
      }
    }

    const row = radialSegments + 1
    for (let i = 1; i <= tubularSegments; i++) {
      for (let j = 1; j <= radialSegments; j++) {
        const a = row * (i - 1) + (j - 1)
        const b = row * i + (j - 1)
        const c = row * i + j
        const d = row * (i - 1) + j
        indices.push(a, b, d, b, c, d)
      }
    }

    return {
      buffers: [
        {
          attributes: {
            position: "float32x3",
            normal: "float32x3",
          },
          data: new Float32Array(vertices),
        },
      ],
      indices: new Uint32Array(indices),
    }
  }

  const objectShader = `
    struct Camera {
      viewProjection: mat4x4f,
    }
    struct Model {
      model: mat4x4f,
    }
    struct Material {
      progress: f32,
    }

    @group(0) @binding(0) var<uniform> camera: Camera;
    @group(0) @binding(1) var<uniform> model: Model;
    @group(0) @binding(2) var<uniform> material: Material;

    struct VertexOut {
      @builtin(position) position: vec4f,
      @location(0) normal: vec3f,
    }

    @vertex
    fn vs_main(
      @location(0) position: vec3f,
      @location(1) normal: vec3f
    ) -> VertexOut {
      var out: VertexOut;
      out.position =
        camera.viewProjection * model.model * vec4f(position, 1.0);
      out.normal = (model.model * vec4f(normal, 0.0)).xyz;
      return out;
    }

    @fragment
    fn fs_main(@location(0) normal: vec3f) -> @location(0) vec4f {
      let pink = vec3f(1.0, 0.0, 0.53);
      let cyan = vec3f(0.05, 0.86, 0.97);
      let light =
        0.55 +
        0.45 *
          dot(normalize(normal), normalize(vec3f(0.4, 0.8, 1.0)));
      let color = mix(pink, cyan, material.progress) * light;
      return vec4f(color, 1.0);
    }
  `

  const presentShader = `
    @group(0) @binding(0) var scene: texture_2d<f32>;
    @group(0) @binding(1) var sceneSampler: sampler;

    fn linearToSrgb(color: vec3f) -> vec3f {
      let x = max(color, vec3f(0.0));
      return select(
        1.055 * pow(x, vec3f(1.0 / 2.4)) - 0.055,
        x * 12.92,
        x <= vec3f(0.0031308)
      );
    }

    @fragment
    fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
      let color = textureSampleLevel(scene, sceneSampler, uv, 0.0);
      return vec4f(linearToSrgb(color.rgb), color.a);
    }
  `

  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,
      format: "rgba16float",
      depth: true,
    })
    const camera = perspectiveCamera({
      fov: 35,
      aspect: canvasSurface.size[0] / canvasSurface.size[1],
      position: [0, 0, 6],
      target: [0, 0, 0],
    })

    const knotGeometry = geometry(gpu, torusKnotGeometry())
    const node = group({
      rotation: [Math.PI * 0.12, -0.2, 0.16],
    })
    const model = uniforms(gpu, { model: node.worldMatrix })
    const material = uniforms(gpu, { progress: 0.5 })

    const knotDraw = draw(gpu, {
      shader: objectShader,
      geometry: knotGeometry,
      cull: "back",
      set: {
        camera: { viewProjection: camera.viewProjection },
        model,
        material,
      },
    })
    runMotionValues(node, material)
    const present = effect(gpu, presentShader, {
      set: {
        scene: sceneTarget,
        sceneSampler: sampler(gpu, {
          minFilter: "linear",
          magFilter: "linear",
        }),
      },
    })

    canvasSurface.onResize(({ width, height }) => {
      sceneTarget.resize([width, height])
      camera.set({ aspect: width / height })
      knotDraw.set({
        camera: { viewProjection: camera.viewProjection },
      })
    })

    function render() {
      model.set({ model: node.worldMatrix })

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

    frame.render(render, true)
  }

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

<style>
  .stage {
    width: 480px;
    max-width: 100vw;
  }

  .stage canvas {
    display: block;
    width: 100%;
    aspect-ratio: 3 / 2;
    cursor: crosshair;
  }
</style>