Skip to content

nel_gltf_extras

Jan Boon edited this page Jul 6, 2026 · 3 revisions

title: NeL-Specific glTF Extras (nel_* keys) description: Catalogue of custom keys the NeL toolchain writes into and reads from glTF nodes' "extras" objects — used to preserve information the standard glTF properties can't carry losslessly (float32 bit-exact TRS, NeL-specific flags) published: true date: 2026-07-05T00:00:00.000Z tags: editor: markdown dateCreated: 2026-07-05T00:00:00.000Z

The NeL pipeline uses glTF 2.0 as a lossless intermediate between authoring tools and the runtime asset formats (.skel, .shape, .anim, …). Standard glTF properties (translation, rotation, scale, matrix) get lossily transformed by import/export layers — assimp, in particular, combines per-node TRS into a single aiNode::mTransformation on read and requires the consumer to Decompose() it back, introducing precision loss on near-identity rotations and non-uniform scales.

To make the intermediate byte-lossless where it matters, tools that generate glTF for the NeL runtime write duplicate values in extras, and NeL's assimp-based readers prefer the extras when present, falling back to standard glTF properties otherwise. This preserves interop — Blender, three.js, and every other glTF consumer sees the standard fields and works correctly — while our roundtrip through the pipeline stays bit-exact.

The extras are scoped: per-node (aiNode::mMetaData after assimp import), per-mesh, etc. Extras on other structures are not currently used; if a future asset class needs them (e.g. per-primitive material flags), add a new section here and note the type mapping.

The extras represent the source truth as the writer saw it, not a suggestion. A reader that respects the extras must not renormalise, quantise, or re-decompose the values behind them — that's the whole point. If a reader wants to normalise (e.g., a runtime that requires unit quaternions), it should either normalise the standard rotation field or normalise a copy, keeping the extras semantics unchanged for downstream tools.

Per-node extras

Aimed at aiNode. Assimp's glTF loader surfaces JSON extras as aiMetadata entries keyed by the JSON property name. Type mapping (assimp 5.x observed):

JSON value form aiMetadataType Notes
true / false AI_BOOL Direct
123 (integer literal, no . or e) AI_UINT64 (or AI_INT32) Writer must force decimal notation for floats
1.5 (with . or e) AI_DOUBLE 9 decimal digits round-trip float32 exactly
"foo" AI_AISTRING
[a, b, c] version-dependent Not used in NeL extras — separate scalar keys instead
{ ... } AI_AIMETADATA (nested) Not used

Skeleton-related keys (pipeline_max_export_skelmesh_utils/assimp_skel)

Written by pipeline_max_export_skel --gltf …; read by mesh_utils's exportSkels() when the source is a glTF file. If any of the T/R/S triples is incomplete, the reader falls back to decomposing mTransformation — so an artist-authored glTF without our extras still works.

Local transform (bit-exact TRS preservation)

Key Type Format Meaning
nel_tx, nel_ty, nel_tz float %.9g with forced decimal Local translation. Byte-identical to what the writer had before conversion.
nel_rx, nel_ry, nel_rz, nel_rw float %.9g with forced decimal Local rotation quaternion. Component order is XYZW to match glTF standard. Not pre-normalised — writer must preserve the source's exact bits, including tiny deviations from unit length.
nel_sx, nel_sy, nel_sz float %.9g with forced decimal Local scale (per-axis).

Rationale for individual scalar keys instead of nel_translation: [x, y, z]: assimp's glTF-to-metadata mapping for JSON arrays has varied across versions. Scalar numbers map cleanly to typed aiMetadataEntry in every 5.x release; arrays are inconsistent. Ten extra keys per node is a small cost compared to a fragile array roundtrip.

Rationale for decimal-forced encoding: assimp's JSON parser picks the smallest integer type when the token has no . or e, so "nel_lodDisableDistance": 0 came through as AI_UINT64. The writer post-processes %.9g output and appends .0 if neither . nor e[E] appears, guaranteeing AI_DOUBLE.

Rationale for %.9g not %.17g: the source values are float32. 9 decimal digits are sufficient to round-trip float32 exactly through a double parser and back; 17 digits (needed for double bit-exactness) would just add noise-looking trailing digits with no information gain.

