Skip to content

Performance design

Fabric ships raymarched SDF shapes with 25 primitives and 8 modifiers, seven material models including a 27-option glass, glTF meshes with skeletal animation, GPU particles, and Bezier text.

Measured on the current build, brotli-compressed: the complete SDF engine — shader, sdfEngine.ts and spatial grid — is 13 KB. The mesh engine with its glTF loader and CPU skinning is under 10 KB. Every lazily loaded subsystem in the build together — both engines, the rig gizmo, the stats overlay, the mip and blit helpers — is 28 KB.

Three.js core is roughly 120 KB gzipped before a loader, a material library, or controls; that is the figure fabric’s design was measured against. Its GLTFLoader alone is larger than fabric’s SDF and mesh engines combined.

That gap is not minification. It comes from three decisions, each of which rejects something three.js does, and from the engine design those three force.

1. Shapes are distance functions on the GPU, not geometry

Section titled “1. Shapes are distance functions on the GPU, not geometry”

In three.js a sphere is a mesh: a vertex buffer, an index buffer, normals, UVs. Smoothness costs vertices — a good sphere is thousands of triangles, and every shape carries its own buffers through the scene graph and the draw loop.

In fabric a sphere is length(p) - r. A shape is one fixed-stride record — 64 floats, 256 bytes — in a single storage buffer. The GPU raymarches the distance function per pixel. There is no geometry, no tessellation, no resolution limit. The entire primitive library is 25 short WGSL functions selected by a type ID.

What this buys

  • Modifiers are arithmetic on the sample point. Twist rotates p.xz by k * p.y. Onion is abs(d) - t. Symmetry is abs(p.x). Eight of them compose into one bitmask per shape; an inactive modifier is a skipped branch. In three.js each of these is a custom shader or a mesh post-process.
  • Merged outlines and occlusion between shapes are free. Because every shape can evaluate every other shape’s distance, the effect path knows which shape owns the outline at a junction and whether a foreground shape occludes a halo. That is the sceneSDF walk, and it is why outlines of adjacent shapes fuse into one silhouette. Three.js has no distance field to ask.
  • The draw is instanced against a unit cube. 36 indices, drawn once per pipeline with an instance range. The cube is only a bounding proxy that gets the fragment shader running over the right pixels; the vertex shader sizes it per shape from a per-primitive envelope, inflated for rounding, glow radius and modifier reach. Three draws cover every SDF shape in the scene.

What this costs, and the design that answers it

Raymarching inverts the usual cost model. Fragments are expensive — a march can run a hundred steps, each evaluating a distance function — and vertices are free. So the frame budget is not triangle count. It is overdraw: every fragment that shades and is then covered by a nearer shape is wasted march time.

The <Fast> pipeline exists for that. It writes frag_depth at the hit, so overlapping instances fail the hardware depth test before their fragment shader runs. A dense cluster of forty shapes shades roughly once per pixel instead of forty times. The trade is that fast shapes give up per-shape outline and glow — the wrapper carries one shared halo instead. See Effect and Fast.

The second cost is the scene walk itself. Comparing against every shape at every step is O(N) per step, paid by every ray. The spatial grid bounds it: a 32³ uniform grid, shapes inserted by bounding box each frame, lookups capped at 15 per cell. The walk becomes O(1) regardless of scene size. See The spatial grid.

The third is the fragment path for shapes that need no effect at all. fs checks whether a shape has an outline or a glow; if it has neither, it skips the scene walk entirely and discards on miss. One distance evaluation per step instead of up to sixteen, with no flag set — the state is already in the record.

Three.js keeps an Object3D tree, and every frame walks it: world matrices, per-object frustum culling, sorting, one draw call per object. React-three-fiber puts React in front of that — state changes re-render components, React reconciles the JSX against the tree, and then three.js walks it. Two graphs, both traversed, every frame.

Fabric is built on phaze’s reactivity model instead, and that model has no graph. Its primitive is a signal read inside an effect; there is no component re-render, no reconciliation pass, and nothing to walk.

