Little-endian, tagged-chunk container. Magic "HMSH" (0x4853'4D48). v2 introduced the DESC + SUBM chunks, made every data chunk a pure array (no inline prelude), and shrank GpuVertex from 36 B to 28 B.
The DESC chunk is the single source of truth for the mesh's shape: counts, strides, and the index width all live there, so every other data chunk (VTXS, IDXS, MLET, ...) is a bare array the loader can mmap-cast directly using DESC.
A glTF/GLB mesh additionally emits a companion material table (.hmat) plus one .ktx2 per referenced image; a skinned + animated source also emits clips (.hanim). A submesh's materialSlot indexes straight into the .hmat table (.hmat row i ⇔ SubMesh::materialSlot i).
+--------------------------------+
| FileHeader (32 B) |
+--------------------------------+
| ChunkTable (24 B * N) |
+--------------------------------+
| Chunk payloads (16 B aligned) |
+--------------------------------+
| Offset | Field | Type | Notes |
|---|---|---|---|
| 0 | magic |
u32 | "HMSH" (0x4853'4D48) |
| 4 | version |
u32 | 2 |
| 8 | chunkCount |
u32 | number of entries in ChunkTable |
| 12 | flags |
u32 | reserved |
| 16 | _reserved1 |
u64 | reserved (was contentHash, held) |
| 24 | _reserved2 |
u64 | reserved |
| Offset | Field | Type | Notes |
|---|---|---|---|
| 0 | fourcc |
u32 | ChunkId value (see below) |
| 4 | flags |
u32 | per-chunk flags (compression hint) |
| 8 | offset |
u64 | byte offset from file start |
| 16 | size |
u64 | payload bytes |
Chunks are written in any order, padded to 16-byte alignment between payloads. Unknown chunks are ignored by readers (forward compatibility).
All data chunks are pure arrays — element counts, the vertex stride, and the index width live in DESC, not inline in the chunk.
| FourCC | Purpose | Payload |
|---|---|---|
DESC |
mesh descriptor | MeshDesc (32 B): counts (vertex/index/meshlet/submesh/material), vertexStride (u16), indexWidth (u8, 2 or 4), flags (u8), meshlet build params |
BNDS |
mesh bounds | MeshBounds (40 B): AABB min/max (vec3) + sphere center/radius |
VTXS |
vertex stream | GpuVertex[vertexCount] (28 B stride) |
IDXS |
index stream | u16[] or u32[] per DESC.indexWidth; global indices, all submeshes |
MLET |
meshlets | Meshlet[]: per-meshlet vertex/triangle offsets and counts |
MLVR |
meshlet vertices | u32[]: per-meshlet local-to-global vertex index remap |
MLTR |
meshlet triangles | MeshletTriangle[]: 3 × u8 per triangle, dense (no inter-meshlet pad) |
MLBN |
meshlet bounds | MeshletBounds[]: per-meshlet center/radius + cone-cull axis/cutoff |
MTRL |
material refs | u64[materialCount] (FNV1a64 of material runtime refs; see below) |
SUBM |
submesh table | SubMesh[submeshCount] (64 B): index/meshlet ranges, materialSlot, per-submesh bounds |
SKIN |
per-vertex skinning | optional GpuSkinVertex[vertexCount] (24 B): u16 joints[4] + f32 weights[4] (sum 1); parallel to VTXS |
SKEL |
skeleton | optional GpuJoint[jointCount] (112 B): inverse-bind matrix, bind-pose TRS, parent index |
LODI |
LOD index buffer | optional u32[] global vertex indices for reduced levels (concatenated) |
LODT |
LOD table | optional LodTableHeader (8 B) + MeshLod[submeshCount*lodCount] (8 B): per-submesh ranges into LODI |
Each SubMesh is one drawable section sharing a material: a contiguous [firstIndex, firstIndex+indexCount) range into IDXS and [firstMeshlet, firstMeshlet+meshletCount) into MLET, plus a materialSlot (index into MTRL/.hmat, or kNoMaterial = 0xFFFFFFFF) and its own AABB+sphere. One glTF primitive → one submesh.
| Offset | Field | Type | Notes |
|---|---|---|---|
| 0 | position |
f32 × 3 | object-local position (unquantized) |
| 12 | normal |
i16 × 2 | octahedral normal (SNORM) |
| 16 | tangent |
i16 × 2 | octahedral tangent + handedness in bit 0 of x (SINT) |
| 20 | uv |
f32 × 2 | UV0 (top-left origin, matches Vulkan) |
The v1 layout carried 8 bytes of reserved padding (two extra i16 per packed slot); v2 dropped them.
Suggested Vulkan vertex input formats: normal → VK_FORMAT_R16G16_SNORM, tangent → VK_FORMAT_R16G16_SINT (shader needs raw integer access to extract the handedness bit).
The MTRL chunk holds FNV1a64 hashes — compact: only materials actually referenced by a submesh, in first-use (dense slot) order. SubMesh::materialSlot indexes into this list (or is kNoMaterial). The same dense slot order is shared by the companion .hmat table, so MTRL[i], .hmat row i, and materialSlot == i all describe the same material.
The hash input is a canonical runtime ref of the form <sourceRef>/<leaf> where:
sourceRefis the source asset's runtime path withassets/prefix stripped, extension dropped, lowercased,/-separated (e.g.models/chair).leafis the lowercased material name if non-empty AND unique within the source's material array; otherwisematerial_<index>(using the source index, so leaf names stay stable regardless of which materials are referenced).
Example: assets/models/chair.glb with source materials ["Wood", "", "Leather", "Wood"], where submeshes reference source materials 2 then 0 (first-use order), produces MTRL = [hash("models/chair/leather"), hash("models/chair/material_0")] — two entries, dense slots 0 and 1.
A skinned glTF (primitives with JOINTS_0/WEIGHTS_0, node with a skin) additionally emits two optional chunks. Static meshes omit both and are byte-identical to before.
SKINis parallel toVTXS— oneGpuSkinVertex(u16 joints[4],f32 weights[4]) per vertex.jointsindex intoSKEL;weightsare renormalized to sum 1 (a weightless vertex falls back to a rigid bind to joint 0).SKELis the joint array. EachGpuJointcarries the inverse-bind matrix (mesh space → joint bind space, column-major), the joint's local bind-pose TRS (bindT/bindRquaternionxyzw/bindS), and aparentindex (−1 for a root). Joint order follows the glTF skin'sjointsarray, soJOINTS_0indices map directly.
Per the glTF spec, a skinned mesh node's own transform is ignored — assetc does not bake the node world matrix into skinned vertices (only static meshes are baked). Only the first skin (skin[0]) is exported; additional skins are warned and ignored.
Every submesh additionally gets reduced LODs via meshoptimizer simplification (default 2 levels at ~50% and ~25% of the triangle count). They live in two optional chunks so the full-res path is untouched: LODI is a u32 index buffer (global vertex indices, same VTXS) and LODT is a header plus a MeshLod{firstIndex, indexCount} per [submesh][lod] row. LOD0 is the full-resolution mesh in IDXS/SUBM/MLET (meshlets are LOD0-only); the reduced levels are for classic distance-based indexed draws. A MeshLod with indexCount == 0 means simplification stalled at that level, so the engine should reuse the previous LOD. ValidateHMesh checks every range lands inside LODI.
Little-endian only. src/sdk/src/runtime_mesh.cpp enforces this with a static_assert(std::endian::native == std::endian::little).