What about the standard translation/rotation/scale fields? The writer emits both. The standard fields make the glTF viewable in Blender, three.js, glTF-Validator etc. — where slight precision loss doesn't matter. The extras make our internal roundtrip bit-exact.

NeL-specific flags (not representable in standard glTF)

Key Type Default when absent Meaning
nel_unheritScale bool false NeL bone's UnheritScale flag (see CBoneBase). true means the bone's scale is applied without inheriting from the parent — matches the Max exporter's read of `INHERIT_SCL_X
nel_lodDisableDistance float 0.0 NeL bone's LodDisableDistance (from NEL3D_APPDATA_BONE_LOD_DISTANCE). Bone is disabled from skeleton evaluation when distance to camera exceeds this. 0 = never disable.

Standard glTF has no concept of either — they're NeL-runtime-specific and would be silently lost by a round-trip through a stock glTF pipeline. Preserving them in extras is what makes the mesh_export roundtrip carry a full skeleton.

Reader semantics

Consumer code should read extras and prefer them over the standard fields when both are present and the extras form a complete set:

float tx, ty, tz, rx, ry, rz, rw, sx, sy, sz;
bool haveTrans = getNelFloat(node, "nel_tx", tx) & getNelFloat(node, "nel_ty", ty) & getNelFloat(node, "nel_tz", tz);
bool haveRot   = getNelFloat(node, "nel_rx", rx) & /* ...ry, rz, rw */;
bool haveScl   = /* nel_sx, sy, sz */;
if (haveTrans && haveRot && haveScl) {
    // use nel_* extras verbatim
} else {
    // fall back to assimp's Decompose(mTransformation)
}

All-or-nothing per triple: if a writer emits only two of three translation components, the reader treats the whole triple as missing rather than mixing extras with decomposed values. Cleaner failure mode, avoids partial-noise cases.

Validation flag: --no-nel-extras

mesh_export exposes a --no-nel-extras flag that makes the reader ignore every nel_* per-node key and reconstruct transforms via Decompose(mTransformation) even when the extras are present. This is the same code path an artist-authored glTF without our extras hits, so with the flag we can:

  • confirm the fallback is functionally correct (semantic content preserved end-to-end),
  • measure the fallback's byte-accuracy floor against our extras-based path,
  • catch regressions in the fallback that would otherwise be hidden by the extras always winning.

On the 9-file non-biped skeleton corpus, A vs B parity is:

with extras (default) with --no-nel-extras
A-vs-B match 100.0% on every file 90.7% – 97.2%

The residual delta with the flag on is float noise in InvBindPos rotation cells from Decompose's row-magnitude renormalisation of the 3×3 — same class of noise Max's own arithmetic produces in the reference outputs (T3-epsilon per pipeline_max_design §9). Semantic correctness — bone hierarchy, names, in-engine animation appearance — is unaffected.

Use this flag in CI as a regression gate on the fallback path: it must always produce a valid .skel, and the byte-parity against the extras-based .skel should stay ≥90% on the corpus.

Conformance

The current writer is nel/tools/3d/pipeline_max_export_skel/main.cpp::writeGltf(). The current reader is nel/tools/3d/mesh_utils/assimp_skel.cpp::walkSkelNode(). Both track this spec; a change to either without updating the other is a bug.

When adding a new extras key:

  1. Update this page (add a row in the appropriate table).
  2. Update both writer and reader in the same commit if both sides need to change.
  3. Add a compile-time or CI check that reader knows about all keys writer emits (nice-to-have, not currently enforced).

Future work

Under consideration but not implemented:

  • Mesh/shape extras for nel_material_flags, nel_lightmap_group, etc. Would move NeL-specific material data out of scene_meta sidecar files and into the glTF itself.
  • Animation extras for nel_track_default, nel_loop_mode etc.
  • Scene extras for nel_scene_version, nel_tag_time — for the tag-file roundtrip protocol from build_gamedata.
  • Deprecation path if the standard glTF KHR_* extensions ever cover our needs (e.g., a glTF extension for non-uniform bone scale semantics). Extras are cheap to add and remove; extensions are conservative.

See also: Pipeline Max Design for the .max-side context, and mesh_utils/scene_meta.h for the JSON sidecar format currently used for non-transform NeL flags.

Clone this wiki locally