<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>