A fabric component runs once. <Box> reads its composer stack, writes one record into a Map, and returns nothing. It never runs again. Thirty-eight of fabric’s files import exactly effect and cleanup from phaze — that is the whole reactive vocabulary the library needs.

Engine readiness is a signal. useFabric().gpu() is a module-scoped phaze signal. Every component’s setup is the same shape:

effect(() => {
const g = ctx.gpu(); if (!g) return
shapesRef.current.set(id, record)
cleanup(() => shapesRef.current.delete(id))
})

The effect tracks gpu. When the engine restarts — a view transition, a remount — every shape re-registers itself; when it tears down, gpu.set(null) runs every cleanup. There is no lifecycle bookkeeping anywhere in the library, because the signal is the lifecycle.

Values are reactive sources, and the engine pulls them. Position, size, rotation and every material option accept a plain number, a photon PhotonValue, or a phaze signal. updateSDFBuffers duck-types as it writes the record — .get() on a PhotonValue, a call on a callable signal, the number as it is. A spring animating a sphere never touches the sphere component; it changes a value the record reads next frame. Flux’s ControlAccessor is a phaze computed with a .set, and fabric reads it like any other signal: one value at its source, consumed straight into a GPU buffer. That is phaze’s rule that reusing signals is the optimisation — the value exists once, and the record write is its reader. Fabric creates no computed of its own; derivation belongs to whoever owns the value.

Invalidation is subscription, not tracking. subscribeToSignals walks a shape’s props structurally and subscribes to every reactive value it finds — .on('change') for a PhotonValue, .subscribe for a signal — with one callback: bump the engine’s dirty counter. Scene, camera and grid props go through wire(prop, key): subscribe, write the scalar into the engine’s ref, bump the counter. It is explicit subscription rather than auto-tracking because tracking across the descendant boundary proved unreliable, and the subscribe form is now the contract.

The engine idles. When the dirty counter reaches zero and no simulation is running, the frame loop returns before touching the GPU. Only a signal wakes it — a value change, a pointer move, a scroll, a spring still in motion. A page with a static scene costs nothing after its last frame, where three.js draws every frame regardless. With Grid autoIdle even the particle simulation rests: the GPU drops out about a second after the last input and returns on the next.

Live edits are buffer writes. A mesh material is applied by a reactive effect gated on a loaded signal, with each material prop subscribed individually. Dragging a flux slider for ior rewrites about 80 bytes of the model uniform. Nothing re-renders, nothing remounts, and the component that declared the material has not run since mount.

Composers are a stack, not a tree. <Effect>, <Fast>, <Visible>, the modifiers and <Rig> push a frame while their children render; each shape reads the top of the stack as it registers. Pipeline, effects and gestures are decided once, at registration. There is no context propagation and no reconciliation, and a wrapper costs a shape nothing per frame.

What is not yet leveraged. updateSDFBuffers rewrites every shape’s record every frame — about seventy reactive reads per shape. At two shapes that is noise; at a hundred it is roughly seven thousand reads to move one slider. The subscriptions to invalidate per shape already exist; routing them to a per-shape dirty flag instead of the global counter is the open item.

Three.js cannot tree-shake its shader chunks — they are assembled at runtime from a chunk library that ships whole. Fabric’s shaders are strings in the bundle, so the build can cut them.

Three levels, in descending order of effect:

  • Import. The main entry exports only Camera, Scene, View, Text and Mesh, as named, side-effect-free exports. Shapes, modifiers and composers sit on direct paths. What you never import is never in the bundle.
  • Dynamic import. Each subsystem initialises behind import() — the SDF engine and its two shaders on the first shape mount, the mesh engine and glTF loader on a <Mesh>, the text engine on a <Text>, the stats overlay, axes, the point-cloud loader, background mips, the gizmo. A Grid-only scene fetches none of them.
  • Region strips. A build plugin scans your source and removes marked regions from fabric’s own code when the app never enables the feature. Every primitive is a region — its distance function and dispatch case leave when its tag appears nowhere — and so is every material branch and every modifier branch. Glass is the extreme case: not one feature but fifteen, each a region — grain, distortion, attenuation, metalness, iridescence, clearcoat, sheen, reflection, chromatic aberration, anisotropy, silhouette, gloss, refraction, slab refraction, bicubic — stripped independently from both the SDF and mesh shaders. Using refraction does not ship iridescence. The same mechanism removes the rig down to its type definitions, and removes import() sites themselves so a chunk nobody would fetch is never emitted.

