Skip to example

Three.js OrbitControls

An example of a Motion-animated OrbitControls camera moving around a fixed reflective logo in a procedural sky environment with Three.js.

JavaScript

Source code

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

<script type="module">
  import { animate, frame, motionValue, press } from "motion"
  import * as THREE from "three"
  import { OrbitControls } from "three/addons/controls/OrbitControls.js"

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

  function runOrbitMotion(controls) {
    /**
     * OrbitControls owns all pointer-to-camera work. Motion only supplies the
     * automatic yaw velocity before and after a drag.
     */
    const orbitYaw = motionValue(controls.getAzimuthalAngle())
    const orbitVelocity = motionValue(ORBIT_SPEED)
    let previousAzimuth = controls.getAzimuthalAngle()
    let unwrappedYaw = previousAzimuth
    let velocityAnimation
    let dragging = false

    function sampleOrbitYaw() {
      const azimuth = controls.getAzimuthalAngle()
      let change = azimuth - previousAzimuth
      if (change > Math.PI) change -= Math.PI * 2
      if (change < -Math.PI) change += Math.PI * 2
      unwrappedYaw += change
      previousAzimuth = azimuth
      orbitYaw.set(unwrappedYaw)
    }

    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
      sampleOrbitYaw()

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

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

      if (!dragging) {
        controls.rotateLeft(-orbitVelocity.get() * seconds)
      }

      controls.update()
      sampleOrbitYaw()
    }, true)
  }

  const renderer = new THREE.WebGLRenderer({
    canvas,
    antialias: true,
  })
  renderer.setPixelRatio(Math.min(devicePixelRatio, 2))
  renderer.toneMapping = THREE.ACESFilmicToneMapping
  renderer.toneMappingExposure = 0.8

  const scene = new THREE.Scene()
  const camera = new THREE.PerspectiveCamera(32, 1, 0.1, 100)
  camera.position.set(2.4, 1.6, 7)

  /**
   * This is the same procedural environment used by the vgpu orbit example.
   * One shader draws the camera-centred background and a CubeCamera captures
   * the same sky once for the logo's reflections.
   */
  const skyShader = `
    varying vec3 vDirection;

    uniform vec3 sunDirection;
    uniform float sunAngularSize;
    uniform vec3 sunColor;
    uniform float sunIntensity;
    uniform vec3 zenithColor;
    uniform float cloudCoverage;
    uniform vec3 horizonColor;
    uniform float cloudScale;
    uniform vec3 groundColor;
    uniform float groundScale;

    float hash(vec2 point) {
      vec3 q = fract(vec3(point.x, point.y, point.x) * 0.1031);
      q += dot(q, q.yzx + 33.33);
      return fract((q.x + q.y) * q.z);
    }

    float valueNoise(vec2 point) {
      vec2 cell = floor(point);
      vec2 local = fract(point);
      vec2 weight = local * local * (3.0 - 2.0 * local);
      float a = hash(cell);
      float b = hash(cell + vec2(1.0, 0.0));
      float c = hash(cell + vec2(0.0, 1.0));
      float d = hash(cell + vec2(1.0, 1.0));
      return mix(mix(a, b, weight.x), mix(c, d, weight.x), weight.y);
    }

    float fbm(vec2 point) {
      float sum = 0.0;
      float amplitude = 0.5;
      for (int octave = 0; octave < 5; octave++) {
        sum += amplitude * valueNoise(point);
        point = point * 2.03 + vec2(17.0, 9.0);
        amplitude *= 0.5;
      }
      return sum;
    }

    vec2 cloudLayer(vec3 direction, vec3 sun) {
      float height = max(direction.y, 0.035);
      vec2 plane = direction.xz / height * cloudScale;
      float base = fbm(plane);
      float detail = fbm(plane * 3.1 + vec2(base * 1.6));
      float density = smoothstep(
        cloudCoverage,
        cloudCoverage + 0.28,
        base * 0.75 + detail * 0.35
      );
      float horizonFade = smoothstep(0.0, 0.12, direction.y);
      float lit = pow(
        clamp(dot(direction, sun) * 0.5 + 0.5, 0.0, 1.0),
        3.0
      );
      return vec2(density * horizonFade, lit);
    }

    float checkerBox(vec2 point, vec2 width) {
      vec2 pattern =
        2.0 *
        (
          abs(fract((point - 0.5 * width) * 0.5) - 0.5) -
          abs(fract((point + 0.5 * width) * 0.5) - 0.5)
        ) /
        width;
      return 0.5 - 0.5 * pattern.x * pattern.y;
    }

    vec3 ground(vec3 direction, vec3 sun) {
      float depth = max(-direction.y, 0.001);
      vec2 plane = direction.xz / depth * groundScale;
      float fade = 1.0 / (1.0 + dot(plane, plane) * 0.006);
      vec3 color =
        groundColor *
        (
          1.0 +
          checkerBox(plane, fwidth(plane) + vec2(1e-3)) * 3.4
        );
      color *= 0.8 + fbm(plane * 0.3) * 0.6;
      color +=
        sunColor *
        0.05 *
        clamp(
          dot(normalize(vec3(plane.x, 0.0, plane.y)), sun),
          0.0,
          1.0
        );
      return mix(horizonColor * 0.22, color, fade);
    }

    void main() {
      vec3 direction = normalize(vDirection);
      vec3 sun = normalize(sunDirection);
      float up = clamp(direction.y, 0.0, 1.0);
      vec3 color = mix(
        horizonColor,
        zenithColor,
        pow(up, 0.75)
      );
      float sunDot = clamp(dot(direction, sun), 0.0, 1.0);
      color += sunColor * pow(sunDot, 60.0) * 0.2;
      color += sunColor * pow(sunDot, 900.0) * 0.8;
      float disk = smoothstep(
        cos(sunAngularSize * 2.2),
        cos(sunAngularSize),
        sunDot
      );
      color += sunColor * sunIntensity * disk;

      vec2 cloud = cloudLayer(direction, sun);
      vec3 cloudColor = mix(
        vec3(0.30, 0.34, 0.44),
        sunColor * 1.05,
        cloud.y
      );
      color = mix(color, cloudColor, cloud.x * (1.0 - disk));

      float horizon = smoothstep(-0.14, 0.02, direction.y);
      color = mix(ground(direction, sun), color, horizon);
      gl_FragColor = vec4(color, 1.0);

      #ifdef TONEMAP_SKY
        #include <tonemapping_fragment>
        #include <colorspace_fragment>
      #endif
    }
  `

  const skyVertexShader = `
    varying vec3 vDirection;

    void main() {
      vDirection = position;
      gl_Position =
        projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    }
  `

  const skyUniforms = {
    sunDirection: {
      value: new THREE.Vector3(-0.724, 0.09, -0.684).normalize(),
    },
    sunAngularSize: { value: 0.018 },
    sunColor: { value: new THREE.Color().setRGB(1, 0.88, 0.72) },
    sunIntensity: { value: 26 },
    zenithColor: { value: new THREE.Color().setRGB(0.05, 0.15, 0.44) },
    cloudCoverage: { value: 0.56 },
    horizonColor: { value: new THREE.Color().setRGB(0.36, 0.48, 0.74) },
    cloudScale: { value: 0.75 },
    groundColor: { value: new THREE.Color().setRGB(0.05, 0.05, 0.056) },
    groundScale: { value: 4.6 },
  }

  function createSkyMaterial(toneMapped) {
    return new THREE.ShaderMaterial({
      uniforms: skyUniforms,
      vertexShader: skyVertexShader,
      fragmentShader: skyShader,
      defines: toneMapped ? { TONEMAP_SKY: "" } : {},
      side: THREE.BackSide,
      depthWrite: false,
      toneMapped,
    })
  }

  const skyGeometry = new THREE.SphereGeometry(50, 64, 32)
  const sky = new THREE.Mesh(skyGeometry, createSkyMaterial(true))
  sky.frustumCulled = false
  scene.add(sky)

  const environmentScene = new THREE.Scene()
  const environmentSky = new THREE.Mesh(
    skyGeometry,
    createSkyMaterial(false)
  )
  environmentScene.add(environmentSky)

  const environmentTarget = new THREE.WebGLCubeRenderTarget(1024, {
    type: THREE.HalfFloatType,
    generateMipmaps: true,
    minFilter: THREE.LinearMipmapLinearFilter,
  })
  const environmentCamera = new THREE.CubeCamera(
    0.1,
    100,
    environmentTarget
  )
  environmentCamera.update(renderer, environmentScene)
  const environment = environmentTarget.texture
  scene.environment = environment

  /**
   * The Motion logo as extruded shapes. Coordinates are the SVG path with
   * y flipped, then centred and scaled to world units.
   */
  function logoShapes() {
    const left = new THREE.Shape()
    left.moveTo(9.587, 9)
    left.lineTo(4.57, 0)
    left.lineTo(0, 0)
    left.lineTo(3.917, 7.028)
    left.bezierCurveTo(4.524, 8.117, 6.039, 9, 7.301, 9)

    const middle = new THREE.Shape()
    middle.moveTo(10.443, 9)
    middle.lineTo(15.013, 9)
    middle.lineTo(9.997, 0)
    middle.lineTo(5.427, 0)

    const right = new THREE.Shape()
    right.moveTo(15.841, 9)
    right.lineTo(20.411, 9)
    right.lineTo(16.494, 1.972)
    right.bezierCurveTo(15.887, 0.883, 14.372, 0, 13.11, 0)
    right.lineTo(10.825, 0)

    const dot = new THREE.Shape()
    dot.absarc(23.079, 6.75, 2.285, 0, Math.PI * 2)

    return [left, middle, right, dot]
  }

  const geometry = new THREE.ExtrudeGeometry(logoShapes(), {
    depth: 1.6,
    bevelEnabled: true,
    bevelThickness: 0.3,
    bevelSize: 0.3,
    bevelSegments: 6,
    curveSegments: 24,
  })
  geometry.center()
  geometry.scale(4 / 25.364, 4 / 25.364, 4 / 25.364)

  const material = new THREE.MeshPhysicalMaterial({
    color: getComputedStyle(document.body)
      .getPropertyValue("--white")
      .trim(),
    metalness: 1,
    roughness: 0.055,
    envMapIntensity: 1.2,
  })
  material.color.multiplyScalar(0.56)
  const logo = new THREE.Mesh(geometry, material)
  scene.add(logo)

  const controls = new OrbitControls(camera, canvas)
  controls.enablePan = false
  controls.minDistance = 4
  controls.maxDistance = 12
  runOrbitMotion(controls)

  function resize() {
    const width = canvas.clientWidth
    const height = canvas.clientHeight
    renderer.setSize(width, height, false)
    camera.aspect = width / height
    camera.updateProjectionMatrix()
  }

  new ResizeObserver(resize).observe(canvas)
  resize()
  frame.render(() => {
    sky.position.copy(camera.position)
    renderer.render(scene, camera)
  }, true)
</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>