Skip to content

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.

loadGLB is a self-contained glTF 2.0 reader, no dependencies:

  1. Chunks. Split the binary into its JSON and BIN chunks.
  2. Accessors. Read any accessor into a Float32Array, handling every component type and normalisation.
  3. 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 its baseColorFactor, then white; UVs if present; indices widened to 32-bit for merging.
  4. Skinned primitives keep their local space. A primitive with JOINTS_0 and WEIGHTS_0 whose 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.
  5. 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.
  6. Centre. The merged mesh is centred on its bounding box, and the offset remembered so animation can re-apply it.
  7. 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.

Five vertex buffers, one per attribute:

BufferRewritten by animation
positionevery frame
normalevery frame
colourno
uvno
bind positionno — 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.

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.

PipelineEntryCullWrites
pipelinefsnonecolour, depth less
glassBackPosPipelinefs_backposfrontworld position to an rgba16float target
glassPipelinefs_glassnonecolour, 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.

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.

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.