Skip to content

Engine overview

Fabric has two engines. Everything else is a subsystem that rides on the frame they share.

EngineDrawsOwns
SDFRaymarched shapesShape record buffer, three pipelines, the spatial grid
MeshRasterised glTFVertex 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.

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.

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.

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.

TermMeans
RecordOne 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 bufferThe GPU storage buffer objects: one record per visible shape, in partition order.
PartitionThe record order — <Fast> shapes, then glass, then the rest — so each pipeline draws one contiguous instance range.
updateSDFBuffersThe 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 arrayThe 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 wordA 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 cubeThe 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.
EnvelopeThe per-primitive extents the vertex shader sizes the proxy cube from, inflated for rounding, effects and modifiers.
Dirty frameA frame on which something changed. Records are rewritten and the frame drawn; otherwise the engine idles.

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.