Rendering: render full multi-material meshes — lift 32-submesh/material cap (Bistro exterior)
Current State
BistroExterior.fbx imports and cooks completely — the cooked TMSH artifact
holds all the geometry (verified by reading the header directly):
| Metric | Value |
|---|---|
| Vertices | 8,496,360 |
| Triangles | 2,832,120 |
| Submeshes | 1,591 |
Unique materials (.meta sub-assets) |
132 |
| Submeshes actually drawn | 32 |
| Materials actually bound | 32 |
Only 32 of 1,591 submeshes reach the GPU, so ~98% of the model is dropped at draw time — this is why the scene shows only a doorframe and a few scattered strips instead of the storefront.
Root cause
A hard MAX_SUBMESH_MATERIALS = 32 ceiling (engine/components/MeshRendererComponent.zig:15)
runs through the whole runtime and clamps everything:
MeshRendererComponent.materialsis a fixed[32]TypedAssetRef(:25).subsystems/render/state.zig:107MAX_SUBMESHES = 32;GpuMesh.submeshesis[32](:118).subsystems/render/assets.zig:218clamps upload tomin(count, 32)— the full 272 MB VBO uploads, but only the first 32 index-ranges are recorded/drawn.subsystems/render/root.zig:483,engine/SoftwareRenderer.zig:327,editor/project/SceneIo.zig:49,121,179,studio/services/AssetResolution.zig:98all apply the samemin(…, 32).
Second, deeper flaw: materials are bound positionally by submesh index
(materials[submesh_index]). With 1,591 submeshes sharing only 132 materials,
this is both wrong-sized and semantically off — Submesh.material_slot (already
stored in TMSH) is the correct key.
Goal
A mesh renderer draws all of its submeshes with the correct material,
keyed by material_slot, with no fixed 32-cap. Bistro Exterior renders in full.
Scope
- Binding model: replace the positional
materials[32]with a table indexed bySubmesh.material_slot, sized to the mesh's unique material count. Both renderers look upmaterials[submesh.material_slot]. - Raise the ceiling to cover real assets (Bistro = 132). Pragmatic v1: bump
the fixed cap to
MAX_MATERIALS = 256and keep the POD array — this preserves the play-mode C-ABI POD exchange (serde GP-faults in the play dylib) with zero extra work. Worst case ~256×44 B ≈ 11 KB/component × 128 objects ≈ 1.4 MB. (An out-of-line material table that removes the cap entirely is a follow-up.) - GPU submeshes dynamic:
GpuMesh.submeshesbecomes a heap slice (1,591 ranges), not[32]. It's engine-runtime state, not POD-over-ABI, so a slice is fine. Remove themin(…, 32)clamp inassets.zig. - Software renderer (
SoftwareRenderer.zig:327) lifts the same cap for parity. - Inspector (
studio/inspector/Inspector.zig) shows the material slots by name (frommaterial:{d}sub-assets), not 32 anonymous GUIDs. modelSubmeshMaterials(AssetResolution.zig) returns the by-slot table.
Breaking change + migration
Scene JSON material_guids semantics shift from per-submesh to
per-material-slot. The existing scenes/.prefab files (e.g. the Bistro
scene.prefab, which has 32 per-submesh entries) must auto-migrate on load
(warn via scene_io log) and get a batch turian-cli migrate-scenes pass —
per the project's breaking-change-migration rule. TMSH geometry format is
unchanged; ideally only the component/scene format migrates (no re-cook needed).
Acceptance
scene.prefabrenders the full Bistro storefront (compare to the reference images in #132), verified with a headlessScreenshots.capture()+ FPS check.- A mesh with >32 materials assigns and renders every slot correctly.
- Old scenes still open (auto-migrated) and the CLI batch tool rewrites them.