Skip to content

Repository files navigation

Entendue

There is nothing to install: pick a demo from the list on the left and drag things around.

Chromatic dispersion: a white beam entering a glass prism and leaving as a spread rainbow

What it is

Entendue is a 2D optics sandbox that runs in your browser. You place light sources, mirrors, lenses, glass, and blockers on a canvas, and it traces where the light actually goes: reflecting, refracting, splitting at partly reflective surfaces, bending continuously through materials whose refractive index varies from point to point, and separating into colors because glass bends blue more sharply than red.

Everything is live. Drag the prism above and the rainbow swings with it, retraced from scratch every frame. Because the picture is built from real ray paths, it works for setups with no closed-form answer: a telescope with four elements, a room shaped so that one lamp can never light all of it, light scattering through a random medium.

It ships with 77 worked demos covering reflection, refraction, lenses, telescopes and microscopes, gradient-index optics, diffraction gratings, and a number of physical curiosities.

Eight of the built-in demos: a solar eclipse, branched flow through a random medium, an LCD subpixel, the mirascope, a dichroic RGB splitter, caustics inside a reflective sphere, a grating pulse compressor, and a holographic sight

What it's a remake of

The demos, the physics, and the scene file format all come from ricktu288/ray-optics, the Ray Optics Simulation project. It is an excellent piece of work, and it is the reason this project has anything to simulate. Entendue is a from-scratch rebuild of its engine that reads the original's scene JSON without conversion, so scenes move between the two apps in both directions.

Original Entendue
Engine source 51,483 lines, 154 files 6,640 lines, 13 files
Runtime dependencies 29 0
Shipped bundle not measured 134 kB (45 kB gzipped)
Optical element classes 42 4 composable primitives
Renderer Canvas 2D WebGPU
Ray tracer CPU CPU, plus a WebGPU compute path
Engine time, 74 comparable demos 12.16 s 2.26 s (5.4× total, 4.8× median)
Demos that never converge 3 0
While dragging the first ~50 ms of a restarted trace a full trace every update

Two caveats worth stating plainly. The original has years of interface work, translations into many languages, and a community module ecosystem behind it; Entendue covers the full gallery but not every long-tail feature. And every number here was measured on one machine against one gallery, by the method described below, so you can reproduce or dispute it.

How four primitives replace forty-two classes

The original grew one class per optical element, and each class carries its own intersection math, drawing code, and editor. Entendue factors that into four kinds built from independent parts (src/core/types.ts):

Kind Composition
Source emission pattern (ray / point fan / angular fan / beam) × brightness × wavelength
Surface boundary × response (mirror, absorb, detector, split, grating, ideal, custom-equation) × optional dichroic filter
Volume closed boundary × medium (homogeneous n + Cauchy B, or GRIN n(x,y,λ) with symbolic ∂n)
Annotation text / arrow / ruler / protractor / freehand; never touches rays

Every shape a scene can contain (segment, circle, arc, polygon with arc edges, sampled function or parametric curve) normalizes at load time to one edge list of lines and arcs, and one intersection kernel serves that list (src/core/shapes.ts). Legacy classes become pure loader constructors (src/compat/loadLegacy.ts): a spherical lens is an arc path, an aperture is two absorbing segments, plane glass is a half-plane volume. Module templates expand fully, including parameters, variables, function definitions, and for and if loops.

Media are tracked per ray as an inside-set. Crossing a volume boundary toggles membership, and the refractive ratio is the quotient of set products, which reproduces the original's surface-merging arithmetic for shared glass edges and coatings without any per-class geometry tests.

The expression compiler (src/core/expr/expr.ts) parses the gallery's LaTeX subset into an AST with evaluation, symbolic differentiation (for GRIN gradients and analytic curve normals), and WGSL emission, and it does this without pulling in mathjs or tex-math-parser.

Two tracers, one renderer

The CPU wavefront tracer (src/core/trace.ts) is the reference implementation and carries the full feature set: Fresnel s/p coefficients, total internal reflection, dispersion, gratings, custom-equation surfaces, detectors, GRIN marching done inline rather than as fake intersections, stochastic truncation resampling, and image and observer modes with sequential pairing.

The GPU compute tracer (src/gpu/tracer.ts) runs whole wavefront generations on the GPU: ping-pong ray buffers with atomic counters, per-thread nearest-hit over the edge list, media as a 32-bit volume bitmask, mirror/absorb/split/ideal/refract responses, dichroic filters, and Cauchy dispersion. Segments append into a GPU buffer that the renderer draws directly, so a trace never touches the CPU.

