Skip to content

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 by nimbus-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:

  • VERT with encoding 1 is the vertex count (u32) followed by the encoded vertices.
  • INDX with encoding 2 holds, for each primitive at the offset its PRIM entry 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.

  • drop lists 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.
  • keep lists 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 atlas pixels a side, normal and metallic-roughness at data_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 input shares by name with source are taken from source: 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: quality for colour, data_quality for 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