nel_gltf_extras - ryzom/ryzomcore GitHub Wiki


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_skel โ†’ mesh_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.

max2gltf whole-file extras (2026-07-14, stage 1)

pipeline_max_export_gltf (max2gltf, see max2gltf plan) writes one glTF per .max with the full node hierarchy and the evaluated shape-process meshes. Its extras are read by mesh_utils/gltf_nel_scene.cpp โ€” directly from the glTF JSON, never through assimp โ€” and reconstruct the exact pre-build state (CMeshBuild/CMeshBaseBuild/CMaterial), so the replayed NL3D build yields a byte-identical .shape on the same host (gated by ctest pipeline_max_gltf_corpus). The exact-tier import triggers on asset.extras.nel_source (the source .max basename); --no-nel-extras forces the generic assimp route.

Per-node (max2gltf)

Every scene node carries the bit-exact TRS set above (nel_t*/r*/s* โ€” values identical to the standard translation/rotation/scale fields, from decompMatrix(getLocalMatrix)). Shape-process nodes add:

Key Type Meaning
nel_shape bool The direct route would export this node as a .shape
nel_shape_name string Output basename (lowercased node name)
nel_shape_class, nel_shape_blob string + hex Special IShape classes (water/remanence/flare): the exact serialized .shape stream (version-patched), built by the same class builders the direct route uses. The importer emits it verbatim โ€” dual representation; structural extras when Blender authoring needs them
nel_coarse bool Routes to the with-coarse-mesh output directory
nel_cast_shadows, nel_rcv_shadows bool CMeshBaseBuild shadow flags (node render-control dword)
nel_lighting_local_atten bool UseLightingLocalAttenuation
nel_collision_mesh_gen int TCameraCollisionGenerate
nel_lod_dist_max, nel_lod_blend_length float LOD slot appdata (defaults 1000 / 0)
nel_lod_blend_in/out, nel_lod_coarse_mesh bool LOD slot flags (present when set)
nel_lod_mrm + nel_mrm_nlods/divisor/skin_reduction/dist_finest/dist_middle/dist_coarsest bool + ints/floats MRM build parameters (appdata)
nel_lod_count, nel_lod_name_<i>, nel_lod_dynamic_mesh int/string/bool Multi-lod parent slot list โ€” the importer assembles the CMeshMultiLod from these: LOD 0 = this node's mesh, LOD 1..count resolved by node name (case-insensitive); each slot's DistMax/BlendLength/flags/MRM come from that slot node's own nel_lod_*/nel_mrm_* keys; StaticLod = NOT nel_lod_dynamic_mesh
nel_lod_slave bool Node is another node's LOD slave (mesh built in the PARENT's material context; produces no standalone .shape)
nel_animated_materials, nel_auto_anim bool setAnimatedMaterial names / setAutoAnim replay
nel_has_morpher, nel_bs_names, nel_bs_factors bool + arrays Morpher channel defaults (BSNames/DefaultBSFactors)
nel_skip_class string Node the glTF carries nothing for, bucketed like the direct route's skips: builder-NULL water/flare/remanence (the direct route skips the same nodes), wavemaker, skinned-skin, skinned-physique, material/encode/shape-serial, mesh-eval. A meshless slot node with mesh-eval or skinned-physique (or an unselected node) just drops its multilod slot โ€” direct parity; any other class on a slot stages the whole multilod out. A multilod PARENT keeps its nel_shape even when its own mesh is missing (the direct route still exports from the surviving slave slots) โ€” except after a failed Physique decode, which kills the whole shape on the direct route too
nel_mapext, nel_lightmap, nel_interface bool Harness bucket tags (Map Extender UVs, LightMap-shader materials, interface weld applied)

Per-mesh / per-primitive (max2gltf)

CMeshGeom::build's VB dedups corners on (original vertex id, attributes) in global face order, and its bbox spans the full original vertex array โ€” the extras carry exactly those three things the standard primitives lose:

