The Pixel Has a Postbox: Build a 3D World with Ray Marching

By Published Updated 22 min read

Live experiment · one WebGL canvas

The Cathedral of Distance

Every pixel sends a question into empty space. Distance writes back. A world appears.

Static Cathedral poster. The interactive shader has not loaded yet.

A symmetrical hall of dark arches recedes into blue fog around a floating black orb. A narrow cyan-and-gold ring expands from the orb and travels across the floor, columns, and archways.

A symmetrical hall of dark arches recedes into blue fog around a floating black orb. A narrow cyan-and-gold ring expands from the orb and travels across the floor, columns, and archways.

The poster is ready.

Pulse is available in Explore mode and completed stage 8. Motion controls are available in Explore mode and completed stage 8.
Scene settings and explanatory views
Source Actual files running this exhibit

These are the host, p5 adapter, shader, lesson stages, and quality policy used by the live scene. Each file remains readable when JavaScript is unavailable.

Svelte exhibit host RayMarchingExhibit.svelte
<script lang="ts">
	import { onMount, tick } from 'svelte';
	import { track } from '@vercel/analytics';
	import RayMarchingCanvas from './RayMarchingCanvas.svelte';
	import RayMarchingSourceExplorer from './RayMarchingSourceExplorer.svelte';
	import { rayMarchingMetadata } from '$lib/visualizations/experiments/ray-marching/metadata';
	import { rayMarchingStages } from '$lib/visualizations/experiments/ray-marching/stages';
	import {
		chooseInitialRayMarchingQuality,
		RAY_MARCHING_QUALITY_PROFILES,
		type RayMarchingQualityHints
	} from '$lib/visualizations/experiments/ray-marching/quality';
	import {
		createRayMarchingState,
		resetRayMarchingState,
		restartRayMarchingMotion
	} from '$lib/visualizations/experiments/ray-marching/state';
	import {
		buildRayMarchingShareUrl,
		parseRayMarchingShareState
	} from '$lib/visualizations/experiments/ray-marching/url-state';
	import type { RayMarchingRenderSnapshot } from '$lib/visualizations/experiments/ray-marching/sketch';
	import type {
		RayMarchingCamera,
		RayMarchingDebugView,
		RayMarchingExperienceState,
		RayMarchingPalette,
		RayMarchingQualityChoice,
		RayMarchingQualityTier,
		RayMarchingStageId
	} from '$lib/visualizations/experiments/ray-marching/types';

	type LifecycleState =
		| 'poster'
		| 'initializing'
		| 'ready'
		| 'paused'
		| 'reduced-motion-paused'
		| 'offscreen-suspended'
		| 'context-lost'
		| 'unavailable'
		| 'shader-error';

	type ConnectionWithHints = {
		saveData?: boolean;
		addEventListener?: (type: 'change', listener: () => void) => void;
		removeEventListener?: (type: 'change', listener: () => void) => void;
	};

	type NavigatorWithHints = Navigator & {
		connection?: ConnectionWithHints;
		deviceMemory?: number;
	};

	const uid = $props.id();
	const canonicalPath = '/blog/visualizations/ray-marching-fragment-shader-from-scratch';
	const qualityLabels: Record<RayMarchingQualityChoice, string> = {
		auto: 'Auto',
		high: 'High',
		balanced: 'Balanced',
		saver: 'Saver'
	};
	const debugLabels: Record<RayMarchingDebugView, string> = {
		beauty: 'Beauty',
		'march-cost': 'March cost',
		normals: 'Normals',
		'distance-bands': 'Distance bands'
	};
	const paletteLabels: Record<RayMarchingPalette, string> = {
		cathedral: 'Cathedral',
		'blue-hour': 'Blue hour',
		'amber-archive': 'Amber archive'
	};

	let shell: HTMLElement;
	let frame: HTMLDivElement;
	let sceneState = $state<RayMarchingExperienceState>(createRayMarchingState());
	let lifecycle = $state<LifecycleState>('poster');
	let statusMessage = $state('Static Cathedral poster. The interactive shader has not loaded yet.');
	let loadRequested = $state(false);
	let ready = $state(false);
	let reducedMotion = $state(false);
	let saveData = $state(false);
	let offscreen = $state(false);
	let documentHidden = $state(false);
	let nativeFullscreen = $state(false);
	let cssExpanded = $state(false);
	let generation = $state(0);
	let restartToken = $state(0);
	let pulseToken = $state(0);
	let pulseStatic = $state(false);
	let qualityTier = $state<RayMarchingQualityTier>('balanced');
	let qualityHints = $state<RayMarchingQualityHints>({
		width: 1280,
		height: 720,
		devicePixelRatio: 1
	});
	let forcedWebglOff = $state(false);
	let captureMode = $state(false);
	let pendingPulse = false;
	let motionExplicitlyAllowed = false;
	let fullscreenTrigger: HTMLButtonElement | null = null;
	let previousBodyOverflow = '';
	let copyStatus = $state('');
	let copyTimer: ReturnType<typeof setTimeout> | undefined;

	let currentStage = $derived(rayMarchingStages[sceneState.stage - 1]);
	let expanded = $derived(nativeFullscreen || cssExpanded);
	let suspended = $derived(ready && (documentHidden || offscreen));
	let snapshot = $derived.by(
		(): RayMarchingRenderSnapshot => ({
			stage: sceneState.stage,
			debugView: sceneState.debugView,
			palette: sceneState.palette,
			fogAmount: sceneState.fogAmount,
			pulseSpeed: sceneState.pulseSpeed,
			focalLength: sceneState.focalLength,
			camera: sceneState.camera,
			playing: sceneState.playing,
			suspended,
			qualityChoice: sceneState.quality,
			qualityTier
		})
	);
	let posterVisible = $derived(
		!ready ||
			lifecycle === 'context-lost' ||
			lifecycle === 'shader-error' ||
			lifecycle === 'unavailable'
	);

	function safeTrack(name: string, properties?: Record<string, string | number | boolean>) {
		try {
			track(name, properties);
		} catch {
			// Measurement is deliberately non-essential; the local exhibit always works without it.
		}
	}

	function replaceState(patch: Partial<RayMarchingExperienceState>) {
		sceneState = createRayMarchingState({ ...sceneState, ...patch });
	}

	function describeReadyState(message?: string) {
		if (suspended) {
			lifecycle = 'offscreen-suspended';
			statusMessage =
				'Rendering is suspended while the exhibit is offscreen or the page is hidden.';
		} else if (!sceneState.playing) {
			lifecycle = reducedMotion ? 'reduced-motion-paused' : 'paused';
			statusMessage = message ?? (reducedMotion ? 'Ready, paused for reduced motion.' : 'Paused.');
		} else {
			lifecycle = 'ready';
			statusMessage = message ?? 'Ready. The Cathedral shader is running.';
		}
	}

	function requestLoad() {
		if (forcedWebglOff) {
			lifecycle = 'unavailable';
			statusMessage =
				'Fallback: WebGL is disabled for this visit. The complete poster and article remain available.';
			return;
		}
		if (loadRequested && lifecycle !== 'shader-error' && lifecycle !== 'context-lost') return;
		loadRequested = true;
		ready = false;
		lifecycle = 'initializing';
		statusMessage = 'Loading p5 and preparing the WebGL shader…';
	}

	function retry() {
		if (forcedWebglOff) return;
		generation += 1;
		loadRequested = true;
		ready = false;
		lifecycle = 'initializing';
		statusMessage = 'Retrying the WebGL shader…';
	}

	function handleSketchStatus(
		status: 'initializing' | 'first-frame' | 'context-lost' | 'shader-error',
		message: string
	) {
		statusMessage = message;
		if (status === 'context-lost') {
			ready = false;
			lifecycle = 'context-lost';
			safeTrack('ray_marching_fallback_shown', { reason: 'context-lost' });
		} else if (status === 'shader-error') {
			ready = false;
			lifecycle = /webgl|context/iu.test(message) ? 'unavailable' : 'shader-error';
			statusMessage = `${lifecycle === 'unavailable' ? 'Fallback' : 'Shader error'}: ${message}`;
			safeTrack('ray_marching_fallback_shown', { reason: lifecycle });
		} else {
			lifecycle = 'initializing';
		}
	}

	function handleReady() {
		ready = true;
		describeReadyState('Ready. The first visible GPU frame replaced the poster.');
		safeTrack('ray_marching_loaded', { tier: qualityTier });
		if (pendingPulse || captureMode) {
			pendingPulse = false;
			pulseStatic = captureMode || !sceneState.playing;
			pulseToken += 1;
		}
	}

	function handleContextRestored() {
		statusMessage = 'WebGL returned. Rebuilding every GPU resource…';
		lifecycle = 'initializing';
		ready = false;
		generation += 1;
	}

	function handleQualityDowngrade(from: RayMarchingQualityTier, to: RayMarchingQualityTier) {
		if (sceneState.quality !== 'auto' || qualityTier === to) return;
		qualityTier = to;
		statusMessage = `Quality changed from ${qualityLabels[from]} to ${qualityLabels[to]} after sustained slow frames.`;
		safeTrack('ray_marching_quality_changed', { from, to });
	}

	function selectStage(stage: RayMarchingStageId) {
		replaceState({ stage, mode: 'build', playing: false });
		const message = `Build stage ${stage} of 8: ${rayMarchingStages[stage - 1].title}.`;
		if (!loadRequested || !ready) requestLoad();
		else describeReadyState(message);
		safeTrack('ray_marching_stage_selected', { stage });
	}

	function explore() {
		replaceState({ stage: 8, mode: 'explore' });
		if (ready) describeReadyState('Explore mode: the finished Cathedral is selected.');
		else statusMessage = 'Explore mode: the finished Cathedral is selected.';
	}

	function buildIt() {
		selectStage(1);
	}

	function togglePlayback() {
		if (sceneState.stage !== 8) {
			statusMessage = 'Motion is available in Explore mode and completed stage 8.';
			return;
		}
		if (!loadRequested || !ready) {
			motionExplicitlyAllowed = true;
			replaceState({ playing: true });
			requestLoad();
			return;
		}
		const playing = !sceneState.playing;
		if (playing) motionExplicitlyAllowed = true;
		replaceState({ playing });
		describeReadyState(playing ? 'Ready. Motion started.' : 'Paused.');
	}

	function pulse() {
		if (sceneState.stage !== 8) {
			statusMessage = 'Pulse is available in Explore mode and completed stage 8.';
			return;
		}
		safeTrack('ray_marching_pulse_used', { control: 'button-or-key' });
		if (!loadRequested || !ready) {
			pendingPulse = true;
			requestLoad();
			return;
		}
		pulseStatic = !sceneState.playing || reducedMotion;
		pulseToken += 1;
	}

	function restartMotion() {
		if (sceneState.stage !== 8) {
			statusMessage = 'Motion is available in Explore mode and completed stage 8.';
			return;
		}
		motionExplicitlyAllowed = true;
		sceneState = restartRayMarchingMotion(sceneState);
		restartToken += 1;
		if (!loadRequested || !ready) requestLoad();
		else
			describeReadyState('Motion restarted at deterministic time zero; scene settings were kept.');
	}

	function resetAll() {
		sceneState = resetRayMarchingState(sceneState);
		pendingPulse = false;
		pulseStatic = false;
		if (reducedMotion) {
			motionExplicitlyAllowed = false;
			replaceState({ playing: false });
		}
		qualityTier = chooseInitialRayMarchingQuality('auto', qualityHints);
		restartToken += 1;
		if (ready)
			describeReadyState('All scene, camera, quality, palette, and motion settings were reset.');
		else statusMessage = 'All scene, camera, quality, palette, and motion settings were reset.';
	}

	function updateCamera(camera: RayMarchingCamera) {
		replaceState({ camera });
	}

	function updateQuality(choice: RayMarchingQualityChoice) {
		replaceState({ quality: choice });
		qualityTier = chooseInitialRayMarchingQuality(choice, qualityHints);
		statusMessage = `Quality set to ${qualityLabels[choice]}${choice === 'auto' ? `; currently ${qualityLabels[qualityTier]}` : ''}.`;
	}

	async function copySceneLink() {
		const shareUrl = buildRayMarchingShareUrl(
			`${window.location.origin}${canonicalPath}`,
			sceneState
		);
		try {
			await navigator.clipboard.writeText(shareUrl);
			copyStatus = 'Scene link copied.';
			safeTrack('ray_marching_scene_link_copied');
		} catch {
			copyStatus =
				'Could not copy automatically; the address bar still has the canonical article URL.';
		}
		if (copyTimer) clearTimeout(copyTimer);
		copyTimer = setTimeout(() => (copyStatus = ''), 2600);
	}

	async function openSource() {
		if (document.fullscreenElement === shell) {
			fullscreenTrigger = null;
			await document.exitFullscreen();
		} else if (cssExpanded) {
			fullscreenTrigger = null;
			leaveCssExpanded(false);
		}
		await tick();
		const details = shell.querySelector<HTMLDetailsElement>('.source-explorer');
		if (!details) return;
		details.open = true;
		details.querySelector<HTMLElement>('summary')?.focus({ preventScroll: false });
	}

	function restoreFullscreenFocus() {
		const target = fullscreenTrigger;
		fullscreenTrigger = null;
		requestAnimationFrame(() => target?.focus({ preventScroll: true }));
	}

	function leaveCssExpanded(shouldRestoreFocus = true) {
		if (!cssExpanded) return;
		cssExpanded = false;
		document.body.style.overflow = previousBodyOverflow;
		if (shouldRestoreFocus) restoreFullscreenFocus();
	}

	async function toggleFullscreen(event: MouseEvent) {
		fullscreenTrigger = event.currentTarget as HTMLButtonElement;
		if (document.fullscreenElement === shell) {
			await document.exitFullscreen();
			return;
		}
		if (cssExpanded) {
			leaveCssExpanded();
			return;
		}

		try {
			if (!shell.requestFullscreen) throw new Error('Fullscreen API unavailable');
			await shell.requestFullscreen();
		} catch {
			previousBodyOverflow = document.body.style.overflow;
			document.body.style.overflow = 'hidden';
			cssExpanded = true;
			statusMessage = 'Expanded view opened. Press Escape or Exit expanded view to return.';
			safeTrack('ray_marching_fullscreen_entered', { mode: 'expanded-fallback' });
		}
	}

	onMount(() => {
		const params = new URLSearchParams(window.location.search);
		forcedWebglOff = params.get('webgl') === 'off';
		captureMode = params.get('capture') === '1';
		const parsedShare = parseRayMarchingShareState(params);
		sceneState = createRayMarchingState({
			...sceneState,
			stage: parsedShare.state.stage,
			mode: parsedShare.state.stage === 8 ? 'explore' : 'build',
			debugView: parsedShare.state.debugView,
			palette: parsedShare.state.palette,
			camera: { yaw: parsedShare.state.yaw, pitch: parsedShare.state.pitch }
		});

		const nav = navigator as NavigatorWithHints;
		const connection = nav.connection;
		const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
		const coarseQuery = window.matchMedia('(pointer: coarse)');
		const updateEnvironment = () => {
			const enteredReducedMotion = motionQuery.matches && !reducedMotion;
			reducedMotion = motionQuery.matches;
			saveData = connection?.saveData === true;
			qualityHints = {
				width: Math.max(1, Math.round(frame?.clientWidth || window.innerWidth)),
				height: Math.max(1, Math.round(frame?.clientHeight || window.innerHeight)),
				devicePixelRatio: window.devicePixelRatio || 1,
				hardwareConcurrency: navigator.hardwareConcurrency,
				deviceMemory: nav.deviceMemory,
				coarsePointer: coarseQuery.matches,
				saveData
			};
			if (sceneState.quality === 'auto') {
				const suggestedTier = chooseInitialRayMarchingQuality('auto', qualityHints);
				const isDowngrade =
					qualityTier === 'high' || (qualityTier === 'balanced' && suggestedTier === 'saver');
				// Once loading starts, automatic environment hints may only remove work.
				if (!loadRequested || isDowngrade) qualityTier = suggestedTier;
			}
			if (enteredReducedMotion) {
				motionExplicitlyAllowed = false;
				restartToken += 1;
			}
			if (reducedMotion && !motionExplicitlyAllowed) {
				replaceState({ playing: false });
				if (ready) describeReadyState();
			}
		};
		updateEnvironment();

		if (captureMode) {
			replaceState({ playing: false, stage: parsedShare.state.stage });
			requestLoad();
		} else if (forcedWebglOff) {
			lifecycle = 'unavailable';
			statusMessage =
				'Fallback: WebGL is disabled for this visit. Showing the complete static version.';
			safeTrack('ray_marching_fallback_shown', { reason: 'query-disabled' });
		}

		const loadObserver = new IntersectionObserver(
			(entries) => {
				if (
					entries.some((entry) => entry.isIntersecting) &&
					!reducedMotion &&
					!saveData &&
					!forcedWebglOff
				) {
					requestLoad();
					loadObserver.disconnect();
				}
			},
			{ rootMargin: '360px 0px' }
		);
		loadObserver.observe(shell);

		const visibilityObserver = new IntersectionObserver(
			(entries) => {
				const entry = entries[0];
				offscreen = entry ? entry.intersectionRatio < 0.03 : false;
				if (ready) describeReadyState();
			},
			{ threshold: 0.03 }
		);
		visibilityObserver.observe(frame);

		const handleVisibility = () => {
			documentHidden = document.hidden;
			if (ready) describeReadyState();
		};
		const handleFullscreenChange = () => {
			const wasFullscreen = nativeFullscreen;
			nativeFullscreen = document.fullscreenElement === shell;
			if (nativeFullscreen && !wasFullscreen) {
				statusMessage = 'Fullscreen view opened. Press Escape to return.';
				safeTrack('ray_marching_fullscreen_entered', { mode: 'native' });
			} else if (!nativeFullscreen && wasFullscreen) {
				restoreFullscreenFocus();
			}
		};
		const handleEscape = (event: KeyboardEvent) => {
			if (event.key === 'Escape' && cssExpanded) leaveCssExpanded();
		};

		documentHidden = document.hidden;
		document.addEventListener('visibilitychange', handleVisibility);
		document.addEventListener('fullscreenchange', handleFullscreenChange);
		document.addEventListener('keydown', handleEscape);
		motionQuery.addEventListener('change', updateEnvironment);
		coarseQuery.addEventListener('change', updateEnvironment);
		connection?.addEventListener?.('change', updateEnvironment);

		return () => {
			loadObserver.disconnect();
			visibilityObserver.disconnect();
			document.removeEventListener('visibilitychange', handleVisibility);
			document.removeEventListener('fullscreenchange', handleFullscreenChange);
			document.removeEventListener('keydown', handleEscape);
			motionQuery.removeEventListener('change', updateEnvironment);
			coarseQuery.removeEventListener('change', updateEnvironment);
			connection?.removeEventListener?.('change', updateEnvironment);
			if (copyTimer) clearTimeout(copyTimer);
			if (cssExpanded) document.body.style.overflow = previousBodyOverflow;
		};
	});
