SkyTales Engine Grammar — three.js / WebGL

Third document of the stack: BRAND.md (vocabulary), ANTIPATTERNS.md (what to refuse), this file (how the engine is configured). Paste all three into any session touching a 3D visualization. Where BRAND.md and this file overlap, this file is the concrete implementation.

The purpose of this document is to end the measuring. Every number below was decided once so it never has to be decided again. Consistency across 70 sims doesn’t come from taste applied 70 times; it comes from the same boilerplate imported 70 times.


1. Scene

  • scene.background = new THREE.Color(SKY.void) always. Never rely on CSS showing through an alpha canvas: post-processing (EffectComposer) outputs opaque black and silently breaks it. Learned the hard way; not relearning it.
  • Fog: new THREE.Fog(SKY.void, 8, 30) when the scene has a floor grid or extends past the subject. Fog color ALWAYS equals background color, so geometry dissolves into sky.
  • No environment maps, no skyboxes, no stars-particle backdrops unless the sim is literally about them.

2. Camera

  • PerspectiveCamera(50, aspect, 0.1, 100). FOV 50, not 75. 75 is the tutorial default and warps edges like a GoPro; 50 reads calm and architectural, which is the 3b1b register.
  • Default position: slightly above the horizon looking gently down at the subject, the canopy view. For a subject of radius R centered at origin: camera.position.set(0, 0.9 * R, 2.6 * R), target (0, 0.15 * R, 0).
  • OrbitControls: enableDamping = true, dampingFactor = 0.05, minDistance = 1.2 * R, maxDistance = 6 * R, maxPolarAngle = 0.52 * Math.PI (you may go to the horizon, barely below it, never under the floor).
  • Never animate the camera on load. The scene is already there when the user arrives; instruments don’t do intros.

3. World scale

  • The subject fits inside a sphere of radius 2 world units, centered at origin. All sims. This single convention is what lets camera, fog, grid, label sizes, and bloom be identical everywhere. Normalize your data into this box; don’t move the camera to chase your data.
  • Right-handed, y-up, gravity along −y, per BRAND.md. Floor grid lies in the xz plane at y = 0 or at the subject’s natural floor.

4. Axes, grids, floor

  • Floor grid: new THREE.GridHelper(10, 10, SKY.grey, SKY.grey), material.transparent = true, material.opacity = 0.10. With fog on, it fades into the void by itself: no custom shader needed.
  • Axis tripod: three Line segments in SKY.grey at 55% opacity from origin to length 2.4, labeled x, y, z with the label system below. THREE.AxesHelper is banned (it is the RGB tripod).
  • Zero-planes and reference planes: MeshBasicMaterial({ color: SKY.textBright, transparent: true, opacity: 0.12, side: DoubleSide }). Reference geometry whispers.
  • Arrowheads on axes and vectors are flat triangle meshes, never ConeGeometry (cones read as cheap old-CG 3D). Coordinate-system sims use bold white axes + orange subgrid + flat arrowheads + z-up: brand/template.html is the reference — see BRAND.md “Coordinate-system visualizations”.

5. Text and labels (the readability rule)

The reason 3b1b looks readable is that his text is huge relative to the frame and rendered crisply, not as blurry textures. Our implementation:

  • All text is DOM, not canvas. Use CSS2DRenderer for labels attached to 3D positions. DOM text stays pixel-crisp at every zoom, inherits Ubuntu/Ubuntu Mono from the page CSS, and is selectable. Text baked into sprite textures is banned except where thousands of labels make DOM infeasible.
  • Sizes, minimums on desktop: axis labels 13 px Ubuntu Mono SKY.textDim; value readouts 14 px Ubuntu Mono SKY.textBright; in-scene annotations 15 px Ubuntu SKY.textMuted. If a label matters, it is at least 13 px on screen. Nothing smaller ships.
  • Equations near the scene render via the LaTeX pipeline (KaTeX/MathJax SVG per BRAND.md), positioned by CSS2D like any label.
  • Titles, legends, and controls live in the HTML layer, never inside the canvas.

6. Materials: the two modes

Every object is in exactly one of two modes. Mixing them per-object is fine; inventing a third is not.

Neon mode (curves, trajectories, wavefunctions, phasors, particles: the subject as light):

  • MeshBasicMaterial / LineBasicMaterial in SKY colors: unlit, so the color is exact.
  • Subject lines 2–3 px equivalent, components 1 px SKY.grey.
  • This mode is what bloom exists for.

