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.materials is a fixed [32]TypedAssetRef (:25).
  • subsystems/render/state.zig:107 MAX_SUBMESHES = 32; GpuMesh.submeshes is [32] (:118).
  • subsystems/render/assets.zig:218 clamps upload to min(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:98 all apply the same min(…, 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 by Submesh.material_slot, sized to the mesh's unique material count. Both renderers look up materials[submesh.material_slot].
  • Raise the ceiling to cover real assets (Bistro = 132). Pragmatic v1: bump the fixed cap to MAX_MATERIALS = 256 and 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.submeshes becomes a heap slice (1,591 ranges), not [32]. It's engine-runtime state, not POD-over-ABI, so a slice is fine. Remove the min(…, 32) clamp in assets.zig.
  • Software renderer (SoftwareRenderer.zig:327) lifts the same cap for parity.
  • Inspector (studio/inspector/Inspector.zig) shows the material slots by name (from material:{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.prefab renders the full Bistro storefront (compare to the reference images in #132), verified with a headless Screenshots.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.
Edited by Bruno Massa