Skip to example

Three.js TSL particle morph

An example of morphing image-coloured Three.js particles between a photograph and a globe with a TSL uniform() node and threeEffect in Motion.

JavaScript

Source code

<div class="stage">
  <canvas
    role="button"
    tabindex="0"
    aria-label="Hold to form a particle globe"
  ></canvas>
</div>

<script type="module">
  import {
    animate,
    frame,
    motionValue,
    press,
    transformValue,
  } from "motion"
  import { threeEffect } from "motion/three"
  import * as THREE from "three/webgpu"
  import {
    attribute,
    cos,
    mix,
    positionLocal,
    sin,
    time,
    uniform,
    vec3,
  } from "three/tsl"

  /**
   * One Motion value controls one TSL uniform. The uniform drives the
   * position morph for every particle in the material's vertex graph.
   * The same value fades the photograph in once the particles settle.
   */
  const globe = uniform(1)
  const globeValue = motionValue(1)

  function highResolutionMix(globeAmount) {
    const amount = Math.max(globeAmount, 0)
    const start = 0.005
    const end = 0.035
    const t = Math.min(1, Math.max(0, (amount - start) / (end - start)))
    return 1 - t * t * (3 - 2 * t)
  }

  const JAPAN_PHOTOS = Array.from(
    { length: 13 },
    (_, index) => `/photos/japan/${index + 1}.jpg`
  )
  const previousPhoto = sessionStorage.getItem("three-tsl-photo")
  const photoChoices = JAPAN_PHOTOS.filter(
    (photo) => photo !== previousPhoto
  )
  const PHOTO_URL =
    photoChoices[Math.floor(Math.random() * photoChoices.length)]
  const COLUMNS = 200
  const globeYaw = Math.random() * Math.PI * 2
  const stage = document.querySelector(".stage")
  const canvas = document.querySelector("canvas")

  sessionStorage.setItem("three-tsl-photo", PHOTO_URL)

  function runParticleMotion(photoMesh) {
    threeEffect(globe, { value: globeValue })
    threeEffect(photoMesh.material, {
      opacity: transformValue(() => highResolutionMix(globeValue.get())),
    })

    animate(globeValue, 0, {
      type: "spring",
      stiffness: 55,
      damping: 16,
      mass: 1,
      delay: 0.8,
    })

    press(canvas, () => {
      animate(globeValue, 1, {
        type: "spring",
        stiffness: 120,
        damping: 18,
        mass: 0.9,
      })

      return () => {
        animate(globeValue, 0, {
          type: "spring",
          stiffness: 70,
          damping: 16,
          mass: 1,
        })
      }
    })
  }

  function loadPhoto(url) {
    return new Promise((resolve, reject) => {
      const image = new Image()

      image.onload = () => {
        const source = document.createElement("canvas")
        source.width = image.naturalWidth
        source.height = image.naturalHeight

        const context = source.getContext("2d", {
          willReadFrequently: true,
        })
        context.drawImage(image, 0, 0)

        const map = new THREE.Texture(image)
        map.colorSpace = THREE.SRGBColorSpace
        map.needsUpdate = true

        resolve({
          width: source.width,
          height: source.height,
          pixels: context.getImageData(
            0,
            0,
            source.width,
            source.height
          ).data,
          map,
        })
      }

      image.onerror = () => reject(
        new Error("Could not load the photograph")
      )
      image.src = url
    })
  }

  function createParticles(photo) {
    const imageAspect = photo.width / photo.height
    const rows = Math.round(COLUMNS / imageAspect)
    const count = COLUMNS * rows
    const imagePositions = new Float32Array(count * 3)
    const globePositions = new Float32Array(count * 3)
    const colours = new Float32Array(count * 3)
    const phases = new Float32Array(count)
    const speeds = new Float32Array(count)
    const colour = new THREE.Color()

    for (let index = 0; index < count; index++) {
      const column = index % COLUMNS
      const row = Math.floor(index / COLUMNS)
      const sampleX = Math.min(
        photo.width - 1,
        Math.floor((column + 0.5) / COLUMNS * photo.width)
      )
      const sampleY = Math.min(
        photo.height - 1,
        Math.floor((row + 0.5) / rows * photo.height)
      )
      const pixel = (sampleY * photo.width + sampleX) * 4
      const offset = index * 3

      colour.setRGB(
        photo.pixels[pixel] / 255,
        photo.pixels[pixel + 1] / 255,
        photo.pixels[pixel + 2] / 255,
        THREE.SRGBColorSpace
      )
      colours[offset] = colour.r
      colours[offset + 1] = colour.g
      colours[offset + 2] = colour.b
      phases[index] = Math.random() * Math.PI * 2
      speeds[index] = 0.7 + Math.random() * 0.35
    }

    const geometry = new THREE.InstancedBufferGeometry().copy(
      new THREE.PlaneGeometry(1, 1)
    )
    geometry.instanceCount = count
    geometry.setAttribute(
      "imagePosition",
      new THREE.InstancedBufferAttribute(imagePositions, 3)
    )
    geometry.setAttribute(
      "globePosition",
      new THREE.InstancedBufferAttribute(globePositions, 3)
    )
    geometry.setAttribute(
      "colour",
      new THREE.InstancedBufferAttribute(colours, 3)
    )
    geometry.setAttribute(
      "phase",
      new THREE.InstancedBufferAttribute(phases, 1)
    )
    geometry.setAttribute(
      "speed",
      new THREE.InstancedBufferAttribute(speeds, 1)
    )

    const imagePosition = attribute("imagePosition")
    const spherePosition = attribute("globePosition")
    const phase = attribute("phase")
    const speed = attribute("speed")
    const particleSize = uniform(new THREE.Vector2(0.01, 0.01))
    const turbulence = vec3(
      sin(time.mul(speed.add(0.6)).add(phase)).mul(0.045),
      cos(
        time.mul(speed.add(0.37)).add(phase.mul(1.71))
      ).mul(0.035),
      sin(
        time.mul(speed.add(0.22)).add(phase.mul(2.13))
      ).mul(0.045)
    )
    const material = new THREE.MeshBasicNodeMaterial({
      toneMapped: false,
    })

    material.colorNode = attribute("colour")
    const particleCenter = mix(
      imagePosition,
      spherePosition.add(turbulence),
      globe
    )
    material.positionNode = particleCenter.add(
      vec3(positionLocal.xy.mul(particleSize), 0)
    )

    return {
      imageAspect,
      columns: COLUMNS,
      rows,
      particleSize,
      object: new THREE.Mesh(geometry, material),
    }
  }

  function createPhotoOverlay(photo) {
    const photoMaterial = new THREE.MeshBasicMaterial({
      map: photo.map,
      toneMapped: false,
      transparent: true,
      depthWrite: false,
      depthTest: false,
      opacity: 0,
    })

    const mesh = new THREE.Mesh(
      new THREE.PlaneGeometry(1, 1),
      photoMaterial
    )
    mesh.renderOrder = 1
    return mesh
  }

  function layoutParticles(particles, photoMesh, aspect) {
    const {
      columns,
      imageAspect,
      object,
      particleSize,
      rows,
    } = particles
    const imageHalfHeight = Math.min(
      0.72,
      0.86 * aspect / imageAspect
    )
    const imageHalfWidth = imageHalfHeight * imageAspect
    const globeRadius = Math.min(0.58, aspect * 0.82)
    const position = object.geometry.getAttribute("imagePosition")
    const sphere = object.geometry.getAttribute("globePosition")
    const yawCos = Math.cos(globeYaw)
    const yawSin = Math.sin(globeYaw)
    const tilt = -0.28

    for (let index = 0; index < position.count; index++) {
      const column = index % columns
      const row = Math.floor(index / columns)
      const x = (column + 0.5) / columns
      const y = (row + 0.5) / rows
      const sphereY = 1 - index / Math.max(position.count - 1, 1) * 2
      const ringRadius = Math.sqrt(
        Math.max(0, 1 - sphereY * sphereY)
      )
      const angle = index * 2.39996323
      const sphereX = Math.cos(angle) * ringRadius
      const sphereZ = Math.sin(angle) * ringRadius
      const spunX = sphereX * yawCos + sphereZ * yawSin
      const spunZ = -sphereX * yawSin + sphereZ * yawCos

      position.setXYZ(
        index,
        (x - 0.5) * imageHalfWidth * 2,
        (0.5 - y) * imageHalfHeight * 2,
        0
      )
      sphere.setXYZ(
        index,
        spunX * globeRadius,
        (
          sphereY * Math.cos(tilt) -
          spunZ * Math.sin(tilt)
        ) * globeRadius,
        (
          sphereY * Math.sin(tilt) +
          spunZ * Math.cos(tilt)
        ) * globeRadius
      )
    }

    position.needsUpdate = true
    sphere.needsUpdate = true
    particleSize.value.set(
      imageHalfWidth * 2 / columns * 1.01,
      imageHalfHeight * 2 / rows * 1.01
    )
    photoMesh.scale.set(
      imageHalfWidth * 2,
      imageHalfHeight * 2,
      1
    )
  }

  async function start() {
    const renderer = new THREE.WebGPURenderer({
      canvas,
      antialias: true,
    })
    await renderer.init()
    renderer.setPixelRatio(Math.min(devicePixelRatio, 2))
    renderer.outputColorSpace = THREE.SRGBColorSpace

    const photo = await loadPhoto(PHOTO_URL)
    const particles = createParticles(photo)
    const photoMesh = createPhotoOverlay(photo)
    const scene = new THREE.Scene()
    const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 10)

    scene.background = new THREE.Color(0xffffff)
    camera.position.z = 4
    scene.add(particles.object)
    scene.add(photoMesh)

    const resize = () => {
      const width = Math.max(stage.clientWidth, 1)
      const height = Math.max(stage.clientHeight, 1)
      const aspect = width / height

      renderer.setSize(width, height, false)
      camera.left = -aspect
      camera.right = aspect
      camera.top = 1
      camera.bottom = -1
      camera.updateProjectionMatrix()
      layoutParticles(particles, photoMesh, aspect)
    }

    new ResizeObserver(resize).observe(stage)
    resize()
    runParticleMotion(photoMesh)
    canvas.dataset.ready = "true"
    frame.render(() => renderer.render(scene, camera), true)
  }

  start().catch((error) => {
    console.error(error)
  })
</script>

<style>
  .stage {
    position: fixed;
    inset: 0;
    width: 100vw;
    height: 100vh;
    background: #fff;
  }

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