Models: the .nmdl format
3D models (landmarks, vehicles) stand on the map in its own frame: metres, x east, y south, z up, a model's front facing north. They reach the renderer in one of two ways:
.nmdl, nimbus's own baked file, made offline from a glTF bynimbus-bake. Read with no serde, no glTF and no JSON: a header, a table of sections, and sections of fixed layout, so reading is a walk over the bytes, and the vertex and index data are close to what the GPU takes.- glTF (
.glb), read at runtime for models that arrive as glTF, such as ones placed through an editing tool. On the web it is read by a separately loaded module (see Building for WASM & iOS).
The reader and writer live in crates/nimbus-model/src/nmdl.rs.
Layout
All numbers are little-endian. Strings are a u32 byte length followed by that many
bytes of UTF-8, with no terminator and no padding.
Header (16 bytes)
| Offset | Size | Field |
|---|---|---|
| 0 | 4 | magic, the ASCII bytes NMDL |
| 4 | 2 | version, u16: 1 |
| 6 | 2 | flags, u16: 0 |
| 8 | 4 | section count, u32 |
| 12 | 4 | reserved, 0 |
Section table (16 bytes per section, right after the header)
| Offset | Size | Field |
|---|---|---|
| 0 | 4 | kind, four ASCII bytes (META, PART, …) |
| 4 | 4 | offset of the section from the start of the file, u32 |
| 8 | 4 | length in bytes, u32 |
| 12 | 1 | encoding: 0 raw; 1 meshopt vertex codec (VERT only); 2 meshopt index codec (INDX only) |
| 13 | 3 | reserved, 0 |
Every section starts on a 16-byte boundary. Order does not matter; each kind appears once.
Encodings. The geometry sections can go through
meshoptimizer's codecs (version 0 of
each, read and written with the Rust port meshopt-rs), as EXT_meshopt_compression
uses them:
VERTwith encoding 1 is the vertex count (u32) followed by the encoded vertices.INDXwith encoding 2 holds, for each primitive at the offset itsPRIMentry gives, the encoded length (u32) followed by the encoded indices, padded to 4 bytes. The index codec may start a triangle from another of its corners: the same triangles come back, in the same order and the same winding.
Whether a codec makes the download smaller depends on the model, so nimbus-bake
writes each section both ways and keeps whichever is smaller once brotli-compressed.
So far the index codec always wins and the vertex codec never does: a regular mesh
leaves brotli more to find in the plain vertex layout than in the codec's output.
META: the model as a whole
| Field | Type |
|---|---|
| name | string |
| credit (the attribution to show while it is on screen) | string |
| bounding box minimum, metres | f32 × 3 |
| bounding box maximum, metres | f32 × 3 |
| quantisation offset | f32 × 3 |
| quantisation scale | f32 × 3 |
| default shadow density, 0 to 1 | f32 |
| replaces the map's buildings under it by default, 0 or 1 | u8 |
PART: pieces a placement can move on their own
u32 count, then for each: name (string), pivot in metres (f32 × 3). A car's
wheels are parts named wheel.FL, wheel.FR, wheel.RL and wheel.RR, turned
about their hubs.
MATL: materials (64 bytes each)
u32 count, then for each:
| Offset | Size | Field |
|---|---|---|
| 0 | 16 | base colour, RGBA f32 × 4 |
| 16 | 4 | metallic, f32 |
| 20 | 4 | roughness, f32 |
| 24 | 12 | emissive, RGB f32 × 3 |
| 36 | 4 | specular, f32 (1 is the usual 4 percent) |
| 40 | 4 | normal map scale, f32 |
| 44 | 4 | alpha cutoff, f32 (used in mask mode) |
| 48 | 1 | alpha mode: 0 opaque, 1 mask, 2 blend |
| 49 | 1 | double-sided, 0 or 1 |
| 50 | 2 | reserved |
| 52 | 10 | textures, u16 × 5, into TEXT: base colour, metallic-roughness, normal, emissive, specular; 0xFFFF for none |
| 62 | 2 | reserved |
TEXT and TDAT: textures
TEXT is a u32 count, then 20 bytes per texture:
| Offset | Size | Field |
|---|---|---|
| 0 | 1 | format: 1 WebP, 2 PNG, 3 JPEG |
| 1 | 1 | colour data (base colour, emissive), 0 or 1; otherwise data, such as a normal map |
| 2 | 2 | reserved |
| 4 | 4 | width in pixels, u32 |
| 8 | 4 | height in pixels, u32 |
| 12 | 4 | offset of its bytes into TDAT, u32 |
| 16 | 4 | length of its bytes, u32 |
TDAT holds the encoded images end to end. They stay encoded in the file: the web
build has the browser decode them, native builds the image crate. Images are one
file for every platform; GPU-compressed formats can get their own format code later.
VERT: vertices (20 bytes each)
| Offset | Size | Field | GPU vertex format |
|---|---|---|---|
| 0 | 8 | position, u16 × 4 (the fourth is 0) |
unorm16x4 |
| 8 | 4 | normal, i8 × 4 (the fourth is 0) |
snorm8x4 |
| 12 | 8 | texture coordinates, f32 × 2 |
float32x2 |
A position is put back, per axis, as offset + q / 65535 × scale with the
quantisation from META: one step is a 65535th of the bounding box, under a tenth
of a millimetre for a 5 m car. A normal is n / 127, normalised. Texture
coordinates stay f32 because a model's own UVs can tile a texture far beyond 0 to
1 (one material of the 205 repeats its texture 1282 times).
INDX and PRIM: draws and their levels of detail
INDX holds every primitive's indices, a run per primitive, each run 16-bit or
32-bit and padded to 4 bytes. PRIM is a u32 count, then 60 bytes per primitive:
| Offset | Size | Field |
|---|---|---|
| 0 | 2 | material, u16; 0xFFFF for glTF's default material |
| 2 | 2 | part, u16; 0xFFFF for none |
| 4 | 4 | first vertex, u32 |
| 8 | 4 | vertex count, u32 |
| 12 | 4 | offset of its indices into INDX, in bytes, u32 |
| 16 | 4 | index count, u32 |
| 20 | 1 | index size: 2 or 4 bytes |
| 21 | 1 | LOD count, 1 to 3 |
| 22 | 2 | reserved |
| 24 | 36 | LODs, 3 × (first index u32, index count u32, error in metres f32) |
Indices count from the primitive's first vertex, so a primitive under 65,536 vertices uses 16-bit indices whatever the model's size. The LODs are runs of the primitive's own indices over the same vertices, the finest first; each records how far, in metres, it strays from the full mesh, which is what tells the renderer when a coarser one is invisible. Unused LOD slots are zero.
How the renderer draws a model
Every model is uploaded in the .nmdl vertex layout, whichever file it came from:
positions quantised into the model's box, normals in signed bytes, 16-bit indices
where a primitive's vertices allow. A .nmdl goes back into the box it was baked
in, so nothing moves; a glTF is quantised on the way in. The vertex shader puts a
position back as offset + q × scale, with the box carried in the placement's
uniforms.
Each frame, each placement is drawn at the coarsest level of detail whose error, on screen, stays under three quarters of a pixel. How many of the model's metres a pixel covers there is the map's own scale, grown by how much further from the eye the model is than the point the camera looks at, and shrunk by the placement's scale. A level's error is the worst of its primitives' at that level. For the 205 at Montpellier's latitude:
| Zoom | A pixel covers | Level drawn |
|---|---|---|
| 22 | about 1.4 cm | 1 (1.0 cm error, 36.6k triangles) |
| 20 | about 5.5 cm | 1 |
| 19 | about 11 cm | 2 (4.9 cm error, 34.6k triangles) |
| 17 | about 43 cm | 2 |
So at the map's own zooms the full mesh is drawn only where the car is nearer the eye than the point the camera looks at, as in a tilted view's foreground. The shadow pass always draws the coarsest level: a shadow is soft, and the coarsest level is the cheapest.
Making a .nmdl
Two steps, both driven by one options file per model, models/<id>.toml (the
models themselves are large and stay out of the repository):
make models # every model in models/; `make wasm` runs it too
# or one at a time, --force to bake it whatever its age:
cargo run -p nimbus-bake --release -- models/peugeot-205-pikes-peak.toml
It is a build step. A model whose .nmdl is newer than everything it is made from
(its options file, its glTF, its source, the bake tool itself) is left alone; one
with a [cook] section is cooked in Blender first when its cooked file is older than
its source, its options or scripts/cook-model.py (Blender is looked for in
/Applications unless BLENDER names it); and one whose sources are not on this
machine is skipped with a note rather than failing the build, since the models
themselves stay out of the repository.
Cook (scripts/cook-model.py, in Blender, only for a model with a [cook]
section): reads source and writes input.
droplists materials whose faces go, such as a car's interior, which nobody sees from outside but which cost the 205 a third of its triangles.keeplists materials left as they are, out of the atlas: transparent ones, whose alpha an opaque atlas would lose.- Every other material is baked into one atlas material: base colour at
atlaspixels a side, normal and metallic-roughness atdata_atlas. The atlas gets a fresh unwrap (Smart UV Project): a model's own UVs can tile a texture far past 0 to 1 and carry degenerate faces, either of which wrecks a packing. Base colour and metalness are baked through an emission shader, since Cycles' diffuse pass reads black on a metal and metalness has no pass of its own.
A material is named by its glTF name, or without Blender's numbered suffix:
suede matches suede.001; 205_external_dyna1.005 matches only itself.
Bake (crates/nimbus-bake, offline only, never shipped): reads input and
writes output.
- Images
inputshares by name withsourceare taken fromsource: Blender's glTF exporter writes the colour of a texture with an alpha channel as black. - Every primitive is ordered for the GPU's vertex cache and simplified with
meshoptimizer (
meshopt-rs) into the coarser levels the[[lod]]entries ask for: a share of the triangles and the largest error allowed, in metres, whichever comes first. A level not at least 10% coarser than the one before is left out. The port does not report the error a level reached, so the bake bisects for it: the smallest error at which simplifying still gets down to as few triangles. It is also an older meshoptimizer than the C++ one, and simplifies a little less far. - Textures are scaled down to
[textures] max_size(the cook's atlases to the sizes[cook]gave them) and encoded once as lossy WebP:qualityfor colour,data_qualityfor normal and metallic-roughness maps. - A model over its
[budget](download in MB once brotli-compressed, texture megapixels, triangles at the finest level) fails the bake, and nothing is written for it.
The Giza pyramids are made by web/pyramids.js, whose stone texture is painted on
an OffscreenCanvas: node scripts/export-pyramids.mjs builds them in headless
Chrome against the dev server and writes their glTF for the bake.
Baked, the monuments the web page places come to:
| Model | glTF, brotli | .nmdl, brotli |
Triangles, finest to coarsest |
|---|---|---|---|
| Eiffel Tower | 1.41 MB | 0.62 MB | 147.6k, 112.4k |
| Colosseum | 1.03 MB | 0.56 MB | 58.0k, 52.5k |
| Stonehenge | 0.15 MB | 0.07 MB | 10.8k, 5.4k, 3.5k |
| Each Giza pyramid | about 12 KB | about 2 KB | 10 to 18 |
It reports what the model costs. For the 205:
cook: dropped 34013 faces; 19 materials into the atlas
cooked: 11 materials dropped, 5 kept out of the atlas, atlases 2048 and 1024 px
8 images taken from models/../web/models/peugeot-205-pikes-peak.glb
file 2.67 MB, 1.56 MB brotli (indices through meshopt)
textures 11 images, 7.37 Mpx, 39.3 MB of GPU memory
geometry 81809 vertices (was 81809), 10 draws, 6 materials
LOD 0 60713 triangles (was 60713), error up to 0.000 m
LOD 1 36645 triangles, error up to 0.010 m
LOD 2 34640 triangles, error up to 0.049 m
against 12.3 MB brotli, 131 MB of GPU memory and 50 draws for the glTF it came from.
Reading rules
A reader refuses, with an error rather than a panic:
- a file that does not start with
NMDL; - a version newer than its own;
- a section whose encoding it does not know;
- anything that runs past the end of the file or of its section;
- a primitive naming a material, part or vertex range the file does not have, an index past its primitive's vertices, or an LOD past its indices;
- a material naming a texture the file does not have.
It skips section kinds it does not know, so a later version can add sections without breaking older readers.
Status
| Piece | State |
|---|---|
Format, reader and writer (nimbus_model::nmdl) |
done, with round-trip and truncation tests |
Nmdl::to_model_data: today's renderer draws a .nmdl at its finest LOD |
done |
loadModel (web) and addModel (native, Swift) taking .nmdl as well as glTF |
done |
nimbus-bake: glTF to .nmdl, with meshoptimizer LODs, WebP textures, budgets |
done |
| meshopt codecs for the geometry, chosen per model | done |
make models as an incremental build step, run by make wasm |
done |
Blender cook script and per-model options (models/<id>.toml), make models |
done |
Every monument the web page places, and the 205, baked and loaded as .nmdl |
done |
| Renderer drawing quantised vertices, choosing LODs, coarsest LOD for shadows | done |