Skip to example

Fractal topography

Two spheres in a field of rising fractal contours. Motion for Three.js animates the shader topology, with blue and red ink, dithering and taupe halftone columns.

JavaScript

Source code

<div class="topography">
  <canvas role="img" aria-label="A red sphere with blue grain floats above a dark sphere. Fine blue contours rise from its changing surface and turn red over taupe halftone columns."></canvas>
  <button class="pause" type="button" aria-label="Pause animation">Pause</button>
  <p class="fallback" hidden>This example needs WebGL 2.</p>
</div>

<script type="module">
  import { animate, frame, cancelFrame } from "motion"
  import { threeEffect } from "motion/three"
  import * as THREE from "three"

  const stage = document.querySelector(".topography")
  const canvas = stage.querySelector("canvas")
  const button = stage.querySelector("button")
  const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)")

  function runTopography(uniforms) {
    animate.addEffect(threeEffect)
    return animate(uniforms, { phase: [0, Math.PI * 2] }, {
      duration: 90,
      ease: "linear",
      repeat: Infinity,
    })
  }

  function createArtwork() {
    let renderer
    try {
      renderer = new THREE.WebGLRenderer({ canvas, antialias: false })
    } catch {
      stage.querySelector(".fallback").hidden = false
      button.hidden = true
      return
    }

    const scene = new THREE.Scene()
    const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 2)
    camera.position.z = 1
    const uniforms = {
      resolution: { value: new THREE.Vector2() },
      phase: { value: 0 },
      redX: { value: 0.075 },
      redY: { value: 0.49 },
      redRadius: { value: 0.285 },
      blueShade: { value: 0.92 },
      dotSize: { value: 7 },
      leftWidth: { value: 0.18 },
      rightWidth: { value: 0.285 },
      columnBottom: { value: 0.06 },
      lowerY: { value: -0.48 },
      lowerRadius: { value: 0.365 },
      density: { value: 22 },
      lineWidth: { value: 0.065 },
      distortion: { value: 1 },
      overlayX: { value: -0.28 },
      overlayTop: { value: -0.56 },
      overlayWidth: { value: 0.055 },
      overlayHeight: { value: 0.48 },
      overlayStrength: { value: 1 },
    }

    // The four print layers share one fragment shader. Sphere normals and
    // longitude contours are reconstructed per pixel, without a texture.
    const material = new THREE.ShaderMaterial({
      uniforms,
      depthTest: false,
      depthWrite: false,
      vertexShader: `
        void main() {
          gl_Position = vec4(position.xy, 0.0, 1.0);
        }
      `,
      fragmentShader: `
        uniform vec2 resolution;
        uniform float phase;
        uniform float redX, redY, redRadius, blueShade;
        uniform float dotSize, leftWidth, rightWidth, columnBottom;
        uniform float lowerY, lowerRadius, density, lineWidth, distortion;
        uniform float overlayX, overlayTop, overlayWidth, overlayHeight, overlayStrength;
        const vec3 TAUPE = vec3(0.714, 0.678, 0.631);
        const vec3 BLACK = vec3(0.025, 0.031, 0.027);
        const vec3 BLUE = vec3(0.51, 0.70, 0.84);
        const vec3 RED = vec3(1.0, 0.235, 0.19);

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

        float noise(vec2 p) {
          vec2 i = floor(p);
          vec2 f = fract(p);
          vec2 u = f * f * (3.0 - 2.0 * f);
          return mix(mix(hash(i), hash(i + vec2(1, 0)), u.x),
                     mix(hash(i + vec2(0, 1)), hash(i + vec2(1, 1)), u.x), u.y);
        }

        float fbm(vec2 p) {
          float value = 0.0;
          float weight = 0.5;
          mat2 turn = mat2(0.8, -0.6, 0.6, 0.8);
          for (int i = 0; i < 3; i++) {
            value += weight * noise(p);
            p = turn * p * 2.03 + 7.1;
            weight *= 0.5;
          }
          return value;
        }

        void main() {
          // Fit the complete portrait to both axes, including narrow embeds.
          float scale = min(resolution.y * 0.49, resolution.x * 0.94);
          vec2 p = (gl_FragCoord.xy - resolution * 0.5) / scale;
          float pixel = 1.0 / scale;
          float grain = hash(floor(gl_FragCoord.xy));
          vec3 color = TAUPE;
          float paper = step(abs(p.x), 0.49) * step(abs(p.y), 0.98);

          if (paper > 0.0) {
            // 1. Black ink. 2. Closely spaced taupe dots in two columns.
            color = BLACK;
            float leftColumn = step(-0.465, p.x) * step(p.x, -0.465 + leftWidth);
            float rightColumn = step(0.465 - rightWidth, p.x) * step(p.x, 0.465);
            float columns = (leftColumn + rightColumn)
              * step(p.y, 0.98) * step(columnBottom, p.y);
            vec2 cell = fract(gl_FragCoord.xy / dotSize) - 0.5;
            float dots = 1.0 - smoothstep(0.35, 0.49, length(cell));
            color = mix(color, TAUPE, columns * dots);

            // 3. A spherical longitude field unfolds into an upward plume.
            // Periodic noise coordinates make the long Motion loop seamless.
            vec2 drift = vec2(cos(phase), sin(phase)) * 0.65;
            float y = (p.y - lowerY) / lowerRadius;
            float rise = smoothstep(-0.05, 0.95, y);
            float warp = (fbm(vec2(p.x * 3.2, p.y * 2.8) + drift) - 0.5) * distortion;
            float fine = (fbm(vec2(p.x * 7.0, p.y * 5.0) - drift) - 0.5) * distortion;
            float sphereWidth = lowerRadius * sqrt(max(0.0, 1.0 - min(y, 0.0) * min(y, 0.0)));
            float width = sphereWidth + rise * (0.035 + 0.035 * sin(p.y * 8.0 + warp * 3.0));
            float x = p.x + (warp * 0.20 + fine * 0.025) * smoothstep(-1.0, 0.6, y);
            x += rise * distortion * (0.055 * sin(p.y * 7.0 + drift.x) + 0.025 * sin(p.y * 16.0 + drift.y));
            float nx = x / max(width, 0.001);
            float body = 1.0 - smoothstep(0.98, 1.0, abs(nx));
            body *= smoothstep(-1.02, -0.96, y);
            float longitude = asin(clamp(nx, -0.999, 0.999));
            float latitude = asin(clamp(y, -0.999, 0.999));
            float field = longitude * density + warp * (2.0 + rise * 2.0)
              + fine * 0.6 + (1.0 - rise) * latitude * 4.0;

            float distanceToLine = abs(fract(field) - 0.5);
            float aa = min(fwidth(field) * 0.65, 0.20);
            float lines = 1.0 - smoothstep(lineWidth, lineWidth + aa, distanceToLine);
            // The lower hemisphere recedes into black with broken, grainy ink.
            float nz = sqrt(max(0.0, 1.0 - nx * nx - y * y));
            float light = clamp(0.3 - nx * 0.45 + nz * 0.35 + y * 0.3, 0.0, 1.0);
            float stipple = smoothstep(grain - 0.12, grain + 0.12, light);
            float fade = mix(stipple * light, 0.90, rise);
            // A grazing blue light catches only the lower-right contours.
            // Keep the spaces between lines black and let the ink break up.
            float rim = pow(max(nx, 0.0), 10.0)
              * smoothstep(-1.0, -0.8, y)
              * (1.0 - smoothstep(-0.1, 0.18, y));
            fade = max(fade, rim * 0.32 * (0.65 + 0.35 * step(grain, 0.65)));
            fade *= 1.0 - smoothstep(0.65, 1.1, p.y);
            vec3 contourInk = mix(BLUE, RED, columns);
            color = mix(color, contourInk, lines * body * fade);

            // 4. The upper sphere has its own stationary stochastic shading.
            vec2 sphere = (p - vec2(redX, redY)) / redRadius;
            float radius = length(sphere);
            float cover = 1.0 - smoothstep(1.0 - pixel / redRadius, 1.0, radius);
            if (cover > 0.0) {
              vec3 normal = vec3(sphere, sqrt(max(0.0, 1.0 - radius * radius)));
              float shade = max(0.0, dot(normal, normalize(vec3(-0.65, 0.85, 0.35))));
              float blueInk = step(grain, pow(shade, 4.0) * blueShade + 0.035);
              vec3 sphereColor = mix(RED, BLUE, blueInk);
              color = mix(color, sphereColor, cover);
            }

            // A final red ink strip tints every layer beneath it. Its lower
            // edge dissolves into grain, leaving a solid, straight top edge.
            float strip = smoothstep(overlayX, overlayX + pixel, p.x)
              * (1.0 - smoothstep(overlayX + overlayWidth - pixel, overlayX + overlayWidth, p.x))
              * (1.0 - smoothstep(overlayTop - pixel, overlayTop, p.y));
            float stripProgress = clamp((p.y - (overlayTop - overlayHeight)) / overlayHeight, 0.0, 1.0);
            float redFade = smoothstep(0.0, 0.85, stripProgress);
            float redGrain = smoothstep(grain - 0.08, grain + 0.08, redFade);
            float redOpacity = strip * redGrain * redFade * overlayStrength;
            vec3 redOverprint = 1.0 - (1.0 - color) * (1.0 - RED);
            color = mix(color, redOverprint, redOpacity);
          }
          color *= 0.97 + grain * 0.06;
          gl_FragColor = vec4(color, 1.0);
        }
      `,
    })
    const geometry = new THREE.PlaneGeometry(2, 2)
    scene.add(new THREE.Mesh(geometry, material))

    const animation = runTopography(uniforms)
    let paused = reducedMotion.matches
    let disposed = false

    function render() {
      if (!canvas.isConnected) {
        dispose()
        return
      }
      renderer.render(scene, camera)
    }

    function syncPlayback() {
      cancelFrame(render)
      if (paused || document.hidden) {
        animation.pause()
        render()
      } else {
        animation.play()
        frame.render(render, true)
      }
      button.textContent = paused ? "Play" : "Pause"
      button.setAttribute("aria-label", paused ? "Play animation" : "Pause animation")
    }

    function resize() {
      renderer.setPixelRatio(Math.min(devicePixelRatio, 1.5))
      renderer.setSize(stage.clientWidth, stage.clientHeight, false)
      renderer.getDrawingBufferSize(uniforms.resolution.value)
      render()
    }

    function toggle() {
      paused = !paused
      syncPlayback()
    }

    function preferenceChanged() {
      paused = reducedMotion.matches
      syncPlayback()
    }

    function dispose() {
      if (disposed) return
      disposed = true
      animation.stop()
      cancelFrame(render)
      observer.disconnect()
      removalObserver.disconnect()
      button.removeEventListener("click", toggle)
      reducedMotion.removeEventListener("change", preferenceChanged)
      document.removeEventListener("visibilitychange", syncPlayback)
      window.removeEventListener("pagehide", dispose)
      geometry.dispose()
      material.dispose()
      renderer.dispose()
    }

    const observer = new ResizeObserver(resize)
    observer.observe(stage)
    // Also release GPU resources if the example host unmounts while paused.
    const removalObserver = new MutationObserver(() => {
      if (!canvas.isConnected) dispose()
    })
    removalObserver.observe(document.body, { childList: true, subtree: true })
    button.addEventListener("click", toggle)
    reducedMotion.addEventListener("change", preferenceChanged)
    document.addEventListener("visibilitychange", syncPlayback)
    window.addEventListener("pagehide", dispose)
    resize()
    syncPlayback()
  }

  createArtwork()
</script>

<style>
  body { margin: 0; background: #b6ada1; }
  .topography {
    position: fixed;
    inset: 0;
    overflow: hidden;
    background: #b6ada1;
  }
  .topography canvas { display: block; width: 100%; height: 100%; }
  .topography .pause {
    position: absolute;
    bottom: max(20px, env(safe-area-inset-bottom));
    right: max(20px, env(safe-area-inset-right));
    padding: 10px 14px;
    border: 1px solid #615a50;
    border-radius: 0;
    color: #24251f;
    background: #b6ada1;
    font: 13px/1.2 system-ui, sans-serif;
    cursor: pointer;
  }
  .topography .pause:hover { background: #c8bfb3; }
  .topography .pause:focus-visible { outline: 2px solid #24251f; outline-offset: 4px; }
  .topography .fallback {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    color: #24251f;
    font: 16px/1.5 system-ui, sans-serif;
  }
</style>