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.xzbyk * p.y. Onion isabs(d) - t. Symmetry isabs(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
sceneSDFwalk, 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.
2. Fabric is phaze-native
Section titled “2. Fabric is phaze-native”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.
3. Unused code is absent, not minified
Section titled “3. Unused code is absent, not minified”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,TextandMesh, 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.
The engine those three force
Section titled “The engine those three force”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.
Reading the history
Section titled “Reading the history”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.