Reference · Contributor Guide · Three.js · WebGL
📅 July 2026 ⏱ ≈ 16 min 🎯 Intermediate

New Simulation Template

Every simulation on mysimulator.uk follows the same skeleton: a self-contained folder with an index.html, a Three.js renderer bootstrap, a resize-safe render loop, and a metadata entry that wires it into the homepage grid, search index, and article links. This reference is the canonical scaffold to copy when starting a new simulation from scratch.

1. File & Folder Layout

Simulations live at the site root, one folder per simulation id, matching the pattern used by existing entries such as /fluid/index.html and /galaxy/index.html. A new simulation called, say, vortex should look like this:

/vortex/
  index.html        // self-contained: markup + inline <style> + inline <script type="module">
  thumb.jpg          // optional 1200×630 preview used for og:image / grid card

Simulations do not get their own build step — the site has no bundler. Import Three.js from the shared CDN import map declared in shared/components.js, exactly like the existing simulations, so the module resolves the same way on every page.

Naming: use a short, lowercase, hyphen-free id (vortex, not Vortex-Field). It becomes the folder name, the JSON id, and part of the canonical URL — keep all three in sync.

3. Renderer Bootstrap

Every simulation initializes the same three pieces — scene, camera, renderer — before touching any simulation-specific logic. Keep the renderer options identical across simulations so performance behaviour stays predictable:

import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";

const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x000000, 0.02);

const camera = new THREE.PerspectiveCamera(
  60, innerWidth / innerHeight, 0.1, 2000
);
camera.position.set(0, 8, 24);

const renderer = new THREE.WebGLRenderer({
  antialias: true,
  powerPreference: "high-performance",
});
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); // cap DPR — see section 8
document.getElementById("canvas-holder").appendChild(renderer.domElement);

const controls = new THREE.OrbitControls
  ? new OrbitControls(camera, renderer.domElement)
  : null;
if (controls) { controls.enableDamping = true; controls.dampingFactor = 0.06; }
Power preference: always request "high-performance". On hybrid-GPU laptops this steers the browser toward the discrete GPU, which matters for particle-heavy simulations.

4. Render Loop & Resize Handling

Use a single requestAnimationFrame loop driven by a delta-time clock so the simulation runs at the same physical speed regardless of frame rate, and always guard against zero-size/backgrounded tabs:

const clock = new THREE.Clock();
let running = true;

function animate() {
  if (!running) return;
  requestAnimationFrame(animate);

  const dt = Math.min(clock.getDelta(), 0.05); // clamp huge dt after tab-switch
  updateSimulation(dt);
  controls?.update();
  renderer.render(scene, camera);
}

addEventListener("resize", () => {
  camera.aspect = innerWidth / innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(innerWidth, innerHeight);
});

document.addEventListener("visibilitychange", () => {
  if (document.hidden) { clock.stop(); }
  else { clock.start(); }
});

animate();

5. Controls & Interaction

The homepage card shows a short controls string (e.g. "Click+drag to splash", "Drag to rotate") — this must match what the simulation actually does. Common interaction patterns already used across the site:

Always expose a visible on-canvas hint (a small bottom-left overlay, faded to ~60% opacity) describing the primary control, since most visitors never open dev tools or read the article.

6. Cleanup & Disposal

Simulations are loaded as standalone pages, not as SPA routes, so full teardown is rarely required — but instanced geometries, render targets, and GPU compute textures used by particle simulations are large enough to matter on pagehide, and are required if the simulation ever gets embedded in an iframe/preview later:

addEventListener("pagehide", () => {
  scene.traverse((obj) => {
    obj.geometry?.dispose();
    if (Array.isArray(obj.material)) obj.material.forEach((m) => m.dispose());
    else obj.material?.dispose();
  });
  renderer.dispose();
});

7. simulations.json Schema

Registering a simulation is what makes it appear on the homepage grid, in the search index, and in category filters. Add one entry to shared/data/simulations.json (English and Ukrainian title/description live side by side in the same object — there is no separate localized JSON file for this):

{
  "id": "vortex",
  "title": "Vortex Field",
  "titleUk": "Вихрове Поле",
  "emoji": "🌀",
  "category": "physics",
  "path": "vortex/index.html",
  "description": "...",
  "descriptionUk": "...",
  "tags": ["Three.js", "GPGPU", "Curl Noise"],
  "difficulty": 3,
  "status": "ready",
  "featured": false,
  "dateAdded": "2026-07-03",
  "articleId": null,
  "fps": 60,
  "controls": "Click+drag to splash",
  "tech": "3D"
}

Field notes:

8. Performance Budget

The site targets 60 fps on a mid-range laptop GPU and a usable frame rate on mobile. Apply these budgets when choosing particle counts and shader complexity:

Pixel ratio cap: min(devicePixelRatio, 2) Particle budget (desktop): up to ~150,000 instanced points, or ~20,000 simulated rigid bodies Particle budget (mobile, viewport < 768px): reduce counts by ~4× and disable shadows Draw calls: keep under ~50 per frame — use InstancedMesh / BufferGeometry merging, not one mesh per particle Shadow maps: disabled by default; enable only for hero/featured simulations, capped at 1024×1024

Detect low-end devices with a simple heuristic (rendered frame time over the first ~2 seconds, or navigator.hardwareConcurrency combined with viewport width) and drop to a reduced particle count preset rather than shipping two separate code paths.

9. Accessibility & Mobile

10. Submission Checklist

Before opening a pull request for a new simulation, confirm:

Reviewers will load the simulation with CPU throttling at 6× and check for dropped frames before merging — plan the particle budget accordingly rather than tuning it after feedback.