The WebGPU renderer (src/gpu/renderer.ts) is the original's float-color renderer model without its constraints: rgba16float additive accumulation, one instanced draw that expands segments into feathered quads in the vertex shader, then a tone-map pass (legacy soft clip, linear, linearRGB, Reinhard, or intensity false-color).

The renderer is always WebGPU. The compute tracer is chosen per scene, because a GPU trace costs 10 to 20 ms of fixed overhead in pipeline setup, per-generation dispatch, and counter readback before it traces a single ray, while the CPU tracer clears a few-hundred-ray scene in well under a millisecond. Entendue routes to the GPU when the trace is genuinely expensive, measured three ways: a wide initial wavefront (5000 rays or more), a large rays × edges product (500k or more — chaff-countermeasure is only 258 rays at default density but 3013 mirror strips, and every bounce tests every edge, so the CPU needs ~90 ms where the GPU needs ~7), or a measured backstop: once a full CPU trace of the current scene has taken over 40 ms, the next comparable trace goes to the GPU regardless of what the static model thinks. Earlier builds keyed on ray count alone and lost either way — 10 to 20 ms of GPU overhead on demos the CPU finished in 1 ms, or 90 ms CPU frames on edge-heavy scenes that never crossed the ray threshold. The settings panel exposes the choice as a three-way GPU rendering toggle — off / auto / forced — where auto is this routing and the other two override it per your preference (persisted across sessions).

