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.
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 |
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.
| 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.
| 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.
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.
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) |
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.
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.
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_materialconversion; - 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_flagsmismatch) 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
--materialswith a warning โ its materials ride in-file and are byte-gated.
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 SAMEnel_*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_exportuses), 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 bothmaterialsandnodessections. - 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
_Secondduplicate-name convention. - Scene-level export properties (default instance-group name, skeleton mode, tag-protocol
fields) ride
asset.extrasof the sidecar under the same rule. - Supersedes the binary
.nelmetasidecar (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.
Dual representation, the same rule as animation tracks:
-
nel_ig_name(per node, string): the node'sNEL3D_APPDATA_IGNAMEappdata โ 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 exactCInstanceGroup::serialstream (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_exportre-emits<name>.igfrom the blob (--ig-dst), byte-identical to the direct route (gated inpipeline_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).
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 aspipeline_max_export_zonewrites them (pmbExportZonesForGltf, guarded main; per-ecosystem--zone-bank).mesh_export --zone-dstre-emits the tree byte-identical (gated inpipeline_max_gltf_corpusover 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 (nonel_shape).nel_proxy_frozenmarks 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).
Dual representation โ the blob first, sampled interop channels as a later additive tier:
-
asset.extras.nel_anim({name, data}): the exactCAnimation::serialstream (hex) as built by the anim process's whole-file flow โpipeline_max_export_anim's $Bip01-first +EXPORT_NODE_ANIMATIONselection, biped COM decode and track build compiled into the writer (pmbExportAnimForGltf, guarded main).nameis the lowercased.maxstem;mesh_exportre-emits<name>.animfrom the blob (--anim-dst), byte-identical to the direct route (gated inpipeline_max_gltf_corpusover 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
CAnimationin the writer and every<node>.pos/rotquat/scaletrack is LINEAR-sampled at 30 fps intoanimations[0]channels targeting the node whose glTF name matches the track prefix (rig rest poses landed with the skins tier;_Secondnames 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. Ctestpipeline_max_gltf_anim_channels.
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.
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_0per primitive (u16/float VEC4): the CSkinWeight values gathered per deduped glTF vertex.JOINTS_0indexesskin.joints, which followsnel_bones_namesorder. Zero-weight slots emit joint 0 / weight 0 (the NeL side leaves their MatrixIds uninitialized โ the geom build never reads them; the exact-tiernel_skin_jointscarries them verbatim). -
skins[]per skinned mesh node:jointsare 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, soworld(joint) @ IBMcancels 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_Secondsuffix in the glTF too โ node names stay unique (assimp refuses duplicate bone names) and matchnel_bones_namesexactly. -
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 (ctestpipeline_max_gltf_skins) โ figure pose and the .max bind pose agree to float precision. Non-biped nodes keep the establishedgetLocalMatrixpath (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).
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.
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/nightCTextureBlend),displace_file/bump_file+ per-mapscale_u/v+speed_u/v, optionalcolor_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), optionalanim_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, optionalocclusion_mesh+occlusion_inherit_scale_rot, per-slot arrayssizes/rel_pos/textures(10 slots, empty string = unused),def_pos.
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.
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.
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:
- Update this page (add a row in the appropriate table).
- Update both writer and reader in the same commit if both sides need to change.
- Add a compile-time or CI check that reader knows about all keys writer emits (nice-to-have, not currently enforced).
Under consideration but not implemented:
-
Mesh/shape extras for
nel_material_flags,nel_lightmap_group, etc. Would move NeL-specific material data out ofscene_metasidecar files and into the glTF itself. -
Animation extras for
nel_track_default,nel_loop_modeetc. -
Scene extras for
nel_scene_version,nel_tag_timeโ for the tag-file roundtrip protocol frombuild_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.