Skip to content

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.

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 rows

Where 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.

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) → sdfPipeline

Each pipeline draws one contiguous instance range with no per-shape branch. See Buffers and uniforms for the record.

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.

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.

PipelineEntryCullDepth writeCompareDraws
sdfFastPipelinefs_fastfrontyesless<Fast> shapes
sdfRefractPipelinefs_refractnoneyeslessglass shapes
sdfPipelinefsnonenoalwayseverything else, last
sdfFastEffectPipelinefs_fast_effectbacknolessthe <Fast> halo, pass 2
sdfDepthOnlyPipelinefs_fast, colour maskednoneyeslesseffect shapes, pre-pass only
sdfFastEffectDepthPipelinefs_fast_effect_depthnoneyeslessthe <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:

  • sdfFastPipeline draws back faces. fs_fast writes frag_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.
  • sdfFastEffectPipeline draws front faces. fs_fast_effect writes 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.

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.

Scene-level distance queries go through a uniform grid rather than every shape. Its own page: The spatial grid.