Key Where Type Meaning
nel_vertices mesh accessor (VEC3 float) The full original CMeshBuild::Vertices, verbatim (unreferenced verts included โ€” the bbox spans them)
nel_vertex_flags mesh int CMeshBuild::VertexFlags
nel_uv_routing mesh array[8] int CMeshBuild::UVRouting
nel_uv_stages mesh array int NeL UV stage per consecutive TEXCOORD_<i> set (sparse stage usage)
nel_materials mesh array int glTF material index per local material id (CMeshBaseBuild::Materials order, unreferenced materials included โ€” optimizeMaterialUsage runs after the build)
nel_iface_counts/_pos/_norm/_links mesh array + accessors Interface-weld polygons + per-vertex weld links (Interfaces/InterfaceLinks/InterfaceVertexFlag)
nel_skin_weights mesh accessor (VEC4 float) Per-ORIGINAL-vertex Physique weights (parallel to nel_vertices; present iff PaletteSkinFlag in nel_vertex_flags)
nel_skin_joints mesh accessor (u32, 4/vertex) Per-original-vertex bone matrix ids (indices into nel_bones_names)
nel_bones_names mesh array string Full skeleton bone list from the Physique decode (the geom build remaps to the used subset itself)
nel_bs_geoms mesh int Number of MRM morph-target builds (the direct route's bsList; consumed by the CMeshMRM branch only)
nel_bs_<i>_vertices mesh accessor (VEC3 float) Morph target i's full vertex array (count == base)
nel_bs_<i>_normals, nel_bs_<i>_colors, nel_bs_<i>_uvw<stage> mesh accessors (per corner) Morph target i's corner attribute streams in GLOBAL face order (index 3*face+corner) โ€” exactly the fields CMRMBuilder::buildBlendShapes reads; the target's own topology is never consulted
nel_material_id primitive int Local material id of this primitive's faces
nel_vertex_ids primitive accessor (u32) Original CCorner::Vertex per glTF vertex (the dedup key half the standard attributes lose)
nel_faces primitive accessor (u32/tri) Original GLOBAL face index per triangle (VB order + rdrpass fill order)
nel_smgroups primitive accessor (u32/tri) CFace::SmoothGroup per triangle

Primitive corners are deduped with the same key CCornerTmp::operator< uses under the build's vertex flags; UVW W components must be zero at this stage (asserted by the writer; TEXCOORD stays VEC2) and NumCoords must be 2.

Per-material (max2gltf)

Standard glTF carries a PBR interop view (baseColorFactor/-Texture from the diffuse + slot-0 texture, alphaMode, doubleSided). The nel_* extras carry the semantic field set of the built export CMaterial; the reader reconstructs through the same public setter sequence the exporter's material builder used and verifies the reconstructed _Flags dword against nel_flags โ€” a codec gap fails loudly instead of shipping a silently-different material. Keys (see nel_gltf/gltf_material.cpp, schema version nel_mtl: 1): nel_flags, nel_lighted, nel_shader, nel_blend, nel_src_blend, nel_dst_blend, nel_alpha_test, nel_alpha_test_thr, nel_zwrite, nel_zfunc, nel_zbias, nel_color/emissive/ambient/ diffuse/specular (hex rrggbbaa; diffuse alpha = opacity), nel_shininess, nel_double_sided, nel_lighted_vcolor, nel_stained_glass, and per stage i: nel_tex<i>_class (file/multi/cube) + file/files/sel/degrade + upload/wrap_s/wrap_t/min_filter/mag_filter/gray_alpha (ITexture base; cube inner texture under nel_tex<i>_cube_*), nel_tex<i>_gen/gen_mode (TexCoordGen), nel_tex<i>_env (canonical 32-bit CTexEnv pack โ€” bitfield order defined in the codec, not compiler layout) + env_color, nel_tex<i>_addr (texture-addressing mode), and nel_tex<i>_usermat (16 floats, artist-visible) + nel_tex<i>_usermat_blob (exact CMatrix::serial stream hex โ€” StateBit- conditional layout; the blob is authoritative, the floats are the view โ€” same dual- representation rule as animation tracks).

Materials are never lightmapped at this stage (1_export is unmapped; the standalone lightmapper adds _LightMaps downstream) โ€” the codec refuses materials carrying lightmaps.

Materials sidecar (assimp route, mesh_export --materials)

The same per-material extras also travel in a materials-only .gltf sidecar (valid glTF: asset + materials array, no nodes/meshes needed โ€” decision 2026-07-14, no new metadata format) for artist-provided model files on the assimp route. mesh_export --materials <sidecar.gltf> (repeatable: shared library first, per-asset second; later files override earlier ones by name) binds sidecar entries to scene materials by material NAME, never positionally (aiProcess stays zero, so assimp preserves source names). Semantics (mesh_utils/material_sidecar.cpp, tested by ctest pipeline_max_materials_sidecar):

  • named scene material with a sidecar entry โ†’ the exact reconstructed CMaterial (flag-dword-verified by the codec), overriding scene-meta materials with a warning;
  • named scene material without an entry โ†’ warning + fallback to assimp_material conversion;
  • unnamed materials โ†’ warning + fallback (they can never bind);
  • sidecar entries without nel_* extras or without a name โ†’ warned and ignored; a codec reconstruction failure (missing keys, nel_flags mismatch) fails the export โ€” a corrupt or outdated sidecar never silently ships different materials;
  • unused sidecar entries are fine (shared material libraries are expected to over-cover);
  • a nel-extras (max2gltf) input ignores --materials with a warning โ€” its materials ride in-file and are byte-gated.

Node-properties sidecar (assimp route โ€” design, 2026-07-15)

The NeL export properties (the Max plugin's per-node appdata: export gates, collision flags, LOD/MRM configuration, lightmap lumel-size and LMC colors, shadow flags, instance naming, accelerator type, ...) travel the same way for artist-provided scenes: a glTF sidecar whose nodes array holds empty dummy nodes matched to the scene's nodes by NAME, each dummy node carrying the standard per-node nel_* extras vocabulary from this document. Decision points:

  • Same container, same parser (nel_gltf), same name-binding discipline as the materials sidecar โ€” never positional, names preserved because aiProcess stays zero. One vocabulary: the sidecar uses the SAME nel_* node-extras keys the max2gltf writer emits, so any max2gltf output is automatically a valid node-properties sidecar for a re-export of the same scene, and the schema needs no second definition.
  • Dummy nodes are legal glTF: nodes need not be referenced by any scene, carry no mesh/children/TRS โ€” they exist purely as named extras anchors.
  • Authoring tool: a dedicated NeL scene-properties editor loads the artist file visually through assimp (the same route mesh_export uses), presents the node tree, and saves ONLY the sidecar โ€” it never rewrites the artist's source file. This is a separate tool from the material editor (different editing scope), but both write the same file format; a single sidecar .gltf MAY carry both materials and nodes sections.
  • Loader precedence mirrors materials: sidecar > in-file node extras > defaults; unmatched sidecar nodes warn (over-coverage tolerated), scene nodes without an entry use defaults; binding is by exact node name with the _Second duplicate-name convention.
  • Scene-level export properties (default instance-group name, skeleton mode, tag-protocol fields) ride asset.extras of the sidecar under the same rule.
  • Supersedes the binary .nelmeta sidecar (mesh_utils/scene_meta.h) โ€” CSceneMeta's fields map onto the extras vocabulary; scene_meta stays as the legacy fallback until the editor exists.

This closes the assimp-route parity story structurally: geometry from the artist file, materials from the materials sidecar, NeL export semantics from the node-properties sidecar โ€” everything 1_export needs (including the .lmscene producer for artist scenes: receivers/occluders from assimp geometry + sidecar flags, lights from KHR_lights_punctual + nel_* light extras) without any information living in a format only Max can read.

Instance groups (max2gltf)

Dual representation, the same rule as animation tracks:

  • nel_ig_name (per node, string): the node's NEL3D_APPDATA_IGNAME appdata โ€” the artist-visible IG membership tag (the node's TRS is its placement).
  • asset.extras.nel_igs (array of {name, data}): per distinct ig name, the exact CInstanceGroup::serial stream (hex) as built by the ig process's full flow โ€” pipeline_max_export_ig's selection/XRef/clusterize/light code compiled into the writer (pmbExportIgsForGltf, guarded main). mesh_export re-emits <name>.ig from the blob (--ig-dst), byte-identical to the direct route (gated in pipeline_max_gltf_corpus). Editing instance placements in Blender does NOT update the blob yet โ€” rebuilding an edited ig from the structural tags is future work; the blob is authoritative until then.

Ligo brick igs (zonematerial-*/zonetransition-* sources) stay direct-route with the rest of the zone pipeline (plan non-goal).

Ligo zones (max2gltf)

PatchMesh has no faithful glTF form โ€” dual representation, blob + baked viewing proxy:

  • asset.extras.nel_zones (array of {name, data}): every file the NeLLigoExportZone flow produces (zones/<name>.zone, zoneligos/<name>.ligozone โ€” the name carries the subdir), bytes verbatim as pipeline_max_export_zone writes them (pmbExportZonesForGltf, guarded main; per-ecosystem --zone-bank). mesh_export --zone-dst re-emits the tree byte-identical (gated in pipeline_max_gltf_corpus over the ligo zone corpus).
  • nel_proxy (per node, bool): a tessellated viewing mesh of the AUTHORED patches (bicubic bezier eval at 2ร— tile density, world space, root-level identity node named <rpo-node>.zoneproxy) โ€” any glTF consumer renders the brick; the exact-tier importer ignores these nodes (no nel_shape). nel_proxy_frozen marks the neighbor-reference context bricks so viewers can dim them. Editing the proxy does NOT edit the zone โ€” the blob is authoritative (importing a proxy through the no-nel-extras emulation route just yields a plain static mesh, which is the honest limit of foreign-tool authoring for patch landscape).

Animations (max2gltf)

Dual representation โ€” the blob first, sampled interop channels as a later additive tier:

  • asset.extras.nel_anim ({name, data}): the exact CAnimation::serial stream (hex) as built by the anim process's whole-file flow โ€” pipeline_max_export_anim's $Bip01-first + EXPORT_NODE_ANIMATION selection, biped COM decode and track build compiled into the writer (pmbExportAnimForGltf, guarded main). name is the lowercased .max stem; mesh_export re-emits <name>.anim from the blob (--anim-dst), byte-identical to the direct route (gated in pipeline_max_gltf_corpus over the 4524-file anim corpus unioned into the sweep).
  • Standard glTF animation channels (sampled viewing tier, 2026-07-14): the blob is deserialized back into a CAnimation in the writer and every <node>.pos/rotquat/scale track is LINEAR-sampled at 30 fps into animations[0] channels targeting the node whose glTF name matches the track prefix (rig rest poses landed with the skins tier; _Second names match). Rotation samples are hemisphere-aligned so viewer slerp never takes the long way. This is a lossy interop VIEW โ€” DCC edits do not flow back; the blob stays authoritative. Bare track names ("pos", not "Node.pos" โ€” the Ryzom per-node convention for the selection root) map to the empty-prefix selection node reported by the anim flow. Non-TRS tracks (materials, note tracks, camera) are not sampled yet. Ctest pipeline_max_gltf_anim_channels.

Standard morph targets (max2gltf viewing tier)

Meshes with MRM morph targets additionally emit per-primitive glTF targets (POSITION deltas per original vertex id โ€” float-exact against nel_bs_<i>_vertices โˆ’ nel_vertices โ€” plus NORMAL deltas from the first-occurrence corner; per-corner target normals collapse to per-vertex, the nel_bs_* corner streams stay authoritative), mesh.weights defaults (NeL percents / 100) and the Blender-convention extras.targetNames (= nel_bs_names). Morph factor tracks (<node>.<target>MorphFactor, or bare for the selection root) sample into "weights" animation channels โ€” no shipped corpus animation carries them (facial emotes are bone-driven; the visage morphs are character-customization sliders), so that path is exercised by future content, not the corpus. Ctest pipeline_max_gltf_morph_targets.

glTF skins (max2gltf viewing tier)

Skinned meshes (Physique) additionally carry the standard glTF skinning form so any viewer can pose them โ€” an interop VIEW; the exact tier reconstructs from nel_skin_* only:

  • JOINTS_0/WEIGHTS_0 per primitive (u16/float VEC4): the CSkinWeight values gathered per deduped glTF vertex. JOINTS_0 indexes skin.joints, which follows nel_bones_names order. Zero-weight slots emit joint 0 / weight 0 (the NeL side leaves their MatrixIds uninitialized โ€” the geom build never reads them; the exact-tier nel_skin_joints carries them verbatim).
  • skins[] per skinned mesh node: joints are the exact scene nodes behind BonesNames (resolved from the physique decode's own skeleton walk โ€” duplicate bone names stay unambiguous), inverseBindMatrices = inverses of the joints' composed world matrices from the same TRS floats the glTF nodes carry, so world(joint) @ IBM cancels at bind pose exactly the way a viewer composes it. The mesh vertices are world-space bind pose (the direct exporter's convention), so rendering with or without the skin agrees. Duplicate scene node names get the skeleton walk's _Second suffix in the glTF too โ€” node names stay unique (assimp refuses duplicate bone names) and match nel_bones_names exactly.
  • Biped rig rest pose: biped TM controllers are not PRS, so the plain node path would leave the whole rig at identity โ€” rig nodes instead decode through the skel exporter's figure-mode reconstruction (biped_rig: COM record, getBipedLocal, and walkNode's PRS-child-of-COM world-frame-rotation rule). Verified: rest-pose skinning identity holds to ~3e-7 m on the fixture (ctest pipeline_max_gltf_skins) โ€” figure pose and the .max bind pose agree to float precision. Non-biped nodes keep the established getLocalMatrix path (non-biped files' output is byte-unchanged).

Editing weights/skeleton in a DCC does NOT update nel_skin_* โ€” the extras stay authoritative for the exact tier (same dual-representation rule as everything else).

Single-output process blobs (max2gltf)

asset.extras.nel_swt / asset.extras.nel_pacs_prim / asset.extras.nel_skel ({name, data}): the skeleton-weight (.swt), PACS-primitives (.pacs_prim XML) and skeleton (.skel, the Bip01-rooted skel-process output โ€” the separate pipeline_max_export_skel --gltf skeleton-glTF route is unchanged) whole-file outputs, bytes verbatim. The flows are SHARED with the standalone tools (PMB_SWT_NO_MAIN / PMB_PACS_PRIM_NO_MAIN guard the mains, which now delegate to the same function) โ€” the blob and the tool's file cannot drift. mesh_export re-emits <name>.swt / <name>.pacs_prim next to the shapes; gated in pipeline_max_gltf_corpus (swt + pacs_prim source sets unioned into the sweep).

asset.extras.nel_vegets / asset.extras.nel_clods / asset.extras.nel_cmbs (arrays of {name, data}): the veget, clod and cmb processes' per-node/per-igname outputs (PMB_VEGET_NO_MAIN/PMB_CLOD_NO_MAIN/PMB_CMB_NO_MAIN, same delegating-main pattern โ€” the microveget, lod_actors and interior-cmb corpora ride the gate plus the ligo bricks through the cmb --ligo XRef path, selected by the zone filename protocol; mesh_export re-emits <name>.veget/<name>.clod/<igname>.cmb).

asset.extras.nel_lmscene ({name, data}): the standalone lightmapper's 1_export intermediate (.lmscene, design doc ยง11-lm โ€” receivers as pre-build CMeshBuild/ CMeshBaseBuild + shape-build recipe, occluder meshes, source-fidelity lights), bytes verbatim as a direct pipeline_max_export_shape --lm-scene run writes them. The flow IS the shape exporter's whole per-file flow (PMB_SHAPE_NO_MAIN guards its main; the writer's own node loop keeps feeding the glTF encoder) run in lightmap-scene-only mode: nodes that can't collect as receivers skip the mesh build, receiver nodes take the full build path so the collected pre-build data is bit-for-bit the direct run's. Blob rather than structural: the pre-build intermediate is its own versioned NeL serial contract, and the glTF mesh accessors feed the shape build โ€” the lightmapper's UV2 unwrap consumes the pre-build receivers, which carry state (per-geom appdata, LOD exclusion sets, light exclusion lists) that the rendering-oriented extras deliberately don't duplicate. mesh_export re-emits <name>.lmscene; rides the singles tier of pipeline_max_gltf_corpus.

Structural special shapes (water / remanence / flare)

Full parity: every field the class builders set rides as artist-visible extras (gltf_special_shape.cpp), and the reader reconstructs the shape through the same setter sequence, serializing with the direct exporter's patches (CWaterShape v7โ†’v4 + truncation). Completeness is enforced by construction: the writer rebuilds the shape from its own extras and byte-compares against the directly-built serialization before dropping the opaque blob; a mismatch warns and falls back to nel_shape_blob (which the reader still emits verbatim when present โ€” never silent loss). nel_shape_class selects the codec.

  • Water (nel_water_*): env / env_under (1 file, or 2 = day/night CTextureBlend), displace_file/bump_file + per-map scale_u/v + speed_u/v, optional color_file + color_mat (6 floats), poly (2D convex hull, x/y pairs), height_factor, pool_id, transition_ratio, splash, scene_env_above/under, def_pos/scale/rot.
  • Remanence (nel_rem_*): num_slices, slice_time, rollup_ratio, texture_shifting, material (nested object, the standard nel_* material codec โ€” flag-dword verified), corners (x/y/z triplets), optional anim_material, def_pos/scale/rot.
  • Flare (nel_flare_*): color/dazzle_color (hex rrggbbaa), persistence, spacing, attenuable + atten_range, first_keep_size, dazzle_atten_range, max_view_dist + _ratio, infinite_dist, scale_disappear/size_disappear/angle_disappear, look_at, optional occlusion_mesh + occlusion_inherit_scale_rot, per-slot arrays sizes/rel_pos/ textures (10 slots, empty string = unused), def_pos.

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.

โš ๏ธ **GitHub.com Fallback** โš ๏ธ