There is nothing to install: pick a demo from the list on the left and drag things around.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.