When a wide wavefront collapses into a serial tail mid-trace (drag a source into the caustics sphere's opening and rays start bouncing hundreds of times), the GPU does not abandon the run: once the tail persists past its worth (alive < 2048 for 150 ms, or a 1 s hard budget) it stops, reads the surviving rays back, and hands exactly those to the CPU tracer, whose tail segments stream into the same GPU segment buffer. Nothing is discarded or re-traced, the badge shows ⚡ GPU + tail, and — because a GPU attempt now always keeps its work — the next retrace simply tries the GPU again. Earlier builds instead threw the GPU work away, re-traced everything on the CPU, and set a sticky per-scene veto, so one deep drag position banished a scene from the GPU until reload.

Gradient-index media trace on the GPU too. A GRIN glass carries symbolic n(x,y,λ) — the expression compiler that already parses the gallery's LaTeX emits WGSL alongside the JS closure and the symbolic partials, so setup inlines each scene's n, ∂n/∂x, ∂n/∂y and absorption into a generated kernel variant (cached per expression set; homogeneous scenes keep using the base kernel compiled at init). Inside a GRIN volume the whole wavefront advances one Euler step per generation — the same d' = normalize(d + h(∇n − (∇n·d)d)/n) update as the CPU tracer, at the same step size and the same 100×-weaker brightness cutoff. branched-flow (290 rays, 133k march steps through a random-potential slab) drops from ~2.6 s on the CPU to ~60–140 ms, with segment counts matching the CPU to one in 133k. Narrow-wavefront GRIN scenes (a 10-ray Luneburg lens, a single-ray spiral) stay on the CPU: marching is per-generation dispatch-bound, so the GPU only pays off when many rays march together — the router estimates march work as rays × (extent/step) and requires a few hundred rays before switching. GRIN-slab's discrete half needs 101 volumes, which exceeds the 32-bit medium mask, so that one demo stays CPU by design.

Arc intersections get special care in f32. A hit computed as p + t·d lands off its circle by ~1e-4 at gallery coordinates, each chord traced from an off-rim point roughly doubles that error, and within a handful of bounces the reflected ray "re-enters" the circle it is sitting on — the kernel then reflects it a second time and ejects it straight through the mirror (14% of rays leaked out of the caustics sphere with the source inside; the same cascade was behind an earlier ~30% whispering-gallery segment deficit). The fix is exact rather than a tolerance: each ray carries the edge it departed from, a self-chord uses Vieta's root (t = −2 f·d) with its endpoint built as a Householder reflection of the center vector — which preserves |f| = r with no cancellation — and rays handed to the CPU tail are first re-projected onto their circle in f64. After the fix the GPU matches the CPU to 2 segments in 26k at the caustics gallery position, and the whisper-band deficit is gone. Remaining honest gap: digital-camera's ±18% stochastic ghost subset (position-hashed resampling picks a different but equally valid sample), brightness- compensated and visually verified against the original at 0.8/255.

Why dragging stays sharp

The original restarts its simulation on every mousemove. Each restart computes for 50 ms, schedules its continuation 100 ms later, and the next mousemove cancels it, so during a drag you only ever see the first 50 ms or so of ray tracing. Entendue makes a full trace cheap enough to finish per update, and keeps showing the last complete trace while a new one is in flight, so dragging stays at full fidelity.

And when nothing changes, nothing runs: the frame loop re-arms itself only while a trace is active. A static scene renders zero frames per second — input events, async completions, and trace requests each buy exactly the frames they need, so an idle tab costs idle-tab energy.

Measured: all 77 demos, both engines, same machine

Every gallery demo was run to convergence in both apps and timed by each engine's own clock: the original's Time elapsed (ms) readout against Entendue's trace timer. Both measure the same interval, from simulation start to completion, pacing included, and segment counts match exactly, so both engines did identical work. public/driver-time.html drives the run and results land in .shots/timing/.

A sample of the 74 scenes both apps completed:

Scene Original Entendue
branched-flow (GRIN) 3192 ms 60–140 ms (GPU) ~30×
simple-double-gauss-lens 1818 ms 308 ms 5.9×
GRIN-slab (module + GRIN) 1189 ms 156 ms 7.6×
solar-eclipses 499 ms 40 ms (GPU) 12.5×
caustics-from-a-reflective-sphere 253 ms 22 ms (GPU) 11.5×
maze-solution (112 mirrors) 194 ms 36 ms 5.4×
rainbows 408 ms 388 ms 1.1×

Across those 74 scenes the total goes from 12.16 s to 2.26 s, a 5.4× speedup with a median of 4.8×, and no scene is slower. The closest is images-formed-by-two-mirrors, a tie at about 3 ms. Three scenes are left out of the table because the original never converged on them inside a 60 s cap: penrose-unilluminable-room, resonator, and optical-cavity are long-tail resonators that Entendue completes outright, in 7.4 s, 4.0 s, and 0.8 s. Wall clock from navigation to a converged image, app boot included and both on dev servers, totals 252 s against 47 s.

Two honest notes on these numbers. Earlier revisions of this file quoted the original at about 20 s for rainbows and "minutes" for branched-flow. Those came from timing it in a hidden browser tab, where Chrome throttles the setTimeout pacing its tracer relies on down to 1 Hz. Measured fairly, with the tab focused, the original is far quicker than that, and the table above replaces those figures. Second, rainbows is the one scene where Entendue holds only a slim lead. Its wavefront collapses into a long serial tail of resampled ghost reflections, which suits neither the GPU (it bails out) nor a wide CPU sweep.

Verified against the original engine, numerically

All 77 gallery scenes were run through both engines headless: the original's dist-node build with its renderer hooked to record every drawn segment, ray, and image point, against Entendue's CPU tracer. The two were then compared on draw counts, 5°-bin angle histograms weighted by both count and length, total drawn length, and image-point positions.

Every scene agrees to within a few percent on every metric, most to well under 1%. Several match draw for draw (solar-eclipses, black-cat, luneburg, maxwell-fisheye), and detector readings are bit-comparable: koehler-illumination and camera-imaging report identical power and bin distributions, and double-gauss agrees to within 0.1%. Seeded random scenes are layout-identical, because Entendue ports the original's ARC4 seedrandom; chaff-countermeasure places all 1000 chaff pieces at the same spots.

This harness caught two systematic bugs, both since fixed: a phantom chord that sealed open parametric mirrors, and arcs stopping one tStep short of tMax. Penrose went from an L1 of 0.31 to 0.007, which is the level at which the original differs from itself under a 10⁻⁶ perturbation. The residuals that remain can be characterized precisely, and none of them is a physics error:

  • specular-and-diffuse-reflection and gan-based-lcd-pixel use unseeded random scatter. The distributions match, with a length-weighted histogram L1 near zero, but individual angles cannot, exactly as two runs of the original differ from each other.
  • Ghost reflections below 1% differ in chromatic-dispersion, zoom-lens, and holographic-sight. The stochastic truncation resampler keeps a different arbitrary subset than the original's equally arbitrary rayIndex % amp. Same expectation value, invisible either way.
  • sea-mirage shows 14 observed rays against 13 through a GRIN mirage, one marginal ray at the edge of the observer circle. Observed image positions agree to 0.7 scene units.

Head-to-head engine speed on identical scenes, headless with raster excluded from both, is 4 to 7 times in the typical case, 11× on simple-double-gauss-lens, and 31× on penrose (117 s down to 3.8 s to the same 10⁶-interaction cap). All of that is before the WebGPU compute path, which is where the scaling comes from.

Verified against the original app, visually

Beyond the numeric harness, every gallery demo was rendered in both apps under the same view transform, with layers composited pixel-faithfully by public/driver.html and a dev-only /__shot sink, and every side-by-side pair was reviewed one at a time. Mean pixel difference across all 77 scenes is 1.7 out of 255.

This sweep caught a class of issues the numeric harness could not see, and led to fixes for all of them: the colors-off coverage tone model (hue pinned to theme yellow rather than per-channel whitening), the colors-on linear-normalized display that reproduces the original's completion transform, per-stroke energy parity with canvas antialiasing and no minimum line width, glass bodies filled below the light layer at log(n)/log(1.5)·0.2, world-unit object chrome, multi-line labels, detector readouts with irradiance maps, protractor and ruler tick and label structure, and three GPU-tracer bugs (reversed arc spans on clockwise boundaries, missing coincident-face merging on shared prism and doublet faces and on mirror-on-glass backs, and bandwidth-0 dichroic filters being ignored).

Of the 77 scenes, 74 agree to a mean pixel difference under 4/255. The rest are penrose and optical-cavity, resonators that never stop converging and were captured at a time cap with identical structure, and the GRIN scenes' index-map shading, which Entendue draws using the original's own flat-tint fallback.

Authoring: every demo is creatable from scratch, and that is checked

src/app/authoring.test.ts walks all 77 gallery scenes and, against the real UI code rather than a hand-kept list, asserts three things: every object has a tool that creates it, every property those objects carry is reachable from placement geometry or a reshape handle or a panel control, and every scene-level setting has a control. It runs in npm test, so the claim cannot silently rot.

Every object places with one of four gestures (one click, two clicks, three clicks, or an n-point polygon), so creation is one placement engine driven by a declarative variant table (src/app/tools.ts): five families (light, mirror, glass, blocker, note) totalling 30 variants, plus a module library holding the gallery's 10 module definitions.

The ⊕ handle tool reproduces the original's object grouping. Click objects, or their individual control points, to bind them, then click empty space to drop the handle. Dragging it translates, rotates, or scales everything bound, with the same transformation modes and step quantization as the original. Point bindings are what let one handle bend a single prism face, and they round-trip through the legacy dragContext {part, index} encoding.

A selected object gets reshape handles, driven by one generic walker over its coordinate fields (arc sag, aperture gap, polygon vertices, lens bulges, module control points), plus a rotate handle above it that drags the whole object around its centroid. Undo and redo come cheap because the scene is JSON and history is just snapshots; delete reindexes bound handles so groups survive it, and there is duplicate.

The toolbar carries the mode switch (rays, extended, images, observer, where observer mode gets a draggable and resizable observer circle), color simulation, new / save / load as plain scene JSON that round-trips with the original's format, and SVG export of the scene with its completed trace. Scene settings cover the name, snap-to-grid and grid size, length scale, a random seed so seeded scenes reproduce exactly, the violet and red wavelength range that recolors infrared and ultraviolet scenes, and a background image inlined as a data URL so the scene stays self-contained. Parameters and equations are edited in a click-to-open property panel.

Placement, dragging, reshaping, panel edits, and module expansion all mutate the scene JSON and reload through the one loader, so anything you build is by construction saveable and identical in semantics to a gallery scene.

Two things in the gallery JSON are deliberately not reproduced as authoring surface, and the audit records why. CropBox is the original's export-dialog rectangle with its export-only ray budget; Entendue exports the current view instead. theme holds per-object-class color and width overrides used by 2 scenes; Entendue draws its own chrome and has no theme editor.

Scene compatibility

All 77 gallery scenes load, covering 5087 objects, and module-expanded scenes (Luneburg shells, GRIN slab stacks, ray relays, chaff) expand correctly. The Bézier-chain classes CurveMirror, CurveGlass, CurveGrinGlass, and CustomCurveSurface are supported too, adaptively sampled and verified against the original engine, as are background images (anchored at world origin like the original and drawn under the light layer via screen blending) and SVG export of any scene with its full trace.

Running it locally

npm install
npm run dev      # http://localhost:5210, add #scene-name to open a gallery demo
npm test         # loads all 77 gallery scenes, traces a sample set, runs the authoring audit
npm run build    # type-check and bundle to dist/

The renderer needs a WebGPU-capable browser. Recent Chrome, Edge, and Safari all qualify.

Credits and license

The gallery scenes, the module definitions, and the physics this reimplements come from ricktu288/ray-optics, created by Yi-Ting Tu. Those files are Copyright 2016-2026 The Ray Optics Simulation authors and contributors, and are used here under the Apache License 2.0. Entendue is an independent reimplementation, not affiliated with or endorsed by that project.

Entendue is likewise licensed under the Apache License 2.0. See LICENSE.

About

A 2D optics sandbox in the browser. Factored WebGPU remake of Ray Optics Simulation: 4 primitives instead of 42 classes, 13% of the code, ~5x faster.

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages