SDF engine
render/sdfEngine.ts owns raymarched shapes: the buffer of shape records, the
pipelines that draw them, and the spatial grid. It loads by dynamic import on
the first shape mount, fetching the grid shader in parallel.
A frame, and where it is measured
Section titled “A frame, and where it is measured”One tick of the engine loop, top to bottom, with the file that does each part.
The two brackets are the CPU and GPU readouts of the
stats overlay: CPU is the JavaScript time from start
to queue.submit(); GPU is the device’s own clock between the two timestamps
⏱0 and ⏱1, read back asynchronously.
rAF tick ───────────────────────────────────────────── core/engine.ts loop()│├─ canvas off-screen? yes ─▶ return overlay shows PAUSED├─ fps cap: too soon? yes ─▶ return frame skipped├─ delta = now − last stats.fps = 1 / delta├─ resize settle pendingResize → canvas.width/height at rest├─ speed 0 and nothing dirty? yes ─▶ return idle: every stat holds├─ dirtyFrames−−, camPos ← camPosRef, internalTime += delta × speed│├─ ▶ start = performance.now() ───────────────────────────────────────────┐│ uploadUniforms render/uniforms.ts matrices, scene, camera → ││ sim + render uniform buffers ││ _sdfUpdate render/sdfEngine.ts updateSDFBuffers: walk the ││ shape map twice, write every │ CPU│ visible record, one │ stats.latency│ writeBuffer, set fastCount / ││ refractCount / mip flags ││ _textUpdate render/textEngine.ts text uniforms ││ submitFrame render/renderer.ts encode the command buffer: ││ │ (with <View>s: uploadUniforms + submitFrame once per view) ││ │ ││ │ ⏱0 ─ first pass encoded ──────────────────────────────────────┐ ││ │ compute particle sim pipelines.ts · simulate.wgsl speed>0│ ││ │ compute grid clear+assign sdfEngine.ts · wgsl/grid.wgsl │ ││ │ pass 1 particles · layers · axes · text │ ││ │ particles.wgsl · axes.ts · textEngine.ts │ GPU││ │ glass? end pass 1, copy swapchain → bgTexture │span││ │ shape pre-pass: fast, fast halo depth, effect depth, │ ││ │ effect → sdf.wgsl fs_fast · fs_fast_effect_depth │ ││ │ · fs_fast (colour off) · fs into shapeTexture │ ││ │ mip chains bgmip.ts back-face pass meshEngine.ts │ ││ │ pass 2 fast → mesh → glass → fast halo → effect → gizmo │ ││ │ fs_fast · meshEngine.ts · fs_refract · fs_fast_effect│ ││ │ · fs · rigGizmo.ts │ ││ │ blit blit.ts, only during a drag-resize │ ││ │ ⏱1 ─ last pass encoded ───────────────────────────────────────┘ ││ │ resolveQuerySet → readback buffer ││ └─ queue.submit() stats.drawCalls · passes · bgMips ││ ◀ stats.latency = performance.now() − start ─────────────────────────────┘│ stats.frameTimes[i] = latency → the sparkline│└─ back to the browser GPU runs the buffer, compositor presents the swapchain mapAsync, a frame or two later: stats.gpuMs = ⏱1 − ⏱0
components/StatsHUD.tsx every 100 ms: stats.fps · latency · gpuMs · drawCalls· passes · bgMips, shapes.size, fastCount, numParticles, renderDirtyFrames,canvas size, performance.memory, adapter → the overlay rowsWhere the timestamps land decides what the GPU number contains. ⏱0 is
written at the beginning of the first pass encoded — the particle sim, else the
grid rebuild, else pass 1 — and ⏱1 at the end of the last — the blit, else
pass 2, else pass 1 when nothing splits the frame. The background copy, the
shape pre-pass, both mip chains and the back-face pass write no timestamp of
their own; they sit inside the span. So with enableReflection or
enableRefraction on, the GPU figure includes the second march of every
visible non-glass shape that the pre-pass costs, and the CPU figure includes the JavaScript that encoded it.
The three early returns sit before start, so a paused, capped or idle frame
measures nothing and every readout holds its last value. The compute passes
count in neither DRAW nor PASS; the mip passes count in DRAW and MIP.
Writing the records
Section titled “Writing the records”Each frame that something changed, updateSDFBuffers walks the shape map
twice.
The first walk decides what draws and where. It skips shapes in a hidden
<Visible> group and, with autoCull on, shapes whose bounding box projects
entirely outside the visible canvas. It counts fast shapes and refractive
shapes, and notes whether any glass wants a mip chain.
The second walk writes each surviving shape’s 64-float record at its index —
fast shapes first, then refractive, then the rest — resolving every reactive
value as it goes. Position, size, rotation and rounding go in as floats;
colours and small options go in as bit-fields, four bytes to a word; a glass
shape’s options fill slots 20 to 51; the modifier mask and its parameters fill
the last three, modA to modC, on every shape, glass included. One
writeBuffer uploads the written range. Nothing is compressed: the record is
a fixed 64 floats whatever the shape uses.
That layout is the partition the renderer draws from:
[0, fastCount) → sdfFastPipeline[fastCount, fastCount + refractCount) → sdfRefractPipeline[fastCount + refractCount, total) → sdfPipelineEach pipeline draws one contiguous instance range with no per-shape branch. See Buffers and uniforms for the record.
The proxy cube
Section titled “The proxy cube”The draw is instanced: 36 indices of a unit cube, one instance per shape. The cube’s only job is to get the fragment shader running over the right pixels. Every face is wound counter-clockwise seen from outside, so a pipeline can cull one set of faces and shade each covered pixel once.
The vertex shader reads the shape’s record by instance index and sizes the
cube from a per-primitive envelope — a sphere’s radius on all three axes, a
capsule’s radius by half-height plus radius — then inflates it for whatever
can reach past the bare shape: rounding, outline thickness, a glow whose
radius is sized from its intensity so the halo fades out just at the box edge,
a <Fast> wrapper’s shared halo, and the modifiers that grow a shape — onion
thickness, displacement amplitude, bend’s arc, elongation. It rotates the
inflated cube into place and emits the world position of each corner.
A shape’s fragments are therefore bounded by its own footprint on screen, and a cube that is too small clips the shape at its silhouette. That is why every primitive has an envelope case; see Adding a shape.
The five fragment entry points
Section titled “The five fragment entry points”All five cast a ray from the camera through the fragment’s world position and sphere-trace the shape’s own distance function. They differ in what else they do.
fs — the effect pipeline. The full path, drawn last with no depth
interaction so its halos composite over everything.
Each step evaluates the shape’s own distance; with lodScaling on, the hit
threshold grows with distance so far surfaces terminate sooner. For a shape
with an outline or glow it also walks the scene — sceneSDF, through the
spatial grid — to learn three things: the nearest neighbour for a fast-skip
when nothing is close, which shape owns the outline at this point, and
whether an opaque shape in front occludes the halo.
On a hit it computes the normal by finite difference, applies roughness bumping, runs the lighting model chosen by the material’s shader ID — lambert, or a phong, cell, fresnel, matcap or anisotropic branch — adds an interior outline rim, and returns a premultiplied colour.
On a miss it enters the fallback zone: a glow from the ray’s closest
approach with exponential falloff, an outline shell if this shape owns the
outline here (or has merge: false), suppressed where an opaque shape sits in
front, and a one-pixel antialiasing rim sized in screen pixels at the point of
closest approach. Surface over shell over glow, premultiplied.
A shape with no outline and no glow skips the scene walk entirely and discards on a miss — one distance evaluation per step instead of up to sixteen, with no flag set. The state is already in the record.
fs_fast — the fast pipeline. The same march with no scene walk and no
fallback zone. On a hit it runs the same lighting and writes frag_depth, so
overlapping shapes fail the depth test before shading. On a miss it draws an
antialiasing rim if the wrapper’s aa asks for one, and otherwise discards.
One more thing lives on its miss path: when the shape has an outline or glow,
it writes the halo’s depth — the depth of closest approach — with colour
masked off. That is dead code for fast shapes, whose effects live on the
wrapper, and live for the effect range, which sdfDepthOnlyPipeline draws
through this entry point during the shape pre-pass so glass has a depth to
test effect shapes against.
fs_fast_effect — the cluster halo. Runs only when a <Fast> wrapper
carries a glow or outline. It marches, discards on a hit (the surface pipeline
already drew it), and on a miss draws the wrapper’s shared outline shell and
glow from a single uniform. It writes no depth and is tested at the proxy
face’s own depth, so nearer geometry occludes the halo. Pass 2 draws it after
the glass, so an outline in front of a glass shape paints over it.
fs_fast_effect_depth — the same halo, for the pre-pass. It adds the
depth of the ray’s closest approach to the shape. The shape pre-pass draws the
halo through it so the band carries its shape’s depth; without that the glass
reads the far clear value there and the band ghosts through refraction. Pass 2
never uses it, so the halo on screen still writes no depth and cannot erase
the mesh behind it.
fs_refract — glass. Marches to the entry point, then through the volume
to the exit, and reads the screen there. Its own page: Glass.
The pipelines
Section titled “The pipelines”| Pipeline | Entry | Cull | Depth write | Compare | Draws |
|---|---|---|---|---|---|
sdfFastPipeline | fs_fast | front | yes | less | <Fast> shapes |
sdfRefractPipeline | fs_refract | none | yes | less | glass shapes |
sdfPipeline | fs | none | no | always | everything else, last |
sdfFastEffectPipeline | fs_fast_effect | back | no | less | the <Fast> halo, pass 2 |
sdfDepthOnlyPipeline | fs_fast, colour masked | none | yes | less | effect shapes, pre-pass only |
sdfFastEffectDepthPipeline | fs_fast_effect_depth | none | yes | less | the <Fast> halo, pre-pass only |
A pipeline that draws both faces of the proxy runs the march twice on every pixel it covers. Two of them cull, in opposite directions:
sdfFastPipelinedraws back faces.fs_fastwritesfrag_depth, which disables early depth rejection, so the second face’s march was pure waste. Back faces rather than front, so a shape still renders with the camera inside its proxy.sdfFastEffectPipelinedraws front faces.fs_fast_effectwrites no depth and is tested at the face’s rasterised depth. Front faces sit nearer and pass where the halo should show; back faces would fail against geometry the halo draws over. The halo also blends once instead of twice, so a translucent outline colour lands at its stated alpha. The price: with the camera inside a shape’s proxy box, that shape’s halo is not drawn.
The refract pipeline is created only if fs_refract survived the build — the
shake plugin removes it when no source uses a glass material, and creating a
pipeline against a missing entry point would fail validation.
Why the effect pipeline cannot write depth, and the two-pipeline split that would let it, is in Depth and z-sort.
Why <Fast> exists
Section titled “Why <Fast> exists”The effect path’s scene walk is what lets outlines merge and shapes occlude each other’s halos, and it costs a neighbour lookup at every step. The fast path skips it, and by writing depth lets the hardware discard overlapping fragments before they shade. On a dense cluster that difference is the whole frame budget. The trade is that fast shapes carry no per-shape outline or glow. See Effect and Fast.
The spatial grid
Section titled “The spatial grid”Scene-level distance queries go through a uniform grid rather than every shape. Its own page: The spatial grid.