Engine overview
Fabric has two engines. Everything else is a subsystem that rides on the frame they share.
| Engine | Draws | Owns |
|---|---|---|
| SDF | Raymarched shapes | Shape record buffer, three pipelines, the spatial grid |
| Mesh | Rasterised glTF | Vertex and index buffers, CPU skinning, mesh materials |
The mesh engine and its loader together are the largest part of fabric. The SDF engine is roughly a third of that. If you are estimating the cost of a change, that ratio is usually the right prior.
The subsystems
Section titled “The subsystems”Smaller, self-contained, each added on first use — except particles, which is built at boot.
- Particles — the primary particle system and its additive layers.
- Text — glyphs drawn directly from their Bezier outlines, no atlas. Its own shader, no code shared with the SDF engine.
- Axes, gizmo, blit, background mips — single-purpose, loaded only when something asks for them.
They are independent
Section titled “They are independent”Each engine and subsystem is behind a dynamic import and initialises on first
use. A scene with no <Mesh> never loads the mesh engine or the glTF loader. A
scene with no SDF shapes never loads the SDF engine, its shader, or the grid
shader.
That independence is the point: coupling them would mean loading both to use either. See Lazy subsystems.
Where they meet
Section titled “Where they meet”submitFrame in render/renderer.ts is the only place they meet. Adding a
render pass means editing that function — there is no pass hook.
Three seams, each documented separately, because each is a place where changing one engine can break the other:
- Depth and z-sort — mesh and SDF share one depth attachment in one render pass. That is the only reason they occlude each other correctly.
- Frame order — what draws when, and the conditional pass split glass forces.
- Buffers and uniforms — the render uniform buffer is shared by the SDF, mesh, text, particle, axes and gizmo draws.
These pages use a small fixed vocabulary for the SDF data path. Each word names one mechanism.
| Term | Means |
|---|---|
| Record | One shape’s 64 floats — 256 bytes — in the shader’s struct Object layout. Fixed size whatever the shape uses; a shape that is not glass never reads its 32 glass slots. |
| Shape buffer | The GPU storage buffer objects: one record per visible shape, in partition order. |
| Partition | The record order — <Fast> shapes, then glass, then the rest — so each pipeline draws one contiguous instance range. |
updateSDFBuffers | The function that, on a dirty frame, walks the shape map, resolves every reactive value, writes every visible record from slot 0, and uploads the written prefix with one writeBuffer. |
| Staging array | The CPU Float32Array those records are written into — kept across frames, rewritten whole, never read back. The image of the shape buffer, not a lookup. |
| Bit-field word | A 32-bit slot holding several bytes or flags — the four colours, the two property words, packed_reserved: seven of the 64 slots. The modifier mask is a bit-field too, held as an integer in the float modA.x. Not compression: nothing in the record ever shrinks. |
| Proxy cube | The unit cube each shape is drawn as, 36 indices, one instance per record. Its only job is to run the fragment shader over the right pixels. |
| Envelope | The per-primitive extents the vertex shader sizes the proxy cube from, inflated for rounding, effects and modifiers. |
| Dirty frame | A frame on which something changed. Records are rewritten and the frame drawn; otherwise the engine idles. |
Two rules from iOS Safari
Section titled “Two rules from iOS Safari”Both exist because a device disagreed with the spec. Breaking either produces intermittent artefacts on iOS Safari and nothing on desktop.
Read the swapchain once per frame. submitFrame calls
context.getCurrentTexture() once, at the top, and every pass that draws to
the screen reuses that view. The spec says repeated calls within a frame return
the same texture; iOS Safari under ProMotion can return a different image on
the second call, and that pass then draws into a texture that is never
presented. Symptom: flicker limited to what the second pass drew, worse at
120 Hz than at 60.
The second pass clears depth, never loads it. When glass splits the frame,
pass 2 sets depthLoadOp: 'clear'. Apple Silicon’s tile-based renderer does
not preserve the depth attachment across pass1.end() → copy → mip generation
→ pass2.begin(), so a loaded depth is garbage and every pass-2 draw fails its
test. Clearing costs nothing — particles draw in pass 1 without writing depth,
so it was at its clear value anyway. Symptom: random frames lose every SDF,
mesh and glass while particles remain, worse while scrolling. The rule applies
to any pass split, not only the glass one.