Simulation Launch Checklist
A working checklist every contributor should run through before a new simulation ships on this site: frame-budget math, DPR and resize handling, WebGL context-loss recovery, memory disposal, accessibility, cross-device QA, and the metadata that makes a page discoverable. Treat every unchecked box below as a merge blocker.
1. Performance Budget
Before writing a single shader, decide the frame budget. On a 60 Hz display you have 16.6 ms per frame; on a 90 Hz mobile panel that shrinks to 11.1 ms. The renderer, your simulation step, and the browser's own compositing all share that window.
60 fps → 16.66 ms 90 fps → 11.11 ms 120 fps → 8.33 ms
Budget split (rule of thumb for a single scene):
JS simulation step ≤ 30% of T_frame
GPU draw + shading ≤ 50% of T_frame
Headroom for GC / compositor / input ≥ 20% of T_frame
Draw-call cost estimate:
T_gpu ≈ n_draws · c_overhead + Σ(triangles_i / throughput)
where c_overhead ≈ 0.01–0.05 ms per draw call on integrated GPUs
- Profile with Chrome DevTools Performance panel on a throttled (4× CPU slowdown) mid-range device, not just your dev machine.
-
Keep draw calls under ~150 for a single scene; merge geometries or
use
InstancedMeshfor repeated objects instead of one mesh per instance. - Target ≤ 500k visible triangles on desktop, ≤ 150k on mobile, unless the simulation's entire point is triangle count (say so in the description if it is).
-
Cap
renderer.setPixelRatio()at 2 even on devices reporting higherdevicePixelRatio— diminishing visual return, real GPU cost. - If the simulation runs a physics step independently of render (fixed timestep), verify it degrades gracefully under frame drops instead of spiralling (cap the accumulator, don't let it try to "catch up" forever).
2. Resize and Pixel-Ratio Handling
Every simulation must respond correctly to window resize, container resize (if embedded in a non-fullscreen panel), and orientation change on mobile — without leaking listeners or stretching the render target.
function setupResize(renderer, camera, container) {
const resize = () => {
const width = container.clientWidth;
const height = container.clientHeight;
const dpr = Math.min(window.devicePixelRatio, 2);
renderer.setPixelRatio(dpr);
renderer.setSize(width, height, false); // false = don't touch CSS size
camera.aspect = width / height;
camera.updateProjectionMatrix();
};
const ro = new ResizeObserver(resize);
ro.observe(container);
resize(); // run once on init
return () => ro.disconnect(); // cleanup handle
}
-
Use
ResizeObserveron the container element, not a globalwindow.resizelistener — it also fires for sidebar/panel layout changes that don't resize the window. -
Always pass
falseas the third argument tosetSize()so three.js doesn't force inline CSS width/height that fights your stylesheet. - Debounce or rAF-throttle the resize handler if it triggers expensive work (render-target recreation, post-processing chain rebuild).
- Return and call a disconnect/cleanup function when the simulation page is torn down (SPA navigation, or before re-init in dev).
- Test orientation change on an actual phone — portrait ↔ landscape must not leave a stale aspect ratio for one frame that snaps visibly.
3. WebGL Context-Loss Recovery
Mobile GPUs and background tabs reclaim WebGL contexts under memory pressure. Every simulation must survive this without a white screen or console spam — the user just sees the scene reappear.
canvas.addEventListener('webglcontextlost', (event) => {
event.preventDefault(); // required, or the context won't be restorable
cancelAnimationFrame(rafHandle);
console.warn('[sim] WebGL context lost — pausing render loop');
}, false);
canvas.addEventListener('webglcontextrestored', () => {
// Textures, geometries, and programs must be re-uploaded —
// three.js does this automatically for objects still in the scene graph,
// but any manually-cached GL resources need manual re-init here.
initRenderTargets();
renderLoop();
}, false);
-
Call
event.preventDefault()in thewebglcontextlosthandler — without it the browser treats the context as permanently dead. -
Stop the render loop (
cancelAnimationFrame) immediately on loss to avoid a flood of GL errors in the console. -
Re-create any render targets, FBOs, or GPU-side buffers you
manage outside three.js's own tracked resources on
webglcontextrestored. -
Force a test with the
WEBGL_lose_contextextension in DevTools console:gl.getExtension('WEBGL_lose_context').loseContext(). - If recovery genuinely fails (some very old iOS Safari versions), show a friendly "reload to continue" message instead of a frozen frame.
4. Memory and Disposal
Geometries, materials, and textures hold GPU buffers that are not garbage-collected by simply dropping JS references — they must be explicitly disposed, or repeated navigation to the same simulation leaks VRAM until the tab crashes.
function disposeScene(root) {
root.traverse((obj) => {
if (obj.geometry) obj.geometry.dispose();
if (obj.material) {
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
for (const mat of mats) {
for (const key in mat) {
const value = mat[key];
if (value && value.isTexture) value.dispose();
}
mat.dispose();
}
}
});
}
// On teardown / route change:
cancelAnimationFrame(rafHandle);
disposeScene(scene);
renderer.dispose();
renderer.forceContextLoss();
controls?.dispose?.();
- Dispose geometry, material, and every texture on that material (map, normalMap, roughnessMap, envMap, etc.) — disposing the material alone does not free its textures.
-
Call
renderer.dispose()andrenderer.forceContextLoss()when a simulation is unmounted, especially in a single-page navigation context. -
Dispose
OrbitControls/custom controls that attach their own document-level event listeners. -
Check
renderer.info.memory(geometries,textures) before and after a mount/unmount cycle in DevTools — the counts must return to zero. - Avoid creating new geometries/materials inside the render loop — allocate once, mutate attributes/uniforms per frame instead.
WebGLRenderTarget) used for
post-processing passes — these hold full-resolution GPU textures and
are easy to miss because they never appear in the visible scene
graph.
5. Input and Interaction
-
Use Pointer Events (
pointerdown/pointermove/pointerup) instead of separate mouse and touch handlers — one code path for both. -
Set
touch-action: noneon the canvas when the simulation captures drag gestures, so the browser doesn't also try to scroll or zoom the page underneath your interaction. - Support keyboard alternatives for any drag-only interaction (arrow keys to nudge a value, Tab to reach controls) where feasible.
-
Debounce raycasting on
pointermoveto at most once per animation frame — never run a raycast synchronously on every fired DOM event. - Clamp orbit/zoom controls to sane bounds (min/max distance, polar angle) so users can't scroll the camera inside geometry or to infinity.
6. Accessibility
-
Respect
prefers-reduced-motion: pause continuous camera auto-rotation or idle animation by default when the media query matches, with an explicit opt-in control. -
Give the canvas an accessible name (
aria-labelor an adjacent<h1>) describing what the simulation shows, since canvas content is invisible to screen readers. -
Every UI control outside the canvas (sliders, toggles, presets)
must be reachable and operable by keyboard, with visible focus
states —
outline: nonewithout a replacement is a blocker. - Maintain WCAG AA contrast (4.5:1 for body text, 3:1 for large text/UI) for all overlay UI text against the simulation background.
- Provide a static fallback (description text or a poster image) for browsers/devices where WebGL is unavailable — never a blank page.
where L1 = relative luminance of the lighter colour, L2 = of the darker, and L1 ≥ L2
WCAG AA: CR ≥ 4.5 (normal text), CR ≥ 3.0 (large text ≥ 18pt or 14pt bold)
7. Cross-Browser and Device QA
Manually verify the simulation on the matrix below before merging. Automated Lighthouse/CI checks catch regressions, but they do not catch broken drag interactions or context-loss bugs.
| Target | Why it matters | Watch for |
|---|---|---|
| Chrome / Edge (desktop) | Largest desktop share, best DevTools profiling | Baseline — should be flawless |
| Safari (desktop, macOS) | Different WebGL/shader compiler, stricter power limits | Shader compile errors, colour-space shifts |
| Safari (iOS) | WebKit-only engine on iPhone/iPad, tight memory ceiling | Context loss on backgrounding, touch-scroll conflicts |
| Chrome (Android, mid-tier) | Represents the median real-world device, not your dev laptop | Frame rate, thermal throttling after ~2 min |
| Firefox (desktop) | Different WebGL driver stack | Extension support gaps, float-texture precision |
| Narrow viewport (<380px width) | Small phones, split-screen layouts | Overlapping UI, unreadable overlay text |
- Test with the browser tab backgrounded and restored (context loss, rAF pause/resume) on at least one mobile browser.
- Test with the OS "reduce motion" setting enabled, not just the media query in isolation.
- Verify there are no uncaught console errors or warnings on load and during interaction.
- Check Lighthouse mobile performance score — flag anything below 70 for follow-up, don't silently ship it.
8. SEO and Metadata
Every new page ships with correct, unique metadata — copy-pasted boilerplate with the wrong title/description is worse than none.
-
Unique
<title>andmeta name="description", both under ~60/155 characters respectively, describing this specific simulation or reference. -
rel="canonical"pointing at the page's own EN URL, plushreflangalternates foren,uk, andx-default. -
Open Graph (
og:title,og:description,og:type,og:image) and a matching Twitter card so shared links render correctly. -
Structured data (
TechArticle,Article, orLearningResourceJSON-LD) with a realdatePublished— don't leave a placeholder date. -
A
BreadcrumbListJSON-LD block matching the visible breadcrumb nav. - Add the new page to the relevant index/listing page and sitemap entry once content review is complete — this reference intentionally does not touch those shared files.
9. Final Pre-Merge Checklist
A condensed version to paste into a pull request description. If any item is not applicable, say so explicitly rather than omitting it.
// Simulation launch checklist — pre-merge
const checklist = [
'Holds target fps on a mid-tier mobile device',
'ResizeObserver + orientation change handled, no CSS size fight',
'devicePixelRatio capped at 2',
'webglcontextlost / webglcontextrestored handled + tested',
'dispose() called for geometry, material, textures, render targets',
'renderer.dispose() + forceContextLoss() on unmount',
'Pointer Events used; touch-action set correctly',
'prefers-reduced-motion respected',
'Canvas has an accessible name; overlay UI is keyboard-operable',
'WCAG AA contrast on overlay text',
'No console errors on load or interaction',
'Title, description, canonical, hreflang, OG, Twitter, JSON-LD all set',
'Manually tested on Chrome, Safari desktop, Safari iOS, Android Chrome',
];
console.assert(checklist.every(Boolean), 'Fix before merge');