Skip to example

vgpu orbit

An example of a Motion-animated orbit camera moving around a fixed reflective logo in a procedurally generated 360 degree environment map with vgpu.

JavaScript

Source code

<div class="stage">
  <canvas aria-label="Orbit a reflective Motion logo in a 360 degree environment"></canvas>
</div>

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

  const stage = document.querySelector(".stage")
  const canvas = document.querySelector("canvas")
  const ORBIT_SPEED = 0.09
  const MAX_RELEASE_SPEED = 2.4

  function runOrbitMotion(controls) {
    const orbitYaw = motionValue(controls.yaw)
    const orbitVelocity = motionValue(ORBIT_SPEED)
    let velocityAnimation
    let dragging = false

    function transferDragVelocity() {
      const releaseVelocity = Math.max(
        -MAX_RELEASE_SPEED,
        Math.min(MAX_RELEASE_SPEED, orbitYaw.getVelocity())
      )
      orbitVelocity.set(releaseVelocity)
      velocityAnimation = animate(orbitVelocity, ORBIT_SPEED, {
        duration: 2.4,
        ease: [0.16, 1, 0.3, 1],
      })
    }

    press(canvas, () => {
      dragging = true
      velocityAnimation?.stop()
      velocityAnimation = undefined
      orbitYaw.set(controls.yaw)

      return () => {
        if (!dragging) return
        dragging = false
        transferDragVelocity()
      }
    })

    frame.update(({ delta }) => {
      const seconds = Math.min(Math.max(0, delta) / 1000, 0.05)
      controls.update(seconds)

      if (dragging) {
        orbitYaw.set(controls.yaw)
        return
      }

      orbitYaw.set(orbitYaw.get() + orbitVelocity.get() * seconds)
      controls.set({ yaw: orbitYaw.get() })
      controls.update(0)
    }, true)
  }

  const skyShader = `
    const PI: f32 = 3.141592653589793;

    struct Sky {
      sun_direction: vec3f,
      sun_angular_size: f32,
      sun_color: vec3f,
      sun_intensity: f32,
      zenith_color: vec3f,
      cloud_coverage: f32,
      horizon_color: vec3f,
      cloud_scale: f32,
      ground_color: vec3f,
      ground_scale: f32,
    }

    @group(0) @binding(0) var<uniform> sky: Sky;

    fn directionFromEquirect(uv: vec2f) -> vec3f {
      let phi = (uv.x - 0.5) * 2.0 * PI;
      let theta = uv.y * PI;
      return vec3f(
        sin(theta) * cos(phi),
        cos(theta),
        sin(theta) * sin(phi)
      );
    }

    fn hash(p: vec2f) -> f32 {
      var q = fract(vec3f(p.x, p.y, p.x) * 0.1031);
      q += dot(q, q.yzx + 33.33);
      return fract((q.x + q.y) * q.z);
    }

    fn valueNoise(p: vec2f) -> f32 {
      let i = floor(p);
      let f = fract(p);
      let w = f * f * (3.0 - 2.0 * f);
      let a = hash(i);
      let b = hash(i + vec2f(1.0, 0.0));
      let c = hash(i + vec2f(0.0, 1.0));
      let d = hash(i + vec2f(1.0, 1.0));
      return mix(mix(a, b, w.x), mix(c, d, w.x), w.y);
    }

    fn fbm(p: vec2f) -> f32 {
      var sum = 0.0;
      var amplitude = 0.5;
      var point = p;
      for (var octave = 0; octave < 5; octave++) {
        sum += amplitude * valueNoise(point);
        point = point * 2.03 + vec2f(17.0, 9.0);
        amplitude *= 0.5;
      }
      return sum;
    }

    fn cloudLayer(direction: vec3f, sun: vec3f) -> vec2f {
      let height = max(direction.y, 0.035);
      let plane = direction.xz / height * sky.cloud_scale;
      let base = fbm(plane);
      let detail = fbm(plane * 3.1 + vec2f(base * 1.6));
      let density = smoothstep(
        sky.cloud_coverage,
        sky.cloud_coverage + 0.28,
        base * 0.75 + detail * 0.35
      );
      let horizonFade = smoothstep(0.0, 0.12, direction.y);
      let lit = pow(
        clamp(dot(direction, sun) * 0.5 + 0.5, 0.0, 1.0),
        3.0
      );
      return vec2f(density * horizonFade, lit);
    }

    fn checkerBox(p: vec2f, width: vec2f) -> f32 {
      let i =
        2.0 *
        (
          abs(fract((p - 0.5 * width) * 0.5) - 0.5) -
          abs(fract((p + 0.5 * width) * 0.5) - 0.5)
        ) /
        width;
      return 0.5 - 0.5 * i.x * i.y;
    }

    fn ground(direction: vec3f, sun: vec3f) -> vec3f {
      let depth = max(-direction.y, 0.001);
      let plane = direction.xz / depth * sky.ground_scale;
      let fade = 1.0 / (1.0 + dot(plane, plane) * 0.006);

      var color =
        sky.ground_color *
        (
          1.0 +
          checkerBox(plane, fwidth(plane) + vec2f(1e-3)) * 3.4
        );
      color *= 0.8 + fbm(plane * 0.3) * 0.6;
      color +=
        sky.sun_color *
        0.05 *
        clamp(
          dot(normalize(vec3f(plane.x, 0.0, plane.y)), sun),
          0.0,
          1.0
        );

      return mix(sky.horizon_color * 0.22, color, fade);
    }

    @fragment
    fn fs_main(@location(0) uv: vec2f) -> @location(0) vec4f {
      let direction = directionFromEquirect(uv);
      let sun = normalize(sky.sun_direction);
      let up = clamp(direction.y, 0.0, 1.0);
      var color = mix(
        sky.horizon_color,
        sky.zenith_color,
        pow(up, 0.75)
      );

      let sunDot = clamp(dot(direction, sun), 0.0, 1.0);
      color += sky.sun_color * pow(sunDot, 60.0) * 0.2;
      color += sky.sun_color * pow(sunDot, 900.0) * 0.8;
      let disk = smoothstep(
        cos(sky.sun_angular_size * 2.2),
        cos(sky.sun_angular_size),
        sunDot
      );
      color += sky.sun_color * sky.sun_intensity * disk;

      let cloud = cloudLayer(direction, sun);
      let cloudColor = mix(
        vec3f(0.30, 0.34, 0.44),
        sky.sun_color * 1.05,
        cloud.y
      );
      color = mix(color, cloudColor, cloud.x * (1.0 - disk));

      let horizon = smoothstep(-0.14, 0.02, direction.y);
      color = mix(ground(direction, sun), color, horizon);

      return vec4f(color, 1.0);
    }
  `

  const logoShader = `
    const PI: f32 = 3.141592653589793;

    struct Camera {
      viewProjection: mat4x4f,
      position: vec3f,
    }
    struct Model {
      model: mat4x4f,
    }
    struct Material {
      color: vec3f,
    }
    struct Surface {
      roughness: 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;
    @group(0) @binding(3) var<uniform> surface: Surface;
    @group(0) @binding(4) var environmentMap: texture_2d<f32>;
    @group(0) @binding(5) var environmentSampler: sampler;

    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 {
      var out: VertexOut;
      let world = model.model * vec4f(position, 1.0);
      out.position = camera.viewProjection * world;
      out.normal = (model.model * vec4f(normal, 0.0)).xyz;
      out.worldPosition = world.xyz;
      return out;
    }

    fn equirectUv(direction: vec3f) -> vec2f {
      let d = normalize(direction);
      return vec2f(
        atan2(d.z, d.x) / (2.0 * PI) + 0.5,
        acos(clamp(d.y, -1.0, 1.0)) / PI
      );
    }

    fn sampleEnvironment(direction: vec3f) -> vec3f {
      return textureSampleLevel(
        environmentMap,
        environmentSampler,
        equirectUv(direction),
        0.0
      ).rgb;
    }

    @fragment
    fn fs_main(
      @location(0) normal: vec3f,
      @location(1) worldPosition: vec3f
    ) -> @location(0) vec4f {
      let n = normalize(normal);
      let v = normalize(camera.position - worldPosition);
      let reflected = reflect(-v, n);
      let facing = max(dot(n, v), 0.0);
      let fresnel =
        material.color +
        (vec3f(1.0) - material.color) * pow(1.0 - facing, 5.0);
      let softenedDirection = normalize(
        mix(reflected, n, surface.roughness * surface.roughness)
      );
      let specular = sampleEnvironment(softenedDirection) * fresnel;
      let diffuse =
        sampleEnvironment(n) *
        material.color *
        surface.roughness *
        0.18;
      return vec4f(specular + diffuse, 1.0);
    }
  `

  const presentShader = `
    const PI: f32 = 3.141592653589793;

    struct PresentCamera {
      forward: vec3f,
      tanHalfFov: f32,
      right: vec3f,
      aspect: f32,
      up: vec3f,
      exposure: f32,
    }

    @group(0) @binding(0) var scene: texture_2d<f32>;
    @group(0) @binding(1) var sceneSampler: sampler;
    @group(0) @binding(2) var environmentMap: texture_2d<f32>;
    @group(0) @binding(3) var environmentSampler: sampler;
    @group(0) @binding(4) var<uniform> presentCamera: PresentCamera;

    fn equirectUv(direction: vec3f) -> vec2f {
      let d = normalize(direction);
      return vec2f(
        atan2(d.z, d.x) / (2.0 * PI) + 0.5,
        acos(clamp(d.y, -1.0, 1.0)) / PI
      );
    }

    fn tonemapAces(color: vec3f) -> vec3f {
      let x = max(color, vec3f(0.0));
      return clamp(
        (x * (2.51 * x + 0.03)) /
          (x * (2.43 * x + 0.59) + 0.14),
        vec3f(0.0),
        vec3f(1.0)
      );
    }

    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 ndc = vec2f(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0);
      let direction = normalize(
        presentCamera.forward +
        presentCamera.right *
          (ndc.x * presentCamera.tanHalfFov * presentCamera.aspect) +
        presentCamera.up * (ndc.y * presentCamera.tanHalfFov)
      );
      let background = textureSampleLevel(
        environmentMap,
        environmentSampler,
        equirectUv(direction),
        0.0
      ).rgb;
      let sceneColor = textureSample(scene, sceneSampler, uv);
      let color = mix(background, sceneColor.rgb, sceneColor.a);
      return vec4f(
        linearToSrgb(tonemapAces(color * presentCamera.exposure)),
        1.0
      );
    }
  `

  function bezier(points, from, control1, control2, to, steps = 10) {
    for (let i = 1; i <= steps; i++) {
      const t = i / steps
      const u = 1 - t
      points.push([
        u * u * u * from[0] +
          3 * u * u * t * control1[0] +
          3 * u * t * t * control2[0] +
          t * t * t * to[0],
        u * u * u * from[1] +
          3 * u * u * t * control1[1] +
          3 * u * t * t * control2[1] +
          t * t * t * to[1],
      ])
    }
  }

  function logoOutlines() {
    const left = [
      [9.587, 0],
      [4.57, 9],
      [0, 9],
      [3.917, 1.972],
    ]
    bezier(
      left,
      [3.917, 1.972],
      [4.524, 0.883],
      [6.039, 0],
      [7.301, 0]
    )

    const middle = [
      [10.443, 0],
      [15.013, 0],
      [9.997, 9],
      [5.427, 9],
    ]

    const right = [
      [15.841, 0],
      [20.411, 0],
      [16.494, 7.028],
    ]
    bezier(
      right,
      [16.494, 7.028],
      [15.887, 8.117],
      [14.372, 9],
      [13.11, 9]
    )
    right.push([10.825, 9])

    const dot = []
    for (let i = 0; i < 48; i++) {
      const angle = (i / 48) * Math.PI * 2
      dot.push([
        23.079 + Math.cos(angle) * 2.285,
        2.25 + Math.sin(angle) * 2.285,
      ])
    }

    const scale = 4 / 25.364
    return [left, middle, right, dot].map((outline) =>
      outline.map(([x, y]) => [
        (x - 12.682) * scale,
        (4.5 - y) * scale,
      ])
    )
  }

  function extrude(outlines, depth) {
    const vertices = []
    const indices = []
    const half = depth / 2
    const push = (point, normal) => {
      vertices.push(...point, ...normal)
      return vertices.length / 6 - 1
    }

    for (let outline of outlines) {
      let area = 0
      for (let i = 0; i < outline.length; i++) {
        const a = outline[i]
        const b = outline[(i + 1) % outline.length]
        area += a[0] * b[1] - b[0] * a[1]
      }
      if (area < 0) outline = [...outline].reverse()

      const count = outline.length
      const front = outline.map((point) =>
        push([point[0], point[1], half], [0, 0, 1])
      )
      const back = outline.map((point) =>
        push([point[0], point[1], -half], [0, 0, -1])
      )
      for (let i = 1; i < count - 1; i++) {
        indices.push(front[0], front[i], front[i + 1])
        indices.push(back[0], back[i + 1], back[i])
      }

      for (let i = 0; i < count; i++) {
        const a = outline[i]
        const b = outline[(i + 1) % count]
        const length = Math.hypot(b[1] - a[1], b[0] - a[0])
        const normal = [
          (b[1] - a[1]) / length,
          -(b[0] - a[0]) / length,
          0,
        ]
        const v0 = push([a[0], a[1], half], normal)
        const v1 = push([b[0], b[1], half], normal)
        const v2 = push([b[0], b[1], -half], normal)
        const v3 = push([a[0], a[1], -half], normal)
        indices.push(v0, v3, v2, v0, v2, v1)
      }
    }

    return {
      data: new Float32Array(vertices),
      indices: new Uint32Array(indices),
    }
  }

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

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

  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 cameraBasis(position, targetPosition, fov, aspect) {
    const forward = normalize(subtract(targetPosition, position))
    const right = normalize(cross(forward, [0, 1, 0]))
    const up = cross(right, forward)
    return {
      forward,
      tanHalfFov: Math.tan((fov * Math.PI) / 360),
      right,
      aspect,
      up,
      exposure: 0.9,
    }
  }

  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 environmentTarget = target(gpu, {
      size: [2048, 1024],
      format: "rgba16float",
    })
    const environmentSampler = sampler(gpu, {
      minFilter: "linear",
      magFilter: "linear",
      addressModeU: "repeat",
      addressModeV: "clamp-to-edge",
    })
    const environment = effect(gpu, skyShader, {
      set: {
        sky: {
          sun_direction: [-0.724, 0.09, -0.684],
          sun_angular_size: 0.018,
          sun_color: [1, 0.88, 0.72],
          sun_intensity: 26,
          zenith_color: [0.05, 0.15, 0.44],
          cloud_coverage: 0.56,
          horizon_color: [0.36, 0.48, 0.74],
          cloud_scale: 0.75,
          ground_color: [0.05, 0.05, 0.056],
          ground_scale: 4.6,
        },
      },
    })
    vgpuFrame(gpu, (currentFrame) => {
      currentFrame.pass(environmentTarget, environment)
    })

    const logo = extrude(logoOutlines(), 0.25)
    const logoGeometry = geometry(gpu, {
      buffers: [
        {
          attributes: {
            position: "float32x3",
            normal: "float32x3",
          },
          data: logo.data,
        },
      ],
      indices: logo.indices,
    })
    const finish = uniforms(gpu, { roughness: 0.055 })

    const cameraPosition = [2.4, 1.6, 7]
    const cameraTarget = [0, 0, 0]
    const fov = 32
    const camera = perspectiveCamera({
      fov,
      aspect: canvasSurface.size[0] / canvasSurface.size[1],
      position: cameraPosition,
      target: cameraTarget,
    })
    const controls = orbitControls(camera, {
      element: canvas,
      damping: 0.12,
      distance: { min: 4, max: 12 },
    })
    runOrbitMotion(controls)
    let presentCamera = cameraBasis(
      cameraPosition,
      cameraTarget,
      fov,
      canvasSurface.size[0] / canvasSurface.size[1]
    )

    const node = group()

    const logoDraw = draw(gpu, {
      shader: logoShader,
      geometry: logoGeometry,
      cull: "back",
      set: {
        surface: finish,
        environmentMap: environmentTarget,
        environmentSampler,
      },
    })
    const present = effect(gpu, presentShader, {
      set: {
        scene: sceneTarget,
        sceneSampler: sampler(gpu, {
          minFilter: "linear",
          magFilter: "linear",
        }),
        environmentMap: environmentTarget,
        environmentSampler,
        presentCamera,
      },
    })

    canvasSurface.onResize(({ width, height }) => {
      sceneTarget.resize([width, height])
      const aspect = width / height
      camera.set({ aspect })
      presentCamera = { ...presentCamera, aspect }
      present.set({ presentCamera })
    })

    function render() {
      presentCamera = cameraBasis(
        camera.position,
        cameraTarget,
        fov,
        canvasSurface.size[0] / canvasSurface.size[1]
      )
      present.set({ presentCamera })

      logoDraw.set({
        camera: {
          viewProjection: camera.viewProjection,
          position: camera.position,
        },
        model: { model: node.worldMatrix },
        material: { color: [0.56, 0.57, 0.58] },
      })

      vgpuFrame(gpu, (currentFrame) => {
        currentFrame.pass(
          { target: sceneTarget, clear: [0, 0, 0, 0] },
          (pass) => pass.draw(logoDraw)
        )
        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: grab;
    touch-action: none;
  }

  .stage canvas:active {
    cursor: grabbing;
  }
</style>