Surface mode (decision surfaces, terrain, membranes, solids: the subject as matter):

  • MeshStandardMaterial({ metalness: 0.1, roughness: 0.85 }) with vertex colors from SKY tokens.
  • Standard light rig, and only this rig: AmbientLight(0xffffff, 0.65) + one DirectionalLight(0xffffff, 0.9) at (2, 3, 2). No point lights for mood, no colored lights ever: color comes from the data, not the lamps.

7. Post-processing

  • Bloom: UnrealBloomPass, strength ≤ 0.45, radius 0–0.2, threshold tuned so ONLY neon-mode subjects bloom, never the surface or the grid. Bloom is seasoning: if a screenshot looks like a lava lamp, halve it.
  • THREE.Line cannot be antialiased — use Line2. 1 px hardware lines (Line / LineSegments + LineBasicMaterial) are rasterized by the driver and neither MSAA nor SMAA can clean them; they will stay jagged no matter how much post-processing you stack. Anything the viewer actually looks at — a subject curve, a sphere’s wireframe, axes — must be Line2 + LineMaterial + LineGeometry (quad-based, antialiases properly). Thin Line is acceptable only for faint background grid fill. If a scene “still looks aliased after SMAA”, this is why.
  • Coplanar geometry needs depthWrite: false. When a projection or morph flattens many lines onto one plane, they land at identical depth and z-fight, which reads as colour clipping and shredded strokes. Disabling depth writes (keeping depth tests) lets them blend in draw order instead of fighting.
  • Anti-aliasing: set antialias: true on the renderer AND add an SMAAPass to the composer. MSAA alone leaves thin Line/grid edges crawling; SMAA cleans them. Required for line-heavy coordinate scenes, not optional. AA is not an “effect” — it is baseline quality.
  • Color correctness: when an EffectComposer is used, the LAST pass MUST be OutputPass (sRGB conversion). Without it the composer emits linear color and the neon palette renders dull and washed out. If the brand colors look muddy the moment you add post-processing, you are missing OutputPass.
  • Everything else (SSAO, DOF, film grain, chromatic aberration, vignette) is banned. The void does the atmosphere.

8. Animation and time

  • All motion is Δt-based (clock.getDelta()), never per-frame increments, so speed is identical at 30, 60 and 144 Hz.
  • Easing for UI-triggered transitions: smoothstep or cubic ease-in-out, 300–500 ms. No bounces, no elastic, no springs on scientific objects: nature already provides the dynamics.
  • brand/template.html ships the reusable animation engine: play({ duration, onUpdate, onDone }) (a generic eased 0→1 progress tween, ease-in-out cubic), makePath(fn) + create() to trace a curve into the frame, transform(path, fnA, fnB) to morph one shape into another, and a vector API whose .animate({ tail, tip }) returns a Promise so steps can be sequenced.
  • Vectors decompose by sliding, not erecting: each component grows along its own axis from the origin, then slides sideways into its tip-to-tail position. Growing a component in place off another’s tip looks wrong; sliding reads as decomposition.
  • document.hidden pauses the loop. Respect prefers-reduced-motion: keep the sim interactive but kill autonomous ambient motion.
  • Sliders drive physics live with no interpolation lag. The slider IS the parameter; latency between hand and consequence breaks the instrument feel.

9. Resize (battle-tested pattern)

The naive ResizeObserver + setSize combination self-triggers and floods the console. The house pattern:

let lastW = 0, lastH = 0, queued = false;
new ResizeObserver(entries => {
  const { width, height } = entries[entries.length - 1].contentRect;
  const w = Math.round(width), h = Math.round(height);
  if (w <= 0 || h <= 0 || (w === lastW && h === lastH) || queued) return;
  queued = true;
  requestAnimationFrame(() => {
    queued = false; lastW = w; lastH = h;
    camera.aspect = w / h; camera.updateProjectionMatrix();
    renderer.setSize(w, h, false);   // false: never let three.js write inline canvas CSS
    composer?.setSize(w, h);
  });
}).observe(container);

10. Performance floor

  • renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)).
  • Dispose geometry and materials on rebuild (geometry.dispose()), especially on slider-driven regeneration.
  • Target 60 fps on a mid-range phone at default settings; expensive fidelity (resolution sliders, bloom) is opt-up, not default.

Appendix: the boilerplate

skytales-three.js: import this, and sections 1, 2, 7, 9 and 10 are done. New sims start here; old sims migrate to it when touched.

