Mesh engine
render/meshEngine.ts and render/loadGLB.ts together are the largest part
of fabric. Both load by dynamic import on the first <Mesh>.
The mesh is the one thing in fabric that is rasterised: real triangles through a vertex and fragment pipeline. It draws in the same render pass as the SDF shapes against the same depth attachment — see Depth and z-sort.
Loading a GLB
Section titled “Loading a GLB”loadGLB is a self-contained glTF 2.0 reader, no dependencies:
- Chunks. Split the binary into its JSON and BIN chunks.
- Accessors. Read any accessor into a
Float32Array, handling every component type and normalisation. - Walk the scene. Depth-first from the scene’s root nodes, accumulating
each node’s TRS into a world matrix. For each primitive: positions and
normals baked into world space, colours from
COLOR_0, then the material’s base colour texture, then itsbaseColorFactor, then white; UVs if present; indices widened to 32-bit for merging. - Skinned primitives keep their local space. A primitive with
JOINTS_0andWEIGHTS_0whose parent is not itself a joint is a combined skinned mesh; its vertices stay in bind space and its joints, weights and inverse bind matrices are kept for skinning later. - Merge. Every primitive into one position, normal, colour, UV and index buffer, with per-primitive metadata recording where it landed and which node it belongs to. Flat normals are generated if the file had none.
- Centre. The merged mesh is centred on its bounding box, and the offset remembered so animation can re-apply it.
- Animations. Each channel becomes a track — node, path, keyframe times, values, interpolation.
Textures are decoded to pixels at load and uploaded as one rgba8unorm
texture; only the first is used. Standard glTF only — Draco, Meshopt and KTX2
are not read.
The buffers
Section titled “The buffers”Five vertex buffers, one per attribute:
| Buffer | Rewritten by animation |
|---|---|
| position | every frame |
| normal | every frame |
| colour | no |
| uv | no |
| bind position | no — a static copy of the skinned bind pose |
The bind-position buffer is why skinning is on the CPU. Roughness bumping and
glass grain sample noise at that coordinate, so their pattern stays fixed to
the surface while the live position animates through it. It must hold the
skinned bind pose, not the loader’s raw positions — for a skinned sub-mesh
those are in bone-local space, and noise sampled there produces a near-constant
bump that slides. <Mesh> overwrites the buffer once, after the first
skinning pass. See Mesh.
Bind groups
Section titled “Bind groups”Group 0 holds only the shared render uniform buffer. It used to borrow the particle pipeline’s layout, which bound five buffers the mesh never read; when the particle system recreated those buffers, the mesh’s in-flight command buffer could reference memory iOS Safari had already released — visible as mesh flicker that scaled with frame rate. Now the mesh references nothing the particle or SDF systems can churn.
Group 1 is the mesh’s own: a 272-byte model uniform, the texture, and a
sampler. The uniform holds the model matrix, the material colour and
parameters, roughness, and — for glass — ten more vec4 slots of glass
options.
Group 2 exists only for glass: the background copy and its sampler, the back-face position texture and its sampler, the shape pre-pass texture, and the pre-pass depth.
The three pipelines
Section titled “The three pipelines”| Pipeline | Entry | Cull | Writes |
|---|---|---|---|
pipeline | fs | none | colour, depth less |
glassBackPosPipeline | fs_backpos | front | world position to an rgba16float target |
glassPipeline | fs_glass | none | colour, depth less |
fs is lambert with an optional phong branch and roughness bumping. The
back-face pipeline culls front faces so only the far side of the mesh is
drawn, with depth less from a clear of 1.0 keeping the first back face the
view ray reaches — the exit surface for a convex shell. fs_glass reads that
texture to find where its ray leaves the volume; see Glass.
Skinning
Section titled “Skinning”buildWorldMats starts from every node’s bind-pose TRS, overrides the
translation, rotation and scale of any node an animation track targets at the
sampled time, builds local matrices, and walks parent to child to produce
world matrices. Rotation tracks interpolate with shortest-path NLERP — the dot
product is checked and one quaternion negated before the lerp — or bones swing
the long way round.
applySkeletalPose then re-bakes every primitive from its saved local
positions: a skinned primitive weights up to four joints per vertex, each
taking the vertex through the joint’s inverse bind matrix and then its world
matrix; a rigid primitive takes its node’s world matrix whole. Normals go
through the same matrices without translation and are re-normalised.
<Mesh> runs both each animation frame and writes the results into the
position and normal buffers. A mesh with no animation is skinned once at load,
so skinned sub-meshes sit in the right place before any clip starts.
Material updates are buffer writes
Section titled “Material updates are buffer writes”updateMeshMaterial writes slices of the model uniform in place — colour and
parameters, roughness, and for glass each of the ten option slots. Switching
shader between lambert, phong and glass flips a flag the renderer reads next
frame to choose the pipeline; nothing is rebuilt.
roughnessScale is normalised by the mesh’s bounding-box extent, written at
upload, so the same value means the same grain on a GLB shipped at 0.05 units
and one shipped at 5. The same flags that gate the SDF mip chains —
roughness or anisotropy on, reflection roughness on — are set here for the
mesh, and the renderer ORs the two.