Contour Bands
Line width divided by the height gradient per pixel, so the lines crowd on steep ground without ever changing thickness.
The scene in this preview is the file shown here. The surrounding demo shell only provides context and is not part of the copied code.
"use client";
import * as React from "react";
import * as THREE from "three";
/**
* Contour Bands — the lines are where a surface crosses a level.
*
* A contour is not a thing that exists on a map. It is the set of
* points at one particular height, and the reason a topographic sheet
* looks the way it does is that the lines crowd where the ground is
* steep and spread where it is flat. Nothing chose that spacing.
*
* Which is what makes the naive version fail so visibly. Take the
* fractional part of a height field, threshold it, and the line is thin
* where the ground is steep and fat where it is flat — the opposite of
* a drawn contour, and on a moving field it shimmers, because a line
* one pixel wide in one place and eight in another aliases everywhere.
*
* The fix is one call: divide the distance to the level by how fast the
* height is changing across a pixel, which fwidth reports directly.
* That gives a line measured in pixels rather than in metres — constant
* width, no shimmer, and the crowding stays, because crowding is the
* spacing of the lines and not the width of them.
*
* Single file, react and three only. No textures, no add-ons.
*/
type Variant = "subtle" | "default" | "playful";
const VARIANTS = {
subtle: { driftRate: 0.05, bandCount: 4, lineOpacity: 0.32, reliefScale: 0.85 },
default: { driftRate: 0.12, bandCount: 7, lineOpacity: 0.52, reliefScale: 1.2 },
playful: { driftRate: 0.28, bandCount: 13, lineOpacity: 0.74, reliefScale: 1.75 },
} as const;
const PALETTES = {
night: {
ground: [0.035, 0.045, 0.055],
line: [0.42, 0.78, 0.72],
high: [0.10, 0.16, 0.22],
ink: 0,
css: "linear-gradient(155deg, #101a1e 0%, #0a1115 60%, #070c0f 100%)",
},
day: {
ground: [0.94, 0.945, 0.95],
line: [0.06, 0.35, 0.33],
high: [0.86, 0.89, 0.89],
ink: 0,
css: "linear-gradient(155deg, #eef2f2 0%, #f4f7f6 60%, #f8faf9 100%)",
},
} as const;
const VERTEX = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position.xy, 0.0, 1.0);
}
`;
const FRAGMENT = /* glsl */ `
varying vec2 vUv;
uniform vec2 uResolution;
uniform float uTime;
uniform float uInk;
uniform float uBands;
uniform float uOpacity;
uniform float uRelief;
uniform vec3 uGround;
uniform vec3 uLine;
uniform vec3 uHigh;
/** The ground. Four ridges at different scales, and nothing else. */
float height(vec2 p, float t) {
return sin(p.x * 1.5 + t * 0.41) * 0.55
+ sin(p.y * 1.9 - t * 0.29) * 0.45
+ sin((p.x * 0.8 + p.y * 1.2) * 2.7 + t * 0.23) * 0.28
+ sin((p.x * 1.7 - p.y * 0.9) * 4.1 - t * 0.13) * 0.13;
}
float hash(vec2 p) {
return fract(sin(dot(p, vec2(41.3, 289.1))) * 43758.5453);
}
void main() {
float aspect = uResolution.x / max(uResolution.y, 1.0);
vec2 p = vec2(vUv.x * aspect, vUv.y) * uRelief * 2.2;
float h = height(p, uTime);
float levels = h * uBands;
// Distance to the nearest level, in level units.
float toLevel = abs(fract(levels) - 0.5);
// …divided by how fast the level count changes across one pixel.
// That division is the entire difference between a contour map and
// a shimmering set of stripes: it converts a distance measured in
// height into one measured in pixels, so every line is the same
// width however steep the ground beneath it happens to be.
float perPixel = fwidth(levels);
float line = 1.0 - smoothstep(0.0, perPixel * 1.6, toLevel);
// A faint fill between lines, so the sheet reads as ground rather
// than as wire. Banded, not smooth — this is a survey, not a blur.
float terrace = floor(levels) / max(uBands, 1.0);
vec3 ground = mix(uGround, uHigh, clamp(terrace * 0.5 + 0.5, 0.0, 1.0));
vec3 color = mix(ground, uLine, clamp(line * uOpacity * 1.35, 0.0, 1.0));
color += (hash(gl_FragCoord.xy) - 0.5) / 255.0;
gl_FragColor = vec4(clamp(color, 0.0, 1.0), 1.0);
}
`;
/** Relative luminance of a computed rgb() colour, 0–1. */
function luminanceOf(color: string): number {
const parts = color.match(/[\d.]+/g);
if (!parts || parts.length < 3) return 0.9;
const [r, g, b] = parts.slice(0, 3).map((value) => Number(value) / 255);
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
export type ContourBandsProps = {
variant?: Variant;
/** Any CSS height. The effect fills whatever box it is given. */
height?: string;
className?: string;
style?: React.CSSProperties;
children?: React.ReactNode;
};
export function ContourBands({
variant = "default",
height = "100%",
className,
style,
children,
}: ContourBandsProps) {
const hostRef = React.useRef<HTMLDivElement | null>(null);
const cfg = VARIANTS[variant] ?? VARIANTS.default;
React.useEffect(() => {
const host = hostRef.current;
if (!host) return;
// A full-bleed effect paints every pixel, so it cannot mix its
// neutrals from the host the way a component can. It reads the
// inherited colour only to decide which way round the page is.
const palette =
luminanceOf(getComputedStyle(host).color) > 0.5 ? PALETTES.night : PALETTES.day;
host.style.background = palette.css;
const reduced =
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let renderer: THREE.WebGLRenderer;
try {
renderer = new THREE.WebGLRenderer({
antialias: false,
alpha: false,
powerPreference: "low-power",
});
} catch {
// No WebGL. The CSS still above is already painted and stands in
// for the effect, so there is nothing further to do.
return;
}
const canvas = renderer.domElement;
canvas.style.cssText =
"position:absolute;inset:0;width:100%;height:100%;display:block;pointer-events:none;";
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
// First child, not last: an absolutely positioned canvas appended
// after the content would paint over it.
host.insertBefore(canvas, host.firstChild);
const uniforms = {
uResolution: { value: new THREE.Vector2(1, 1) },
uTime: { value: 0 },
uInk: { value: palette.ink },
uBands: { value: 7 },
uOpacity: { value: 0.52 },
uRelief: { value: 1.2 },
uGround: { value: new THREE.Vector3(...palette.ground) },
uLine: { value: new THREE.Vector3(...palette.line) },
uHigh: { value: new THREE.Vector3(...palette.high) },
};
uniforms.uBands.value = cfg.bandCount;
uniforms.uOpacity.value = cfg.lineOpacity;
uniforms.uRelief.value = cfg.reliefScale;
const geometry = new THREE.PlaneGeometry(2, 2);
const material = new THREE.ShaderMaterial({
vertexShader: VERTEX,
fragmentShader: FRAGMENT,
uniforms,
depthTest: false,
depthWrite: false,
});
const scene = new THREE.Scene();
scene.add(new THREE.Mesh(geometry, material));
const camera = new THREE.Camera();
const resize = () => {
const width = Math.max(1, host.clientWidth);
const heightPx = Math.max(1, host.clientHeight);
renderer.setSize(width, heightPx, false);
uniforms.uResolution.value.set(width, heightPx);
};
resize();
const observer = new ResizeObserver(resize);
observer.observe(host);
let frame = 0;
let phase = 0;
let last = 0;
let running = false;
const draw = () => renderer.render(scene, camera);
const tick = (now: number) => {
const delta = last ? Math.min((now - last) / 1000, 0.05) : 0;
last = now;
phase += delta * cfg.driftRate * 6.0;
uniforms.uTime.value = phase;
draw();
frame = requestAnimationFrame(tick);
};
const start = () => {
if (running) return;
running = true;
last = 0;
frame = requestAnimationFrame(tick);
};
const stop = () => {
running = false;
cancelAnimationFrame(frame);
};
// A lost context is routine, not exceptional: a browser grants a
// limited number and drops the oldest when a page asks for more.
// preventDefault is what makes the restore possible at all.
const onLost = (event: Event) => {
event.preventDefault();
stop();
};
const onRestored = () => {
resize();
start();
};
canvas.addEventListener("webglcontextlost", onLost);
canvas.addEventListener("webglcontextrestored", onRestored);
if (reduced) {
// A contour map is a still image by nature. One frame keeps every
// line, every terrace and all the crowding that carries the shape.
uniforms.uTime.value = 3.1;
draw();
} else {
start();
}
return () => {
stop();
observer.disconnect();
canvas.removeEventListener("webglcontextlost", onLost);
canvas.removeEventListener("webglcontextrestored", onRestored);
geometry.dispose();
material.dispose();
renderer.dispose();
// dispose() releases three's own objects; the context itself is
// only handed back here, and a grid of these depends on it being
// handed back promptly.
renderer.forceContextLoss();
canvas.remove();
};
}, [cfg]);
return (
<div
ref={hostRef}
className={className}
style={{
position: "relative",
width: "100%",
height,
overflow: "hidden",
background: PALETTES.night.css,
...style,
}}
>
{children}
</div>
);
}
export default ContourBands;About this effect
For something spatial — a coverage map, a region summary, a survey panel — where reading the backdrop as terrain does work rather than being scenery. A contour is not a thing that exists on a map: it is the set of points at one particular height, and a topographic sheet looks the way it does because the lines crowd where the ground is steep and spread where it is flat. Nothing chose that spacing. Which is exactly what makes the naive version fail visibly: take the fractional part of a height field and threshold it, and the line comes out thin where the ground is steep and fat where it is flat, which is the opposite of a drawn contour — and on a moving field it shimmers, because a line one pixel wide in one place and eight in another aliases everywhere. The fix is a single call. Dividing the distance to the level by how fast the height changes across one pixel, which fwidth reports directly, converts a distance measured in height into one measured in pixels: constant width, no shimmer, and all the crowding preserved, because crowding is the spacing of the lines and never the width of them.
Related effects
- Aurora CurtainRays that arrive where a sampled sheet folds end-on, so no line of code decides where a ray belongs.
- Fog BankSlabs composited back to front, each dimming what is behind it, so the bank has an inside rather than a surface.
- Plasma BloomThree fields multiplied rather than added, so light appears only where all three agree and most of the frame stays dark.