The measured shape of this, brotli-compressed: on the scene measured when the primitive, material and modifier strips landed, the SDF shader chunk — all five fragment entry points — went from 39.13 KB raw and 9.11 KB compressed to 27.22 KB and 6.61 KB. sdfEngine.ts is 3.3 KB, the grid shader 1.2 KB. The stats overlay is 1.6 KB that leaves entirely when stats is false; the rig gizmo is 2.3 KB that leaves when no <Rig> is mounted.

What it costs. The build scans for fingerprints, so a literal false strips and any other expression keeps. Shape components must carry a literal type: N. And a shader is handwritten WGSL — there is no node graph or translation layer to generate it from, which is also why it compiles fast and reads as what it is.

One render pass, one depth buffer. Mesh and SDF draw into the same pass against one depth attachment: fast shapes → mesh → glass → the <Fast> halo → effect shapes. They occlude each other with no inter-pass copy. Three.js integrates custom raymarching through a separate render target and a composite. Merging the mesh draw into the SDF pass removed one render pass and one depth texture per frame. See Depth and z-sort.

Everything beyond that pass is conditional. The pass split, the background copy, the shape pre-pass, each mip chain, the mesh back-face pass — every one is conditional: the split and copy on a glass material, the pre-pass on a scene flag, each mip chain on an option that reads it, the back-face pass on a glass mesh. A scene with no glass runs one beginRenderPass and no copy. See Frame order.

Glass reads one copy of the screen. Three.js’s transmission material renders the scene into an offscreen target per transmissive object, then samples it. Fabric copies the swapchain once, after particles and text, into a background texture that every glass surface shares. SDF glass then does what a mesh cannot: it marches through its own volume to find the true exit point — analytically for spheres, by a short inner march for everything else — so refraction bends at both surfaces and absorption knows the real path length. A glass mesh gets the same result from a back-face pre-pass that records where the ray leaves. Two scene flags let glass see the other SDF shapes as well, and both share one pre-pass, so enabling the second costs almost nothing.

One uniform buffer for every draw. Camera, viewport, pointer and scene parameters are written once per frame into a buffer created at boot and never recreated. SDF, mesh, particle, text, axes and gizmo pipelines all bind it. Bind groups that reference it never go stale.

Skinning on the CPU, on purpose. Three.js skins on the GPU. Fabric recomputes skinned positions each frame and uploads them. That is slower for high bone counts, and it makes a second buffer possible: a static copy of the skinned bind pose that roughness bumping samples from, so the pattern stays fixed to the surface while the mesh animates. GPU skinning would have to be rebuilt to expose that.

Text is Bezier winding, not an atlas. Each glyph is a set of quadratic curves in a storage buffer; the fragment shader computes winding per sample. No texture, no atlas generation, sharp at any scale. Glyph quads shrink to the glyph’s own bounds so a period costs a handful of fragments.

The largest category of performance change in fabric’s record was not “make this faster.” It was finding something already wrong: a grid shader still declaring a 128-byte record after the buffer grew to 208, so every shape past the first read from inside its neighbour; a depth attachment lost across a pass boundary on iOS; a swapchain configured at zero width mid-navigation; a blit pipeline fetched too late.

Each surfaced as a visual bug and was found by measuring — the stride bug by tinting halos per instance parity and counting double-painted pixels, 85 before and 0 after. When something is slow and the algorithm looks right, look here first.