Two references now exist: skytales-three.js (this boilerplate) for physical sims on a floor, and brand/template.html for coordinate-system sims (bold axes, orange subgrid, flat arrowheads, z-up, 2D+3D views, the eased animation engine). Copy whichever matches the sim’s kind; both obey the same palette and type rules.

import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { CSS2DRenderer, CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';

export const SKY = {
  void: 0x000000, navy: 0x262C53,
  orange: 0xFF6417, cyan: 0x38BDF8, violet: 0x8B7CF6,
  green: 0x34D399, red: 0xF43F5E, grey: 0x64748B,
  textBright: 0xF1F5F9, textMuted: 0x94A3B8, textDim: 0x475569,
};

export function initSkyScene(container, { R = 2, bloom = true, fog = true } = {}) {
  const scene = new THREE.Scene();
  scene.background = new THREE.Color(SKY.void);
  if (fog) scene.fog = new THREE.Fog(SKY.void, 4 * R, 15 * R);

  const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 100);
  camera.position.set(0, 0.9 * R, 2.6 * R);

  const renderer = new THREE.WebGLRenderer({ antialias: true });
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  container.appendChild(renderer.domElement);

  const labelRenderer = new CSS2DRenderer();
  Object.assign(labelRenderer.domElement.style,
    { position: 'absolute', top: 0, pointerEvents: 'none' });
  container.appendChild(labelRenderer.domElement);

  const controls = new OrbitControls(camera, renderer.domElement);
  controls.enableDamping = true; controls.dampingFactor = 0.05;
  controls.minDistance = 1.2 * R; controls.maxDistance = 6 * R;
  controls.maxPolarAngle = 0.52 * Math.PI;
  controls.target.set(0, 0.15 * R, 0);

  const composer = new EffectComposer(renderer);
  composer.addPass(new RenderPass(scene, camera));
  let bloomPass = null;
  if (bloom) {
    bloomPass = new UnrealBloomPass(new THREE.Vector2(1, 1), 0.42, 0.15, 0.6);
    composer.addPass(bloomPass);
  }

  scene.add(new THREE.AmbientLight(0xffffff, 0.65));
  const sun = new THREE.DirectionalLight(0xffffff, 0.9);
  sun.position.set(2, 3, 2);
  scene.add(sun);

  const grid = new THREE.GridHelper(5 * R, 10, SKY.grey, SKY.grey);
  grid.material.transparent = true; grid.material.opacity = 0.10;
  scene.add(grid);

  // guarded resize (section 9)
  let lastW = 0, lastH = 0, queued = false;
  new ResizeObserver(entries => {
    const { width, height } = entries[entries.length - 1].contentRect;
    const w = Math.round(width), h = Math.round(height);
    if (w <= 0 || h <= 0 || (w === lastW && h === lastH) || queued) return;
    queued = true;
    requestAnimationFrame(() => {
      queued = false; lastW = w; lastH = h;
      camera.aspect = w / h; camera.updateProjectionMatrix();
      renderer.setSize(w, h, false);
      labelRenderer.setSize(w, h);
      composer.setSize(w, h);
    });
  }).observe(container);

  const clock = new THREE.Clock();
  function frame(update) {
    const loop = () => {
      requestAnimationFrame(loop);
      if (document.hidden) return;
      const dt = clock.getDelta();
      update?.(dt);
      controls.update();
      composer.render();
      labelRenderer.render(scene, camera);
    };
    loop();
  }

  function label(text, position, cls = 'sky-label') {
    const el = document.createElement('div');
    el.className = cls;   // style in page CSS: Ubuntu Mono, 13px, SKY.textDim
    el.textContent = text;
    const obj = new CSS2DObject(el);
    obj.position.copy(position);
    return obj;
  }

  return { scene, camera, renderer, controls, composer, bloomPass, frame, label, SKY };
}

Usage in a sim:

import { initSkyScene, SKY } from '/assets/skytales-three.js';
const sky = initSkyScene(document.getElementById('view'));
// add your physics to sky.scene, then:
sky.frame(dt => { /* advance the simulation by dt */ });

Migration order for the existing ~70

  1. New sims: born on the boilerplate. Non-negotiable from today.
  2. Top 10 by traffic or portfolio value: migrated deliberately, one at a time, screenshot-diffed.
  3. Everything else: boy scout rule. Touched for any reason = migrated. Never a mass migration; mass migrations are how agents break 60 things to fix 10.