Skip to example

vgpu scene nodes

An example of nested vgpu scene nodes forming a kinetic sculpture, with inherited rotation, pointer tilt and press-driven spring transforms animated by Motion.

JavaScript

Source code

<div class="stage">
  <canvas aria-label="A kinetic hierarchy of nested vgpu scene nodes"></canvas>
</div>

<script type="module">
  import {
    animate,
    frame,
    hover,
    motionValue,
    press,
    springValue,
  } from "motion"
  import { vgpuEffect } from "motion/vgpu"
  import {
    draw,
    effect,
    frame as vgpuFrame,
    geometry,
    init,
    sampler,
    surface,
    target,
  } from "vgpu"
  import {
    box,
    group,
    mesh,
    perspectiveCamera,
    srgb,
    unlitMaterial,
  } from "vgpu/scene"

  animate.addEffect(vgpuEffect)

  const token = (name) =>
    getComputedStyle(document.body).getPropertyValue(name).trim()

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

  function runSceneMotion({ root, rings, ringSpecs, entries, core }) {
    rings.forEach((ring, index) => {
      const spec = ringSpecs[index]
      animate(
        ring,
        { rotateZ: [spec.tilt[2], spec.tilt[2] + 360 * spec.direction] },
        {
          duration: spec.duration,
          ease: "linear",
          repeat: Infinity,
        }
      )
    })

    animate(
      core,
      { rotateX: [20, 380], rotateY: [35, -325] },
      { duration: 16, ease: "linear", repeat: Infinity }
    )

    const tiltXTarget = motionValue(-8)
    const tiltYTarget = motionValue(10)
    const tiltX = springValue(tiltXTarget, {
      stiffness: 90,
      damping: 22,
    })
    const tiltY = springValue(tiltYTarget, {
      stiffness: 90,
      damping: 22,
    })
    vgpuEffect(root, { rotateX: tiltX, rotateY: tiltY })

    canvas.addEventListener("pointermove", (event) => {
      const bounds = canvas.getBoundingClientRect()
      const x = (event.clientX - bounds.left) / bounds.width
      const y = (event.clientY - bounds.top) / bounds.height
      tiltXTarget.set(12 - y * 30)
      tiltYTarget.set(-20 + x * 40)
    })

    hover(canvas, () => {
      return () => {
        tiltXTarget.set(-8)
        tiltYTarget.set(10)
      }
    })

    function setContracted(contracted) {
      entries.forEach((entry, index) => {
        const distance = contracted ? 0.43 : 1
        const scale = contracted ? 0.7 : 1
        const delay = contracted
          ? index * 0.006
          : (entries.length - index) * 0.004

        animate(
          entry.node,
          {
            x: entry.position[0] * distance,
            y: entry.position[1] * distance,
            z: contracted
              ? (entry.ringIndex - 1) * 0.2
              : entry.position[2],
            rotateZ:
              entry.rotation[2] +
              (contracted ? (entry.nodeIndex % 2 ? -28 : 28) : 0),
            scaleX: entry.scale[0] * scale,
            scaleY: entry.scale[1] * scale,
            scaleZ: entry.scale[2] * scale,
          },
          contracted
            ? {
                duration: 0.45,
                ease: [0.32, 0.72, 0, 1],
                delay,
              }
            : {
                type: "spring",
                visualDuration: 0.75,
                bounce: 0.24,
                delay,
              }
        )
      })

      animate(
        core,
        {
          scaleX: contracted ? 0.58 : 0.34,
          scaleY: contracted ? 0.58 : 0.34,
          scaleZ: contracted ? 0.58 : 0.34,
        },
        {
          type: "spring",
          visualDuration: 0.55,
          bounce: 0.3,
        }
      )
    }

    press(canvas, () => {
      setContracted(true)
      return () => setContracted(false)
    })
  }

  const objectShader = `
    struct Camera {
      viewProjection: mat4x4f,
      position: vec3f,
    }
    struct Model {
      model: mat4x4f,
    }
    struct Material {
      color: vec3f,
      glow: 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,
      @location(1) worldPosition: vec3f,
    }

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

    @fragment
    fn fs_main(
      @location(0) normal: vec3f,
      @location(1) worldPosition: vec3f
    ) -> @location(0) vec4f {
      let n = normalize(normal);
      let view = normalize(camera.position - worldPosition);
      let key = max(dot(n, normalize(vec3f(-0.45, 0.8, 0.55))), 0.0);
      let fill = max(dot(n, normalize(vec3f(0.7, -0.2, 0.4))), 0.0);
      let rim = pow(1.0 - max(dot(n, view), 0.0), 2.5);
      let light = 0.16 + key * 1.2 + fill * 0.28 + rim * material.glow;
      return vec4f(material.color * light, 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 sceneColor = textureSampleLevel(scene, sceneSampler, uv, 0.0);
      let backdrop = vec3f(0.00335, 0.00518, 0.00605);
      let lit = 1.0 - exp(-sceneColor.rgb * 1.18);
      let color = mix(backdrop, lit, sceneColor.a);
      return vec4f(linearToSrgb(color), 1.0);
    }
  `

  function degrees(value) {
    return (value * Math.PI) / 180
  }

  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: 42,
      aspect: canvasSurface.size[0] / canvasSurface.size[1],
      position: [0, 0.35, 8.4],
      target: [0, 0, 0],
    })

    const moduleShape = box({ size: 1 })
    const moduleGeometry = geometry(gpu, moduleShape)
    const materials = [
      unlitMaterial({ color: srgb(token("--hue-0")) }),
      unlitMaterial({ color: srgb(token("--hue-1")) }),
      unlitMaterial({ color: srgb(token("--hue-5")) }),
    ]
    const coreMaterial = unlitMaterial({ color: [1, 1, 1] })

    const root = group({ label: "kinetic-root" })
    const entries = []
    const ringSpecs = [
      {
        count: 20,
        radius: 2.55,
        tilt: [58, 10, 0],
        scale: [0.54, 0.1, 0.13],
        duration: 24,
        direction: 1,
      },
      {
        count: 16,
        radius: 1.85,
        tilt: [-48, 38, 0],
        scale: [0.44, 0.095, 0.11],
        duration: 18,
        direction: -1,
      },
      {
        count: 12,
        radius: 1.18,
        tilt: [24, -58, 0],
        scale: [0.34, 0.085, 0.1],
        duration: 13,
        direction: 1,
      },
    ]

    const rings = ringSpecs.map((spec, ringIndex) => {
      const ring = group({
        label: `ring-${ringIndex + 1}`,
        rotation: spec.tilt.map(degrees),
      })
      const nodes = Array.from({ length: spec.count }, (_, nodeIndex) => {
        const angle = (nodeIndex / spec.count) * Math.PI * 2
        const position = [
          Math.cos(angle) * spec.radius,
          Math.sin(angle) * spec.radius,
          0,
        ]
        const rotation = [0, 0, angle + Math.PI / 2]
        const node = mesh(moduleShape, materials[ringIndex], {
          label: `ring-${ringIndex + 1}-module-${nodeIndex + 1}`,
          position,
          rotation,
          scale: spec.scale,
        })
        entries.push({
          node,
          position,
          rotation: rotation.map((value) => value / degrees(1)),
          scale: spec.scale,
          ringIndex,
          nodeIndex,
        })
        return node
      })

      ring.add(...nodes)
      return ring
    })
    root.add(...rings)

    const core = mesh(moduleShape, coreMaterial, {
      label: "core",
      scale: [0.34, 0.34, 0.34],
      rotation: [degrees(20), degrees(35), degrees(10)],
    })
    root.add(core)
    runSceneMotion({
      root,
      rings,
      ringSpecs,
      entries,
      core,
    })

    const moduleDraws = entries.map(() =>
      draw(gpu, {
        shader: objectShader,
        geometry: moduleGeometry,
        cull: "back",
      })
    )
    const coreDraw = draw(gpu, {
      shader: objectShader,
      geometry: moduleGeometry,
      cull: "back",
    })
    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 })
    })

    function render() {
      vgpuFrame(gpu, (currentFrame) => {
        currentFrame.pass(
          {
            target: sceneTarget,
            clear: [0, 0, 0, 0],
            clearDepth: 1,
          },
          (pass) => {
            entries.forEach((entry, index) => {
              const currentDraw = moduleDraws[index]
              currentDraw.set({
                camera: {
                  viewProjection: camera.viewProjection,
                  position: camera.position,
                },
                model: { model: entry.node.worldMatrix },
                material: {
                  color: entry.node.material.color,
                  glow: 1.25,
                },
              })
              pass.draw(currentDraw)
            })

            coreDraw.set({
              camera: {
                viewProjection: camera.viewProjection,
                position: camera.position,
              },
              model: { model: core.worldMatrix },
              material: {
                color: core.material.color,
                glow: 1.8,
              },
            })
            pass.draw(coreDraw)
          }
        )
        currentFrame.pass(canvasSurface, present)
      })
    }

    frame.render(render, true)
  }

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

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

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