</script>

<figure
	bind:this={shell}
	class:is-expanded={expanded}
	class="cathedral-exhibit not-prose my-10 overflow-hidden rounded-2xl border border-cyan-100/15 bg-[#030812] text-slate-100 shadow-[0_30px_90px_rgba(2,8,23,0.38)]"
	aria-labelledby={`${uid}-title`}
	aria-describedby={`${uid}-description ${uid}-caption`}
	data-ray-marching-state={lifecycle}
>
	<header class="exhibit-header">
		<div class="min-w-0">
			<p class="eyebrow">Live experiment · one WebGL canvas</p>
			<h2 id={`${uid}-title`}>{rayMarchingMetadata.title}</h2>
			<p class="deck">{rayMarchingMetadata.deck}</p>
		</div>
		<p id={`${uid}-status`} class="visible-status" aria-live="polite" aria-atomic="true">
			<span class="status-dot" aria-hidden="true"></span>{statusMessage}
		</p>
	</header>

	<p id={`${uid}-description`} class="sr-only">{rayMarchingMetadata.posterAlt}</p>

	<div bind:this={frame} class="stage-frame">
		<img
			src={rayMarchingMetadata.poster}
			alt={rayMarchingMetadata.posterAlt}
			width="1600"
			height="900"
			loading="eager"
			fetchpriority="high"
			class:poster-hidden={!posterVisible}
			class="poster"
		/>

		{#if loadRequested && !forcedWebglOff}
			<RayMarchingCanvas
				{snapshot}
				{qualityHints}
				load={loadRequested}
				{generation}
				{expanded}
				interactive={ready}
				{reducedMotion}
				{restartToken}
				{pulseToken}
				{pulseStatic}
				onstatus={handleSketchStatus}
				onready={handleReady}
				oncontextrestored={handleContextRestored}
				onqualitydowngrade={handleQualityDowngrade}
				oncamera={updateCamera}
				onpulse={pulse}
				ontoggleplayback={togglePlayback}
			/>
		{/if}

		{#if lifecycle === 'poster'}
			<div class="stage-overlay">
				<div class="overlay-card">
					<p>{reducedMotion || saveData ? 'Static mode is active.' : 'The poster is ready.'}</p>
					<button type="button" class="pill pill-primary" onclick={requestLoad}>
						Load interactive version
					</button>
				</div>
			</div>
		{:else if lifecycle === 'initializing'}
			<div class="loading-badge" role="status">Preparing one WebGL canvas…</div>
		{:else if lifecycle === 'unavailable' || lifecycle === 'shader-error' || lifecycle === 'context-lost'}
			<div class="stage-overlay fallback-overlay">
				<div class="overlay-card fallback-card">
					<strong
						>{lifecycle === 'context-lost'
							? 'WebGL context lost'
							: lifecycle === 'shader-error'
								? 'Shader could not start'
								: 'Static fallback'}</strong
					>
					<p>{statusMessage}</p>
					{#if !forcedWebglOff}
						<button type="button" class="pill" onclick={retry}>Retry</button>
					{/if}
				</div>
			</div>
		{/if}

		<noscript>
			<p class="noscript-note">
				JavaScript is unavailable, so the deterministic shader poster is shown. The complete
				walkthrough, diagram, and source remain readable below.
			</p>
		</noscript>
	</div>

	<div class="control-deck">
		<div class="primary-controls" aria-label="Cathedral controls">
			<div class="segmented" aria-label="Experience mode">
				<button
					type="button"
					class:active={sceneState.mode === 'explore'}
					aria-pressed={sceneState.mode === 'explore'}
					onclick={explore}>Explore</button
				>
				<button
					type="button"
					class:active={sceneState.mode === 'build'}
					aria-pressed={sceneState.mode === 'build'}
					onclick={buildIt}>Build it</button
				>
			</div>

			{#if sceneState.mode === 'build'}
				<button
					type="button"
					class="pill compact"
					disabled={sceneState.stage === 1}
					onclick={() => selectStage((sceneState.stage - 1) as RayMarchingStageId)}
					aria-label="Previous build stage">←</button
				>
				<span class="stage-count" aria-hidden="true">{sceneState.stage}/8</span>
				<button
					type="button"
					class="pill compact"
					disabled={sceneState.stage === 8}
					onclick={() => selectStage((sceneState.stage + 1) as RayMarchingStageId)}
					aria-label="Next build stage">→</button
				>
			{/if}

			<button
				type="button"
				class="pill pulse-button"
				disabled={sceneState.stage !== 8}
				aria-describedby={`${uid}-pulse-help`}
				onclick={pulse}>Pulse</button
			>
			<span id={`${uid}-pulse-help`} class="sr-only"
				>Pulse is available in Explore mode and completed stage 8.</span
			>
			<button
				type="button"
				class="pill"
				disabled={sceneState.stage !== 8}
				aria-describedby={`${uid}-motion-help`}
				onclick={togglePlayback}
			>
				{sceneState.playing ? 'Pause' : 'Start'}
			</button>
			<button
				type="button"
				class="pill"
				disabled={sceneState.stage !== 8}
				aria-describedby={`${uid}-motion-help`}
				onclick={restartMotion}>Restart motion</button
			>
			<span id={`${uid}-motion-help`} class="sr-only"
				>Motion controls are available in Explore mode and completed stage 8.</span
			>
			<button type="button" class="pill" onclick={resetAll}>Reset all</button>
			<button type="button" class="pill" onclick={toggleFullscreen}>
				{expanded ? 'Exit expanded view' : 'Fullscreen'}
			</button>
			<button type="button" class="pill" onclick={openSource}>Source</button>
		</div>

		{#if sceneState.mode === 'build'}
			<nav class="stage-rail" aria-label="Ray-marching build stages">
				{#each rayMarchingStages as stage (stage.stage)}
					<button
						type="button"
						class:active={stage.stage === sceneState.stage}
						aria-current={stage.stage === sceneState.stage ? 'step' : undefined}
						onclick={() => selectStage(stage.stage)}
					>
						<span>{stage.label}</span>{stage.title}
					</button>
				{/each}
			</nav>

			<section class="stage-reading" aria-labelledby={`${uid}-stage-title`}>
				<div>
					<p class="stage-kicker">Stage {currentStage.stage} of 8</p>
					<h3 id={`${uid}-stage-title`}>{currentStage.title}</h3>
					<p>{currentStage.explanation}</p>
					<p class="stage-callout">{currentStage.callout}</p>
				</div>
				<div class="stage-source">
					<p>{currentStage.filename} · running excerpt</p>
					<!-- svelte-ignore a11y_no_noninteractive_tabindex (keyboard-scrollable running source) -->
					<pre tabindex="0"><code>{currentStage.code}</code></pre>
				</div>
			</section>
		{/if}

		<details class="settings">
			<summary>Scene settings and explanatory views</summary>
			<div class="settings-grid">
				<label>
					<span
						>Quality <output
							>{qualityLabels[sceneState.quality]}{sceneState.quality === 'auto'
								? ` · ${qualityLabels[qualityTier]}`
								: ''}</output
						></span
					>
					<select
						value={sceneState.quality}
						onchange={(event) =>
							updateQuality(event.currentTarget.value as RayMarchingQualityChoice)}
					>
						<option value="auto">Auto</option>
						<option value="high">High</option>
						<option value="balanced">Balanced</option>
						<option value="saver">Saver</option>
					</select>
					<small>
						{qualityTier === 'saver'
							? '48 march steps; shadows and AO are compiled out.'
							: qualityTier === 'balanced'
								? `${RAY_MARCHING_QUALITY_PROFILES.balanced.mainSteps} march, 14 shadow, 4 AO samples.`
								: '96 march, 24 shadow, 5 AO samples.'}
					</small>
				</label>

				<label>
					<span>Debug view <output>{debugLabels[sceneState.debugView]}</output></span>
					<select
						value={sceneState.debugView}
						onchange={(event) =>
							replaceState({ debugView: event.currentTarget.value as RayMarchingDebugView })}
					>
						{#each Object.entries(debugLabels) as [value, label] (value)}
							<option {value}>{label}</option>
						{/each}
					</select>
					<small>March cost varies luminance as well as hue.</small>
				</label>

				<label>
					<span>Palette <output>{paletteLabels[sceneState.palette]}</output></span>
					<select
						value={sceneState.palette}
						onchange={(event) =>
							replaceState({ palette: event.currentTarget.value as RayMarchingPalette })}
					>
						{#each Object.entries(paletteLabels) as [value, label] (value)}
							<option {value}>{label}</option>
						{/each}
					</select>
				</label>

				<label>
					<span>Fog <output>{Math.round(sceneState.fogAmount * 100)}%</output></span>
					<input
						type="range"
						min="0.2"
						max="1"
						step="0.01"
						value={sceneState.fogAmount}
						oninput={(event) => replaceState({ fogAmount: Number(event.currentTarget.value) })}
					/>
				</label>

				<label>
					<span>Pulse speed <output>{sceneState.pulseSpeed.toFixed(1)}×</output></span>
					<input
						type="range"
						min="0.5"
						max="1.8"
						step="0.1"
						value={sceneState.pulseSpeed}
						oninput={(event) => replaceState({ pulseSpeed: Number(event.currentTarget.value) })}
					/>
				</label>

				<label>
					<span>Focal length <output>{sceneState.focalLength.toFixed(2)}</output></span>
					<input
						type="range"
						min="1.1"
						max="2.2"
						step="0.05"
						value={sceneState.focalLength}
						oninput={(event) => replaceState({ focalLength: Number(event.currentTarget.value) })}
					/>
				</label>
			</div>

			<div class="settings-footer">
				<button type="button" class="pill" onclick={copySceneLink}>Copy scene link</button>
				<p aria-live="polite">{copyStatus}</p>
			</div>
		</details>
	</div>

	<RayMarchingSourceExplorer onopen={() => safeTrack('ray_marching_source_opened')} />

	<figcaption id={`${uid}-caption`}>
		<strong>The Cathedral of Distance.</strong> A symmetrical hall of dark arches recedes into blue fog
		around a floating black orb. A narrow cyan-and-gold ring expands over the floor, columns, and archways.
		The visible 3D surfaces are procedural and implicit; p5 still rasterises one host rectangle underneath.
	</figcaption>
</figure>

<style>
	.cathedral-exhibit {
		--panel: #07111f;
		--line: rgb(165 243 252 / 0.17);
		isolation: isolate;
		width: 100%;
		max-width: 100%;
	}

	.exhibit-header {
		display: grid;
		grid-template-columns: minmax(0, 1fr) minmax(14rem, 0.42fr);
		gap: 1rem;
		align-items: end;
		padding: 1.1rem 1.25rem;
		border-bottom: 1px solid var(--line);
		background:
			radial-gradient(circle at 12% -20%, rgb(34 211 238 / 0.13), transparent 42%), #030812;
	}

	.eyebrow,
	.deck,
	.visible-status,
	.stage-kicker,
	.stage-source > p,
	.settings-footer p {
		margin: 0;
		text-align: left;
	}

	.eyebrow,
	.stage-kicker {
		font-size: 0.68rem;
		font-weight: 800;
		letter-spacing: 0.16em;
		text-transform: uppercase;
		color: #67e8f9;
	}

	.exhibit-header h2 {
		margin: 0.2rem 0 0;
		font-size: clamp(1.25rem, 2.5vw, 1.8rem);
		line-height: 1.15;
		color: white;
	}

	.deck {
		margin-top: 0.45rem;
		font-size: 0.92rem;
		line-height: 1.5;
		color: #bac8da;
	}

	.visible-status {
		display: flex;
		gap: 0.5rem;
		align-items: flex-start;
		font-size: 0.75rem;
		line-height: 1.45;
		color: #94a3b8;
	}

	.status-dot {
		width: 0.5rem;
		height: 0.5rem;
		margin-top: 0.27rem;
		flex: none;
		border-radius: 999px;
		background: #22d3ee;
		box-shadow: 0 0 0.7rem rgb(34 211 238 / 0.7);
	}

	.stage-frame {
		position: relative;
		width: 100%;
		min-height: 18rem;
		aspect-ratio: 16 / 9;
		overflow: hidden;
		background: #02050b;
	}

	.poster {
		position: absolute;
		z-index: 2;
		inset: 0;
		width: 100%;
		height: 100%;
		object-fit: cover;
		opacity: 1;
		transition: opacity 180ms ease;
	}

	.poster-hidden {
		opacity: 0;
		pointer-events: none;
	}

	.stage-overlay {
		position: absolute;
		z-index: 4;
		inset: 0;
		display: grid;
		place-items: end center;
		padding: 1rem;
		pointer-events: none;
	}

	.overlay-card {
		display: flex;
		gap: 0.75rem;
		align-items: center;
		justify-content: space-between;
		max-width: 36rem;
		padding: 0.55rem 0.65rem 0.55rem 1rem;
		border: 1px solid rgb(255 255 255 / 0.24);
		border-radius: 999px;
		background: rgb(2 6 23 / 0.88);
		box-shadow: 0 0.8rem 2.5rem rgb(0 0 0 / 0.45);
		backdrop-filter: blur(10px);
		pointer-events: auto;
	}

	.overlay-card p {
		margin: 0;
		font-size: 0.78rem;
		color: #d7e3ef;
	}

	.fallback-overlay {
		place-items: center;
		background: rgb(2 6 23 / 0.2);
	}

	.fallback-card {
		display: grid;
		max-width: 34rem;
		border-radius: 0.9rem;
		padding: 1rem;
	}

	.fallback-card strong {
		color: white;
	}

	.loading-badge {
		position: absolute;
		z-index: 5;
		right: 0.8rem;
		bottom: 0.8rem;
		padding: 0.55rem 0.8rem;
		border: 1px solid rgb(103 232 249 / 0.3);
		border-radius: 999px;
		background: rgb(2 6 23 / 0.86);
		font-size: 0.75rem;
		color: #cffafe;
	}

	.noscript-note {
		position: absolute;
		z-index: 6;
		inset: auto 0 0;
		margin: 0;
		padding: 0.75rem 1rem;
		background: rgb(2 6 23 / 0.9);
		font-size: 0.78rem;
		color: #e2e8f0;
	}

	.control-deck {
		border-top: 1px solid var(--line);
		background: #050c17;
	}

	.primary-controls {
		display: flex;
		flex-wrap: wrap;
		gap: 0.5rem;
		align-items: center;
		padding: 0.8rem 1rem;
	}

	.pill,
	.segmented button,
	.stage-rail button {
		min-height: 2.75rem;
		border: 1px solid rgb(148 163 184 / 0.34);
		border-radius: 0.62rem;
		background: rgb(15 23 42 / 0.76);
		padding: 0.55rem 0.85rem;
		font: inherit;
		font-size: 0.78rem;
		font-weight: 750;
		color: #e2e8f0;
		cursor: pointer;
	}

	.pill:hover,
	.segmented button:hover,
	.stage-rail button:hover {
		border-color: rgb(103 232 249 / 0.65);
		background: #101d2e;
	}

	.pill:focus-visible,
	.segmented button:focus-visible,
	.stage-rail button:focus-visible,
	.settings summary:focus-visible,
	.settings :is(select, input):focus-visible {
		outline: 2px solid #67e8f9;
		outline-offset: 2px;
	}

	.pill:disabled {
		cursor: not-allowed;
		opacity: 0.4;
	}

	.pill-primary,
	.pulse-button,
	.segmented .active {
		border-color: #67e8f9;
		background: #67e8f9;
		color: #03111a;
	}

	.pulse-button {
		border-color: #fbbf24;
		background: linear-gradient(110deg, #67e8f9, #fbbf24);
	}

	.compact {
		width: 2.75rem;
		padding-inline: 0;
		font-size: 1rem;
	}

	.segmented {
		display: inline-grid;
		grid-template-columns: 1fr 1fr;
		border-radius: 0.68rem;
		background: #020617;
	}

	.segmented button:first-child {
		border-radius: 0.62rem 0 0 0.62rem;
	}

	.segmented button:last-child {
		margin-left: -1px;
		border-radius: 0 0.62rem 0.62rem 0;
	}

	.stage-count {
		min-width: 2.2rem;
		text-align: center;
		font:
			700 0.75rem/1 ui-monospace,
			monospace;
		color: #a5f3fc;
	}

	.stage-rail {
		display: grid;
		grid-template-columns: repeat(8, minmax(6.5rem, 1fr));
		gap: 0.45rem;
		overflow-x: auto;
		padding: 0 1rem 0.9rem;
		scrollbar-color: #334155 transparent;
	}

	.stage-rail button {
		display: grid;
		gap: 0.18rem;
		min-width: 6.5rem;
		text-align: left;
		font-size: 0.72rem;
		font-weight: 650;
	}

	.stage-rail button span {
		font:
			800 0.66rem/1 ui-monospace,
			monospace;
		color: #67e8f9;
	}

	.stage-rail button.active {
		border-color: #fbbf24;
		background: rgb(251 191 36 / 0.1);
		box-shadow: inset 0 -2px #fbbf24;
	}

	.stage-reading {
		display: grid;
		grid-template-columns: minmax(16rem, 0.72fr) minmax(0, 1.28fr);
		gap: 1rem;
		padding: 1rem;
		border-top: 1px solid var(--line);
		background: #06101d;
	}

	.stage-reading h3 {
		margin: 0.2rem 0 0.55rem;
		font-size: 1.2rem;
		color: white;
	}

	.stage-reading p {
		margin: 0;
		font-size: 0.82rem;
		line-height: 1.6;
		color: #cbd5e1;
	}

	.stage-reading .stage-callout {
		margin-top: 0.75rem;
		padding-left: 0.75rem;
		border-left: 2px solid #fbbf24;
		color: #fde68a;
	}

	.stage-source {
		min-width: 0;
		overflow: hidden;
		border: 1px solid #1e293b;
		border-radius: 0.65rem;
		background: #020617;
	}

	.stage-source > p {
		padding: 0.55rem 0.75rem;
		border-bottom: 1px solid #1e293b;
		font:
			600 0.68rem/1.4 ui-monospace,
			monospace;
		color: #94a3b8;
	}

	.stage-source pre {
		max-height: 15rem;
		margin: 0;
		overflow: auto;
		padding: 0.8rem;
		background: transparent;
		font-size: 0.72rem;
		line-height: 1.55;
		color: #e2e8f0;
	}

	.settings {
		border-top: 1px solid var(--line);
	}

	.settings summary {
		min-height: 2.75rem;
		padding: 0.8rem 1rem;
		cursor: pointer;
		font-size: 0.8rem;
		font-weight: 750;
		color: #cbd5e1;
	}

	.settings-grid {
		display: grid;
		grid-template-columns: repeat(3, minmax(0, 1fr));
		gap: 1rem;
		padding: 0.2rem 1rem 1rem;
	}

	.settings label {
		display: grid;
		align-content: start;
		gap: 0.4rem;
		min-width: 0;
		font-size: 0.76rem;
		font-weight: 700;
		color: #e2e8f0;
	}

	.settings label > span {
		display: flex;
		justify-content: space-between;
		gap: 0.5rem;
	}

	.settings output,
	.settings small {
		font-weight: 500;
		color: #94a3b8;
	}

	.settings :is(select, input[type='range']) {
		width: 100%;
		min-height: 2.75rem;
	}

	.settings select {
		border: 1px solid #475569;
		border-radius: 0.45rem;
		background: #0f172a;
		padding: 0 0.65rem;
		color: white;
	}

	.settings input[type='range'] {
		accent-color: #22d3ee;
		cursor: pointer;
	}

	.settings-footer {
		display: flex;
		gap: 0.75rem;
		align-items: center;
		padding: 0 1rem 1rem;
	}

	.settings-footer p {
		font-size: 0.75rem;
		color: #a5f3fc;
	}

	figcaption {
		padding: 0.85rem 1rem;
		border-top: 1px solid var(--line);
		font-size: 0.78rem;
		line-height: 1.55;
		color: #94a3b8;
	}

	figcaption strong {
		color: #dbeafe;
	}

	.cathedral-exhibit.is-expanded,
	.cathedral-exhibit:fullscreen {
		display: flex;
		flex-direction: column;
		width: 100vw;
		height: 100dvh;
		max-width: none;
		margin: 0;
		border: 0;
		border-radius: 0;
		background: #02050b;
	}

	.cathedral-exhibit.is-expanded {
		position: fixed;
		z-index: 1000;
		inset: 0;
	}

	.is-expanded .stage-frame,
	.cathedral-exhibit:fullscreen .stage-frame {
		flex: 1 1 auto;
		min-height: min(58dvh, 42rem);
		aspect-ratio: auto;
	}

	.is-expanded .control-deck,
	.cathedral-exhibit:fullscreen .control-deck {
		max-height: 42dvh;
		overflow: auto;
	}

	.is-expanded :global(.source-explorer),
	.cathedral-exhibit:fullscreen :global(.source-explorer),
	.is-expanded figcaption,
	.cathedral-exhibit:fullscreen figcaption {
		display: none;
	}

	@media (max-width: 760px) {
		.exhibit-header {
			grid-template-columns: 1fr;
			gap: 0.6rem;
			padding: 0.9rem;
		}

		.stage-frame {
			min-height: 16rem;
			aspect-ratio: 4 / 3;
		}

		.poster {
			object-position: center;
		}

		.primary-controls,
		.stage-rail,
		.stage-reading,
		.settings-grid,
		.settings-footer {
			padding-right: 0.75rem;
			padding-left: 0.75rem;
		}

		.stage-reading {
			grid-template-columns: 1fr;
		}

		.settings-grid {
			grid-template-columns: 1fr;
		}

		.overlay-card {
			width: 100%;
			border-radius: 0.8rem;
		}

		.settings-footer {
			align-items: flex-start;
			flex-direction: column;
		}
	}

	@media (max-width: 380px) {
		.stage-frame {
			min-height: 15rem;
		}

		.primary-controls {
			gap: 0.4rem;
		}

		.pill,
		.segmented button {
			padding-inline: 0.66rem;
			font-size: 0.73rem;
		}
	}

	@media (max-height: 520px) and (orientation: landscape) {
		.cathedral-exhibit.is-expanded .exhibit-header,
		.cathedral-exhibit:fullscreen .exhibit-header {
			display: none;
		}

		.is-expanded .stage-frame,
		.cathedral-exhibit:fullscreen .stage-frame {
			min-height: 62dvh;
		}
	}

	@media (prefers-reduced-motion: reduce) {
		.poster,
		.pill,
		.segmented button,
		.stage-rail button {
			transition: none;
		}
	}
</style>
Svelte canvas host RayMarchingCanvas.svelte
<script lang="ts">
	import { onMount, untrack } from 'svelte';
	import {
		mountRayMarchingSketch,
		type RayMarchingRenderSnapshot,
		type RayMarchingSketchController,
		type RayMarchingSketchStatus
	} from '$lib/visualizations/experiments/ray-marching/sketch';
	import { normalizeRayMarchingPointer } from '$lib/visualizations/experiments/ray-marching/interaction';
	import { clampRayMarchingCamera } from '$lib/visualizations/experiments/ray-marching/state';
	import type {
		RayMarchingCamera,
		RayMarchingQualityTier
	} from '$lib/visualizations/experiments/ray-marching/types';
	import type { RayMarchingQualityHints } from '$lib/visualizations/experiments/ray-marching/quality';

	type Props = {
		snapshot: RayMarchingRenderSnapshot;
		qualityHints: RayMarchingQualityHints;
		load: boolean;
		generation: number;
		expanded: boolean;
		interactive: boolean;
		reducedMotion: boolean;
		restartToken: number;
		pulseToken: number;
		pulseStatic: boolean;
		onstatus: (status: RayMarchingSketchStatus, message: string) => void;
		onready: (canvas: HTMLCanvasElement) => void;
		oncontextrestored: () => void;
		onqualitydowngrade: (from: RayMarchingQualityTier, to: RayMarchingQualityTier) => void;
		oncamera: (camera: RayMarchingCamera) => void;
		onpulse: () => void;
		ontoggleplayback: () => void;
	};

	let {
		snapshot,
		qualityHints,
		load,
		generation,
		expanded,
		interactive,
		reducedMotion,
		restartToken,
		pulseToken,
		pulseStatic,
		onstatus,
		onready,
		oncontextrestored,
		onqualitydowngrade,
		oncamera,
		onpulse,
		ontoggleplayback
	}: Props = $props();

	let host: HTMLDivElement;
	let mounted = $state(false);
	let controller: RayMarchingSketchController | null = null;
	let activeMount = 0;
	let attemptedGeneration = -1;
	let resizeFrame = 0;
	let lastWidth = 0;
	let lastHeight = 0;
	let handledRestartToken = 0;
	let handledPulseToken = 0;

	type PointerGesture = {
		id: number;
		kind: string;
		startX: number;
		startY: number;
		lastX: number;
		lastY: number;
		dragging: boolean;
		verticalIntent: boolean;
	};

	let gesture: PointerGesture | null = null;
	const DRAG_THRESHOLD = 7;
	const canvasLabel =
		'Interactive view of The Cathedral of Distance. Drag horizontally to turn, use arrow keys to adjust the camera, Home to centre it, P to pulse, and Space to pause or start.';

	function mountErrorMessage(error: unknown): string {
		const detail = error instanceof Error ? error.message : String(error);
		return `The interactive renderer could not start: ${detail.replace(/\s+/gu, ' ').trim().slice(0, 220) || 'unknown loading error'}`;
	}

	function scheduleResize() {
		if (!host || resizeFrame) return;
		resizeFrame = requestAnimationFrame(() => {
			resizeFrame = 0;
			const width = Math.round(host.getBoundingClientRect().width);
			const height = Math.round(host.getBoundingClientRect().height);
			if (width <= 0 || height <= 0 || (width === lastWidth && height === lastHeight)) return;
			lastWidth = width;
			lastHeight = height;
			controller?.resize(width, height);
		});
	}

	function handlePointerDown(event: PointerEvent) {
		if (event.pointerType === 'mouse' && event.button !== 0) return;
		host.focus({ preventScroll: true });
		gesture = {
			id: event.pointerId,
			kind: event.pointerType,
			startX: event.clientX,
			startY: event.clientY,
			lastX: event.clientX,
			lastY: event.clientY,
			dragging: false,
			verticalIntent: false
		};
	}

	function handlePointerMove(event: PointerEvent) {
		if (!gesture || gesture.id !== event.pointerId) return;
		const totalX = event.clientX - gesture.startX;
		const totalY = event.clientY - gesture.startY;
		const distance = Math.hypot(totalX, totalY);

		if (!gesture.dragging) {
			if (gesture.kind === 'touch' && !expanded) {
				if (Math.abs(totalY) > DRAG_THRESHOLD && Math.abs(totalY) > Math.abs(totalX) * 1.15) {
					gesture.verticalIntent = true;
					return;
				}
				if (Math.abs(totalX) <= DRAG_THRESHOLD || Math.abs(totalX) <= Math.abs(totalY) * 1.2) {
					return;
				}
			} else if (distance <= DRAG_THRESHOLD) {
				return;
			}

			gesture.dragging = true;
			host.setPointerCapture?.(event.pointerId);
		}

		if (gesture.verticalIntent) return;
		event.preventDefault();
		const rect = host.getBoundingClientRect();
		// Normalisation is shared with unit tests and includes the DOM-to-GL y inversion.
		const current = normalizeRayMarchingPointer(event.clientX, event.clientY, rect);
		const previous = normalizeRayMarchingPointer(gesture.lastX, gesture.lastY, rect);
		const camera = clampRayMarchingCamera({
			yaw: snapshot.camera.yaw + (current.x - previous.x) * 1.25,
			pitch: snapshot.camera.pitch + (current.y - previous.y) * 0.72
		});
		gesture.lastX = event.clientX;
		gesture.lastY = event.clientY;
		oncamera(camera);
	}

	function finishPointer(event: PointerEvent, cancelled = false) {
		if (!gesture || gesture.id !== event.pointerId) return;
		const completed = gesture;
		gesture = null;
		if (host.hasPointerCapture?.(event.pointerId)) host.releasePointerCapture(event.pointerId);
		const distance = Math.hypot(event.clientX - completed.startX, event.clientY - completed.startY);
		if (
			!cancelled &&
			!completed.dragging &&
			!completed.verticalIntent &&
			distance < DRAG_THRESHOLD
		) {
			onpulse();
		}
	}

	function handleKeydown(event: KeyboardEvent) {
		let handled = true;
		const cameraStep = event.shiftKey ? 0.08 : 0.035;
		switch (event.key) {
			case 'ArrowLeft':
				oncamera(
					clampRayMarchingCamera({ ...snapshot.camera, yaw: snapshot.camera.yaw - cameraStep })
				);
				break;
			case 'ArrowRight':
				oncamera(
					clampRayMarchingCamera({ ...snapshot.camera, yaw: snapshot.camera.yaw + cameraStep })
				);
				break;
			case 'ArrowUp':
				oncamera(
					clampRayMarchingCamera({ ...snapshot.camera, pitch: snapshot.camera.pitch + cameraStep })
				);
				break;
			case 'ArrowDown':
				oncamera(
					clampRayMarchingCamera({ ...snapshot.camera, pitch: snapshot.camera.pitch - cameraStep })
				);
				break;
			case 'Home':
				oncamera({ yaw: 0, pitch: 0 });
				break;
			case 'p':
			case 'P':
				onpulse();
				break;
			case ' ':
				ontoggleplayback();
				break;
			default:
				handled = false;
		}
		if (handled) event.preventDefault();
	}

	onMount(() => {
		mounted = true;
		const resizeObserver = new ResizeObserver(scheduleResize);
		resizeObserver.observe(host);
		const handleOrientation = () => scheduleResize();
		window.addEventListener('orientationchange', handleOrientation, { passive: true });
		scheduleResize();

		return () => {
			mounted = false;
			activeMount += 1;
			resizeObserver.disconnect();
			window.removeEventListener('orientationchange', handleOrientation);
			if (resizeFrame) cancelAnimationFrame(resizeFrame);
			resizeFrame = 0;
			controller?.destroy();
			controller = null;
		};
	});

	$effect(() => {
		const requestedGeneration = generation;
		if (!mounted || !load || !host || requestedGeneration === attemptedGeneration) return;
		attemptedGeneration = requestedGeneration;
		const mountId = ++activeMount;
		controller?.destroy();
		controller = null;
		void mountRayMarchingSketch({
			host,
			qualityHints: untrack(() => qualityHints),
			getSnapshot: () => snapshot,
			isCancelled: () => !mounted || mountId !== activeMount || requestedGeneration !== generation,
			onStatus: (status, message) => {
				if (mountId === activeMount) onstatus(status, message);
			},
			onReady: (canvas) => {
				if (mountId === activeMount) onready(canvas);
			},
			onContextRestored: oncontextrestored,
			onQualityDowngrade: onqualitydowngrade
		})
			.then((mountedController) => {
				if (!mountedController) return;
				if (!mounted || mountId !== activeMount) {
					mountedController.destroy();
					return;
				}
				controller = mountedController;
				if (restartToken !== handledRestartToken) {
					handledRestartToken = restartToken;
					controller.restart();
				}
				if (pulseToken !== handledPulseToken) {
					handledPulseToken = pulseToken;
					controller.pulse(pulseStatic || (reducedMotion && !snapshot.playing));
				}
				scheduleResize();
			})
			.catch((error: unknown) => {
				if (!mounted || mountId !== activeMount) return;
				controller = null;
				onstatus('shader-error', mountErrorMessage(error));
			});
	});

	$effect(() => {
		// Reading all fields makes paused parameter changes redraw one—and only one—frame.
		void snapshot.stage;
		void snapshot.debugView;
		void snapshot.palette;
		void snapshot.fogAmount;
		void snapshot.pulseSpeed;
		void snapshot.focalLength;
		void snapshot.camera.yaw;
		void snapshot.camera.pitch;
		void snapshot.playing;
		void snapshot.suspended;
		void snapshot.qualityChoice;
		void snapshot.qualityTier;
		if (!controller) return;
		controller.syncPlayback();
		if (!snapshot.playing || snapshot.suspended || snapshot.stage !== 8) controller.redraw();
	});

	$effect(() => {
		const token = restartToken;
		if (!controller || token === handledRestartToken) return;
		handledRestartToken = token;
		controller.restart();
	});

	$effect(() => {
		const token = pulseToken;
		if (!controller || token === handledPulseToken) return;
		handledPulseToken = token;
		controller.pulse(pulseStatic || (reducedMotion && !snapshot.playing));
	});
</script>

<!-- svelte-ignore a11y_no_noninteractive_tabindex (the labelled surface intentionally exposes its documented keyboard controls) -->
<div
	bind:this={host}
	class:expanded
	class:inactive={!interactive}
	class="ray-marching-canvas"
	role={interactive ? 'application' : undefined}
	tabindex={interactive ? 0 : -1}
	aria-label={interactive ? canvasLabel : undefined}
	aria-hidden={!interactive}
	inert={!interactive}
	onpointerdown={handlePointerDown}
	onpointermove={handlePointerMove}
	onpointerup={(event) => finishPointer(event)}
	onpointercancel={(event) => finishPointer(event, true)}
	onkeydown={handleKeydown}
></div>

<style>
	.ray-marching-canvas {
		position: absolute;
		inset: 0;
		min-width: 0;
		min-height: 0;
		overflow: hidden;
		outline: none;
		touch-action: pan-y;
		user-select: none;
		-webkit-user-select: none;
	}

	.ray-marching-canvas.expanded {
		touch-action: none;
	}

	.ray-marching-canvas.inactive {
		pointer-events: none;
	}

	.ray-marching-canvas:focus-visible {
		box-shadow: inset 0 0 0 3px rgb(103 232 249 / 0.9);
	}

	.ray-marching-canvas :global(canvas) {
		display: block;
		width: 100% !important;
		height: 100% !important;
		touch-action: pan-y !important;
	}

	.ray-marching-canvas.expanded :global(canvas) {
		touch-action: none !important;
	}
</style>
p5 sketch adapter sketch.ts
import type p5 from 'p5';
import fragmentTemplate from './fragment.frag?raw';
import vertexSource from './vertex.vert?raw';
import {
	buildFragmentSource,
	createRayMarchingQualityMonitor,
	observeRayMarchingQualityFrame,
	resolveRayMarchingFramebufferSize,
	type QualityFramePhase,
	type RayMarchingQualityHints,
	type RayMarchingQualityMonitor
} from './quality';
import {
	createRayMarchingClock,
	resetRayMarchingClock,
	setRayMarchingClockPlaying,
	setRayMarchingClockSuspended,
	tickRayMarchingClock,
	type RayMarchingClock
} from './clock';
import type {
	RayMarchingCamera,
	RayMarchingDebugView,
	RayMarchingPalette,
	RayMarchingQualityChoice,
	RayMarchingQualityTier,
	RayMarchingStageId
} from './types';

export type RayMarchingRenderSnapshot = Readonly<{
	stage: RayMarchingStageId;
	debugView: RayMarchingDebugView;
	palette: RayMarchingPalette;
	fogAmount: number;
	pulseSpeed: number;
	focalLength: number;
	camera: RayMarchingCamera;
	playing: boolean;
	suspended: boolean;
	qualityChoice: RayMarchingQualityChoice;
	qualityTier: RayMarchingQualityTier;
}>;

export type RayMarchingSketchStatus =
	| 'initializing'
	| 'first-frame'
	| 'context-lost'
	| 'shader-error';

export type MountRayMarchingSketchOptions = Readonly<{
	host: HTMLDivElement;
	qualityHints: RayMarchingQualityHints;
	getSnapshot: () => RayMarchingRenderSnapshot;
	isCancelled: () => boolean;
	onStatus: (status: RayMarchingSketchStatus, message: string) => void;
	onReady: (canvas: HTMLCanvasElement) => void;
	onContextRestored: () => void;
	onQualityDowngrade: (from: RayMarchingQualityTier, to: RayMarchingQualityTier) => void;
}>;

export type RayMarchingSketchController = Readonly<{
	redraw: () => void;
	syncPlayback: () => void;
	resize: (cssWidth: number, cssHeight: number) => void;
	restart: () => void;
	pulse: (staticPosition?: boolean) => void;
	destroy: () => void;
	canvas: () => HTMLCanvasElement | null;
}>;

const DEBUG_IDS: Record<RayMarchingDebugView, number> = {
	beauty: 0,
	'march-cost': 1,
	normals: 2,
	'distance-bands': 3
};

const PALETTE_IDS: Record<RayMarchingPalette, number> = {
	cathedral: 0,
	'blue-hour': 1,
	'amber-archive': 2
};

const MAX_PULSE_AGE_SECONDS = 7.5;
const STATIC_PULSE_AGE_SECONDS = 1.7;
const FIRST_FRAME_ATTEMPTS = 5;

type P5WithLockedWebGL1 = p5 & {
	_glAttributes: (WebGLContextAttributes & { version: 1 }) | null;
	_renderer?: { _pixelDensity: number };
};

function conciseError(error: unknown): string {
	const message = error instanceof Error ? error.message : String(error);
	return message.replace(/\s+/gu, ' ').trim().slice(0, 260) || 'The shader could not start.';
}

function compileShaderSource(
	gl: WebGLRenderingContext,
	type: number,
	source: string,
	label: string
): void {
	const shader = gl.createShader(type);
	if (!shader) throw new Error(`The browser could not allocate the ${label}.`);
	gl.shaderSource(shader, source);
	gl.compileShader(shader);
	const compiled = gl.getShaderParameter(shader, gl.COMPILE_STATUS) as boolean;
	const log = gl.getShaderInfoLog(shader)?.trim();
	gl.deleteShader(shader);
	if (!compiled) throw new Error(`${label} compilation failed${log ? `: ${log}` : '.'}`);
}

/**
 * One deliberately bounded startup read proves that the poster is not retired for a blank buffer.
 * It never runs again after readiness and is not part of the animation hot path.
 */
function firstFrameHasVariation(gl: WebGLRenderingContext, width: number, height: number): boolean {
	const samples = [
		[0.18, 0.22],
		[0.5, 0.5],
		[0.82, 0.24],
		[0.28, 0.76],
		[0.72, 0.72]
	] as const;
	const pixel = new Uint8Array(4);
	let minimum = 255;
	let maximum = 0;
	let total = 0;

	gl.flush();
	for (const [x, y] of samples) {
		gl.readPixels(
			Math.min(width - 1, Math.max(0, Math.floor(width * x))),
			Math.min(height - 1, Math.max(0, Math.floor(height * y))),
			1,
			1,
			gl.RGBA,
			gl.UNSIGNED_BYTE,
			pixel
		);
		for (let channel = 0; channel < 3; channel += 1) {
			minimum = Math.min(minimum, pixel[channel]);
			maximum = Math.max(maximum, pixel[channel]);
			total += pixel[channel];
		}
	}

	return total > 24 && maximum - minimum > 3;
}

function shouldContinuouslyAnimate(
	snapshot: RayMarchingRenderSnapshot,
	pulseAge: number,
	ready: boolean
): boolean {
	if (!ready) return true;
	if (snapshot.suspended) return false;
	if (snapshot.playing && snapshot.stage === 8) return true;
	return snapshot.playing && pulseAge >= 0;
}

export async function mountRayMarchingSketch(
	options: MountRayMarchingSketchOptions
): Promise<RayMarchingSketchController | null> {
	options.onStatus('initializing', 'Loading p5 and preparing the WebGL shader…');
	const { default: P5 } = await import('p5');
	if (options.isCancelled()) return null;
	// This instance supplies all callbacks explicitly. The global-mode verifier otherwise
	// attempts to parse Svelte's bundled module as a sketch and emits a false console error.
	P5.disableFriendlyErrors = true;

	let disposed = false;
	let canvasElement: HTMLCanvasElement | null = null;
	let clock: RayMarchingClock = createRayMarchingClock({ playing: false });
	let pulseAge = -1;
	let ready = false;
	let contextLost = false;
	let firstFrameAttempts = 0;
	let lastCssWidth = 0;
	let lastCssHeight = 0;
	let lastTier: RayMarchingQualityTier | null = null;
	let qualityMonitor: RayMarchingQualityMonitor = createRayMarchingQualityMonitor({
		choice: options.getSnapshot().qualityChoice,
		hints: options.qualityHints,
		initialTier: options.getSnapshot().qualityTier,
		nowMs: performance.now()
	});
	let nextQualityPhase: QualityFramePhase = 'compile';
	let removeContextListeners = () => {};
	const controllerHolder: { current: RayMarchingSketchController | null } = { current: null };

	new P5((p) => {
		const shaderCache = new Map<RayMarchingQualityTier, p5.Shader>();
		const validatedSources = new Set<RayMarchingQualityTier>();

		function currentCanvasSize(): Readonly<{ width: number; height: number }> {
			const width = Math.max(1, Math.round(options.host.clientWidth));
			const height = Math.max(1, Math.round(options.host.clientHeight));
			return { width, height };
		}

		function applyCanvasSize(cssWidth: number, cssHeight: number): void {
			if (!canvasElement || disposed) return;
			const snapshot = options.getSnapshot();
			const resolved = resolveRayMarchingFramebufferSize(
				cssWidth,
				cssHeight,
				window.devicePixelRatio || 1,
				snapshot.qualityTier
			);
			if (
				resolved.cssWidth === lastCssWidth &&
				resolved.cssHeight === lastCssHeight &&
				lastTier === snapshot.qualityTier
			) {
				return;
			}

			lastCssWidth = resolved.cssWidth;
			lastCssHeight = resolved.cssHeight;
			lastTier = snapshot.qualityTier;
			const renderer = (p as P5WithLockedWebGL1)._renderer;
			if (renderer) renderer._pixelDensity = resolved.pixelRatio;
			p.resizeCanvas(resolved.cssWidth, resolved.cssHeight, true);
			nextQualityPhase = 'resize';
		}

		function shaderForTier(tier: RayMarchingQualityTier): p5.Shader {
			const cached = shaderCache.get(tier);
			if (cached) return cached;
			const gl = p.drawingContext as WebGLRenderingContext;
			const fragmentSource = buildFragmentSource(fragmentTemplate, tier);
			if (!validatedSources.has(tier)) {
				compileShaderSource(gl, gl.VERTEX_SHADER, vertexSource, 'vertex shader');
				compileShaderSource(gl, gl.FRAGMENT_SHADER, fragmentSource, `${tier} fragment shader`);
				validatedSources.add(tier);
			}
			const shader = p.createShader(vertexSource, fragmentSource);
			shaderCache.set(tier, shader);
			nextQualityPhase = 'compile';
			return shader;
		}

		function applyUniforms(shader: p5.Shader, snapshot: RayMarchingRenderSnapshot): void {
			if (!canvasElement) return;
			const pulseRadius =
				pulseAge < 0 ? -1 : pulseAge * (3.25 * Math.max(0.5, snapshot.pulseSpeed));
			const pulseStrength = pulseAge < 0 ? 0 : Math.max(0, 1 - pulseAge / MAX_PULSE_AGE_SECONDS);
			shader.setUniform('u_resolution', [canvasElement.width, canvasElement.height]);
			shader.setUniform('u_time', clock.elapsedSeconds);
			shader.setUniform('u_stage', snapshot.stage);
			shader.setUniform('u_debug', DEBUG_IDS[snapshot.debugView]);
			shader.setUniform('u_camera', [snapshot.camera.yaw, snapshot.camera.pitch]);
			shader.setUniform('u_focalLength', snapshot.focalLength);
			shader.setUniform('u_fogAmount', snapshot.fogAmount);
			shader.setUniform('u_palette', PALETTE_IDS[snapshot.palette]);
			shader.setUniform('u_pulseRadius', pulseRadius);
			shader.setUniform('u_pulseStrength', pulseStrength);
		}

		function handleContextLost(event: Event): void {
			if (disposed) return;
			event.preventDefault();
			contextLost = true;
			p.noLoop();
			clock = setRayMarchingClockSuspended(clock, true);
			options.onStatus(
				'context-lost',
				'The WebGL context was lost. The poster is visible while the renderer waits to recover.'
			);
		}

		function handleContextRestored(): void {
			if (disposed) return;
			options.onContextRestored();
		}

		p.setup = () => {
			try {
				const initialSize = currentCanvasSize();
				const initialSnapshot = options.getSnapshot();
				const resolved = resolveRayMarchingFramebufferSize(
					initialSize.width,
					initialSize.height,
					window.devicePixelRatio || 1,
					initialSnapshot.qualityTier
				);
				// p5 2.3 reads this before RendererGL creates its first and only context.
				// Calling setAttributes() after createCanvas() would recreate the context.
				(p as P5WithLockedWebGL1)._glAttributes = {
					version: 1,
					alpha: false,
					antialias: false,
					depth: false,
					stencil: false,
					preserveDrawingBuffer: false,
					premultipliedAlpha: false,
					powerPreference: 'high-performance'
				};
				const canvasRenderer = p.createCanvas(1, 1, p.WEBGL);
				const renderer = (p as P5WithLockedWebGL1)._renderer;
				if (renderer) renderer._pixelDensity = resolved.pixelRatio;
				p.resizeCanvas(resolved.cssWidth, resolved.cssHeight, true);
				canvasElement = canvasRenderer.elt as HTMLCanvasElement;
				canvasElement.dataset.rayMarchingCanvas = 'true';
				canvasElement.tabIndex = -1;
				canvasElement.setAttribute('aria-hidden', 'true');
				p.noStroke();
				lastCssWidth = resolved.cssWidth;
				lastCssHeight = resolved.cssHeight;
				lastTier = initialSnapshot.qualityTier;
				clock = createRayMarchingClock({
					playing: initialSnapshot.playing,
					suspended: initialSnapshot.suspended
				});

				canvasElement.addEventListener('webglcontextlost', handleContextLost);
				canvasElement.addEventListener('webglcontextrestored', handleContextRestored);
				removeContextListeners = () => {
					canvasElement?.removeEventListener('webglcontextlost', handleContextLost);
					canvasElement?.removeEventListener('webglcontextrestored', handleContextRestored);
				};
				void shaderForTier(initialSnapshot.qualityTier);
				options.onStatus(
					'first-frame',
					'The shader compiled. Waiting for its first visible frame…'
				);
			} catch (error) {
				p.noLoop();
				options.onStatus('shader-error', conciseError(error));
			}
		};

		p.draw = () => {
			if (disposed || contextLost || !canvasElement) return;
			const frameStartedAt = performance.now();
			const snapshot = options.getSnapshot();
			try {
				clock = setRayMarchingClockPlaying(clock, snapshot.playing);
				clock = setRayMarchingClockSuspended(clock, snapshot.suspended);
				const previousElapsed = clock.elapsedSeconds;
				clock = tickRayMarchingClock(clock, frameStartedAt);
				let elapsedDelta = clock.elapsedSeconds - previousElapsed;
				if (elapsedDelta < 0) elapsedDelta += 600;
				if (pulseAge >= 0 && snapshot.playing && !snapshot.suspended) {
					pulseAge += Math.min(0.05, Math.max(0, elapsedDelta));
					if (pulseAge > MAX_PULSE_AGE_SECONDS) pulseAge = -1;
				}

				const shader = shaderForTier(snapshot.qualityTier);
				applyUniforms(shader, snapshot);
				p.shader(shader);
				p.rect(-p.width / 2, -p.height / 2, p.width, p.height);

				if (!ready) {
					firstFrameAttempts += 1;
					const gl = p.drawingContext as WebGLRenderingContext;
					if (
						firstFrameHasVariation(gl, canvasElement.width, canvasElement.height) ||
						firstFrameAttempts >= FIRST_FRAME_ATTEMPTS
					) {
						if (
							firstFrameAttempts >= FIRST_FRAME_ATTEMPTS &&
							!firstFrameHasVariation(gl, canvasElement.width, canvasElement.height)
						) {
							throw new Error('The shader rendered a blank or uniform drawing buffer.');
						}
						ready = true;
						options.onReady(canvasElement);
					}
				}

				const measuredFrameMs = Math.max(
					0.01,
					performance.now() - frameStartedAt,
					Number.isFinite(p.deltaTime) ? p.deltaTime : 0
				);
				const phase = nextQualityPhase;
				nextQualityPhase = 'active';
				if (
					qualityMonitor.choice !== snapshot.qualityChoice ||
					(snapshot.qualityChoice !== 'auto' && qualityMonitor.tier !== snapshot.qualityTier)
				) {
					qualityMonitor = createRayMarchingQualityMonitor({
						choice: snapshot.qualityChoice,
						hints: options.qualityHints,
						initialTier: snapshot.qualityTier,
						nowMs: frameStartedAt
					});
				}
				const qualityUpdate = observeRayMarchingQualityFrame(qualityMonitor, {
					nowMs: frameStartedAt,
					frameMs: measuredFrameMs,
					phase
				});
				qualityMonitor = qualityUpdate.state;
				if (qualityUpdate.downgradedFrom && qualityUpdate.downgradedTo) {
					options.onQualityDowngrade(qualityUpdate.downgradedFrom, qualityUpdate.downgradedTo);
				}

				if (!shouldContinuouslyAnimate(snapshot, pulseAge, ready)) p.noLoop();
			} catch (error) {
				p.noLoop();
				options.onStatus('shader-error', conciseError(error));
			}
		};

		controllerHolder.current = Object.freeze({
			redraw: () => {
				if (!disposed && !contextLost) p.redraw(1);
			},
			syncPlayback: () => {
				if (disposed || contextLost) return;
				const snapshot = options.getSnapshot();
				if (lastTier !== snapshot.qualityTier && lastCssWidth > 0 && lastCssHeight > 0) {
					applyCanvasSize(lastCssWidth, lastCssHeight);
				}
				clock = setRayMarchingClockPlaying(clock, snapshot.playing);
				clock = setRayMarchingClockSuspended(clock, snapshot.suspended);
				nextQualityPhase = snapshot.suspended ? 'hidden' : 'resume';
				if (shouldContinuouslyAnimate(snapshot, pulseAge, ready)) p.loop();
				else p.noLoop();
			},
			resize: (cssWidth, cssHeight) => {
				applyCanvasSize(cssWidth, cssHeight);
				if (!shouldContinuouslyAnimate(options.getSnapshot(), pulseAge, ready)) p.redraw(1);
			},
			restart: () => {
				clock = resetRayMarchingClock(clock, { playing: true });
				pulseAge = -1;
				nextQualityPhase = 'resume';
				if (shouldContinuouslyAnimate(options.getSnapshot(), pulseAge, ready)) p.loop();
				else p.redraw(1);
			},
			pulse: (staticPosition = false) => {
				pulseAge = staticPosition ? STATIC_PULSE_AGE_SECONDS : 0;
				if (staticPosition || !options.getSnapshot().playing) p.redraw(1);
				else p.loop();
			},
			destroy: () => {
				if (disposed) return;
				disposed = true;
				removeContextListeners();
				const gl = p.drawingContext as WebGLRenderingContext | undefined;
				p.remove();
				gl?.getExtension('WEBGL_lose_context')?.loseContext();
				canvasElement = null;
				shaderCache.clear();
			},
			canvas: () => canvasElement
		});
	}, options.host);

	const mountedController = controllerHolder.current;
	if (options.isCancelled()) {
		mountedController?.destroy();
		return null;
	}

	return mountedController;
}

export { fragmentTemplate as rayMarchingFragmentTemplate, vertexSource as rayMarchingVertexSource };
Vertex shader vertex.vert
#ifdef GL_ES
precision highp float;
#endif

attribute vec3 aPosition;

uniform mat4 uProjectionMatrix;
uniform mat4 uModelViewMatrix;

void main() {
	gl_Position = uProjectionMatrix * uModelViewMatrix * vec4(aPosition, 1.0);
}
Fragment shader fragment.frag
#ifdef GL_ES
#ifdef GL_FRAGMENT_PRECISION_HIGH
precision highp float;
precision highp int;
#define RM_FRAGMENT_HIGHP 1
#else
precision mediump float;
precision mediump int;
#define RM_FRAGMENT_HIGHP 0
#endif
#endif

// These literal tokens are replaced by buildFragmentSource() before compilation.
#define RM_MAIN_STEPS __MAIN_STEPS__
#define RM_SHADOW_STEPS __SHADOW_STEPS__
#define RM_AO_SAMPLES __AO_SAMPLES__
#define RM_ENABLE_SHADOWS __ENABLE_SHADOWS__
#define RM_ENABLE_AO __ENABLE_AO__

uniform vec2 u_resolution;
uniform float u_time;
uniform float u_stage;
uniform float u_debug;
uniform vec2 u_camera;
uniform float u_focalLength;
uniform float u_fogAmount;
uniform float u_palette;
uniform float u_pulseRadius;
uniform float u_pulseStrength;

const float FAR_CLIP = 30.0;
const float NEAR_CLIP = 0.08;
const float SAFETY_FACTOR = 0.8;
const float MINIMUM_STEP = 0.003;
const vec3 ORB_POSITION = vec3(0.0, 1.55, -7.0);

const float MATERIAL_FLOOR = 1.0;
const float MATERIAL_STONE = 2.0;
const float MATERIAL_CYAN = 3.0;
const float MATERIAL_AMBER = 4.0;
const float MATERIAL_ORB = 5.0;

float currentStage() {
	return floor(clamp(u_stage, 1.0, 8.0) + 0.5);
}

mat2 rotate2d(float angle) {
	float c = cos(angle);
	float s = sin(angle);
	return mat2(c, -s, s, c);
}

vec2 nearer(vec2 firstSurface, vec2 secondSurface) {
	return secondSurface.x < firstSurface.x ? secondSurface : firstSurface;
}

float smoothUnion(float firstDistance, float secondDistance, float radius) {
	float blend = clamp(0.5 + 0.5 * (secondDistance - firstDistance) / radius, 0.0, 1.0);
	return mix(secondDistance, firstDistance, blend) - radius * blend * (1.0 - blend);
}

float sdPlane(vec3 point) {
	return point.y;
}

// @excerpt one-distance:start
float sdSphere(vec3 point, vec3 centre, float radius) {
	return length(point - centre) - radius;
}
// @excerpt one-distance:end

float sdRoundedBox(vec3 point, vec3 halfSize, float radius) {
	vec3 offset = abs(point) - halfSize + radius;
	return length(max(offset, 0.0)) + min(max(offset.x, max(offset.y, offset.z)), 0.0) - radius;
}

float sdCappedCylinderY(vec3 point, float halfHeight, float radius) {
	vec2 offset = abs(vec2(length(point.xz), point.y)) - vec2(radius, halfHeight);
	return min(max(offset.x, offset.y), 0.0) + length(max(offset, 0.0));
}

// An upper-half annulus, subtracted in 2D and then extruded a short distance in z.
float sdArchExtrusion(
	vec3 point,
	float outerRadius,
	float innerRadius,
	float halfDepth
) {
	float radialDistance = length(point.xy);
	float ring = max(radialDistance - outerRadius, -(radialDistance - innerRadius));
	ring = max(ring, -point.y);
	return max(ring, abs(point.z) - halfDepth);
}

float sdColumn(vec3 point) {
	float shaft = sdCappedCylinderY(point - vec3(0.0, 1.32, 0.0), 1.18, 0.27);
	float base = sdRoundedBox(point - vec3(0.0, 0.18, 0.0), vec3(0.49, 0.18, 0.43), 0.07);
	float capital = sdRoundedBox(
		point - vec3(0.0, 2.48, 0.0),
		vec3(0.46, 0.18, 0.39),
		0.06
	);
	// The only smooth blend joins parts carrying the same stable stone material.
	return smoothUnion(shaft, min(base, capital), 0.055);
}

// @excerpt fold-space:start
vec2 boundedRepeatDepth(float worldDepth) {
	const float spacing = 4.0;
	float coordinate = worldDepth + 1.5;
	float cell = clamp(floor(coordinate / spacing + 0.5), -6.0, 0.0);
	return vec2(coordinate - cell * spacing, cell);
}

vec3 foldArchitecturalSpace(vec3 point) {
	vec2 repeated = boundedRepeatDepth(point.z);
	vec3 localPoint = vec3(point.x, point.y, repeated.x);
	// A small per-cell rotation is rigid and therefore distance-preserving.
	localPoint.xy = rotate2d(repeated.y * 0.022) * localPoint.xy;
	return localPoint;
}
// @excerpt fold-space:end

// @excerpt constructive-geometry:start
vec2 mapBay(vec3 point, float includeEmissiveSeams) {
	vec2 result = vec2(1000.0, MATERIAL_STONE);

	float leftColumn = sdColumn(point - vec3(-2.46, 0.0, 0.0));
	float rightColumn = sdColumn(point - vec3(2.46, 0.0, 0.0));
	result = nearer(result, vec2(min(leftColumn, rightColumn), MATERIAL_STONE));

	vec3 archPoint = point - vec3(0.0, 2.5, 0.0);
	float arch = sdArchExtrusion(archPoint, 2.76, 2.19, 0.24);
	result = nearer(result, vec2(arch, MATERIAL_STONE));

	float leftButtress = sdRoundedBox(
		point - vec3(-3.25, 1.38, 0.0),
		vec3(0.31, 1.38, 0.31),
		0.08
	);
	float rightButtress = sdRoundedBox(
		point - vec3(3.25, 1.38, 0.0),
		vec3(0.31, 1.38, 0.31),
		0.08
	);
	result = nearer(result, vec2(min(leftButtress, rightButtress), MATERIAL_STONE));

	if (includeEmissiveSeams > 0.5) {
		float cyanArch = sdArchExtrusion(archPoint, 2.84, 2.77, 0.265);
		result = nearer(result, vec2(cyanArch, MATERIAL_CYAN));

		float leftCapitalSeam = sdRoundedBox(
			point - vec3(-2.46, 2.68, 0.0),
			vec3(0.5, 0.035, 0.42),
			0.018
		);
		float rightCapitalSeam = sdRoundedBox(
			point - vec3(2.46, 2.68, 0.0),
			vec3(0.5, 0.035, 0.42),
			0.018
		);
		result = nearer(
			result,
			vec2(min(leftCapitalSeam, rightCapitalSeam), MATERIAL_AMBER)
		);
	}

	return result;
}
// @excerpt constructive-geometry:end

// mapScene is the only source of visible three-dimensional geometry.
vec2 mapScene(vec3 point) {
	float stage = currentStage();
	if (stage < 5.0) {
		return vec2(sdSphere(point, vec3(0.0, 1.55, -4.4), 1.0), MATERIAL_STONE);
	}

	vec2 result = vec2(sdPlane(point), MATERIAL_FLOOR);
	vec3 architecturalPoint = point;
	if (stage >= 6.0) {
		architecturalPoint = foldArchitecturalSpace(point);
	} else {
		architecturalPoint.z += 4.5;
	}
	result = nearer(result, mapBay(architecturalPoint, stage >= 8.0 ? 1.0 : 0.0));

	if (stage >= 6.0) {
		float orb = sdSphere(point, ORB_POSITION, 0.62);
		result = nearer(result, vec2(orb, MATERIAL_ORB));
	}

	if (stage >= 8.0) {
		float leftAisleSeam = sdRoundedBox(
			point - vec3(-1.28, 0.025, -12.8),
			vec3(0.035, 0.025, 11.8),
			0.012
		);
		float rightAisleSeam = sdRoundedBox(
			point - vec3(1.28, 0.025, -12.8),
			vec3(0.035, 0.025, 11.8),
			0.012
		);
		result = nearer(result, vec2(leftAisleSeam, MATERIAL_CYAN));
		result = nearer(result, vec2(rightAisleSeam, MATERIAL_AMBER));
	}

	return result;
}

// @excerpt camera-rays:start
void makeCameraRay(vec2 screen, out vec3 rayOrigin, out vec3 rayDirection) {
	float yaw = clamp(u_camera.x, -0.7, 0.7);
	float pitch = clamp(u_camera.y, -0.3, 0.25);
	vec3 forward = normalize(vec3(sin(yaw) * cos(pitch), sin(pitch), -cos(yaw) * cos(pitch)));
	vec3 right = normalize(cross(forward, vec3(0.0, 1.0, 0.0)));
	vec3 up = normalize(cross(right, forward));
	rayOrigin = vec3(0.0, 1.55, 5.6);
	rayDirection = normalize(
		forward * clamp(u_focalLength, 1.0, 2.4) + right * screen.x + up * screen.y
	);
}
// @excerpt camera-rays:end

float distanceAwareEpsilon(float distanceTravelled) {
#if RM_FRAGMENT_HIGHP == 1
	return 0.0018 * (1.0 + 0.065 * distanceTravelled);
#else
	// The larger fallback tolerates mediump's coarser spacing in the far bays.
	return 0.006 * (1.0 + 0.03 * distanceTravelled);
#endif
}

// @excerpt walking-loop:start
vec4 marchScene(vec3 rayOrigin, vec3 rayDirection) {
	float distanceTravelled = NEAR_CLIP;
	float material = -1.0;
	float normalizedSteps = 1.0;
	float glowAccumulator = 0.0;

	for (int stepIndex = 0; stepIndex < RM_MAIN_STEPS; stepIndex++) {
		vec3 samplePoint = rayOrigin + rayDirection * distanceTravelled;
		vec2 sceneSample = mapScene(samplePoint);
		float epsilon = distanceAwareEpsilon(distanceTravelled);

		if (
			currentStage() >= 8.0 &&
			sceneSample.y > MATERIAL_STONE + 0.1 &&
			sceneSample.y < MATERIAL_ORB - 0.1
		) {
			float nearbyGlow = max(0.0, 0.055 - abs(sceneSample.x)) * 0.045;
			glowAccumulator = min(0.16, glowAccumulator + nearbyGlow);
		}

		if (sceneSample.x < epsilon) {
			material = sceneSample.y;
			normalizedSteps = float(stepIndex + 1) / float(RM_MAIN_STEPS);
			break;
		}

		distanceTravelled += max(sceneSample.x * SAFETY_FACTOR, MINIMUM_STEP);
		if (distanceTravelled > FAR_CLIP) {
			distanceTravelled = FAR_CLIP + 1.0;
			normalizedSteps = float(stepIndex + 1) / float(RM_MAIN_STEPS);
			break;
		}
	}

	return vec4(distanceTravelled, material, normalizedSteps, glowAccumulator);
}
// @excerpt walking-loop:end

// @excerpt surface-direction:start
vec3 estimateNormal(vec3 point, float distanceTravelled) {
	float epsilon = max(0.0015, distanceAwareEpsilon(distanceTravelled) * 0.72);
	vec2 offset = vec2(epsilon, -epsilon);
	return normalize(
		offset.xyy * mapScene(point + offset.xyy).x +
		offset.yyx * mapScene(point + offset.yyx).x +
		offset.yxy * mapScene(point + offset.yxy).x +
		offset.xxx * mapScene(point + offset.xxx).x
	);
}
// @excerpt surface-direction:end

vec3 materialColour(float material) {
	vec3 coolStone = mix(vec3(0.035, 0.065, 0.11), vec3(0.07, 0.12, 0.17), step(0.5, u_palette));
	vec3 warmStone = mix(vec3(0.055, 0.07, 0.09), vec3(0.1, 0.075, 0.055), step(1.5, u_palette));
	if (material < 1.5) return vec3(0.018, 0.027, 0.043);
	if (material < 2.5) return mix(coolStone, warmStone, step(1.5, u_palette));
	if (material < 3.5) return vec3(0.02, 0.72, 1.15);
	if (material < 4.5) return vec3(1.18, 0.48, 0.12);
	return vec3(0.0025, 0.003, 0.005);
}

#if RM_ENABLE_AO == 1
float ambientOcclusion(vec3 point, vec3 normal) {
	float occlusion = 0.0;
	float weight = 1.0;
	for (int sampleIndex = 0; sampleIndex < RM_AO_SAMPLES; sampleIndex++) {
		float sampleDistance = 0.075 + float(sampleIndex) * 0.095;
		float sceneDistance = mapScene(point + normal * sampleDistance).x;
		occlusion += max(0.0, sampleDistance - sceneDistance) * weight;
		weight *= 0.62;
	}
	return clamp(1.0 - occlusion * 2.15, 0.32, 1.0);
}
#endif

#if RM_ENABLE_SHADOWS == 1
float softShadow(vec3 rayOrigin, vec3 rayDirection, float maximumDistance) {
	float visibility = 1.0;
	float distanceTravelled = 0.035;
	for (int stepIndex = 0; stepIndex < RM_SHADOW_STEPS; stepIndex++) {
		float sceneDistance = mapScene(rayOrigin + rayDirection * distanceTravelled).x;
		visibility = min(visibility, 11.0 * sceneDistance / max(distanceTravelled, 0.02));
		distanceTravelled += clamp(sceneDistance * SAFETY_FACTOR, 0.025, 0.55);
		if (sceneDistance < 0.0025 || distanceTravelled > maximumDistance) break;
	}
	return clamp(visibility, 0.18, 1.0);
}
#endif

// @excerpt believable-light:start
vec3 lightSurface(
	vec3 point,
	vec3 normal,
	vec3 viewDirection,
	float material,
	float stage
) {
	vec3 base = materialColour(material);
	vec3 lightDirection = normalize(vec3(-0.42, 0.78, 0.32));
	float hemisphere = 0.22 + 0.25 * (normal.y * 0.5 + 0.5);
	float diffuse = max(dot(normal, lightDirection), 0.0);
	vec3 reflectedLight = reflect(-lightDirection, normal);
	float floorMask = 1.0 - step(1.5, material);
	float specularPower = mix(34.0, 68.0, floorMask);
	float specular = pow(max(dot(reflectedLight, viewDirection), 0.0), specularPower);
	float fresnel = pow(1.0 - max(dot(normal, viewDirection), 0.0), 5.0);

	float ao = 1.0;
	float shadow = 1.0;
	if (stage >= 7.0) {
#if RM_ENABLE_AO == 1
		ao = ambientOcclusion(point, normal);
#endif
#if RM_ENABLE_SHADOWS == 1
		shadow = softShadow(point + normal * 0.012, lightDirection, 9.0);
#endif
	}

	vec3 colour = base * hemisphere;
	colour += base * diffuse * shadow * 0.9;
	if (stage >= 7.0) {
		colour += vec3(0.72, 0.86, 1.0) * specular * mix(0.12, 0.32, floorMask) * shadow;
		colour += vec3(0.04, 0.11, 0.18) * fresnel * mix(0.18, 0.42, floorMask);
	}
	return colour * ao;
}
// @excerpt believable-light:end

vec3 proceduralBackground(vec3 rayDirection) {
	float horizon = pow(max(0.0, 1.0 - abs(rayDirection.y + 0.03)), 7.0);
	float upper = clamp(rayDirection.y * 0.5 + 0.5, 0.0, 1.0);
	return vec3(0.0015, 0.0035, 0.009) + vec3(0.002, 0.009, 0.021) * upper +
		vec3(0.0, 0.007, 0.018) * horizon;
}

vec3 marchCostColour(float normalizedSteps, float hitMask) {
	float cost = clamp(normalizedSteps, 0.0, 1.0);
	vec3 lowCost = vec3(0.02, 0.19, 0.28);
	vec3 highCost = vec3(1.0, 0.34, 0.08);
	vec3 colour = mix(lowCost, highCost, smoothstep(0.08, 0.92, cost));
	// Luminance also rises with cost, so the view remains legible without colour.
	colour *= 0.28 + cost * 0.82;
	return mix(colour * 0.3, colour, hitMask);
}

vec3 distanceBandView(vec2 screen) {
	vec3 crossSectionPoint = vec3(screen.x * 3.8, screen.y * 2.7 + 2.0, -7.0);
	float distanceBound = mapScene(crossSectionPoint).x;
	float band = 0.5 + 0.5 * cos(distanceBound * 18.0);
	float zeroContour = 1.0 - smoothstep(0.0, 0.035, abs(distanceBound));
	vec3 outsideColour = vec3(0.025, 0.2, 0.29);
	vec3 insideColour = vec3(0.42, 0.12, 0.035);
	vec3 signColour = mix(insideColour, outsideColour, step(0.0, distanceBound));
	return signColour * (0.35 + band * 0.65) + zeroContour * vec3(0.95);
}

float stableDither(vec2 pixelCoordinate) {
	vec3 seed = fract(vec3(pixelCoordinate.xyx) * 0.1031);
	seed += dot(seed, seed.yzx + 33.33);
	return fract((seed.x + seed.y) * seed.z) - 0.5;
}

vec3 toneMapAndEncode(vec3 linearColour) {
	vec3 mapped = linearColour / (vec3(1.0) + max(linearColour, vec3(0.0)));
	return pow(max(mapped, vec3(0.0)), vec3(1.0 / 2.2));
}

// @excerpt lose-horizon:start
vec3 finishCathedral(
	vec3 linearColour,
	vec3 point,
	vec3 normal,
	vec3 viewDirection,
	float material,
	float distanceTravelled,
	float pathGlow,
	vec3 background
) {
	vec3 cyanEmission = vec3(0.0, 0.72, 1.32);
	vec3 amberEmission = vec3(1.35, 0.46, 0.08);
	float seamBreath = 0.94 + 0.06 * sin(u_time * 0.45);
	if (material > 2.5 && material < 3.5) {
		linearColour += cyanEmission * 1.45 * seamBreath;
	}
	if (material > 3.5 && material < 4.5) {
		linearColour += amberEmission * 1.35 * seamBreath;
	}

	float pulseDistance = length(point - ORB_POSITION);
	float pulseOffset = (pulseDistance - max(u_pulseRadius, 0.0)) / 0.085;
	float pulseBand = exp(-pulseOffset * pulseOffset) * clamp(u_pulseStrength, 0.0, 1.0);
	float pulseColourMix = 0.5 + 0.5 * sin(point.x * 2.35 + point.z * 0.62);
	vec3 pulseColour = mix(cyanEmission, amberEmission, pulseColourMix * 0.78);
	linearColour += pulseColour * pulseBand * (0.72 + 0.28 * max(dot(normal, viewDirection), 0.0));

	if (material > 4.5) {
		float orbRim = pow(1.0 - max(dot(normal, viewDirection), 0.0), 3.0);
		linearColour += mix(cyanEmission, amberEmission, 0.52) * orbRim * 0.22;
	}

	linearColour += mix(cyanEmission, amberEmission, 0.5) * pathGlow;
	float fogAmount = 1.0 -
		exp(-clamp(u_fogAmount, 0.0, 1.2) * distanceTravelled * 0.095);
	return mix(linearColour, background, clamp(fogAmount, 0.0, 0.985));
}
// @excerpt lose-horizon:end

void main() {
	vec2 resolution = max(u_resolution, vec2(1.0));
	vec2 screen = (2.0 * gl_FragCoord.xy - resolution) / resolution.y;
	vec3 rayOrigin;
	vec3 rayDirection;
	makeCameraRay(screen, rayOrigin, rayDirection);
	float stage = currentStage();

	if (u_debug > 2.5) {
		vec3 debugColour = distanceBandView(screen);
		gl_FragColor = vec4(clamp(debugColour, 0.0, 1.0), 1.0);
		return;
	}

	if (stage < 1.5) {
		vec3 rayColour = 0.5 + 0.5 * rayDirection;
		gl_FragColor = vec4(rayColour, 1.0);
		return;
	}

	vec4 marchResult = marchScene(rayOrigin, rayDirection);
	float hitMask = step(marchResult.x, FAR_CLIP);
	vec3 background = proceduralBackground(rayDirection);

	float beautyView = 1.0 - step(0.5, u_debug);
	if (
		(beautyView > 0.5 && stage > 2.5 && stage < 3.5) ||
		(u_debug > 0.5 && u_debug < 1.5)
	) {
		gl_FragColor = vec4(marchCostColour(marchResult.z, hitMask), 1.0);
		return;
	}

	if (hitMask < 0.5) {
		gl_FragColor = vec4(toneMapAndEncode(background), 1.0);
		return;
	}

	if (beautyView > 0.5 && stage < 2.5) {
		vec3 silhouette = vec3(0.08, 0.68, 0.82);
		gl_FragColor = vec4(silhouette, 1.0);
		return;
	}

	vec3 hitPoint = rayOrigin + rayDirection * marchResult.x;
	vec3 normal = estimateNormal(hitPoint, marchResult.x);

	if (
		(beautyView > 0.5 && stage > 3.5 && stage < 4.5) ||
		(u_debug > 1.5 && u_debug < 2.5)
	) {
		gl_FragColor = vec4(normal * 0.5 + 0.5, 1.0);
		return;
	}

	vec3 viewDirection = normalize(-rayDirection);
	vec3 linearColour = lightSurface(
		hitPoint,
		normal,
		viewDirection,
		marchResult.y,
		stage
	);

	if (stage >= 8.0) {
		linearColour = finishCathedral(
			linearColour,
			hitPoint,
			normal,
			viewDirection,
			marchResult.y,
			marchResult.x,
			marchResult.w,
			background
		);
	} else if (stage >= 6.0) {
		float teachingFog = 1.0 - exp(-marchResult.x * 0.025);
		linearColour = mix(linearColour, background, teachingFog * 0.65);
	}

	vec3 encodedColour = toneMapAndEncode(linearColour);
	if (stage >= 8.0) encodedColour += stableDither(gl_FragCoord.xy) / 255.0;
	gl_FragColor = vec4(clamp(encodedColour, 0.0, 1.0), 1.0);
}
Stage definitions stages.ts
import fragmentTemplate from './fragment.frag?raw';
import { extractShaderExcerpt } from './source-markers';
import type { RayMarchingStageDefinition, RayMarchingStageId } from './types';

export type RayMarchingStage = RayMarchingStageDefinition & {
	slug: string;
	stage: RayMarchingStageId;
	label: string;
	shortExplanation: string;
	callout: string;
	sourceMarker: string;
	filename: 'fragment.frag';
	language: 'glsl';
	code: string;
};

function stage(
	definition: Omit<
		RayMarchingStage,
		| 'id'
		| 'label'
		| 'summary'
		| 'sourceFilename'
		| 'sourceExcerpt'
		| 'filename'
		| 'language'
		| 'code'
	>
): RayMarchingStage {
	const sourceExcerpt = extractShaderExcerpt(fragmentTemplate, definition.sourceMarker);
	return {
		...definition,
		id: definition.stage,
		label: String(definition.stage).padStart(2, '0'),
		summary: definition.shortExplanation,
		sourceFilename: 'fragment.frag',
		sourceExcerpt,
		filename: 'fragment.frag',
		language: 'glsl',
		code: sourceExcerpt
	};
}

export const rayMarchingStages = [
	stage({
		slug: 'camera-rays',
		stage: 1,
		title: 'Camera rays',
		shortExplanation:
			'Turn each aspect-correct screen coordinate into a direction from one camera.',
		explanation:
			'The camera has an origin and an orthonormal forward, right, and up basis. Focal length weights the forward vector before the screen coordinate is added, so every fragment receives a different direction without moving the underlying rectangle.',
		callout:
			'The RGB view encodes ray direction: both hue and brightness change as the direction changes.',
		sourceMarker: 'camera-rays'
	}),
	stage({
		slug: 'one-distance',
		stage: 2,
		title: 'One distance',
		shortExplanation: 'Ask one exact sphere SDF how far a sample lies from its surface.',
		explanation:
			'For a sphere, length(point − centre) − radius is positive outside, zero on the surface, and negative inside. The stage uses that same running function to produce a deliberately plain silhouette before adding lighting or architecture.',
		callout: 'At this stage the distance is exact Euclidean signed distance.',
		sourceMarker: 'one-distance'
	}),
	stage({
		slug: 'walking-loop',
		stage: 3,
		title: 'The walking loop',
		shortExplanation:
			'Advance by a conservative fraction of the returned distance until a hit or miss.',
		explanation:
			'This is sphere tracing inside the broader family of ray-marching methods. A distance-aware epsilon defines a hit, the finite far clip defines a miss, and the fixed tier budget bounds the work; colour and luminance reveal how much of that budget each ray spends.',
		callout:
			'The 0.8 safety factor matters once composed scene functions are conservative bounds rather than exact SDFs.',
		sourceMarker: 'walking-loop'
	}),
	stage({
		slug: 'surface-direction',
		stage: 4,
		title: 'Surface direction',
		shortExplanation: 'Estimate the distance-field gradient only after the marcher confirms a hit.',
		explanation:
			'Four tetrahedrally arranged distance queries estimate how the field changes around the hit point. Normalising that gradient produces a surface direction for lighting; it is calculated from the field, not fetched from a mesh.',
		callout:
			'RGB encodes the estimated normal components, making discontinuities immediately visible.',
		sourceMarker: 'surface-direction'
	}),
	stage({
		slug: 'constructive-geometry',
		stage: 5,
		title: 'Constructive geometry',
		shortExplanation:
			'Combine a plane, rounded boxes, capped pillars, and a subtracted arch into one bay.',
		explanation:
			'Union chooses the nearer bound; subtraction cuts the inner radius from the outer arch before extrusion. A restrained smooth union joins only same-material column parts, so the material ID remains stable through the blend.',
		callout:
			'The visible surfaces are implicit in mapScene; p5 still rasterises one ordinary host rectangle underneath.',
		sourceMarker: 'constructive-geometry'
	}),
	stage({
		slug: 'fold-space',
		stage: 6,
		title: 'Fold space',
		shortExplanation: 'Reuse one architectural distance question across seven bounded depth cells.',
		explanation:
			'Centred coordinate repetition maps several world-space depths into one local bay without allocating objects. Clamping the cell index keeps the hall finite, generous empty margins avoid modulo-boundary artefacts, and a small rigid per-cell rotation creates a slow impossible twist.',
		callout: 'Only the architecture repeats; the focal orb and floor remain in world space.',
		sourceMarker: 'fold-space'
	}),
	stage({
		slug: 'believable-light',
		stage: 7,
		title: 'Make light believable',
		shortExplanation:
			'Layer material, hemisphere fill, diffuse, restrained highlights, AO, and soft shadow.',
		explanation:
			'Lighting work begins only after a confirmed hit. High and Balanced compile AO and shadow loops with different fixed budgets; Saver replaces both features at compile time, retaining direct and hemispheric light without paying for hidden samples.',
		callout:
			'The wet-looking floor uses specular and Fresnel terms only—there is no reflected second scene.',
		sourceMarker: 'believable-light'
	}),
	stage({
		slug: 'lose-horizon',
		stage: 8,
		title: 'Lose the horizon',
		shortExplanation:
			'Add stable seams, the surface pulse, depth fog, tone mapping, gamma, and dithering.',
		explanation:
			'The pulse compares world-space distance from the orb with a JavaScript-controlled radius and adds a narrow illumination band at confirmed surface hits. Exponential fog conceals the finite far clip; tone mapping, gamma encoding, and static coordinate dithering finish the bounded image without temporal flicker.',
		callout:
			'The pulse changes illumination, not geometry, and is not a physical simulation of light, sound, water, or material motion.',
		sourceMarker: 'lose-horizon'
	})
] as const satisfies readonly RayMarchingStage[];
Quality policy quality.ts
import type { RayMarchingQualityChoice, RayMarchingQualityTier } from './types';

export type RayMarchingQualityProfile = Readonly<{
	tier: RayMarchingQualityTier;
	label: 'High' | 'Balanced' | 'Saver';
	mainSteps: number;
	shadowSteps: number;
	aoSamples: number;
	maxFramebufferPixels: number;
	devicePixelRatioCap: number;
}>;

export const RAY_MARCHING_QUALITY_PROFILES = Object.freeze({
	high: Object.freeze({
		tier: 'high',
		label: 'High',
		mainSteps: 96,
		shadowSteps: 24,
		aoSamples: 5,
		maxFramebufferPixels: 1_350_000,
		devicePixelRatioCap: 1.5
	}),
	balanced: Object.freeze({
		tier: 'balanced',
		label: 'Balanced',
		mainSteps: 72,
		shadowSteps: 14,
		aoSamples: 4,
		maxFramebufferPixels: 720_000,
		devicePixelRatioCap: 1.25
	}),
	saver: Object.freeze({
		tier: 'saver',
		label: 'Saver',
		mainSteps: 48,
		shadowSteps: 0,
		aoSamples: 0,
		maxFramebufferPixels: 360_000,
		devicePixelRatioCap: 1
	})
} as const satisfies Record<RayMarchingQualityTier, RayMarchingQualityProfile>);

export type RayMarchingQualityHints = Readonly<{
	width: number;
	height: number;
	devicePixelRatio: number;
	hardwareConcurrency?: number;
	deviceMemory?: number;
	coarsePointer?: boolean;
	saveData?: boolean;
}>;

export type RayMarchingFramebufferSize = Readonly<{
	cssWidth: number;
	cssHeight: number;
	backingWidth: number;
	backingHeight: number;
	pixelRatio: number;
	pixelCount: number;
	limitedByBudget: boolean;
}>;

export const AUTO_QUALITY_WARMUP_MS = 900;
export const AUTO_QUALITY_POOR_FRAME_MS = 28;
export const AUTO_QUALITY_POOR_WINDOW_MS = 2_400;
export const AUTO_QUALITY_COOLDOWN_MS = 8_000;
export const AUTO_QUALITY_LONG_FRAME_MS = 180;
const MAX_COUNTED_FRAME_MS = 50;

export type QualityFramePhase = 'active' | 'resume' | 'compile' | 'resize' | 'hidden';

export type QualityFrameObservation = Readonly<{
	nowMs: number;
	frameMs: number;
	phase?: QualityFramePhase;
}>;

export type QualityObservationIgnoredReason =
	| 'explicit-choice'
	| 'resume'
	| 'compile'
	| 'resize'
	| 'hidden'
	| 'long-frame'
	| 'invalid-frame'
	| 'non-monotonic';

export type RayMarchingQualityMonitor = Readonly<{
	choice: RayMarchingQualityChoice;
	tier: RayMarchingQualityTier;
	warmupActiveMs: number;
	poorActiveMs: number;
	cooldownUntilMs: number;
	lastObservationMs: number | null;
	downgradeCount: number;
}>;

export type QualityMonitorUpdate = Readonly<{
	state: RayMarchingQualityMonitor;
	downgradedFrom: RayMarchingQualityTier | null;
	downgradedTo: RayMarchingQualityTier | null;
	ignoredReason: QualityObservationIgnoredReason | null;
}>;

function finitePositive(value: number, fallback: number): number {
	return Number.isFinite(value) && value > 0 ? value : fallback;
}

function finiteDimension(value: number): number {
	return Math.max(1, Math.round(finitePositive(value, 1)));
}

export function rayMarchingQualityProfile(tier: RayMarchingQualityTier): RayMarchingQualityProfile {
	return RAY_MARCHING_QUALITY_PROFILES[tier];
}

/**
 * Produces the tier-specific GLSL variant from one inspectable fragment template. Integer feature
 * switches let the GLSL preprocessor remove Saver shadow/AO work instead of merely skipping it at
 * runtime.
 */
export function buildFragmentSource(template: string, tier: RayMarchingQualityTier): string {
	const profile = rayMarchingQualityProfile(tier);
	const replacements = {
		__MAIN_STEPS__: String(profile.mainSteps),
		__SHADOW_STEPS__: String(profile.shadowSteps),
		__AO_SAMPLES__: String(profile.aoSamples),
		__ENABLE_SHADOWS__: profile.shadowSteps > 0 ? '1' : '0',
		__ENABLE_AO__: profile.aoSamples > 0 ? '1' : '0'
	} as const;

	let source = template;
	for (const [token, replacement] of Object.entries(replacements)) {
		source = source.split(token).join(replacement);
	}
	return source;
}

export function chooseInitialRayMarchingQuality(
	choice: RayMarchingQualityChoice,
	hints: RayMarchingQualityHints
): RayMarchingQualityTier {
	if (choice !== 'auto') return choice;

	const shortEdge = Math.min(finiteDimension(hints.width), finiteDimension(hints.height));
	const stronglyConstrained =
		hints.saveData === true ||
		(hints.hardwareConcurrency !== undefined && hints.hardwareConcurrency <= 4) ||
		(hints.deviceMemory !== undefined && hints.deviceMemory <= 4) ||
		(hints.coarsePointer === true && shortEdge <= 480);

	// Auto deliberately never selects High. High is an informed, explicit visitor choice.
	return stronglyConstrained ? 'saver' : 'balanced';
}

/**
 * Resolves the CSS surface and its independent drawing-buffer dimensions. The result preserves
 * orientation and aspect while respecting both the tier DPR cap and its absolute pixel budget.
 */
export function resolveRayMarchingFramebufferSize(
	cssWidthInput: number,
	cssHeightInput: number,
	devicePixelRatioInput: number,
	tier: RayMarchingQualityTier
): RayMarchingFramebufferSize {
	const cssWidth = finiteDimension(cssWidthInput);
	const cssHeight = finiteDimension(cssHeightInput);
	const profile = rayMarchingQualityProfile(tier);
	const devicePixelRatio = finitePositive(devicePixelRatioInput, 1);
	const requestedPixelRatio = Math.min(devicePixelRatio, profile.devicePixelRatioCap);
	const cssPixels = cssWidth * cssHeight;
	const budgetPixelRatio = Math.sqrt(profile.maxFramebufferPixels / cssPixels);
	const pixelRatio = Math.max(
		1 / Math.max(cssWidth, cssHeight),
		Math.min(requestedPixelRatio, budgetPixelRatio)
	);
	const backingWidth = Math.max(1, Math.floor(cssWidth * pixelRatio));
	const backingHeight = Math.max(1, Math.floor(cssHeight * pixelRatio));
	const pixelCount = backingWidth * backingHeight;

	return Object.freeze({
		cssWidth,
		cssHeight,
		backingWidth,
		backingHeight,
		pixelRatio,
		pixelCount,
		limitedByBudget: budgetPixelRatio < requestedPixelRatio
	});
}

export function nextLowerRayMarchingQuality(tier: RayMarchingQualityTier): RayMarchingQualityTier {
	return tier === 'high' ? 'balanced' : 'saver';
}

export function createRayMarchingQualityMonitor(options: {
	choice?: RayMarchingQualityChoice;
	hints: RayMarchingQualityHints;
	initialTier?: RayMarchingQualityTier;
	nowMs?: number;
}): RayMarchingQualityMonitor {
	const choice = options.choice ?? 'auto';
	const selectedTier = chooseInitialRayMarchingQuality(choice, options.hints);
	const tier = choice === 'auto' && options.initialTier ? options.initialTier : selectedTier;
	const nowMs = Number.isFinite(options.nowMs) && (options.nowMs ?? 0) >= 0 ? options.nowMs! : 0;

	return Object.freeze({
		choice,
		tier,
		warmupActiveMs: 0,
		poorActiveMs: 0,
		cooldownUntilMs: nowMs,
		lastObservationMs: null,
		downgradeCount: 0
	});
}

function ignoredUpdate(
	state: RayMarchingQualityMonitor,
	observation: QualityFrameObservation,
	reason: QualityObservationIgnoredReason
): QualityMonitorUpdate {
	const validNow = Number.isFinite(observation.nowMs) && observation.nowMs >= 0;
	return Object.freeze({
		state: Object.freeze({
			...state,
			poorActiveMs: 0,
			lastObservationMs: validNow ? observation.nowMs : null
		}),
		downgradedFrom: null,
		downgradedTo: null,
		ignoredReason: reason
	});
}

/**
 * Records one representative active frame. Compilation, resize, visibility and resume frames are
 * explicit discontinuities: they reset the persistence window and never influence adaptation.
 */
export function observeRayMarchingQualityFrame(
	state: RayMarchingQualityMonitor,
	observation: QualityFrameObservation
): QualityMonitorUpdate {
	const phase = observation.phase ?? 'active';
	if (state.choice !== 'auto') return ignoredUpdate(state, observation, 'explicit-choice');
	if (phase !== 'active') return ignoredUpdate(state, observation, phase);
	if (
		!Number.isFinite(observation.nowMs) ||
		observation.nowMs < 0 ||
		!Number.isFinite(observation.frameMs) ||
		observation.frameMs <= 0
	) {
		return ignoredUpdate(state, observation, 'invalid-frame');
	}
	if (state.lastObservationMs !== null && observation.nowMs < state.lastObservationMs) {
		return ignoredUpdate(state, observation, 'non-monotonic');
	}
	if (observation.frameMs >= AUTO_QUALITY_LONG_FRAME_MS) {
		return ignoredUpdate(state, observation, 'long-frame');
	}

	const countedFrameMs = Math.min(observation.frameMs, MAX_COUNTED_FRAME_MS);
	if (state.warmupActiveMs < AUTO_QUALITY_WARMUP_MS) {
		return Object.freeze({
			state: Object.freeze({
				...state,
				warmupActiveMs: Math.min(AUTO_QUALITY_WARMUP_MS, state.warmupActiveMs + countedFrameMs),
				poorActiveMs: 0,
				lastObservationMs: observation.nowMs
			}),
			downgradedFrom: null,
			downgradedTo: null,
			ignoredReason: null
		});
	}

	if (observation.nowMs < state.cooldownUntilMs) {
		return Object.freeze({
			state: Object.freeze({
				...state,
				poorActiveMs: 0,
				lastObservationMs: observation.nowMs
			}),
			downgradedFrom: null,
			downgradedTo: null,
			ignoredReason: null
		});
	}

	const poorActiveMs =
		observation.frameMs >= AUTO_QUALITY_POOR_FRAME_MS ? state.poorActiveMs + countedFrameMs : 0;
	const shouldDowngrade = poorActiveMs >= AUTO_QUALITY_POOR_WINDOW_MS && state.tier !== 'saver';
	const tier = shouldDowngrade ? nextLowerRayMarchingQuality(state.tier) : state.tier;

	return Object.freeze({
		state: Object.freeze({
			...state,
			tier,
			poorActiveMs: shouldDowngrade ? 0 : poorActiveMs,
			cooldownUntilMs: shouldDowngrade
				? observation.nowMs + AUTO_QUALITY_COOLDOWN_MS
				: state.cooldownUntilMs,
			lastObservationMs: observation.nowMs,
			downgradeCount: state.downgradeCount + (shouldDowngrade ? 1 : 0)
		}),
		downgradedFrom: shouldDowngrade ? state.tier : null,
		downgradedTo: shouldDowngrade ? tier : null,
		ignoredReason: null
	});
}

The Cathedral of Distance. A symmetrical hall of dark arches recedes into blue fog around a floating black orb. A narrow cyan-and-gold ring expands over the floor, columns, and archways. The visible 3D surfaces are procedural and implicit; p5 still rasterises one host rectangle underneath.
Audio article

Uses the speech voice supplied by your browser or device.

Quick Answer

A ray-marched picture asks one question for each fragment: if a ray leaves the camera in this direction, what does it meet? A distance function returns a safe step towards the nearest surface. The ray advances and asks again until a near-zero answer marks a hit, or the finite budget marks a miss. The exhibit repeats that conversation inside one fragment shader, then estimates a normal and adds deliberately approximate light and fog. The browser still rasterises a full-screen plane; the apparent columns, arches, floor and orb are implicit surfaces calculated in the shader.

Key Terms

  • Camera ray: an origin and normalised direction for one line of sight.
  • Ray marching: methods that sample repeatedly along a ray.
  • Sphere tracing: ray marching with steps supplied by a distance bound.
  • Signed-distance function (SDF): Euclidean distance to a surface, signed to distinguish outside from inside.
  • Distance estimator or bound: a conservative distance-like value used when an operation no longer preserves an exact SDF.
  • Hit epsilon: the distance below which the marcher accepts a hit.
  • Surface normal: here, an estimated direction perpendicular to an implicit surface, not stored mesh data.
  • Constructive geometry: union, intersection or subtraction made by combining distances.

From a luminous field to a solid-looking world

In Hello, Fragment: Your First Shader from Scratch, every fragment turned position, time, pointer and resolution into colour. Its rings were a procedural field: no particle crossed the canvas and no water equation was solved.

This sequel keeps that honest little programme and changes its question. Instead of asking, “What colour belongs at this coordinate?”, each fragment asks, “What lies along the line from a camera through this coordinate?”

Think of the marcher as a tiny postman. At each address it asks, “How far may I travel without crossing a surface?” The distance field writes back. It takes that safe step and asks again. A tiny reply means the letter has reached a wall, floor, column or orb. Repeated across the view, those deliveries assemble the Cathedral of Distance.

The metaphor is useful because it names the conversation. The equations tell us precisely how the postman walks.

Why a flat rectangle can depict an implicit 3D surface

WebGL does not invoke a fragment shader in a vacuum. The host supplies and rasterises a plane covering the view, and the fragment shader runs for the resulting fragments. Here one fragment usually contributes colour at one framebuffer location, but a fragment and a physical display pixel are not universally identical.

None of the visible architecture is sent as triangles. mapScene instead returns the nearest distance or conservative bound and a stable material identifier for any 3D point. Camera, marcher and lighting code interrogate that function. This is a rasterised rectangle depicting implicit 3D surfaces, not a world without host geometry.

Give every fragment a camera ray

Let the camera origin be ro\mathbf{r}_o and its normalised viewing direction for one fragment be rd\mathbf{r}_d. Every point along that ray is

p(t)=ro+trd,\mathbf{p}(t)=\mathbf{r}_o+t\mathbf{r}_d,

where tt is accumulated travel distance. At t=0t=0, the point is at the camera. Increasing tt moves it forwards.

The shader constructs rd\mathbf{r}_d from a camera basis. Forward points to the target. Right is perpendicular to forward and world up; up is perpendicular to both. Centred, aspect-correct fragment coordinates choose amounts of right and up, while focal length controls how strongly forward dominates. Normalising the sum produces a ray direction.

Without aspect correction against the backing buffer’s real dimensions, a sphere becomes an egg. Pointer mapping separately begins with the canvas’s CSS rectangle and accounts for the shader’s bottom-up coordinates.

Signed distance: outside, surface, inside

For a sphere centred at c\mathbf{c} with radius rr, the exact signed distance is

D(p)=pcr.D(\mathbf{p})=\lVert\mathbf{p}-\mathbf{c}\rVert-r.

Outside, the answer is positive; on the surface, zero; inside, negative. The sign describes both a boundary and which side contains solid material.

Excerpt from the running fragment shader:

float sdSphere(vec3 point, vec3 centre, float radius) {
  return length(point - centre) - radius;
}

The isolated sphere is exact, but that word does not extend casually to the whole cathedral. Smooth blends, displacement, non-uniform transforms and deformations may produce only an estimator or bound. The final scene therefore uses a conservative step multiplier: “distance-like and safe” is the honest claim for its composed architecture.

Sphere tracing: how the postman walks

At iteration ii, the marcher samples its current point:

di=D(p(ti)).d_i=D\bigl(\mathbf{p}(t_i)\bigr).

It then advances by

ti+1=ti+sdi,t_{i+1}=t_i+s\,d_i,

where ss is a conservative safety multiplier: here, s=0.8s=0.8. An exact distance permits the whole step. The smaller multiplier leaves margin for blends, deformations and floating-point arithmetic where the composed field is only a bound.

Excerpt from the running fragment shader:

vec4 marchScene(vec3 rayOrigin, vec3 rayDirection) {
  float distanceTravelled = NEAR_CLIP;
  float material = -1.0;
  float normalizedSteps = 1.0;
  float glowAccumulator = 0.0;

  for (int stepIndex = 0; stepIndex < RM_MAIN_STEPS; stepIndex++) {
    vec3 samplePoint = rayOrigin + rayDirection * distanceTravelled;
    vec2 sceneSample = mapScene(samplePoint);
    float epsilon = distanceAwareEpsilon(distanceTravelled);

    if (
      currentStage() >= 8.0 &&
      sceneSample.y > MATERIAL_STONE + 0.1 &&
      sceneSample.y < MATERIAL_ORB - 0.1
    ) {
      float nearbyGlow = max(0.0, 0.055 - abs(sceneSample.x)) * 0.045;
      glowAccumulator = min(0.16, glowAccumulator + nearbyGlow);
    }

    if (sceneSample.x < epsilon) {
      material = sceneSample.y;
      normalizedSteps = float(stepIndex + 1) / float(RM_MAIN_STEPS);
      break;
    }

    distanceTravelled += max(sceneSample.x * SAFETY_FACTOR, MINIMUM_STEP);
    if (distanceTravelled > FAR_CLIP) {
      distanceTravelled = FAR_CLIP + 1.0;
      normalizedSteps = float(stepIndex + 1) / float(RM_MAIN_STEPS);
      break;
    }
  }

  return vec4(distanceTravelled, material, normalizedSteps, glowAccumulator);
}

The constant ceiling keeps GLSL ES 1.00 portable. A hit needs a small distance within the finite range; the far clip or quality tier’s step budget produces a miss. Fog hides that boundary instead of pretending calculation continues forever.

The diagram shows the same process in a 2D slice. Each circle is the returned safe distance; its controls select a step, while the table gives accumulated tt, returned distance and hit or miss state without relying on motion or colour.

Sphere tracing, one reply at a time

How far may the ray walk safely?

The circle at each sample has the radius returned by the signed-distance function. This diagram advances by 0.82 times that value, leaving a conservative margin until the returned distance falls below 0.02.

At p0, the accumulated distance is 0.000. The field returns 3.839. The next safe advance is 3.148.

Sphere-tracing samples with accumulated ray distance, returned distance and hit state
SteptReturned dState
00.0003.839Walking
13.1480.691Walking
23.7150.124Walking
33.8170.022Walking
43.8350.004Hit
The drawing is a two-dimensional slice through one exact sphere SDF. The Cathedral uses the same walking rule with a conservative bound for its composed scene.

John C. Hart named and analysed this distance-guided method as sphere tracing. Ray marching is the wider family; sphere tracing is the particular member whose field supplies the step bound.

Build the cathedral in eight views

The exhibit starts at stage eight because the reward should arrive before the scaffolding. Choose Build it to make the same canvas and shader reveal the construction. An integer stage uniform changes the rendering mode; the page does not keep eight WebGL contexts burning below the prose.

1. Camera rays

The first view encodes ray direction as RGB. There is no march or surface. Dragging changes the camera basis and colours. Missing aspect correction bends the bands; a flipped vertical coordinate makes pointer and camera disagree.

2. One distance

The sphere SDF enters alone, distinguishing positive space, the zero boundary and negative interior before rendering a plain silhouette. This is the last simple object, not the destination. The final orb uses the same primitive outside the repeated architecture, keeping one stable focal object.

3. The walking loop

Now marchScene repeatedly calls mapScene. Colour and luminance encode normalised iteration count: broad empty regions are cheap; grazing angles ask more questions. Maximum steps limit work, the far clip ends fruitless journeys, the hit epsilon defines “near enough”, and a minimum step prevents vanishing progress. This is a work map, not a benchmark.

4. Surface direction

Only after a hit does the shader estimate a normal and map its signed components to RGB. Curves change smoothly; planes stay nearly constant. No visible mesh stores these directions.

5. Constructive geometry

A plane becomes the floor; rounded boxes and capped cylinders form bases, columns and beams. An extruded 2D arch profile is opened by subtraction. Union joins pieces and one restrained smooth union softens selected joints. Simple lighting waits until silhouettes read clearly.

Excerpt from the running fragment shader:

vec2 mapScene(vec3 point) {
  float stage = currentStage();
  if (stage < 5.0) {
    return vec2(sdSphere(point, vec3(0.0, 1.55, -4.4), 1.0), MATERIAL_STONE);
  }

  vec2 result = vec2(sdPlane(point), MATERIAL_FLOOR);
  vec3 architecturalPoint = point;
  if (stage >= 6.0) {
    architecturalPoint = foldArchitecturalSpace(point);
  } else {
    architecturalPoint.z += 4.5;
  }
  result = nearer(result, mapBay(architecturalPoint, stage >= 8.0 ? 1.0 : 0.0));

  if (stage >= 6.0) {
    float orb = sdSphere(point, ORB_POSITION, 0.62);
    result = nearer(result, vec2(orb, MATERIAL_ORB));
  }

  if (stage >= 8.0) {
    float leftAisleSeam = sdRoundedBox(
      point - vec3(-1.28, 0.025, -12.8),
      vec3(0.035, 0.025, 11.8),
      0.012
    );
    float rightAisleSeam = sdRoundedBox(
      point - vec3(1.28, 0.025, -12.8),
      vec3(0.035, 0.025, 11.8),
      0.012
    );
    result = nearer(result, vec2(leftAisleSeam, MATERIAL_CYAN));
    result = nearer(result, vec2(rightAisleSeam, MATERIAL_AMBER));
  }

  return result;
}

The material ID travels beside distance, letting floor, stone, seams and orb receive stable treatments. Smooth blends choose IDs deliberately to prevent boundary flicker.

6. Fold space

The architectural coordinate repeats through bounded depth cells. It folds many world positions into one local question rather than allocating columns. A small per-cell rotation adds the slow impossible twist. The aisle remains open, the orb is separate, and solids stay away from modulo boundaries to avoid false seams.

7. Make light believable

Material colour gains hemispheric fill, diffuse light and restrained specular/Fresnel. AO makes a few short post-hit probes. High and Balanced trace a bounded soft-shadow ray from a normal-offset point; Saver compiles both the shadow and AO loops out. These are depth cues, not global illumination or a physical wet-floor renderer.

8. Lose the horizon

Cyan and amber emission reveals selected seams. Exponential fog merges distant geometry into a blue-black procedural background before the far clip shows. Tone mapping contains the pulse, gamma encoding prepares display colour, and coordinate-stable dither reduces banding without flicker. This is the Cathedral of Distance.

Estimating a direction that was never stored

An implicit surface has no stored normal. Near a hit, its field changes fastest outwards, so nearby samples can estimate and normalise that gradient.

Excerpt from the running fragment shader:

vec3 estimateNormal(vec3 point, float distanceTravelled) {
  float epsilon = max(0.0015, distanceAwareEpsilon(distanceTravelled) * 0.72);
  vec2 offset = vec2(epsilon, -epsilon);
  return normalize(
    offset.xyy * mapScene(point + offset.xyy).x +
    offset.yyx * mapScene(point + offset.yyx).x +
    offset.yxy * mapScene(point + offset.yxy).x +
    offset.xxx * mapScene(point + offset.xxx).x
  );
}

This tetrahedral estimate uses four post-hit queries. Too small an epsilon shimmers under limited precision; too large an epsilon erases nearby detail.

Combining, subtracting and repeating questions

For two distances, min(a, b) selects their union; max(a, -b) subtracts b from a. A smooth minimum rounds a joint but may cease to be exact, so smooth union stays rare and the safety factor remains 0.8.

Rigidly rotating a query preserves distance. Centred repetition reuses a primitive without copied geometry when cells are bounded and features avoid fold boundaries. The cathedral transforms questions, not a warehouse of columns.

Light, shadow, occlusion, emission and fog

The shading order is intentionally readable:

material → ambient/hemisphere → diffuse → restrained specular/Fresnel → AO → shadow → emission/pulse → fog → tone map → gamma

Ambient and hemispheric fill rescue unlit faces. Diffuse light follows the normal. Narrow specular and Fresnel terms make the floor look polished without tracing a reflection. AO darkens cramped neighbourhoods; soft shadow measures a ray’s clearance towards the light. Emission is added, then fog spends distant contrast until the bounded hall feels vast.

Every query costs time. Misses skip normal, AO and shadow work; Saver removes expensive branches rather than merely blurring them.

The signal carried from the first shader

A click, tap, Pulse button or focused P starts a JavaScript timer. The host converts pulse age to u_pulseRadius; the SDF remains untouched. At each hit, a narrow difference between world-space distance from the orb and that radius becomes emission.

Excerpt from the running fragment shader:

float pulseDistance = length(point - ORB_POSITION);
float pulseOffset = (pulseDistance - max(u_pulseRadius, 0.0)) / 0.085;
float pulseBand = exp(-pulseOffset * pulseOffset) * clamp(u_pulseStrength, 0.0, 1.0);
float pulseColourMix = 0.5 + 0.5 * sin(point.x * 2.35 + point.z * 0.62);
vec3 pulseColour = mix(cyanEmission, amberEmission, pulseColourMix * 0.78);
linearColour += pulseColour * pulseBand *
  (0.72 + 0.28 * max(dot(normal, viewDirection), 0.0));

Every surface tests the band at its own hit position, so it crosses floor, columns, arches and ornament. It neither deforms mapScene nor starts another march, and simulates no physical light, sound, water or material wave. It is procedural illumination carrying the first article’s motif into 3D.

Drag rotates without firing a pulse: release counts as a click only below a movement threshold. Clamped pitch and yaw prevent tumbles and floor entry.

Break it deliberately

The debug views become clearer after these deliberate failures.

Too few march steps

Rays stop before distant surfaces. Rear arches vanish, grazing edges tear, and fog cannot restore unsampled information. Lower tiers also remove shading and framebuffer demand so their geometry budget stays useful.

A hit epsilon that is too large

The marcher accepts points away from surfaces: seams swell, corners round and shapes fuse. A distance-aware epsilon may grow gently as distant geometry covers fewer pixels; an indiscriminately large one is different.

Unsafe overstepping

Stepping beyond a valid bound can leap through columns and flash holes. The 0.8 multiplier protects composed bounds, while details stay thicker than the hit epsilon.

Starting the camera inside geometry

A negative first sample can become a false hit with a wrong normal. Camera clamps, the clear aisle and a near clip keep the origin outside every solid.

Modulo seams

Naive repetition is discontinuous at cell boundaries, where a folded copy can become falsely nearest. Keeping architecture inside each cell stops the seam winning the query.

Shadow acne

A shadow ray launched on a surface sees what it just left and produces dark freckles. A small normal offset prevents this; too much detaches shadows.

What this visualization does—and does not—claim

This is ray marching; its distance-guided loop is sphere tracing. It is not path tracing, polygonal ray tracing, global illumination, physical glass, fluid simulation or a real building. No model, photograph, texture, cube map or recording supplies the world. p5.js manages WebGL and the host plane, not hidden architectural meshes.

Some primitives begin as exact SDFs. Composed mapScene is a distance estimator or conservative bound where operations break exactness. AO, shadows, wet highlights, fog, tone mapping and pulse are perceptual devices, not physical completeness.

Why a phone may choose less work

Cost multiplies: fragments × primary steps × post-hit AO and shadow samples. A dense phone screen can request more fragments than a laptop with less sustained GPU headroom; CSS size hides that workload.

The exhibit starts Balanced. Auto measures active frames after warm-up, ignores compilation, resize, background and resume spikes, and may downgrade once after persistent trouble. It never silently climbs to High or oscillates.

TierMain stepsShadow stepsAO samplesApproximate framebuffer cap
High962451.35 million pixels
Balanced72144720 thousand pixels
Saver4800360 thousand pixels

Saver removes the shadow and AO loops. saveData may retain the poster until explicitly loaded. Drawing stops while paused, offscreen or hidden and resumes only when still wanted. Reduced motion begins at a deterministic still, uses no camera drift or automatic pulse, and awaits Start; direct controls redraw one frame.

These are workload policies, not a frame-rate promise. Headless software rendering verifies behaviour, not phone performance.

Experiments to try

  1. Move between Camera rays, March cost and Normals. Which colours show direction, and which show work?
  2. In March cost, compare a column’s grazing edge with a ray aimed at the floor.
  3. Reduce fog until you find the finite horizon it normally hides.
  4. Compare Balanced with Saver for missing shadows and contact darkening, not only sharpness.
  5. Pause and adjust the view: one static frame should redraw without continuous motion.
  6. Launch a pulse in Distance bands. Illumination moves; geometry does not.
  7. Enter fullscreen by keyboard, use arrows and Home, leave with Escape, and check focus returns.
  8. Add ?webgl=off: poster, prose, diagram and source should remain useful.

Frequently asked questions

What is ray marching?

Ray marching is a family of rendering methods that advance sample positions along a ray and evaluate something at each position. The advance may be fixed, adaptive or supplied by a field. The term alone does not guarantee an SDF, a particular lighting model or physical accuracy.

What is sphere tracing?

Sphere tracing is distance-guided ray marching. At each point, a distance bound defines a sphere known not to cross the nearest surface, so the ray may advance by that radius, usually with a conservative multiplier when the field is only an estimator. John C. Hart formalised the method for implicit surfaces.

How is this different from ordinary ray tracing?

Conventional polygonal ray tracing usually asks for intersections between a ray and explicit triangles or analytic primitives. This exhibit repeatedly samples an implicit distance field instead. Both send rays through a scene, but their geometry representation and intersection work differ. Neither term implies path tracing or multiple light bounces.

What is a signed distance field?

An exact SDF returns Euclidean distance to the nearest surface, positive on one side and negative on the other. Zero identifies the boundary. Some composed functions in practical graphics preserve only a conservative distance estimate or bound, so the article names that distinction rather than calling every result exact.

Why does the browser still draw a rectangle?

A fragment shader needs rasterised host geometry to create fragments. p5.js draws one full-screen plane or rectangle; the fragment shader calculates the apparent three-dimensional surfaces inside those fragments. The claim is that the visible architecture is implicit, not that the graphics pipeline has no triangles at all.

Why can the scene run slowly on a phone?

Every framebuffer fragment may call mapScene dozens of times, and a surface hit can add normal, AO and shadow queries. High device-pixel ratio multiplies fragments. Saver reduces framebuffer pixels and removes shader work, while offscreen, hidden and paused states stop continuous drawing.

Does the scene use Three.js, a model or a texture?

No. It uses the site’s installed p5.js WebGL host and GLSL shaders. The hall, orb, materials, seams, background and dither are procedural. There is no imported mesh, model, photograph, cube map, stock texture or CSS filter pretending to be the scene.

What happens when WebGL is unavailable?

The server-rendered poster remains in its reserved figure, followed by the full article, ray-step table and source material. A concise fallback explains that the live shader could not start and offers Retry when retrying could help. Context loss similarly keeps the poster and application state while GPU resources are rebuilt.

Sources and further reading

The first article taught the fragment to turn shared numbers into colour. This one gave that fragment a postbox, a direction and permission to keep asking the world how far it may safely go.

Word Cloud

Word cloud for The Pixel Has a Postbox: Build a 3D World with Ray Marching