Mesh Node Specification
Status: Delivered — Phases 0–6 landed (writer, cull kernels, drawable, shaded, picking + panel + stats, docs; §11); real WebGPU verified pixel-equivalent to WebGL (§11 row 6). kind=partition (§9.2), SUBSTITUTIVE LOD levels (§9) and the §9.1 reveal ladder (authoring AND the viewer half) all now ship. The additive prefix ladder AS A LOD, spatial indexing, exact nD triangle clipping, volumetric blending and worker projection remain deliberate non-goals (§9).
Scope: A fourth first-class geometry type — mesh — symmetric to Points, Lines and GSplats.
Non-goals: the additive prefix ladder as a LOD (the §9.1 reveal, which reuses its subgroup layout, has landed), spatial indexing, exact nD triangle clipping, volumetric blending, worker projection. (Substitutive LOD levels — decimation — and kind=partition were non-goals and have since landed.) See §9.
Target data: isosurfaces and segmentation boundaries — 3D geometry whose hidden dimensions are
discrete (time, channel). This is a deliberate narrowing; it is what makes §5, §7 and §9 defensible.
The groundwork this spec called for has already landed
An earlier draft proposed a preparatory track — single-source the geometry vocabulary, collapse the loader registry, table-drive the hand-written dispatch — as a precondition for adding a fourth type cheaply. That work shipped before any mesh code, in three PRs: #1079 (vocabulary + registry), #1099 (uniform per-type pipeline + descriptor table) and #1150 (metadata symmetry + dead-code removal).
This document is therefore now only the mesh node, written against the post-consolidation architecture. §10 records what that consolidation means for a fourth type instead of arguing for it.
1. Motivation
Luxar had three first-class geometry types, all of which are soft, emissive, per-element primitives rendered as instanced quads. None of them can represent a surface: an isosurface from a volume, a segmentation boundary, a cortical/organ mesh, a CAD or simulation domain, or a molecular solvent surface. These are routine outputs in the same scientific pipelines Luxar already serves, and until this spec landed they could only be approximated by dense point clouds.
A mesh node closes that gap with indexed triangles and real surface shading.
1.1 Existing forward-declaration
The viewer reserved the name ahead of the data model. One of those placeholders has since been folded into the contract:
packages/luxar-viewer/src/types/data-monitor-types.ts—SceneGraphNodeTypewasNodeTypeName | 'mesh'whilemeshwas a viewer-only forward declaration; #1220 putmeshin the contract and deleted the local extension, so it is now the plain aliasSceneGraphNodeType = NodeTypeName.packages/luxar-viewer/src/data/scene-loader/monitor/scene-graph-converter.ts:26—'mesh'is in the display-type whitelist, and that entry is now backed by a realGraphNodeTyperather than by a reservation.
These were display-only placeholders; there was no data model, loader, geometry, or material behind them. This spec makes the name real end to end.
2. Where mesh is symmetric, and where it is not
The three-geometry symmetry rule (same attribute names, same decomposition, same shared helpers, parallel tests) applies at the node, attribute, writer and loader layers. It does not apply at the storage layer, nor to the ADDITIVE LOD ladder (the substitutive flavour is symmetric — see the table), and pretending otherwise would produce a worse design.
Layer |
Symmetric with Points/Lines/GSplats? |
Notes |
|---|---|---|
|
✅ Yes |
Direct mirror of |
Scene adder ( |
✅ Yes |
Mirror of |
Zarr writer, encoders, shared dataset helpers |
✅ Yes |
Reuses |
Render attrs (opacity/gamma/intensity/offset/blending/colormap/transform/nd_transform) |
✅ Yes |
Reuses |
nD slicing |
⚠️ Partial |
Reuses the slab semantics, not the clipping algorithm, and needs its own tolerance strategy (the Lines one is derived from segment interpolation and would render nothing) — see §5, §5.2.1 |
GPU storage |
❌ No |
Indexed triangles, not instanced quads — see §2.1 |
Per-element extent |
❌ No |
A mesh has no |
Depth sorting |
⚠️ Partial |
Registration/worker/kernel reused; the APPLY is per-triangle index permutation, not per-instance — see §6.3 |
LOD (substitutive) |
✅ Yes |
Levels are decimated surfaces — see §9. The additive ladder stays excluded as a LOD; the §9.1 reveal ships |
|
⚠️ Partial |
Supported via |
2.1 The storage layer does not transfer
Points, Lines and GSplats all render as THREE.Mesh + InstancedBufferGeometry over a shared
4-vertex base quad, with per-element data packed into an RGBA32F element texture and addressed via
aSortedIndex (rendering/element-texture-layout.ts, rendering/element-storage.ts; 4 texels/splat,
3/point, 6/segment). See docs/guides/specs/GSPLAT_DEPTH_SORTING_SPEC.md §4/§8.
A mesh is not a collection of independent elements — it is a connected indexed structure. It renders
as a plain THREE.BufferGeometry with position / normal / color / scalar attributes plus an
index buffer, drawn once. Consequently the mesh vertical does not use:
element-texture-layout.ts/element-storage.tsthe
gpu-buffer-poolper-geometry adaptersaSortedIndexaddressingthe depth-sort coordinator’s per-instance APPLY (its registration, worker and kernel are shared — see §6.3)
This is not a gap to be closed later; it is the correct shape for the primitive.
2.2 There is no primary size scalar
Points→radii, Lines→widths, GSplats→amplitudes are each a required per-element extent, and each
feeds cull-box expansion (LinesProjectionBounds.maxWidth, LinesUserData.maxWidth, and the
equivalents for points/gsplats). A mesh vertex has no extent. Mesh bounds are therefore the raw
position AABB with zero expansion, and MeshUserData carries no maxWidth-analog field.
This also removes an entire class of writer work: no write_positive_scalar call, no max_* attr, no
radius-scale uniform.
3. Data model
3.1 Node type
mesh joins the canonical node-type vocabulary at its single source of truth:
# format-contract/contract.yaml
node_types: ["scene", "group", "points", "lines", "gsplats", "mesh"] # done (#1220)
geometry_types: ["points", "lines", "gsplats", "mesh"] # writable leaf subset — done (#1220)
loader_types: ["points", "lines", "gsplats", "mesh"] # viewer-drawable subset — done (#1241)
There are three lists, and they answer three different questions (the split landed in #1220).
node_types is the full vocabulary; geometry_types (added in #1079) is the writable leaf subset that
keys the Python writer side (bounds, luxar info, LOD backfill); loader_types (added in #1220) is the
viewer-drawable subset that keys per-geometry dispatch. The generator enforces the nesting
loader_types ⊆ geometry_types ⊆ node_types, that geometry_types and loader_types are each
non-empty and duplicate-free, and that geometry_types is free of the container types — so a half-done
addition fails check-contract with a named error rather than drifting silently.
mesh is already in node_types and geometry_types (#1220) — and #1220 also landed the whole
Python writer vertical behind those entries (core/mesh.py, add_mesh, validators, compiler writer,
reader, info; the Phase-1 checklist in §8) — so a mesh leaf is writable today.
mesh is now also in loader_types (the Phase-3 switch-on landed in #1241),
which turned on the three §10.2 compile errors that mapped out the viewer work — all
since resolved. The contract still names the writable and drawable sets separately
because a type becomes authorable before it becomes drawable; today both sets include
mesh.
make gen-contract regenerated both projections
(packages/luxar/src/luxar/typing_utils/_format_contract.py and
packages/luxar-viewer/src/types/format-contract.ts) for the node-type addition, and hatch run check-contract gates drift. As part of #1220 the 'mesh' local extension in data-monitor-types.ts
was deleted — SceneGraphNodeType is now plain NodeTypeName — and NodeType.MESH = "mesh" /
NODE_TYPE_MESH = "mesh" were added to luxar/typing_utils/enums.py and constants.py. Adding mesh
to loader_types (the Phase-3 switch-on, #1241) re-ran gen-contract for the viewer projection.
3.2 Arrays
Array |
dtype |
Shape |
Required |
Semantic type |
Notes |
|---|---|---|---|---|---|
|
float32 |
|
yes |
|
nD, exactly like |
|
uint32 |
|
yes |
|
Triangle vertex indices |
|
float32 |
|
no |
|
Per-vertex; paired with a required |
|
uint8/uint16/float32 |
|
no |
color helpers |
RGB or RGBA; the 4th component is a load-bearing per-vertex opacity — see §6.2 |
|
float32/float16/uint8 |
|
no |
scalar helpers |
Colormap lookup |
|
float32 |
|
no |
|
Required iff |
|
raw numeric or encoded uint8 bytes |
|
no |
|
Per-node base colour; mutually exclusive with other base-colour sources |
|
— |
CSR |
no |
— |
Per-vertex hover tooltips |
|
— |
CSR |
no |
— |
Per-vertex hover thumbnails |
faces uses SemanticType.INDEX with deduplicate=False and allow_lut=False, for exactly the
reason Lines.segments does: the loader reads it as raw chunked zarr and does not resolve array_ref,
so dedup would silently drop geometry for a byte-identical sibling, and LUT encoding of grid-snapped
values would decode as garbage topology.
KTX2 is authored from uint8 (H, W, 3|4) pixels but stored as an opaque (B,)
container. Authoring requires toktx 4.1.0 or newer and rejects a produced
container whose KTX2 identifier is absent, or whose supercompression scheme or
DFD colour model does not match the requested codec. Admission charges
ceil(width * height * 4 / 3) bytes for the native compressed surface plus its
full mip tail. A renderer with no native ASTC, ETC1/2, S3TC/BC or PVRTC target
rejects the node; an uncompressed RGBA8 transcode fallback is not permitted
because it would exceed that device-independent charge.
UASTC authoring defaults to quality 2, RDO lambda 0.25, and zstd level 9;
texture_ktx2_rdo_l and texture_ktx2_zcmp override those UASTC-only controls.
Set texture_ktx2_rdo_l=0 to disable RDO while retaining zstd compression.
ETC1S remains selected explicitly with texture_ktx2_mode="etc1s" and uses its
own texture_ktx2_quality scale.
Winding convention: faces are wound counter-clockwise as seen with the mesh’s authored spatial
triple in ascending index order (front-facing under FrontSide, §6.1). For a 3D mesh that frame is
trivially [0,1,2]; for an nD mesh it is sorted(normal_dims) when normals are present — there is no
other signal for which three axes the author wound against, and no winding can be counter-clockwise
under every 3D projection of an nD mesh (orientation under a different axis triple is per-triangle
data-dependent). The viewer restores front-facing winding only when the displayed set equals that
frame and its order is an odd permutation of it (§5.4/§7); for any other displayed triple — or an nD
mesh with no stored normals — double_sided: false falls back to DoubleSide for the epoch (§5.4).
The frame depends only on the set of normal_dims, not its order (§3.4).
Naming. faces (not triangles, not indices) — it parallels segments as the topology array,
reads correctly in the mesh domain, and leaves indices free for its existing meaning in the Lines
line_type='indexed' authoring API.
3.3 Metadata attrs
Mirrors LinesMetadata minus the extent/line-type fields:
type: "mesh"
n_vertices: int
n_faces: int
ndim: int
has_normals: bool
normal_dims: [int, int, int] # required iff has_normals; see 3.4
has_colors: bool
has_scalars: bool
has_uvs: bool
has_texture: bool
texture_encoding: "raw" | "png" | "webp" | "jpeg" | "ktx2"
texture_width: int
texture_height: int
texture_channels: 1 | 3 | 4
texture_color_space: "srgb" | "linear"
texture_data_range: [float, float] # HDR raw textures only
has_labels: bool
has_image_labels: bool
shading: "smooth" | "flat" | "none" # "none" is explicit unlit; never the default
double_sided: bool # default true
position_bounds: {"min": [...], "max": [...]} # nD vertex bbox, per io/_compiler/bounds.py
ordering: "none" # v1 always; reserved for a future spatial index
plus the standard render attrs already handled by apply_default_render_attrs and
prepare_transform_attrs (opacity, gamma, intensity, offset, absorption, blending_mode,
colormap, scalar_data_range, layer, transform, nd_transform, extend_to_all), plus the
opt-in material family (docs/guides/specs/MESH_PHYSICAL_MATERIALS_SPEC.md §3.1), written only when
authored:
material: "luxar" | "physical" # absent = "luxar", the §6.2 house shader
roughness, metalness, clearcoat, clearcoat_roughness, iridescence, sheen: float in [0, 1]
sheen_color: "#rrggbb"
transmission: float in [0, 1] # the glass family (spec §3.4)
ior: float in [1, 2.333]
thickness: float >= 0
attenuation_color: "#rrggbb"
attenuation_distance: float > 0 # absent = no attenuation
dispersion: float >= 0
refract_data: bool # Phase 3: draw after, and refract, the emissive data
The fourteen physical knobs are accepted only with material: "physical", and a physical mesh
refuses ambient / shade_exponent / specular / shininess, blending_mode, colormap, a
texture and shading: "none" — none of them has a meaning under that material (§6.4).
thickness, attenuation_color, attenuation_distance, dispersion and refract_data are refused
without a transmission above zero, because three evaluates the knobs only inside its transmission
path and a surface that transmits nothing has nothing to refract.
MESH_RESERVED_ATTRS is added to io/_compiler/node_common.py alongside the other three frozensets:
MESH_RESERVED_ATTRS = frozenset({
"type", "n_vertices", "n_faces", "ndim",
"has_normals", "normal_dims", "has_colors", "has_scalars", "has_uvs",
"has_texture", "texture_encoding", "texture_width", "texture_height",
"texture_channels", "texture_color_space", "texture_data_range", "has_labels",
"has_image_labels", "has_keys", "shading", "double_sided", "position_bounds",
"ordering", # mesh-only: no spatial index, so a supplied ordering can't be honoured
})
All four sets — POINTS_/LINES_/GSPLATS_RESERVED_ATTRS and MESH_RESERVED_ATTRS — now reserve
has_image_labels; #1220 aligned the three sibling sets with the mesh set, so every set covers every
presence flag its writer stamps.
3.4 Normals are 3D, positions are nD
Positions live in nD like every other geometry type. Normals are a display-space quantity: they are
only meaningful for the three displayed dimensions, so re-deriving them when displayDims rotates (not
on a plain slice move) is the correct behaviour — the displayDims-change rebuild path in §7.
Therefore normals is stored as (V, 3), accompanied by a required companion attr recording which
three dimension indices those components correspond to:
normals: (V, 3) float32
normal_dims: [i, j, k] # center-column indices, e.g. [0,1,2] or [1,2,3]
Stored normals are used iff shading == "smooth" and normal_dims equals the active displayDims.
Otherwise — when shading == "flat", whenever normals is absent, or whenever normal_dims no longer
matches the active displayDims — the viewer computes flat face normals from the projected triangle
via a cross product (§6.2), which is exactly why the derivative fallback is not optional. shading is thus
a first-class input to this decision, not inert metadata: an explicit "flat" overrides otherwise-valid
stored normals to give a faceted surface.
⚠️ Do not store normals against an implicit “first three dimensions”. For a (t, x, y, z) mesh the
first three dims are (t, x, y) and such a normal is meaningless. This is a bug class the codebase has
already been burned by and documented: rendering/depth-sort-coordinator/render-order.ts:79,110 warns
that the serialized BSP axis is a center-column index which must be mapped through displayDims,
and that “the two coincide only for displayDims == [0, 1, 2]”. An explicit index list is also the
established convention on the Python side (gsplat transform --spatial-dims,
cli/gsplat_ops/transforms/commands.py:717).
Making normal_dims explicit turns an invisible wrong-orientation render into a cheap, checkable
equality — and costs one attr.
An alternative — storing a full (V, D, 3) normal frame so any displayDims has true smooth normals —
was rejected as over-engineering for v1; the flat-normal fallback covers it correctly, just without
smoothing.
3.5 Validation
New shared validators in luxar/validation/base.py, following the existing validate_*_for_writing
convention (fail-fast, before any zarr group is created):
validate_vertices_for_writing(vertices)— enforcesn_vertices = vertices.shape[0] <= 2^27, the same alias-free bound as the §6.5 pick vote-key stride and the §3.5 loader gate below. This mirrors the loader gate at write time so the publicadd_meshpath cannot emit a store that Luxar’s own loader then rejects — restoring the fail-fast contract this section opens with, and honouring the §6.5 house rule that an unenforced bound is not a bound. It also restores a secondary guarantee: withn_vertices <= 2^27pinned at write time,validate_faces_for_writing’smax < n_verticescheck again guarantees every admitted face index (max< 2^27, well under2^32) survives the.astype(np.uint32)cast. RaiseValidationError(message, hint)with a remediation hint, matching the shared-validator half of the split below; the cap is a pure function of theverticesarray and independently testable, so it belongs on the shared-validator side, not among the cheap structural gates that need writer context and stay inline. The validator’s sole job is this vertex-count cap; generic coordinate finiteness/shape stays in the shared coordinate-writing path.validate_faces_for_writing(faces, n_vertices)— shape(F, 3)or flat(3F,); integer dtype (reject float, which.astype(np.uint32)would silently truncate);min >= 0;max < n_vertices;F >= 1. Mirrors theline_type='indexed'index gate atgeometry_writers/lines.py:134-170, which is the closest precedent and already encodes each of these traps.validate_uvs_for_writing(uvs, n_vertices)— shape(V, 2), exactly one finite(u, v)pair per vertex. Values outside[0, 1]remain legal becausetexture_wrap="repeat"intentionally tiles them.validate_normals_for_writing(normals, n_vertices)— shape(V, 3), finite. Zero-length normals are warned, not rejected (degenerate triangles legitimately produce them). Render-time handling is pointwise, not per-face: on a shared-vertex indexed mesh the interpolated normal blends toward the neighbouring vertices’ directions, so the stored-normal fragment variant (§6.2) simply epsilon-guards itsnormalize— when the interpolated normal is not affirmatively valid (!(dot(N, N) >= ε)before normalization — the negated form on purpose:NaNfails every comparison, so a corrupt store’sNaNnormal takes the same fallback instead of slipping past adot(N, N) < εtest and normalizing intoNaNshading) it falls back to the §6.2 screen-space-derivative flat normal rather than normalizing a zero vector into NaN shading. That substituted normal also bypasses §6.2’sgl_FrontFacingtwo-sided flip, which would otherwise negate an already-viewer-facing normal — see the two-sided-normal bullet there. Shading near a degenerate vertex is therefore locally distorted rather than cleanly flat; the warning exists so authors fix the normals instead of relying on the guard.normal_dims(§3.4) — exactly 3 entries, integers, distinct, each0 <= i < ndim. Required whennormalsis supplied; rejected when it is not. It is an explicitadd_meshparameter (§4) — as writer-reserved metadata (§3.3) it cannot ride in through**attrs.
Raise ValidationError, not ValueError. Note the precedent cited above is split: the shared
validate_*_for_writing family in validation/base.py raises ValidationError(message, hint) — a
two-arg form that gives the user a remediation hint — whereas the Lines indexed-index checks are
inline in the writer and raise bare ValueError. Mesh should follow the shared validator half of
that precedent: vertex/face/normal validation belongs in validation/base.py as reusable, independently
testable functions, matching validate_widths_for_writing / validate_radii_for_writing. Only the
cheap structural gates that need writer context stay inline.
Tests mirror validation/tests/test_lines_validation.py, which is already parametrized over
(factory, error_pattern, test_id) triples — reuse that shape so each rejection in this section gets
its own named case, and verify each fails before the validator exists (a test that passes against
a no-op validator is vacuous).
Loader-side validation (viewer). The validators above run at write time and protect only stores this
writer produced; the viewer loads arbitrary — externally produced or corrupted — stores and hands faces
straight to the §5.4 kernels. An out-of-range face index panics the Rust kernel (the crate is
panic = "abort", so the trap escapes as an opaque, uncatchable RuntimeError: unreachable rather
than a node-scoped error) and silently corrupts the TS backend
(out-of-bounds reads yield undefined), so the loader must structurally validate before either backend
is invoked. But the whole-node loader (§7) fetches and decodes every array in full up front, so a check
that runs only after decode arrives too late for the quantities that gate admission: a corrupt or
hostile store (the viewer loads arbitrary ?src= URLs) can declare enormous arrays and exhaust tab
memory before the LoaderError containment (“one node lost, not the scene”) is ever reachable. The gate
therefore runs in two stages, and everything decidable from metadata is checked first, before a
single chunk is fetched.
Stage 1 — metadata preflight (before any array materialization). Runs purely on the node attrs
(n_vertices, n_faces, ndim, has_normals/normal_dims, the presence flags §3.3) and each array’s
zarr .zarray metadata (declared shape, chunks, and dtype), touching no chunk data. It (a) rejects
n_vertices > 2^27 — the pick vote-key stride bound (§6.5) — so a giant vertex count is refused before
allocation, not after a multi-gigabyte fetch; (b) bounds n_faces, which the writer floors at F >= 1
but never caps, against a viewer-side per-node ceiling MESH_DECODE_BUDGET_BYTES (a viewer src/config/
constant, default 512 MiB — a few-million-triangle mesh’s vertices+faces run to tens–hundreds of MB,
so the default admits the §7 workload expectation with several-fold headroom; why it must sit well
under what a tab survives is the transient-peak multiplier below): both the summed declared
footprint — each array’s declared shape × its declared-dtype itemsize: vertices V·D·4, faces F·3·itemsize (8 bytes per index for
an external int64 store, not the canonical uint32’s 4 — budgeting the canonical dtype instead of the
declared one would let a 64-bit store fetch twice the audited bytes), every present optional array and
the CSR arrays included — and each array’s per-chunk decode
allocation (chunks × itemsize, edge chunks padded to the full chunk shape) must fall under it — the
per-chunk term because zarr allocates chunk-shaped buffers, not shape-shaped ones, and zarr v2 does not
require chunks <= shape, so a faces "shape": [100, 3], "chunks": [268435456, 3] declaration would
otherwise slip a ~3 GB first-chunk allocation past a shape-only budget; (c) cross-checks every declared
.zarray shape and dtype against n_vertices/n_faces and the §3.2 array table — vertices (V, D)
float32 with D == ndim (a vertices width that disagrees with the ndim attr would otherwise index
out of slice bounds in the §5.4 slab kernel — a panic = "abort" trap, the exact class this gate
exists to stop), faces (F, 3) of an integer dtype (so faces materializes to a multiple of 3; float is
rejected because it truncates in the u32 cast, mirroring the write-side validate_faces_for_writing
rule — §3.2’s uint32 is this writer’s canonical dtype, but an external integer store is coerced to u32,
a coercion Stage 2 makes value-preserving by range-checking the source values first), and each
present optional
normals (V, 3), colors (V, 3|4), scalars (V,) — because these optionals bind as enabled
vertex attributes on an indexed draw (§6.1): an undersized attribute doesn’t trap, it makes
drawElements read past the buffer (an invalid-operation draw or silent zeros, backend-dependent) and
mis-shades every vertex it covers, and a declared-undersized array is caught here from its shape alone;
and (d) checks normal_dims well-formed whenever normals are present — exactly 3 entries, distinct
integers, each 0 <= i < ndim — decidable from the attrs alone, so it never forces a fetch. The decode
layer must in turn allocate from the declared chunk nbytes and reject any stream that decompresses to
a different size, so a blosc header claiming gigabytes cannot win either. Be clear about what the
ceiling bounds: the declared source footprint plus any single decode buffer — not the loader’s whole
transient peak. On the admission path the decoded sources coexist with derived copies — the u32-coerced
faces, the extracted display-space position (§6.1), the driver-side GPU upload — each itself bounded
by the source footprint, so the worst-case transient peak is a small known multiple (≈ 3–4×) of the
ceiling. The default prices that multiplier in: 512 MiB of admitted declaration keeps the worst-case
transient around 2 GiB, comfortably inside a 64-bit tab — which is also why the ceiling must never be
raised toward “what a tab survives”; the tab has to survive the multiple, not the ceiling. The ceiling
is per node —
N nodes can still sum to N×budget, so the “one node lost, not the scene” guarantee is per-node; v1
imposes no aggregate cap. A §9.1 reveal ladder is a single node for this purpose: its levels are summed
and charged once against the same ceiling, on the ladder’s first load — before any level’s chunks are
fetched, and at the same point a leaf’s own budget is enforced, so a refusal gets the same failure
containment (recorded, banner entry, siblings unaffected) as a leaf’s — retryable for a level’s own
preflight rejection, while the aggregate over-budget verdict is cached and re-thrown rather than
re-derived, no retry being able to make the sum fit. Any failure fails
the node with a LoaderError (one node lost, not the scene)
without fetching a single chunk, preserving the blast radius before allocation.
The n_vertices <= 2^27 cap belongs at this preflight because mesh’s pick elementId is gl_VertexID
(§6.5) — the one type not bounded by the element-texture capacity — and once a vertex ordinal reaches
the pick vote-key stride (2^27) the vote key silently aliases across nodes (the largest ordinal is
n_vertices - 1, so n_vertices <= 2^27 is the exact alias-free bound: every admitted ordinal stays
strictly under the stride), so the bound must be enforced here, not assumed from the §7 whole-load
workload.
Stage 2 — post-decode value checks (after fetch + decode). The remaining checks genuinely need the
materialized arrays. First, each materialized array’s length/shape must equal the shape Stage 1 admitted
(vertices/normals/colors/scalars length V, faces 3F) — Stage 1 vets only the declared
.zarray shape, so a store that declares correctly but materializes a short array (a raw or mis-sized
chunk, a non-compliant decoder) would otherwise resurrect the undersized-attribute drawElements
over-read Stage 1(c) closes. Then every face index in [0, V) — a two-sided check on the
source-typed values, before the integer→u32 coercion, because each side of the cast hides its own
wrap-around: an externally produced signed store’s -1 passes a one-sided pre-cast < V check and
wraps to 0xffffffff, while a 64-bit store’s 2^32 + 1 survives a check run only after the cast —
it wraps to 1, lands inside [0, V), and silently rewrites topology instead of trapping. The
two-sided source-value check rejects both, and because Stage 1 admits only V <= 2^27, every index it
passes is preserved bit-for-bit by the u32 cast — so the values checked are exactly the values the
kernels receive. Finally, the label and image-label CSR offsets monotone and in-bounds (§3.2). Same
LoaderError, same one-node
blast radius; these run only once Stage 1 has admitted the declared shapes and budget, so the
fetch+decode they gate is already bounded.
Stage 2 deliberately does not finite-scan the float arrays (vertices, normals, colors,
scalars). A non-finite value can neither trap a kernel nor over-read a buffer, and its blast radius
is already per-node without a gate: a NaN/±Inf coordinate on a hidden dimension hides the vertex
(§5.2’s #806 rule), a non-finite displayed coordinate corrupts at most that node’s rasterization and
bounding sphere (which the depth-sort coordinator already refuses to sort by —
depth-sort-coordinator/render-order.ts checks Number.isFinite on every sphere it uses), non-finite
colors/alpha are clamped by the shared shader sanitizers (§6.2’s sanitizeAlpha and the
materials/_shared helpers), and a non-finite stored normal degrades only that node’s shading —
contained because the §3.5 normalize guard is written in its NaN-robust negated form (above). No
sibling loader finite-scans its decoded positions either; mesh matches that policy rather than
inventing a stricter one here.
3.7 dim_order, normal_dims and face winding
add_mesh(dim_order=[...]) renumbers the vertex columns into the scene’s
dimension order (widening to the scene’s ndim, filling any dimension the caller
did not name). This section exists because that interaction was unspecified until
#2141 — and the omission cost a reviewer a wrong diagnosis before it cost anyone a
bug, which is the more useful thing to record.
normal_dims names SCENE dimension indices, i.e. the layout after
dim_order. This is the contract, and it is worth stating baldly because the
alternative reading is superficially more natural — the array is “authored”, so
surely its companion attr indexes authored columns? It does not. Three
independent pieces of evidence:
§3.4’s own example, “for a
(t, x, y, z)mesh those are(t, x, y)”, describes the stored layout;the writer range-checks
normal_dimsagainst the post-dim_orderndim;demo_lsystem_forestauthors four columns (dim_order=["season", "x", "y", "z"]) and passesnormal_dims=[2, 3, 4]— one of which exceeds every authored column index — with the inline comment “The three scene dims the normals describe”.
So normals needs no companion transform, and this is where mesh differs from
gsplats. GSplats’ Cholesky factors are authored in the source frame, so
apply_dim_order_cholesky must carry them through the map alongside the centers.
A mesh’s normal components are already expressed in the destination frame by
contract, so there is nothing to remap: an apply_dim_order_normals would be a
double transform, and would corrupt exactly the callers who read the contract
correctly. The asymmetry between the two types is real, and it is in the
contract, not in the adders’ completeness.
Face winding is the part dim_order can invalidate, and Luxar deliberately
does not repair it. Since cross(Ra, Rb) = det(R)·R·cross(a, b), an
orientation-reversing column permutation negates a triangle’s geometric normal
while leaving its stored corner order untouched. Whether that makes the store
wrong depends on which frame the caller wound in:
The caller wound faces CCW in… |
After an orientation-reversing |
|---|---|
the SCENE column order (what §3.2 asks for, read literally, since the frame is |
already correct — nothing to fix |
their own AUTHORED column order |
now clockwise in the scene frame; violates §3.2 |
Nothing in the store distinguishes the two, so an automatic flip would fix the
second caller by breaking the first. The writer therefore warns — the same
warn-only posture as §3.6’s unwelded-vertices lint — naming both consequences:
with double_sided: false the surface renders inside-out and an open surface can
vanish; with stored normals, gl_FrontFacing chooses the wrong sign and flips the
shading gradient even when double_sided is true. The one-line winding remedy is
faces[:, [0, 2, 1]]. It is silent only with no normals, because
sorted(normal_dims) is the only declared winding frame there is (§3.2) and the
viewer already renders such a mesh DoubleSide regardless.
Handedness is judged on the frame’s preimage: walk sorted(normal_dims) in
ascending scene order, record which authored column each axis came from, and test
whether that sequence is an odd permutation. A frame axis with no preimage (an
unmapped, constant-filled scene dimension) makes the restricted map not a
permutation at all, and is skipped rather than guessed at.
faces is still never reindexed, for the reason the add_mesh docstring gives:
it addresses vertex rows, and dim_order permutes columns, so a row index
names the same physical vertex afterwards. The lesson of #2141 is that this was
the whole of the recorded reasoning — faces was considered as indices and
never as orientation — and that the missing sentence was about winding, not about
the arrays.
4. Python API
scene.add_mesh(
name: str,
vertices: NDArray[np.float32], # (V, D)
faces: NDArray[np.uint32], # (F, 3)
normals: NDArray[np.float32] | None = None,
normal_dims: Sequence[int] | None = None, # required iff normals is given — §3.4/§3.5
colors: NDArray | Sequence[float] | None = None,
scalars: NDArray[np.float32] | float | None = None,
uvs: NDArray[np.float32] | None = None,
texture: NDArray | None = None,
*,
texture_encoding: Literal["raw", "png", "webp", "jpeg", "ktx2"] = "raw",
texture_width: int | None = None,
texture_height: int | None = None,
texture_channels: int | None = None,
texture_color_space: Literal["srgb", "linear"] = "srgb",
texture_ktx2_mode: Literal["uastc", "etc1s"] = "uastc",
texture_ktx2_quality: int | None = None,
texture_ktx2_rdo_l: float | None = None,
texture_ktx2_zcmp: int | None = None,
shading: Literal["smooth", "flat", "none"] | None = None,
double_sided: bool = True,
labels: Sequence[str] | None = None,
image_labels: Any | None = None,
**attrs, # opacity, colormap, transform, blending_mode, ...
) -> Mesh
shading resolves as: None (the default) → "smooth" when normals is supplied, else "flat"; an explicit
"smooth" with no stored normals has nothing to smooth — the writer stamps the value as given and the
viewer’s §6.2 rule falls back to the flat derivative normal at render time (no write-time rewrite); an
explicit "flat" is always honored and renders the faceted derivative-normal surface even when normals
is present (§3.4, §6.2). Explicit "none" is unlit and computes no normal.
normal_dims is §3.4’s required companion attr, surfaced as an explicit keyword because it has no
other way in: it is a member of MESH_RESERVED_ATTRS (§3.3), so passing it through **attrs fails the
write as a reserved-key collision. The adder forwards it to the writer alongside normals, and §3.5
validates the pair (required when normals is supplied, rejected when it is not).
Placement mirrors the other three exactly:
Concern |
File |
|---|---|
Node class |
|
Adder |
|
Wiring |
|
Writer |
|
Facade |
|
Reader |
|
CLI |
|
add_mesh accepts a broadcast scalar color/scalar exactly as add_lines does, and routes through the
same validate_broadcast_color / validate_scalars_preflight gate.
5. nD semantics: per-vertex slab test, whole-triangle cull
5.1 The decision
Lines clip a segment against the nD slab and interpolate every attribute at the clip parameter
(wasm/rust/src/lines_clipping.rs, 1381 LOC + a 591-LOC TypeScript parity backend). The exact
equivalent for a triangle is nD polygon clipping: a triangle cut by the slab becomes a convex polygon
of up to 3 + k vertices, requiring fan re-triangulation and attribute interpolation at each new
vertex, every frame the slice moves.
v1 does not do this. Instead:
A triangle is rendered iff all three of its vertices pass the nD slab membership test.
5.2 Semantics, reused verbatim from the lines kernel
For each non-displayed dimension d, with slice_min = slice_position[d] - tolerance[d] and
slice_max = slice_position[d] + tolerance[d]:
vertex is in iff
v[d] >= slice_min && v[d] <= slice_maxfor every suchd;a non-finite (
NaNor±Inf) coordinate on any non-displayed dimension makes the vertex invisible — matching the#806rule enforced identically in both lines backends (lines_clipping.rs:75-80,lines-clipping.ts);extend_to_alldimensions get infinite tolerance via the existingEXTEND_TO_ALL_TOLERANCEpath (= 1e10), unchanged.
This is precisely the p1_in branch of clip_segment_single, applied per vertex and AND-ed across the
three vertices of a face.
5.2.1 Tolerance — mesh needs its own strategy, and cannot reuse the Lines one
computeTolerance(geometryType, …) switches per type, and each existing strategy is derived from
that type’s per-element extent:
Type |
Hidden spatial dim |
Hidden discrete dim |
|---|---|---|
Points |
|
quarter-cell ( |
Lines |
|
quarter-cell, or half-cell when |
GSplats |
float-safety epsilon |
quarter-cell |
⚠️ Mesh must NOT copy the Lines row. Lines can use 0 because segment clipping interpolates
through the slab — a segment crossing the slice yields a clipped intersection even at zero thickness.
Mesh has whole-triangle cull (no interpolation) and no per-element extent (§2.2), so a spatial
tolerance of 0 reduces the membership test to exact float equality with the slice plane and the
node renders nothing.
Mesh therefore adds a fourth arm to computeHiddenDimTolerance:
Discrete hidden dims →
discreteDimMembershipTolerance(half-cell). Mesh’s slab test is a MEMBERSHIP gate, exactly like the lines projection-clipping slab, but unlike lines it does not request that role: mesh has no spatial index and issues no range query, so membership is the only rule it has andcomputeMeshHiddenTolerancedeliberately IGNORESdiscreteRole(data-processor-mesh.tspasses only the authoredmeshSlabTolerance, never a role). Honouring a'query'role here would hand back the fetch reach (deliberately< 0.5 × step) to the one caller that is asking about visibility, and drop on-grid geometry. This is the dominant real case — a mesh’s hidden dimensions are almost always time or channel.Continuous hidden spatial dims →
step × meshSlabTolerance, default1.0(one cell). This is a half-width: the slab spansslice ± step × slab_tolerance. It is authored, not viewer-internal: the mesh-onlyslab_tolerancenode attr is measured in cells of the hidden dimension’s ownstep, strictly positive (zero would reduce membership to exact float equality with the slice plane and render nothing), and rejected on every non-mesh node. The viewer reads it offMeshMetadataand forwards it asToleranceOptions.meshSlabTolerance; see the format guide’s nD slicing: whole-triangle cull for the user-facing account. Mesh is the only type with a tunable continuous arm: a mesh has no per-element extent, so the slab thickness is invented rather than measured. GSplats exposes no equivalent slab knob — its chunk bounds already carry the realtruncation_radius · σextent, so its continuous arm is a float-safety epsilon (gsplatsContinuousDimTolerance); the two inputs that do vary it are read off the store, not authored — the node’s owntruncation_radius(ToleranceOptions.truncationRadius) and the writer’s published barrier set (ToleranceOptions.barrierDims, theslice_dimsattr, which decides whether a dim takes this arm at all). Neither reaches a mesh: it publishes noslice_dims(no spatial index) and has no truncation radius, andisBarrierDimhonours a published set for gsplats ONLY — precisely because both of a mesh’s arms are membership gates, so narrowing one would change what the user sees. SomeshSlabTolerancereally is the only control a mesh has.
Be honest about what the second bullet means: with per-vertex cull there is no such thing as a true
cut, so a continuous hidden dimension renders a thick slab (“the surface near this slice”), not a
planar section, and the slab thickness is the only control. Exact nD clipping (§9) is the fix; until
then a mesh whose hidden dims are continuous and spatial is a poor fit for this node type, and the
loader says so once, by name — noticeContinuousHiddenDim in
data/scene-loader/process/data-processor-mesh.ts logs an info line naming every such dimension
and its unit, deduplicated per node and dimension (a node’s not-yet-reported dimensions are
named together in one line, and an axis that becomes hidden later still gets its own). One
carve-out, so silence is not over-read: a dimension listed in extend_to_all is slice-invariant, so
its membership slab is infinite and the approximation cannot bite — it is deliberately NOT reported.
That line is also the evidence gate on §9’s deferral of exact clipping.
5.3 Consequence, stated plainly
A surface cut by a slice shows a ragged, triangle-quantized boundary rather than a clean planar cut. For a well-tessellated mesh sliced with a tolerance comparable to the edge length this reads as a slightly jagged edge; for a coarse mesh with a thin tolerance it can drop whole regions. This is a real visual limitation and must be documented in the user guide, not glossed.
Discharged in #2144, and worth recording how long it took: this sentence carried no §8 checklist
item, so for six phases it was neither ticked nor missed. docs/guides/user/LUXAR_ZARR_FORMAT.md §5
now carries an nD slicing: whole-triangle cull subsection (the rule, the ragged edge, the thick slab,
slab_tolerance, and when to reach for gsplats instead) and VIEWER_GUIDE.md’s nD Navigation section
cross-references it — every other rule there describes per-element visibility, which is exactly what a
mesh does not do. A “must” in this document with no checklist item behind it is a “maybe”.
It is the right v1 trade: it costs ~140 LOC of new kernel instead of ~1500, requires no re-triangulation, no new vertices, and no attribute interpolation machinery.
5.4 Kernel
Two new functions, one per backend, kept in 1:1 parity like every other kernel pair
(the TypeScript reference is also the production ndim > 16 backend — see the WASM 16-dimension note
in CLAUDE.md):
// wasm/rust/src/mesh_culling.rs (~90 LOC)
/// Per-vertex nD slab membership. Output mask [num_vertices], 1 = in.
pub fn mesh_vertex_visibility_mask(
positions: &[f32], slice_position: &[f32], tolerance: &[f32],
display_dims: &[u32], ndim: usize, num_vertices: usize,
output: &mut [u8],
) -> u32;
/// Compact `faces` to those whose three vertices are all visible.
/// Writes ORIGINAL (un-remapped) vertex indices into `output`.
pub fn compact_visible_faces(
faces: &[u32], vertex_mask: &[u8],
num_faces: usize, output: &mut [u32],
) -> u32;
No vertex compaction. On a per-slice change (displayDims unchanged) only the index buffer is
rebuilt; the vertex attribute buffers are uploaded once, in full, and left alone. The one exception is a
displayDims change: because position and normal are both displayDims-derived (§6.1, §3.4), it
re-extracts and re-uploads the position buffer and re-decides the normal attribute (§7). This is
re-extraction of the display-space projection, not compaction — compaction is still never done.
A reveal-ladder level is a second exception: each level writes its grown vertex/face prefix into the SAME
capacity-sized buffers (sized to the ladder’s lifetime totals, never resized), rather than uploading
“once, in full” the way an unladdered mesh does.
drawElements never fetches an unreferenced vertex, so culled vertices cost nothing to draw, and the
mesh is resident in full anyway (§7). This deliberately avoids:
a generic mask-compaction helper over the vertex buffers, which would be
&[f32]-only and could not compact the nativeuint8/uint16colors §3.2 permits without a widening pass (theuint8/float16scalars are uploaded asf32on the attribute path anyway, §6.1.1, but the colors stay native, only padded RGB→RGBA);a
vertex_remaparray and the index remapping that goes with it;re-uploading every attribute buffer on each slice change (on a slice change the index buffer alone is re-uploaded; a
displayDimschange additionally re-uploadsposition/normal, §7).
The only cost is VRAM for vertices that are currently invisible — bounded by the mesh size, which is already the resident working set.
Winding. compact_visible_faces preserves the authored order, so it is winding-agnostic. Parity is
decidable only against the authored winding frame (§3.2): when the displayed set equals that frame and
the displayDims (x,y,z) column order is an odd permutation of it — a reflection of display
space — a post-pass swaps two of each triangle’s three indices to restore front-facing winding. This is
keyed to the current displayDims parity, so it runs on every index build in an odd-parity epoch
(initial load, slice move, and displayDims change alike), not only at the moment displayDims
changes. Equivalently, render the opposite material side for the duration of the odd-parity epoch — a
persistent form that needs no per-rebuild post-pass. This equivalence holds only while nothing consumes
gl_FrontFacing: the stored-normal shading flip (§6.2) requires the index post-pass form, since the
opposite side of a DoubleSide mesh is DoubleSide and leaves projected winding (and thus
gl_FrontFacing) reversed. When the displayed set is a different triple
than the frame (e.g. [0,1,2] → [1,2,3]), or an nD mesh declares no frame (no stored normals),
projected orientation varies per triangle and no index post-pass can fix it — the viewer renders
DoubleSide for that epoch regardless of double_sided: false, and logs a one-time notice naming the
node.
mesh_vertex_visibility_mask calls validate_ndim like its siblings and therefore panics above 16D;
pickBackend(ctx, ndim) (workers/data-worker/state.ts:60) returns ctx.tsFallback — the whole
module — for ndim > 16. No change to the routing logic is needed, but because it swaps modules
wholesale, both new kernels must be declared on the WasmModule interface (wasm/types.ts:7) and
implemented by both backends, or the TS module will not structurally satisfy the interface.
5.5 Fast path
When displayDims.length === ndim (no hidden dimensions — the common 3D case), the mask is trivially
all-ones and the cull is skipped: with displayDims unchanged, positions are extracted once via the
existing extract_3d_positions and the index buffer is uploaded verbatim (no compaction) — after the §5.4 parity
post-pass, which reverses the winding if the initial displayDims is odd-parity (nothing restricts the
opening/restored view to ascending order). Skipping the cull is not
the same as doing no work, though: a displayDims change on this fast path still re-extracts positions
via extract_3d_positions, re-decides the normal attribute per §3.4, recomputes bounds, and — for an
odd-parity permutation — reverses the index winding (§7); only the visibility-mask recompute is elided.
A non-3D dataset triggers the full cull path.
Combined with §5.4’s no-compaction rule, this means a plain 3D mesh uploads every buffer once per
displayDims epoch (once until displayDims changes), and only a pure slicePosition/tolerance move
with unchanged displayDims is truly zero-work — updateView returns early. A displayDims change is
never zero-work, even here (it rebuilds position/normal).
6. Rendering
6.1 Geometry
rendering/mesh-geometry.ts builds a plain THREE.BufferGeometry:
Attribute |
Size |
Source |
|---|---|---|
|
3 |
|
|
3 |
stored normals when valid (§3.4), else omitted |
|
3 or 4 |
always bound (never left to the GL default |
|
1 |
|
index |
— |
|
Drawn as THREE.Mesh with side: DoubleSide when double_sided, else FrontSide.
6.1.1 Color/scalar vertex-attribute dtypes
Bind uint8/uint16 colors with new THREE.BufferAttribute(u8, 4, /* normalized */ true), keeping the
native dtype (the GPU normalizes to [0,1] for free) rather than widening to Float32Array — but always
at 4 components. When colors is RGB (§3.2 permits (V, 3)), pad it to RGBA at geometry-assembly
time with a fully-opaque alpha (255 for uint8, 65535 for uint16, both normalizing to 1.0); RGBA
input binds as-is. This is load-bearing, not cosmetic: the TSL materials run on a real WebGPURenderer
(?renderer=webgpu negotiates an adapter and passes the device; forceWebGL is a diagnostic mode only),
and three r184’s WebGPURenderer exposes no 3-component 8/16-bit vertex format for that family — its
GPUVertexFormat table lists only unorm8x2/unorm8x4 and unorm16x2/unorm16x4 — and WebGPU requires
arrayStride to be a multiple of 4. A tightly-packed size-3 uint8/uint16 attribute has a 3-byte /
6-byte stride (three r184 uploads it packed and sets
arrayStride = itemSize · BYTES_PER_ELEMENT); neither stride is a multiple of 4, so createRenderPipeline
fails validation and an RGB uint8/uint16 mesh renders nothing on the WebGPU backend — violating
§6.4’s “both backends must produce matching output”. Padding to unorm8x4 (4-byte stride) / unorm16x4
(8-byte stride) fixes both the format and the stride, and the memory win over float32 widening survives:
4 bytes/vertex for uint8 RGBA vs 12 for f32×3. float32 colors need no padding — float32x3 is a
valid WebGPU format with a 12-byte (4-multiple) stride — so they bind at their native 3 or 4
components. This still mirrors the loader doctrine — LoadedLinesData and LoadedPointsData keep
Uint8Array | Uint16Array | Float32Array colors — and §5.4 removed the one place mesh would have needed a
full f32 widen of the color array (the compaction pass); the RGBA pad is a 3→4 component copy in the
native dtype, not a dtype widen.
uint8/float16 scalars are uploaded as float32 on the attribute path for the same root cause:
three r184 has no itemSize-1 vertex format for a Uint8Array or a native Float16Array
(typeArraysToVertexFormatPrefixForItemSize1 maps neither → “Vertex format not supported yet” and a
broken pipeline). The partial escape hatches r184 does ship rescue nothing here. A non-normalized
Uint8Array is caught by the buffer-side “patch for INT16 and UINT16” widen, which rebuilds the whole
buffer as Uint32Array and binds an integer uint32 attribute — four bytes per scalar, the exact
memory of the float32 upload, but integer-typed in the shader graph (getTypeFromArray → uint)
instead of the float the material reads; a normalized one skips that widen and dies on the format
lookup miss, with a 1-byte stride that violates WebGPU’s multiple-of-4 rule anyway. Three’s own
Float16BufferAttribute (which is Uint16Array-backed) fails just as loudly, only more confusingly:
getTypeFromAttribute special-cases it to a float shader input while the attribute path binds a
uint32 vertex format, so createRenderPipeline rejects the input/format class mismatch. Widening to
float32 (4-byte stride, valid on both backends, float-typed everywhere) sidesteps the whole minefield
at zero memory cost over the only native path that even binds. float32 scalars bind directly as
float32. Mesh is the first geometry type to feed these dtypes to vertex attributes — the siblings
route colors/scalars through the RGBA32F element texture — which is why nothing in the shipped tree has
hit this before.
There is one color attribute regardless of the source component count, and hence one shader that
reads it as a vec4. RGB input still carries a per-vertex opacity of 1.0 for free — the same “1.0 for
RGB data” contract the gsplat/line shaders document (materials/gsplat/shader-glsl.ts,
materials/line/shader-glsl.ts) — but mesh obtains that 1.0 two ways depending on the format: for
uint8/uint16 RGB it is the CPU-side pad alpha (exactly as the siblings write 1.0 into their
element texels), and only for a format WebGPU already accepts (float32x3 — the absent-colors white fill
in §6.1, and any float32 RGB) is it left to the size-3 w = 1.0 attribute default. That default holds
on both backends, but only for a valid format; it cannot rescue the invalid uint8/uint16 size-3
layout, which is exactly why those are padded rather than left size-3. No separate RGB-vs-RGBA material
variant is needed; the alpha handling in §6.2 is unconditional.
6.2 Shading
The other three geometry types are purely emissive and have no lighting whatsoever. A mesh without shading is a flat silhouette and effectively unreadable, so mesh is the first type to shade. The v1 model is deliberately minimal and light-free:
⚠️ Two refinements the implementation forced, both now shipped. (a) The derivative fallback’s
normalize(cross(dFdx(vViewPos), dFdy(vViewPos)))is explicitly forced toz >= 0in both backends. The cross product carries the sign of the fragment-space y axis, and GLSL’sdFdyis bottom-up while WGSL’sdpdyis top-down — so “orientation-defined by the rasterized fragment, so it always faces the viewer” is only true once enforced. That an unenforced flat variant would collapse touAmbienton WebGPU alone is a spec-derived RISK, not an observed behaviour: the real-WebGPU A/B (§11 row 6) MEASURED a face-on flat quad rendering identically with the flip REMOVED, so on Chrome + Apple Silicon the two conventions coincide and the flip is currently inert there. It is kept as insurance — one instruction, correct under either convention, and neither shading-language spec promises the two conventions agree. (b) The stored-normal view transform is written out asviewMatrix · (modelNormalMatrix · n)rather than through three’stransformNormalToView, whosetransformDirectionnormalizes: since the writer accepts zero-length normals with a warning (§3.5), that normalize would produceNaNand interpolate it across every triangle touching the vertex, flat-shading all of them instead of distorting locally. Also: because thenormalattribute must not be bound/unbound per epoch (that grows or shrinks a live geometry’s WebGPU vertex layout), it is bound for the node’s lifetime wheneverhas_normals; only the shader variant swaps.
Normal source: the stored
normalattribute is used iffshading == "smooth"and the stored normals are valid for the active view (present andnormal_dims == displayDims, §3.4); otherwise — an explicitshading == "flat", absent normals, or anormal_dims/displayDimsmismatch — a flat normal is derived in the fragment shader from screen-space derivatives of the view position (normalize(cross(dFdx(vViewPos), dFdy(vViewPos)))). The derivative fallback means a mesh with no stored normals still shades correctly, and it is what makes §3.4’s “recompute on non-default displayDims” cheap: whennormal_dimsno longer matchesdisplayDims, the mesh drops the stored normals and shades from derivatives. Because a declared-but-unboundnormalreads(0,0,0,1)rather than “absent”, this stored↔flat choice is a compile-time shader variant (§6.4’smesh.fragmentvsmesh-flat-normal.fragment), selected identically by both the GLSL and TSL backends — soshadingdrives the variant, it is not inert metadata. The selection is computed once per node (increateMeshNode, fromshading, the stored normals’ presence andnormal_dims, and the activedisplayDims) and handed to both material factories, so the two backends never re-derive it independently. Its two conjuncts differ in stability:shadingis view-independent, so ashading == "flat"node is statically the flat variant and never swaps; thenormal_dims == displayDimsconjunct is view-dependent, so ashading == "smooth"node re-evaluates the rule — swapping variant and binding/omitting thenormalattribute — when adisplayDimschange flips its validity, the same event that re-extracts the display-spaceposition(§6.1, the §7displayDims-change rebuild).View-space normals (stored-normal variants). The
normalattribute binds in the node’s local display frame, but every other input to the shade term is view-space by construction —vViewPos, the fixed view axisV, and the derivative fallback’scross(dFdx(vViewPos), dFdy(vViewPos)). The stored-normal vertex stage must therefore carry the normal into view space before interpolation:vNormal = normalMatrix * normalin GLSL (the built-inmat3 normalMatrix, the inverse-transpose of the model-view matrix), and the TSL twin viatransformNormalToView. The inverse-transpose is load-bearing, not pedantry: a mesh node carries the standard 4×4transformattr (§3.3), and anisotropic scaling is routine in this domain (voxel size z ≠ xy), under which the plain model-view linear map skews normals off-perpendicular — while a raw untransformed normal mislights any rotated node (the same mesh shades correctly under the flat variant and wrongly under the smooth one, since only the latter reads the attribute). The fragment-stage renormalization (two-sided bullet below) absorbs the length changenormalMatrixintroduces under scaling, so no vertex-stage normalize is needed. The TSL backend’s generated.vertexcodegen snapshots (§6.4) pin this transform for that backend; the hand-written GLSL twin has no codegen snapshot and is instead pinned by the §8 rotated/anisotropically-scaled shading test.Shade term: a camera-anchored offset key light,
L = normalize(vec3(-0.35, 0.55, 0.75)), above and slightly left of the view axis. It remains light-free in the scene sense:Lis a view-space constant that rides with the camera, so there is no light object, scene-graph state, or per-frame light uniform. The diffuse term keeps the existing wrap,shade = mix(uAmbient, 1.0, pow(saturate(dot(N, L) * 0.5 + 0.5), uShadeExponent)), preserving the readable ambient floor on away-facing surfaces. A subtle additive Blinn–Phong highlight supplies a second curvature cue: withV = vec3(0, 0, 1), the half-vector is also constant,H = normalize(L + V), andspec = uSpecular * pow(max(dot(N, H), 0.0), uShininess). The final lit RGB isfinalColor * shade + vec3(spec). Defaults areuAmbient = 0.25,uShadeExponent = 1.5,uSpecular = 0.12, anduShininess = 24;uAmbient = 1.0removes the diffuse gradient anduSpecular = 0.0disables the highlight.Two-sided normal (stored-normal variants). Every non-flat fragment build —
mesh.fragmentand itsmesh-additive/mesh-max/mesh-colormapsiblings (§6.4) — renormalizes the interpolated normal in the fragment stage and then flips it to face the camera BEFORE the lighting term:N = gl_FrontFacing ? N : -Nin GLSL, and the TSL twin via thefrontFacingnode. Without it, a back-facing fragment keeps an unflipped normal, so the wrap term typically lands on the dimmer side of its range and the back side shades with an inverted gradient collapsing towarduAmbient(dark at the head-on interior, rising to a mid value at the silhouette) instead of the front-facing gradient — visible immediately becausedouble_sideddefaults true (§3.3) and §5’s whole-triangle cull exposes the interior back faces of a sliced closed isosurface, exactly the target data. The derivative fallback (mesh-flat-normal.fragment) needs no such flip:normalize(cross(dFdx(vViewPos), dFdy(vViewPos)))is orientation-defined by the rasterized fragment, not the winding, so it always faces the viewer (§7’s “always faces the camera regardless of winding” is correct for that variant) — the flip is a stored-normal-variant-only concern.That exemption is per fragment, not per variant, which matters because §3.5’s epsilon guard substitutes the same derivative normal inside these stored-normal builds whenever the interpolated normal is not affirmatively valid. Such a fragment must take the fallback’s rule, not the variant’s: the substituted normal already faces the viewer, so applying
gl_FrontFacing ? N : -Nto it would negate a viewer-facing normal on every back-facing fragment and reintroduce exactly the inverted,uAmbient-collapsing shade the flip exists to remove — worst precisely at the degenerate and corrupt vertices the guard is there to rescue. So the flip is gated on whether the stored normal survived the guard; only normals that did are flipped. One structural constraint on the fragment build follows: the guard’s condition reads an interpolated varying, so a branch on it is non-uniform control flow, where GLSL leavesdFdx/dFdyundefined (normal validity can differ between fragments of the same 2×2 quad). The derivative fallback normal is therefore computed unconditionally, before any guard-dependent branching, and the guard selects per fragment between the flipped stored normal and that precomputed fallback — only thegl_FrontFacingflip, never the derivative evaluation, sits behind the guard.The flip is well-defined wherever it applies: stored normals are only active when
normal_dims == displayDims(§3.4), where §5.4’s parity post-pass keeps winding coherent — specifically its index post-pass form, since the flip consumesgl_FrontFacingand needs it to correlate with the authored orientation — so the flip puts the back face on the lit side of its own view-anchored gradient instead of the inverted,uAmbient-collapsing side.Base color: the colormap LUT applied to
aScalarunderUSE_COLORMAP, else the vertexcolorattribute — which is opaque white whencolorsis absent (§6.1, filled CPU-side exactly ascreate-points-node.ts:91does for points). So the minimaladd_mesh(vertices, faces)call (no colors, no scalars) renders a readable opaque-white surface, not the GL-default black that an unboundcolorattribute would give. The colormap path is the samegetColormapTexture/updateScalarRangepathcreateLinesNodeuses, including the same fail-closed guard whencolormapis set withouthas_scalars. This default is identical in both the GLSL and TSL backends (§6.4).Per-vertex alpha (load-bearing). The
colorattribute’s 4th component is a per-vertex opacity and is carried through a smoothly-interpolated varyingvAlpha— the vertex stage writesvAlpha = sanitizeAlpha(color.a)and the rasterizer interpolates it across the triangle. Contrast the gsplat shader, whose per-splat data is a per-instance constant and is thereforeflat-qualified (flat out mediump vec3 vColor;,flat out mediump float vAlpha;inmaterials/gsplat/shader-glsl.ts, whose comment states “alpha is load-bearing in EVERY mode”): a mesh vertex is not an instance constant, so its alpha must interpolate across the face exactly as the line shader’svAlphainterpolates along a segment (vAlpha = mix(sanitizeAlpha(lineT5.z), sanitizeAlpha(lineT5.w), tEff);inmaterials/line/shader-glsl.ts). RGB data suppliesvAlpha = 1.0for free (§6.1.1), so no RGB-vs-RGBA variant is needed. This rule holds identically in both the GLSL and TSL material backends (§6.4).Tail. The shaded RGB then goes through the standard
intensity→offset→gammachain, after which the coverage that drives blending is formed and the fragment is emitted per blending mode:Mesh has no per-element
intensity/amplitude/falloff scalar (§2.2) — it is a solid shaded surface — so the coverage entering the blend is simply the per-vertex alpha times node opacity:float a = vAlpha * uOpacity; // the single coverage term; NOT intensity * uOpacity
The §6.2 diffuse shade factor and additive specular are lighting terms applied to RGB only; neither enters
a. Emission then branches on the blending mode’sshaderOutputMode(blending-state.ts), mirroring the line shader’s fragment tail (materials/line/shader-glsl.ts):additive/luminous/normal→ alpha-weighted (SrcAlpha/OneorSrcAlpha/OneMinusSrcAlphaapplyaat composite): emitfragColor = vec4(shadedColor, a);.max→ rgb-contribution:MaxEquation + OneFactor/OneFactordoes not weight source RGB by alpha at composite, so premultiply by coverage — emitfragColor = vec4(shadedColor * a, a);— exactly the line shader’sLUXAR_MAX_RGB_CONTRIBUTIONbranch (vec4(gammaColor * a, a)).opaque(mesh default) → a hard alpha cutout, see below.
For
normal, per-triangle depth sorting orders the triangles back-to-front (§6.3), so per-vertex alpha composites in a sane order rather than in authoring order. It is an approximation, not an exact solve — see §6.3 for the two ways a centroid key falls short — and that is the reasonopaque, which is depth-correct per fragment, remains the mesh default.The max-premultiply and the opaque-cutout emissions are distinct per-mode shader variants — a GLSL
#defineexactly like the siblings’LUXAR_MAX_RGB_CONTRIBUTIONbranch (and a graph-baked TSL twin) — so each is a separately compiled shader that carries its own codegen snapshot (§6.4), not one free runtime branch. (Compile-time#defineand runtime-uniform branches coexist in the shipped materials — e.g. gsplat’s opaque/peak split is a runtimeuProjectionModebranch that TSL bakes per graph — but either way the harness snapshots each mode separately, which is the point here.)opaque(the mesh default) → alpha is a hard cutout, not smooth transparency. Decision, stated rather than left silent:opaqueis depth-writing and order-independent (shaderOutputMode: 'opaque',blending-state.ts), which is precisely why it is the only mode correct with no sorting at all — including when depth sorting is switched off (§6.3) — and smooth partial transparency is contradictory there. So underopaquethe coveragea = vAlpha · uOpacityacts as a hard, order-independent cutout:float a = vAlpha * uOpacity; if (a < uAlphaCutoff) discard; // masks / holes; order-independent fragColor = vec4(shadedColor, 1.0); // survivors are fully opaque, depth written normally
uAlphaCutoffis a material uniform with a sane default (0.5). This keepsopaquecorrect without sorting while giving authored alpha a defined, useful meaning (masks, holes, alpha-tested detail); the pick pass applies the same cutout so holes are neither pickable nor depth-occluding (§6.5).Stated plainly: because node
opacityis folded into the cutoff,opacitydoes not dim a default (opaque) mesh — it sweeps the cutout threshold. On an RGB mesh (vAlpha ≡ 1) that is a hard step:opacity < uAlphaCutoffdissolves the whole surface at once and anyopacityabove it produces no change. With authored per-vertex RGBA alpha it instead erodes — asopacitydrops, more vertices fall below the cutoff and the surface eats away — never a uniform fade. Either way, animating opacity on a default mesh does not cross-fade; a user who wants a smooth opacity fade selectsnormalinstead (which is depth sorted, §6.3).⚠️ This chain is not currently shared.
materials/_shared/provides only sanitizers, near-fade, sorted-index addressing (glsl-lib.ts,tsl-helpers.ts) and theclampGamma/isGammaOne/isNoGOGdefines helpers (uniform-helpers.ts). ThevColor * uIntensity + uOffset/ gamma / blend chain itself is written out independently in each of the sixmaterials/{point,line,gsplat}/shader-{glsl,tsl}.tsfiles.Mesh therefore has two options: (a) copy the chain a fourth time, consistent with the current codebase, or (b) lift it into
_sharedfirst as a prerequisite refactor. (a) is proposed for v1 — (b) touches all six existing shader files and their codegen snapshots, and mixing that into the mesh PR would make the diff unreviewable. Lifting the tail is worth a separate follow-up once there are four copies to justify it.Perspective near fade — the shared
perspectiveNearFade, evaluated PER FRAGMENT. The other three types already suppress geometry approaching the near plane with the one helper inmaterials/_shared/glsl-lib.ts/tsl-helpers.ts(0 behind the camera,smoothstepacross[nearCull, 2·nearCull], 1.0 under ortho), in both their visual and their pick shaders. Mesh does the same, so a surface fades out as the camera flies into it instead of clipping hard — the last near-plane asymmetry between the four types.The stage differs from the sibling types and is forced, not chosen. Points and gsplats evaluate the fade per VERTEX, which is exact because an instanced quad has one center depth. A triangle spans depth, so a per-vertex value would interpolate the ramp across the face and a large triangle straddling the band would smear a linear gradient over the smoothstep. Mesh therefore evaluates it in the fragment stage, off the
vViewPosvarying the shade term already carries:float nearFade = perspectiveNearFade(uIsOrtho, vViewPos.z, max(uNearCull, 1e-20)); if (nearFade < 0.01) discard; // every mode — see below
Three consequences worth stating:
The
< 0.01reject applies in every blending mode, not just the translucent ones. A mesh may WRITE depth —opaquealways does, andnormaldoes at opacity ≥ 0.99 (rendering/blending-state.ts::normalModeDepthWrite, which mesh feeds its real opacity) — and a fully-faded but still-rasterized fragment would then sit in the depth buffer occluding everything behind it while contributing nothing visible. Unconditional rather than gated on that predicate: gating would buy a runtime uniform in order to save adiscard.Under
opaquethe emission isvec4(shadedColor, 1.0)— there is no alpha to fade — so the fade ramps the shaded RGB instead:vec4(shadedColor * nearFade, 1.0). Every other mode folds it into the coverageabefore the emission branch, exactly as points and lines do, which is also howmax’s RGB premultiply picks it up. The cutout comparison itself reads the unfaded coverage: the fade is a distance effect, not an authored mask, and letting it move the comparison would dissolve the holes open as the camera approached.So in
opaque— the mesh default — the near fade darkens rather than dissolves, and that is an accepted trade rather than an oversight. The fragment keeps writing depth and keeps emitting alpha 1.0, so over a non-black background the near shell goes visibly BLACK for the width of the band before the< 0.01reject removes it; over a black background it reads as a dissolve. Every other mode dissolves properly, via coverage. Two things make the trade the right one. The band is[nearCull, 2·nearCull]withnearCull = 1e-3 · diagonal, so the darkened shell sits 0.1–0.2% of the scene diagonal in front of the eye and is ~0.1% thick — a distance a real approach crosses in a frame or two. And each alternative is worse in its own way. The only DETERMINISTIC way to dissolve — moving the cutout comparison onto the faded coverage (a * nearFade < uAlphaCutoff) — was rejected above for a stronger reason than the fade: it would let a distance effect rewrite an authored mask, opening the surface’s holes as the camera closed in. A stochastic reject (discardwhennearFade < hash(gl_FragCoord.xy)) would dissolve an order-independent depth-writing surface properly and without touching the mask; it is declined rather than overlooked, because it costs a hash plus a codegen variant and because a non-deterministic fragment would downgrade the parity harness’s exact-factor lock (mesh-near-fadeis pinned at precisely0.15625 ×its un-faded reference, per pixel) to a coverage-fraction test. Keeping only the< 0.01reject and dropping the RGB ramp is the fourth option, and is the hard near-plane clip this section exists to remove. An order-independent depth-writing mode drawn without per-triangle sorting (§6.3) has no honest partial coverage to fade; the answer for a user who wants a dissolve isnormal. The viewer answers the same structural question identically elsewhere:scene/lod-fade.ts’sBLENDABLE_MODESisadditive/luminous/volumetriconly, so anopaque/normal/maxlayer keeps a hard LOD swap rather than a cross-fade, on the same premise that a mode with no linear opacity knob does not get a fake one.uIsOrthoanduNearCullare the two camera inputs mesh consumes, which is why all four mesh material wrappers implementCameraAwareMaterialand join the material manager’s camera broadcast — ignoringfovandresolution, which size a screen-space sprite a mesh does not have. Both are runtime uniforms, so an ortho-mode toggle is a uniform write and never recompiles a mesh program (the TSL graphs therefore takeperspectiveNearFadeTSL, not the compile-time-ortho…StaticTSLvariant the line graphs use).
Under ORTHO the fade returns 1.0 for mesh exactly as for the other three, so nothing changes there; NDC near/far clipping stays the sole cull authority. The per-type stage table lives in
rendering/materials/_shared/README.md, and the near-plane floor derivation that depends on it inscene/scene-manager/clipping/bounds-math.ts.
6.3 Blending and depth
v1 supports opaque, normal, additive, luminous, and max.
volumetric is not supported: it is an emission–absorption model over per-element optical depth and
has no meaning for an opaque surface. The observable behavior is a one-time warning naming the node,
then an opaque fallback, applied in createMeshNode — a warning rather than a load failure, for the
inheritance reason at the end of this section. An explicitly-authored per-node
blending_mode='volumetric' is additionally refused at authoring time (add_mesh and the
Mesh.blending_mode setter raise) — that case is never an inheritance, so the fail-fast rejection and
the viewer fallback coexist: the fallback remains the handler for a mode inherited from an ancestor and
for pre-existing stores.
normal on a mesh is per-triangle depth sorted. The registration, the SortWorker and the sort
kernel are shared with the other three types — a triangle’s “center” is its vertex centroid, which is
3 floats per element exactly like a splat center or a segment midpoint. Only the APPLY differs, and it
differs structurally: the instanced types permute the aSortedIndex draw-slot indirection, while a
mesh has no indirection to permute and its ordering is written into geometry.index itself
(rendering/depth-sort-coordinator/triangle-ordering.ts).
Two consequences of that difference are worth stating, because they are not free choices:
The write is atomic, never chunked. The instanced path streams a new ordering into an inactive twin attribute and flips a
uSortedIndexSlotuniform on completion, so no frame ever samples a half-applied permutation.geometry.indexis BOUND state, not sampled state — a shader cannot select between two index buffers, and reassigninggeometry.indexis the drawn-geometry rebindapplyMeshIndicesexists to avoid. So the whole visible prefix is written in one pass, because a partially-permuted index buffer is not a permutation: some triangles would be drawn twice and others not at all, a wrong picture rather than a stale one. The cost is bounded by a quantity the mesh path already pays —applyMeshIndicesre-uploads the same prefix on every slice move.The permutation is applied to the canonical triples, not to the live buffer. The index buffer already holds the previous permutation, so permuting it again would compose the two. The coordinator retains the commit’s
ProjectedMeshData.indicesfor exactly as long as the node is being sorted.
Sorting by centroid is an approximation, and it is worth being precise about how. The kernel ranks each element by the view-space z of ONE point, so:
Two triangles that never intersect can still be ordered wrongly: they may overlap in screen space with one consistently in front across the shared region while their centroids — possibly both outside that region — rank the other way. This is a consequence of reducing a triangle to a single depth sample; an exact answer needs a per-fragment method (depth peeling, OIT) or a BSP split.
Interpenetrating triangles have no correct order at all, since which one is in front changes across the shared region. No primitive-granularity sort can fix that with any key.
Both residuals are shared with the other three geometry types, whose quads sort by a single center for
the same reason. And both are why opaque stays the mesh default: it depth-tests and depth-writes, so
it is exactly correct per fragment whatever the index order is — and it stays correct when depth
sorting is switched off (?depthSort=0) or the SortWorker is unavailable.
Making opaque the mesh default is a deliberate asymmetry — it is the only mode that is unconditionally
correct without sorting, and it is what a surface should look like.
That asymmetry also makes Mesh the common trigger for cross-geometry depth hazards: a default-additive
Points/Lines/GSplats node ignores the depth written by an opaque mesh, while two overlapping
order-dependent nodes cannot be globally interleaved. The authoring rules and finalize-time diagnostics
are specified in docs/guides/specs/GSPLAT_DEPTH_SORTING_SPEC.md §1 under
“Overlapping-node authoring rule”.
Where the default lives — and where it must NOT
⚠️ The mesh default must be applied viewer-side only, in createMeshNode:
blendingMode: (nodeAttrs.blending_mode as BlendingMode) ?? 'opaque', // mesh
// cf. create-{points,lines,gsplats}-node.ts, all `?? 'additive'`
It must not be stamped by the writer. apply_default_render_attrs deliberately omits
blending_mode from the attrs it defaults, and says why: unlike the identity-valued compositing attrs
(opacity/gamma/intensity/offset/absorption, all no-ops under hierarchical composition), a
stamped blending_mode would override an ancestor’s setting under the viewer’s nearest-setter-wins
rule. Writing blending_mode="opaque" into every mesh node would silently break
group(blending_mode="additive") for its mesh children.
So: mesh joins the other three in not stamping the attr, and diverges only in the viewer-side ??
fallback. Ancestor inheritance is preserved exactly.
⚠️ The ?? only fires if composition preserves the unset state. nodeAttrs here is the composed
record (applyEffectiveAttrs → composeAttrs, data/attrs-composer.ts), and
normalizeBlendingMode(undefined) returns 'additive' — so composing unconditionally through it hands
createMeshNode an 'additive' indistinguishable from an authored one and the ?? 'opaque' becomes
dead code (it was, until the first end-to-end render caught it — §11 row 6). composeAttrs therefore
normalizes only a value some level actually set, and leaves a fully-unset chain undefined.
volumetric handling belongs in the same place — createMeshNode, warn once and fall back to opaque
(§6.3 above) — not in the writer, for the same reason: the mode may be inherited from an ancestor the
mesh node knows nothing about at write time.
6.4 Materials
Per the repo’s dual-backend rule, mesh ships two visual materials and two picking materials,
registered in the existing tables at rendering/material-manager/factories.ts:
export const VISUAL_FACTORIES = {
point: { glsl: PointMaterial, tsl: PointTSLMaterial },
line: { glsl: LineMaterial, tsl: LineTSLMaterial },
gsplat: { glsl: GSplatMaterial, tsl: GSplatTSLMaterial },
mesh: { glsl: MeshMaterial, tsl: MeshTSLMaterial }, // NEW
} as const;
// PICKING_FACTORIES gains the matching mesh entry.
with MeshMaterialProperties alongside the existing three property interfaces. Materials are per
node (the convention for all three types since the texture-storage migration), so there is no cache
key and no LRU.
There are no per-type material cache maps — every material is per-node, so getCacheStats() reports
only registry size and create-time, never a cache size. Mesh follows the same convention: no cache map,
createMeshMaterial constructs directly.
The physical family (material="physical")
Mesh is also the only type with a second material family. material="physical"
(docs/guides/specs/MESH_PHYSICAL_MATERIALS_SPEC.md, Phases 1–4 shipped) hands the mesh to three’s own
physically based material — MeshPhysicalMaterial on WebGL, MeshPhysicalNodeMaterial on WebGPU —
behind two thin wrappers in rendering/materials/mesh-physical/ that share ONE attr→property mapping
(config.ts). It is registered as its own VISUAL_FACTORIES.meshPhysical entry, not as a variant of
mesh, because none of the house contracts in this section apply to it:
No codegen snapshot. The snapshot harness pins TSL Luxar writes; three’s materials are three’s to pin. The acceptance test is instead a real-WebGPU vs WebGL A/B on the demo scene judged by structural similarity (
packages/luxar-viewer/scripts/ab-webgpu-vs-webgl.mjs), not pixel equality.Picking is unchanged. There is deliberately no
PICKING_FACTORIES.meshPhysical: picking renders geometry, not appearance, so a physical mesh picks through the house mesh-pick material (§6.5). Its pick mode follows its compositing — the cutout when opaque, every fragment when translucent.Never triangle-sorted. The material stamps
userData.blendingMode = 'opaque'when opaque and nothing when translucent, so the depth-sort coordinator (§6.3) releases it in both cases while the node state — and with it thelayer_orderband rank — is still created by the ordinary commit.No blending mode at all. Translucency is read off the data:
opacity < 1,transmission > 0(glass), or per-vertex alpha without an authoredalpha_cutoff; with a cutoff the alpha is a cutout (alphaTest), as in the houseopaquemode. Translucent surfaces do not write depth. An inheritedblending_modeis ignored with a one-time notice, the way an inheritedvolumetricis on a house mesh.Glass draws first in its band — unless
refract_data, then last. Three renders transmission by sampling a copy of what was drawn before the glass; on WebGPU the glass shares the transparent render list with every emissive layer and lands its fragments with alpha 1, so a cluster whose centre sorted farther than the sphere would be drawn first and painted over. The material stampsuserData.drawBeforeEmissivewhiletransmission > 0, and the depth-sort coordinator’s cross-node pass (§6.3) orders such a mesh first within itslayer_orderband (renderOrder −1 when unranked), so data in front of or behind glass stays crisp and unrefracted on both backends — the spec §3.4 default. Withrefract_datathe stamp isuserData.drawAfterEmissiveinstead: the coordinator gives the mesh a rank even when nothing else would and alastflag that puts it after every other group in its band (the containment hoist skips such groups), and the post-processing pipeline’sDataRefractionSplitrenders the frame in passes on both backends: the glass’s front-face depth, the data behind the glass, the glass (on WebGL with a screen-space quad so three’s transmission target holds the data), then the data in front of the glass — each data fragment classifies itself against that depth in its own shader (materials/_shared/glass-partition.ts), so nothing nearer than the glass is painted over. Both wrappers pin the transmitted alpha to 1, because the HDR framebuffer’s alpha is an overdraw count, not coverage (spec §3.4, the alpha finding).Lit by the scene environment, not by the §6.2 key: a prefiltered
RoomEnvironmentonscene.environment, built lazily on the first physical material (rendering/environment/). House materials never read it, so a scene without a physical mesh renders byte-identically.The shared surface stays.
opacity,intensity(a scalar base-colour gain),offset(emissive radiance),alpha_cutoff(alphaTest),shadingsmooth/flat (flatShading),double_sided,layer_order, nD slicing, picking, partition and LOD groups all work as on a house mesh.gammais recorded but has no physical term; the Layers panel hides that slider and the shading sliders for a physical layer and shows one live slider per numeric physical knob instead, plus the “Refract data” switch (the two colour knobs are read-only rows); Reset — whole panel or one row — restores the authored values.
Both backends must produce matching output; the existing codegen snapshot harness
(src/tests/__codegen__/) gates the TSL-generated shaders (the hand-written GLSL twins are pinned
by behavior tests instead — e.g. §8’s stored-normal view-space-transform check), and keys one snapshot variant per blend-mode build — whether a GLSL
#define (the sibling line-max, point-max, gsplat-normal-premult) or a runtime-uniform branch the
TSL path bakes per graph (gsplat-opaque, from gsplat’s runtime uProjectionMode split). Note the
harness (tsl-codegen-snapshot.spec.ts) asserts both stages of every variant unconditionally, so
each variant is a .vertex + .fragment snapshot pair — the shipped inventory is 39 such pairs, i.e.
78 files under src/tests/__codegen__/. (This count drifts as OTHER types gain variants; mesh’s own six
pairs are the part this section is responsible for. It read 37/74 until #2144 corrected it.)
Mesh’s per-mode emissions (§6.2) are therefore separately snapshotted — and note the mesh default is
opaque, unlike the siblings whose default is the alpha-weighted additive. New variants — six, i.e.
twelve snapshot files: mesh (the opaque default — alpha cutout, §6.2), mesh-additive (the
alpha-weighted emission shared by additive/luminous/normal, §6.2), mesh-max (the max
premultiply, §6.2), mesh-flat-normal, mesh-colormap, mesh-pick.
TSL house rule (from the depth-sorting spec’s remediation): both vertex stages must trace inside
Fn()with explicit.toVar()statements; the fragment must reconstruct the bottom-left fragcoord asvec2(x, screenSize.y - y)if it reads screen coordinates at all; and a value shared between two fragment entry points (colorNodeanddepthNode) must be ASSIGNED in an unconditional prologue that both of them call first. The third rule is the one the mesh pick shader learned the hard way (#1683): the order in which three builds the two entry points is not part of its API — it flipped from colour-first to depth-first between r184 and r185 — and a.toVar()is assigned wherever three first builds it, so a branchingdepthNodeburied the shared coverage/fade/brightness chain inside oneifarm and every top-level reader saw 0, discarding every fragment of the pick pass. The mesh fragment shaders sidestep the fragcoord trap (neither the visual nor the pick one needs fragcoord), but the pick fragment sits squarely in the third rule and follows it viafragmentPrologueinpicking/mesh/pick.tsl.ts.
6.5 Picking
Standard mechanism: pickingSystem.allocatePickId(), a shadow THREE.Mesh sharing the same
BufferGeometry with the pick material, registered via pickingSystem.registerNode — exactly as
points / lines / gsplats do it.
v1 picks at vertex granularity. Mesh has no sorted-index indirection — its depth-sort ordering
permutes geometry.index itself (§6.3) — so unlike the other three types the mesh pick vertex shader
does not bind aSortedIndex and does not call the shared luxarElementIdParts() helper (which
reads it). That is not a gap the sort has to close: for an indexed draw gl_VertexID (WGSL
@builtin(vertex_index)) IS the value fetched from the index buffer, so the element ordinal is
invariant under any permutation of the triples. Depth sorting a mesh therefore cannot desynchronise
picking from rendering, which is the failure the instanced types’ slot-syncing exists to prevent. Instead it splits uint(gl_VertexID) into low/high 16-bit halves exactly as that
helper does and writes them into the same flat out highp vec2 vElementId varying the readback already
understands:
uint i = uint(gl_VertexID);
vElementId = vec2(float(i & 0xFFFFu), float(i >> 16u)); // split like luxarElementIdParts()
The mesh is drawn indexed (faces is the index buffer, §6.1), so under drawElements
gl_VertexID is the ordinal of the vertex in the vertices array — a stable per-vertex id, not a
triangle ordinal (gl_VertexID / 3 would be meaningless: shared vertices break it, and WebGL2 has no
gl_PrimitiveID). This pick vertex shader is the one place mesh diverges from the shared helper; the
mesh-pick.{vertex,fragment} codegen snapshots of §6.4 cover it. The TSL twin (§6.4) must declare the
vElementId (and vNodeId) varyings with .setInterpolation('flat'): TSL varying() interpolates
linearly by default, and the point TSL pick (rendering/picking/point/pick.tsl.ts) only escapes without
flat because a single-instance quad’s four corners all carry the same id (interpolation is the
identity). That identity fails for a mesh — gl_VertexID differs at every triangle corner, so a
linearly-interpolated vElementId would arrive fractional and Math.round in the readback would resolve
to arbitrary wrong vertices. Follow the LINE pick precedent (rendering/picking/line/pick.tsl.ts), not
the point pick, whose implicit-identity interpolation is unsafe for a shared-vertex indexed draw.
Provoking-vertex convention (which corner wins). A flat varying is sourced from one corner of
the triangle, and the two backends do not default to the same one: OpenGL ES 3.0 (the GLSL backend) fixes
the provoking vertex to the last vertex of the primitive, while the WGSL @interpolate( flat ) three
emits for .setInterpolation('flat') defaults to first-vertex sampling. Left there, the same click on
the same triangle (i0, i1, i2) reports i2 on WebGL and i0 on WebGPU. The pick contract at
vertex granularity is therefore “a corner vertex of the front-most triangle under the cursor” — the
cursor is over the face, not a vertex, so every corner is an equally valid answer, and no consumer may
assume a specific one. To keep the backends bit-identical where the platform allows, the GLSL pick path
enables the WEBGL_provoking_vertex extension when present and sets
provokingVertexWEBGL(FIRST_VERTEX_CONVENTION_WEBGL), aligning WebGL with WebGPU’s first-vertex rule —
context-wide state, but safe: every other flat varying in the shipped materials is a per-instance
constant (point/gsplat quads, line segments), identical at all corners, so the convention flip is
observable only by mesh. Where the extension is unavailable the last-vs-first divergence stands as a
documented exception to §6.4’s matching-output rule, and pick parity tests must assert the returned
id is a corner of the expected face (membership), not one exact corner. (Making the id
corner-independent outright would need per-corner pick data — a de-indexed shadow geometry, 3× pick
memory — abandoning the shared-BufferGeometry design above for the pick pass alone; rejected for v1.)
Pick fragment output. The mesh-pick.fragment writes the same shared vec4 the readback decodes —
vec4(vNodeId, vElementId.x, brightness, vElementId.y) — with brightness the fragment’s
coverage/opacity (1.0 for a fully opaque mesh), so the cross-node brightness-weighted vote still has a
value. Depth is keyed on the blending mode, mirroring the gsplat pick wrapper’s
setSurfacePickDepth(isNormalMode(mode) || isOpaqueMode(mode)) sync (rendering/picking/README.md):
under the depth-ordered surface modes — opaque and normal — the mesh writes real projected depth
(gl_FragDepth = gl_FragCoord.z, i.e. leaves the default), matching gsplat’s surface-mode branch
(uSurfaceDepth == 1 in rendering/picking/gsplat/shaders.ts), so the front-most surface wins;
otherwise every fully-opaque fragment collapses to depth 0 and the mesh neither self-occludes nor
occludes other nodes correctly in the shared pick buffer. Under the commutative modes — additive,
luminous, max — it writes the 1.0 - clamp(brightness, 0, 1) brightness-as-depth that points /
lines / commutative-mode gsplats use: real surface depth there would let a dim, barely-visible mesh in
front depth-occlude a brighter node behind it, contradicting the brightest-wins vote. See
rendering/picking/README.md; the mesh-pick.fragment codegen snapshot (§6.4) is the final authority.
Alpha in the pick pass — the cutout must match. The pick material computes the same coverage
a = vAlpha · uOpacity (§6.2), so its vertex shader binds the color attribute and carries an
interpolated vAlpha varying — the only vertex attribute it needs beyond position (element ids come
from the gl_VertexID built-in, not an attribute). In opaque mode it
applies the identical if (a < uAlphaCutoff) discard; before writing, so a cutout hole is neither
pickable nor depth-occluding; without this a discarded-in-visual hole would still rasterize in the pick
pass at true surface depth, becoming pickable and occluding picks of nodes visible through it. In the
translucent modes a is the brightness coverage term the readback already votes on (replacing the
“1.0 for a fully opaque mesh” placeholder above whenever alpha is authored). This is a runtime-uniform
branch in the single pick fragment (keyed on the blending mode, like the uSurfaceDepth split above),
not a separate #define — so mesh-pick stays a single snapshot variant and §6.4’s count is
unchanged.
The near fade must match too. For the same reason the cutout must: the pick pass reproduces the
visual shader’s perspectiveNearFade per fragment, off a vViewZ varying the pick vertex stage adds
(just the z — the visual stage’s whole vViewPos exists to be differentiated for the flat-normal
fallback, which the pick pass has none of). It folds into brightness the way the point and gsplat
pick shaders fold theirs, so pick salience tracks visible salience, and a fragment below the same 0.01
threshold is discarded so it writes neither an id nor depth. Without it a surface the user can barely
see would stay fully pickable and keep depth-occluding the nodes behind it. This is what makes the
mesh pick materials CameraAwareMaterials — uIsOrtho / uNearCull only; there is still no
uResolution and no focal length, since a mesh has no screen-space footprint to size.
Stability. §5.4 rewrites only the index buffer per slice (compact_visible_faces) and never remaps
vertex attributes (“No vertex compaction”). A face ordinal would be renumbered on every slice change; a
vertex ordinal is invariant across slices. That is why vertex — not face — granularity is chosen for the
compacted draw.
Label mapping. The returned vertex ordinal indexes the per-vertex label_offsets / label_bytes
CSR (§3.2) directly, precisely as a point/line element ordinal indexes its own per-element label CSR, so
hover tooltips resolve with no extra mapping.
Capacity. As with the other three types, the two-half 16-bit split keeps vertex counts exact past the
float32 24-bit mantissa (the readback recombines the halves); vertex count V uses the same split (see
rendering/picking/README.md for the rationale). The pick readback also packs
nodeId * VOTE_KEY_STRIDE + elementId for brightness-weighted voting, whose alias-free stride is 2^27
(rendering/picking/picking-system/pick-render.ts). The §7 ≤-few-million-triangles figure is a workload
expectation, not an invariant: for the three texture-fed types the alias-free condition is
structural — elementId is bounded by getMaxElementCapacityPerNode, well under 2^27 — whereas
mesh’s gl_VertexID source is bounded only by V. The cap is therefore enforced: n_vertices <= 2^27 at the §3.5 loader gate’s metadata preflight (Stage 1, before any chunk is fetched — fail the
node with a LoaderError; the largest admitted ordinal
2^27 - 1 is the last alias-free one, and its worst-case vote key
(2^24 - 1) * 2^27 + 2^27 - 1 = 2^51 - 1 stays exactly representable), invoking the stride’s own house
rule that an unenforced bound is not a bound (the reason MAX_PICK_NODE_ID is checked at allocation,
rendering/picking/picking-system/pick-render.ts). Note that pick-render.test.ts’s existing headroom
test only pins the texture-layout maxima, so this vertex cap needs its own pin — a dedicated test
that the vote key stays exact up to the largest admitted vertex ordinal and that a mesh with
n_vertices > 2^27 is rejected with a LoaderError. The writer also rejects n_vertices > 2^27 at
write time (ValidationError, via validate_vertices_for_writing, §3.5) — the fail-fast twin of this
loader-side LoaderError pin.
Accepted v1 limitation. vElementId is a flat varying, so within a triangle it resolves to that
triangle’s provoking vertex, not the cursor’s barycentric-nearest vertex. Hovering a triangle
therefore reports a well-defined vertex of that triangle. Barycentric-nearest-vertex resolution would
need a de-indexed pick geometry or per-corner attributes plus barycentrics — a follow-up, not v1.
FACE granularity (e.g. highlighting a whole triangle) is deferred: it needs either a de-indexed pick geometry or a per-corner face-id attribute, plus a compacted→original face map to stay stable under §5.4 compaction. It is a natural follow-up, pairing with the §9 partition work.
7. Loading
v1 uses a whole-node loader, and the fetch is explicitly gated by §3.5’s two-stage loader-side
validation. First the metadata preflight (§3.5 Stage 1) runs on the node attrs and every array’s
.zarray shape, chunks, and dtype — the n_vertices <= 2^27 cap, the n_faces/MESH_DECODE_BUDGET_BYTES
byte budget, the §3.2 shape cross-checks, and normal_dims well-formedness — before any chunk is
fetched, so an oversized or malformed declaration fails with a LoaderError without allocating.
Then, on a store that clears preflight, it fetches vertices, faces and the optional attribute
arrays in full, decodes, and holds them. No spatial index and no chunk-bounds query: one leaf loads
whole. The only progressive path is §9.1’s reveal ladder, which is one such whole-node load per
additive_<i> level (data/mesh/mesh-progressive-loader.ts, drained by data/mesh/lod-refinement.ts)
rather than a partial load of any one of them. Immediately after decode the arrays get §3.5’s Stage 2 post-decode value checks (materialized
lengths, face indices in [0, V), CSR offsets), before anything reaches the §5.4 kernels.
Justification: meshes in this domain are typically ≤ a few million triangles and fit comfortably; the
dual-index machinery in lines-spatial-index-loader.ts (1010 LOC) exists because line datasets reach
tens of millions of vertices with a meaningful per-slice working set. A mesh’s working set after a
displayDims change is the whole mesh regardless.
The loader still implements the standard MeshDataLoader interface (loadMesh / updateView /
dispose + the optional monitor surface), so a spatial-index implementation can be swapped in behind
it later with no caller change.
It DOES implement that monitor surface (addEventListener / removeEventListener / getMetrics /
getActiveQueries, reporting type: 'mesh-whole-node'), and the shape is load-bearing rather than
decorative: connect-loader-to-monitor.ts duck-types the complete set of four and silently skips a
loader that lacks any of them. What it reports is a whole-node loader’s honest telemetry — loads,
bytesLoaded (decoded), avgLoadTime, elementsLoaded in TRIANGLES, and memoryUsed (payload plus
the per-node projection scratch) — with queries / avgQueryTime / spatialIndex deliberately at
zero/absent, since there is no index and a view change re-serves the resident mesh. visibleElements
is pushed IN by commit-mesh-geometry.ts (recordVisibleElements), because the count is produced
downstream of the loader: projection, not the loader, decides which faces reach the index buffer.
updateView distinguishes two kinds of view change:
slicePosition/tolerance change only (
displayDimsunchanged): recompute the visibility mask and index buffer (§5) and return (the index build still applies the current-parity winding post-pass §5.4, so an odd-parity epoch stays correct across slice moves); on the fast path (§5.5) it is a no-op returning the cached data.displayDimschange: because bothpositionandnormalaredisplayDims-derived (§6.1, §3.4), the rebuild runs these steps, in order:re-run
extract_3d_positions(vertices, displayDims)and re-upload thepositionbuffer;recompute
geometry.boundingBox/boundingSphere— re-uploadingpositiondoes not invalidate Three.js’s cached bounds, whichfrustumCulledand the raycaster/picking broad phase consult, and the display-space AABB (§2.2) changes under a permutation. (A defect this path introduces; the per-slice path never touched bounds.)re-decide the
normalattribute per §3.4 — attach stored normals iffshading == "smooth"andnormal_dimsequals the activedisplayDimselement-wise (same length, same order — not a JS===, which compares array identity and would silently force flat shading on every rebuild), else omit so the shader’s flat-normal fallback (§6.2) takes over. (For ashading == "flat"node the first conjunct is always false, so it stays the flat variant and this step never swaps it — §6.2.) Stored↔flat is a compile-time shader variant (§6.4’s separatemesh.fragment/mesh-flat-normal.fragment), not mere attribute presence — a declared-but-unboundnormalreads(0,0,0,1), not “absent” — so flipping the choice must switch the material variant through each backend’s existing variant path (§6.4), not by attaching/detaching the buffer alone;recompute the mask and index buffer; when the displayed set equals the winding frame (§3.2), the index build applies the current-
displayDimswinding post-pass (§5.4) — an odd-parity selection reflects display space, so without it adouble_sided: falsemesh (§6.1,FrontSide) renders inside-out (an open surface vanishes). When the newdisplayDimsis a different triple than the frame, the material instead falls back toDoubleSidefor the epoch (§5.4) — projected orientation is per-triangle data-dependent there and no post-pass can correct it. The §6.2 derivative-normal fallback is unaffected either way — it always faces the camera regardless of winding.
This holds even on the §5.5 fast path: a 3D axis permutation leaves the mask all-ones but still requires
position/normalre-derivation, bounds recompute, and (for an odd permutation) winding reversal.
Files:
Concern |
File |
|---|---|
Loader |
|
Node load |
|
Projection/process |
|
Commit |
|
Types |
|
Projection runs in-process in v1 (workers/data-worker/projection/in-process.ts gains a mesh
dispatch). Worker offload is a follow-up: the payload is a single large transfer rather than lines’ many
small ones, so the worker’s benefit profile is different and should be measured before being built —
consistent with the measure-first performance doctrine.
8. Integration checklist
Most of the dispatch plumbing is now table-driven (§10), so this list is dominated by genuinely new mesh code rather than by edits to existing branches. The items that remain hand-written are called out as such.
⚠️ This is the planning checklist, written before the phases ran. Every box is now ticked — the
Python vertical in #1220 (Phase 1), the cull kernels in #1232 (Phase 2), and the viewer loader,
materials, picking, fixtures and demo across Phases 3–6 — so read it as the original work breakdown
and as the record of what each subsystem had to change, not as current status. §11 is the
authoritative delivery record. Paths are as they stood when each phase landed; later refactors
have moved or removed a few of them (the types/index.ts barrel is gone, for instance), so treat a
name here as a pointer to the subsystem rather than to a live file.
Python
format-contract/contract.yaml→make gen-contract— the writable-side edit (node_types+geometry_types) landed in #1220; the Phase-3loader_typesswitch-on re-ran it (#1241, §3.1)typing_utils/enums.py—NodeType.MESHandtyping_utils/constants.py—NODE_TYPE_MESH— both landed in #1220. They must matchcontract.yaml:test_node_type_enum_matches_contractguards the enum andtest_named_node_type_constants_match_contracttheNODE_TYPE_*constants — that pair is how the missing constant was caught in #1220core/mesh.py,core/group/adders/mesh.py— landed in #1220core/group/group.py,core/__init__.py,luxar/__init__.py— landed in #1220;core/scene/scene.pyneeded no edit (ScenesubclassesGroup, so it inheritsadd_mesh)core/group/dim_order.py— landed in #1220 as anticipated:add_meshcallsapply_dim_order_positionsforverticeslike the other three adders (nodim_order.pyedit was needed);facesis index data and is not reorderedio/_compiler/geometry_writers/mesh.py,io/_compiler/node_common.py(MESH_RESERVED_ATTRS) — landed in #1220io/compiler.py(write_meshfacade),io/reader.py(MeshData/get_mesh/list_meshes) — landed in #1220validation/base.py(validate_vertices_for_writing,validate_faces_for_writing,validate_normals_for_writing) — landed in #1220cli/info_command.py— landed in #1220Partition rejection — landed in #1220, in a stronger form than this item planned:
add_partition_group_implasks the shared capability table (supports_partitionintyping_utils/geometry_capabilities.py) instead of hardcoding a tuple. Superseded: the exclusion itself has since been lifted (§9.2) — mesh’spartitioncapability is nowtrue, so the guard passes for mesh and the test that pinned the refusal is nowtest_mesh_under_a_mesh_partition_group_is_allowed. The capability-table indirection is what made lifting it a one-row change.LOD rejection — landed in #1220. The hole this item flagged was real:
compute_lod_display_typesimply returnedresolve_display_type(children[-1]), which falls through tonode.attrs.get("type", "group")for a plain leaf, so a mesh child would have been silently accepted into akind=lodgroup withdisplay_type="mesh"that no viewer path can load. It now callsrequire_lod_display_type(capability-driven, matching the partition guard), and the explicitdisplay_type=route throughadd_lod_group_implis gated the same way. Superseded: the exclusion itself has since been lifted (§9) — mesh’slodcapability is nowtruefor the SUBSTITUTIVE flavour, so the capability-driven guard passes for mesh and the test that pinned the refusal is nowtest_mesh_under_a_lod_group_is_accepted. An ADDITIVE ladder over an arbitrary order is still refused — not by this guard, but byMESH_ADDITIVE_METHODSnamingradialas the only accepted method (a reveal ladder is accepted; see §9.1).io/_compiler/finalize/lod_backfill.py— already handled by #1079.resolve()now testst in GEOMETRY_TYPESinstead of a hardcoded tuple, someshis recognised as a leaf the moment it enters the contract, with no edit here. All four child-iteration sites also moved togroup_keys(), which removes the failure this spec previously described: a leaf group’skeys()lists its arrays (['faces', 'vertices']), so the old code could recurse into a zarrArrayand raise a bareAttributeErrormid-finalize.
TypeScript
types/mesh.ts,types/index.ts,types/window.d.ts,data/data-loader-types.tstypes/data-monitor-types.ts— the local| 'mesh'extension is already deleted (#1220), soSceneGraphNodeTypeis now plainNodeTypeNamedata/scene-loader/loaders/loader-registry.ts— one line inLoaderByKind. The three parallel maps became a single kind-keyed store in #1079;getLoaderType/disposeAll/ the counters are one implementation each. Omitting the entry is a compile error (TS2339: Property 'mesh' does not exist on type 'LoaderByKind'), not a silent gapdata/scene-loader/geometry-descriptors.ts— one row inGEOMETRY_DESCRIPTORS, carryingloadNode,applyPartialExtendTolerance,retryCommitand the two loader factories. This is the row thatload-scene-nodes,lifecycle/retryandprefetch/slice-prefetcherall read, so those three files need no mesh edit at all. A missing row fails the build withTS2741: Property 'mesh' is missing … required in type 'Record<GeometryKind, …>'data/scene-loader/loaders/loader-factory.ts,nodes/build-scene-graph.ts(bare-leaf-root union),nodes/build-ctx.ts(theprocessMeshData/commitMeshGeometrypair, matching the uniform shape #1099 gave all three existing types)data/scene-loader/monitor/monitor-wiring.ts,scene-graph-converter.ts,data/scene-loader-monitor-port.tsdata/scene-loader/lifecycle/retry.ts— no edit needed. Theelse if (gsplatsLoader)chain became a descriptor lookup in #1099. The hazard this spec flagged — a missing arm meaning a failed mesh load could never be retried, with nothing in the type system to say so — is now a build failure at the descriptor table insteaddata/scene-loader/prefetch/slice-prefetcher.ts— no dispatch edit, but mesh has no meaningful slice prefetch in v1 (whole-node resident, §7). The prefetcher is driven by three hardcodedprefetchNode(path, 'points'|'lines'|'gsplats', …)call sites, so mesh is excluded by simply not adding a fourth — confirm that stays true rather than assuming itdata/loaders/spatial-query/tolerance-computer.ts— add themesharm tocomputeHiddenDimToleranceandmeshSlabTolerancetoToleranceOptions(§5.2.1). Callers must passdiscreteRole: 'membership'. Do not default the spatial arm to Lines’0wasm/types.ts— declare both new kernels on theWasmModuleinterface (§5.4);pickBackendswaps the module wholesale, so a kernel missing from either backend breaks the>16Dpathdata/attrs-composer.ts,data/stats/{aggregator,scene-stats}.tsrendering/mesh-geometry.ts,rendering/node-factory.ts,rendering/node-factory/create-mesh-node.tsrendering/materials/mesh/{material,shader}-{glsl,tsl}.ts,rendering/picking/mesh/*rendering/material-manager/factories.ts,rendering/material-colormap-helpers.tsscene/scene-manager/camera/camera-framing.ts— its own arm, since a mesh contributes DRAWN triangles (drawRange.count / 3) rather than aninstanceCount.scene/lod-freshness.tsneeded no literal while mesh was LOD-excluded, since it discriminates onsupportsLod(nodeType). Superseded: the substitutive path ships (§9), soGEOMETRY_CAPABILITIES.mesh.lodistrue, mesh levels ARE freshness-tracked, andcountFromUserDatacarries its owncase 'mesh':arm — without it the empty-level display guard silently no-ops for meshes.scene/synthetic-scene.tsis deliberately not extended — the?debugperf-bench injector still builds points/lines/gsplats only, mesh having no pooled instanced path to benchui/layers/{layer-apply,layer-state,layers-panel}.tsui/data-loading-monitor.ts+data-loading-monitor/{templates,advisor}.tscore/app/debug/{debug-interface,debug-state}.ts
Verified type-agnostic — no mesh change needed. Swept for geometry-type literals and found clean, so mesh rides these subsystems for free. Recorded so an implementer doesn’t re-derive it:
Subsystem |
Evidence |
|---|---|
|
No geometry-type literals; keyed by path + chunk |
|
No type literals; |
|
No type literals |
|
No type literals; the offline/native bundlers copy the store wholesale |
|
|
|
Mesh reuses the scalar→LUT path unchanged (§6.2) |
Deliberately NOT touched — each of these enumerates 'points' | 'lines' | 'gsplats' and must
keep doing so. Adding mesh to any of them silently re-enables something §9 excludes, with no
error to catch it:
Site |
Why mesh stays out |
|---|---|
|
Includes |
|
Includes |
|
Mesh doesn’t use the buffer pool (§2.1) |
|
No spatial index in v1 (§7). Note: this is the query builder only — |
|
Mesh doesn’t support |
A reviewer should treat a | 'mesh' appearing in any of the three remaining rows (pool stats,
spatial-query builder, absorption range) as a defect. The two display_type unions have since
been widened deliberately, each behind its capability flag.
Rust / WASM
wasm/rust/src/mesh_culling.rs+lib.rsregistrationwasm/typescript/mesh-culling.ts+index.tsregistration (also the production>16Dbackend)Rust↔TS parity tests
Docs
docs/guides/user/LUXAR_ZARR_FORMAT.md— mesh node layout — landed in #1220CLAUDE.md— geometry-types line flipped to “four first-class geometry types” once mesh rendered (Phase 3+); #1220 had named it writable-but-not-renderablePackage READMEs:
core/andio/_compiler/geometry_writers/were updated in #1220; the viewer ones (data/mesh/,rendering/materials/mesh/,rendering/picking/mesh/) arrived with their packagesCHANGELOG.md— mesh entries landed with each phase (loader, material pair, picking + panel)
Tests
Python — landed in #1220 (
core/tests/test_mesh.py,validation/tests/test_mesh_validation.py): writer round-trip, validators (incl. every rejection in §3.5 — among themn_vertices > 2^27rejected at write time withValidationErrorviavalidate_vertices_for_writing, the fail-fast twin of the §6.5 loader-siden_vertices > 2^27 → LoaderErrorpin, verified to fail before the validator exists per this section’s rule), authoring lint, broadcast color/scalar,extend_to_all, readerRust:
mesh_vertex_visibility_mask/compact_visible_facesunit tests incl. the non-finite ruleTS unit: loader, geometry assembly, cull correctness, colormap fail-closed guard, Rust↔TS kernel parity, corrupt-store rejection (out-of-range face index →
LoaderError, not a WASM trap — including a 64-bit index like2^32 + 1whose bare u32 cast would wrap into range, pinning §3.5 Stage 2’s source-value check; an undersizednormals/colors/scalarsarray →LoaderError, §3.5;n_vertices > 2^27→LoaderError, its own pin sincepick-render.test.tsonly covers the texture-layout maxima, §6.5; an oversized declaration —n_vertices > 2^27, or ann_faces/.zarrayshape-or-chunksfootprint exceedingMESH_DECODE_BUDGET_BYTES— rejected by the §3.5 Stage 1 metadata preflight, asserting no chunk-key request (an<array>/<chunk-coords>key) is ever issued — only the.zarray/.zattrsmetadata keys the preflight legitimately reads — verified to fail before the preflight exists), and thevolumetric→opaquefallback warning (§6.3)TS unit (alpha chain, §6.2): an RGBA mesh produces different fragment output than the same mesh RGB-only (goes red if
vAlphais dropped — the exact “(V,4) renders like (V,3)” defect); underopaque, fragments witha < uAlphaCutoffare discarded (cutout) and survivors write alpha 1.0; undermax, the emitted RGB is premultiplied bya(vec4(shadedColor * a, a)). Each must be verified to fail before the fix.TS unit (default base color, §6.1/§6.2): a bare
add_mesh(vertices, faces)mesh (nocolors, noscalars) renders opaque white, not black — thecolorattribute is filled(1,1,1)rather than left unbound. Verified to go red if the white fill is dropped (an unboundcolorreads the GL default(0,0,0,1)and, times the multiplicative §6.2 shade term, the surface comes out solid black).TS unit (native vertex-attr dtypes, §6.1.1): geometry assembly binds a
uint8/uint16RGB mesh as a 4-component normalizedcolorattribute (padded RGBA, opaque alpha) and auint8/float16scalar as afloat32aScalar— never a size-3uint8/uint16color nor an itemSize-1uint8/float16scalar, both of which failcreateRenderPipelinevalidation on the WebGPU backend. Assert the resultingBufferAttributeitemSize/array-type and that the padded alpha is opaque (vAlpha == 1.0); pairs with the §6.4 codegen-snapshot harness that exercises the TSL/WebGPU path.TS unit: on a
double_sided: falsemesh,updateViewfor adisplayDimschange[0,1,2]→[0,2,1]re-extracts positions, re-decides thenormalattribute, recomputes bounds, and reverses the index winding so front faces stay visible (goes red without the reversal precisely becauseFrontSideculls the flipped triangles) — rebuildsposition/normal/index, not just the index (§7). Separately, on adouble_sided: false, ≥4D mesh carrying normals (sosorted(normal_dims)declares the winding frame, §3.2) displayed with an odd-parity ordering of that frame, a pure slicePosition move (unchangeddisplayDims) rebuilds the index only BUT still preserves front-facing winding (front faces stay visible), proving the winding post-pass persists across slice moves (§5.4), not just the displayDims-change event. And on the same mesh, adisplayDimschange to a different axis triple than the frame falls back toDoubleSidefor the epoch — both orientations render (§5.4/§7)TS unit: flat-vs-smooth on the same normal-bearing mesh — one mesh with valid stored normals (
normal_dims == displayDims; use vertex-averaged normals that differ from the geometric face normals, so smooth and flat genuinely disagree — a faceted face-normal fixture would render the two variants identically even in a correct build) selects the stored-normalmesh.fragmentvariant undershading="smooth", and the SAME mesh undershading="flat"selectsmesh-flat-normal.fragmentand shades from screen-space derivatives (§3.4, §6.2). Verified to differ — a build that ignoresshadingrenders both identically and the test goes red. And undershading="smooth"on adouble_sidedmesh, a back-viewed face shades on the lit side of its own view-anchored gradient, NOT collapsed toward flatuAmbient— verified to go red without the §6.2gl_FrontFacingnormal flip (the back face shades the unflipped, inverted gradient instead). And on the same back-viewed mesh with one vertex’s stored normal zeroed, the fragments where §3.5’s epsilon guard fires shade from the substituted viewer-facing derivative normal without the flip — verified to go red against a build that appliesgl_FrontFacing ? N : -Nto the fallback normal (§6.2’s per-fragment exemption)TS unit (both backends — GLSL and TSL): stored-normal view-space transform — a smooth-shaded mesh whose stored per-vertex normals equal its geometric face normals, with at least one face normal that mixes the differently-scaled axes — a nonzero component both along z and within the xy-plane (a tetrahedron, whose four face normals positively span R³, guarantees this; an axis-aligned box, or a quad tilted only within the equal-scale xy-plane, does not: for any normal that is an eigenvector of the scale
S·nstays parallel toS⁻¹·n, so the inverse-transpose error hides), placed on a node whose §3.3 4×4transformboth rotates and non-uniformly scales (voxel z ≠ xy). Undershading="smooth"it shades from thenormalMatrix-transformed (inverse-transpose of model-view) view-space normal (§6.2): the gradient stays head-on / camera-anchored and — on this faceted fixture — matches the flat variant’s derivative-normal response on the same geometry. Run on the hand-written GLSL backend as well as TSL, since §6.2 designates this behavior test (not a codegen snapshot) as the GLSL twin’s pin. Verified to go red against a build that binds the raw untransformed normal, or uses the plain model-view instead of the inverse-transpose — either mislights the rotated node / skews normals off-perpendicular under the anisotropic scaleTS unit / E2E (pick corner contract, §6.5): a pick on a mesh triangle resolves to a corner vertex of that face — assert membership in
(i0, i1, i2), never one exact corner (the provoking vertex is backend-dependent: last on WebGL, first on WebGPU;WEBGL_provoking_vertexaligns them only where available)Codegen snapshots: 6 new variants = 12 files (§6.4) — incl. the per-blend-mode
mesh-additive/mesh-maxvariantsFixture:
tests/fixtures/generate_test_data.pygains a mesh fixture (auto-picked up byvitest.config.tsglobalSetup)E2E: one
mesh-rendering.spec.ts, plus extend the existing multi-geometrytests/e2e/geometry-types.spec.ts(it assertsuserData.nodeTypeper type and already covers lines + gsplats) with a mesh caseOne demo exercising the type end to end
Every test must be verified to fail before the fix — mutate the implementation and confirm the test goes red. A cull test that passes against an all-ones mask is vacuous.
9. Explicitly out of scope
Each of these is a deliberate exclusion, not an oversight. Each should surface clearly — an error, or
for volumetric the named one-time warning + opaque fallback of §6.3 — rather than silently misbehave.
This section carried two code follow-ups it OVERSTATED its own compliance on, and the §6.3 exclusion one of them mitigated has since been lifted:
The
add_meshrefusal message no longer justifies refusing substitutive LOD with additive’s reason. It used to read “the additive/substitutive ladder reduces independent elements (a surface is connected)” — true of the additive flavour, false of the substitutive one. Substitutive levels have since landed, so only an additive ladder over an arbitrary order is still refused, with its own reason (MESH_ADDITIVE_METHODSnamingradialas the sole accepted method, inpackages/luxar/src/luxar/core/group/lod/mesh.py).§6.3’s translucent-
normalwarning was implemented incommit-mesh-geometry.ts, and has since been removed — not regressed. It existed as the named mitigation for the per-triangle-depth-sort exclusion, and that exclusion no longer exists (§6.3, and the note under the table below). What is left to say about an unsortednormalmesh is exactly what is true of the other three types with depth sorting switched off, and none of them warn.
Excluded |
Why |
Natural follow-up |
|---|---|---|
Additive LOD ladder |
A prefix of an index buffer is a holed surface, not a coarse one. That is the difference from a splat prefix, which genuinely is a sparser approximation of the same field — so the ladder degrades gracefully there and produces a wrong picture here. The legitimate refinement scheme is a progressive mesh (base mesh + vertex-split records), which cannot use the prefix-count ladder at all: different data structure, not a widening. |
Not a LOD in any form. A deliberate progressive-draw effect IS worth exposing, and has shipped for all four types as the |
Exact nD triangle clipping |
~1500 LOC across two backends. §5 covers the dominant real case (hidden dims are discrete — time/channel) for ~10% of the cost, but gives only a thick slab, never a true cut, when a hidden dim is continuous and spatial (§5.2.1). |
Slot in behind the same |
Spatial index |
Not merely “see §7”: a chunk of faces is not independently meaningful, because the index buffer references vertices anywhere in the array — so a face chunk draws only with the whole vertex buffer resident, or after the same remap/duplicate bookkeeping the partition row describes. An efficiency cliff, not an impossibility: partial loading is achievable, it just forfeits most of the bandwidth win a chunk index exists to buy. Moot in practice as well, since the 512 MiB per-node byte budget binds first (≈22.4M vertices for a 3D float32 mesh, measured), well under §7’s ≤-few-million-triangle expectation. |
Mirror the lines dual-index loader over faces |
|
Not about opacity — about path length. Emission–absorption integrates κ over the distance a ray spends inside a participating medium, and a triangle is zero-thickness, so τ = 0 however translucent the surface is. The adjacent feature that DOES make sense — volume rendering bounded by a mesh’s front and back faces — is a different thing entirely and is not what this excludes. |
— |
Worker projection |
Measure first (§7). |
— |
~~Mesh import formats (PLY/OBJ/STL/VTP/glTF)~~ — landed |
Independent of the node type, which is why it could ship on its own afterwards. |
Shipped as |
~~ |
The exclusion was bookkeeping, not correctness, and the bookkeeping is now written. |
Shipped as |
Two rows have been lifted since this table was written, for opposite reasons: one was over-estimated, the other under-estimated.
Per-triangle depth sorting. It was the weakest exclusion here, and the only one with a live
user-visible consequence. It turned out to be almost entirely reuse — the registration, the
SortWorker and the sort kernel are geometry-agnostic and a triangle’s centroid is 3 floats like
any other center — with one genuinely different piece: the apply permutes geometry.index
atomically instead of streaming an aSortedIndex indirection, because a half-written index
buffer is not a permutation. See §6.3 and
rendering/depth-sort-coordinator/triangle-ordering.ts.
GEOMETRY_CAPABILITIES.mesh.depthSortable is now true.
Substitutive LOD levels. The row was right that
nothing structural blocked them — a level is an independently-authored (vertices, faces)
pair selected by coverage_fraction, and the machinery makes no independence assumption —
but its cost estimate was wrong twice over, in the same way in two places.
It called the viewer side “a two-line widening: flip GEOMETRY_CAPABILITIES.mesh.lod and
extend LODGroupMetadata.display_type”. Both edits are real and necessary, but flipping
the capability makes canDefer admit mesh children, and the defer dispatch then needs a
loadMeshNodeCheap/loadMeshNodeExpensive split the mesh loader did not have. Without it
the dispatch throws; with the flip but no split, every level loads eagerly at scene open —
measured 4/4 levels resident at first paint, against 1/4 once the split landed.
lod-freshness.ts::countFromUserData needed a mesh arm too, or the empty-level display
guard silently no-ops for meshes.
The Python side was assumed free and is not, for a reason that only shows up on reading
resolve_substitutive_axis: Points and Lines share one resolver because both coarsen by
LIFTING to gsplats, so its vocabulary carries truncation_radius, max_aspect, device
and seed — four keys that exist only because of that lift — plus a method set of
Gaussian-mixture reducers. A mesh is decimated instead, so it needs its own resolver
(core/group/lod/mesh.py) with method in {auto, cluster, qem} and each lift-only key
refused by name. Widening the shared one would have accepted five words that quietly do
nothing.
The producer is luxar.mesh.decimate: vectorized vertex clustering for very large
surfaces, or Garland-Heckbert QEM edge collapse with a link-condition veto when
manifoldness must survive. method="auto" selects QEM through 10,000 vertices and
clustering above that measured worst-case open-surface envelope. QEM ladders reuse one
collapse sequence and snapshot each requested level rather than restarting from the
original mesh. Each coarse level is therefore a strict collapse-subsequence of the finer
one, so a LOD swap cannot reshuffle the surface between independent approximations. On
an open near-planar surface the orientation veto can stop well above the requested count
and therefore shorten the ladder; cluster is the tier to use when closely hitting the
count matters more than topology preservation. A QEM quadric also requires a
normal direction outside the triangle span, so fewer than three coarsening dimensions
make auto fall back to clustering and make explicit qem invalid. It is reached from
add_mesh(substitutive_lod=…) or luxar mesh lod. Per-level picking needed no work,
exactly as the row predicted: the LOD registry hides inactive levels and the picking system
skips hidden nodes.
Appearance preservation is opt-in through attribute_weight (default 0, so existing
geometry-only output is unchanged). QEM adds a normalized colour/scalar within-cluster
sum-of-squares term to the geometric edge-collapse quadric; attribute_weight=1 puts one
full-range appearance-channel step on the scale of the source spatial extent. The vectorized
cluster tier has no edge-cost quadric to extend, so any positive weight instead adds the
exact per-vertex colour/scalar tuple to the grid key as a hard class barrier. This is
deliberately method-specific: continuous attributes that vary at every vertex can prevent
cluster reduction, while QEM can trade their penalty against geometry.
Every substitutive level also carries level_stats.geometric_error: the maximum distance
from a source vertex to the coarse representative it collapsed into, normalized by the
source spatial bounding-box diagonal. Because that representative is a vertex of the coarse
surface, the value is a conservative source-vertex-to-surface error bound; the original
finest level is stamped 0. It is not mixture quality = 1 - ||A-B||²/||B||², and mesh
still carries neither reference_energy nor energy_fraction_cum. The viewer therefore
folds it separately: the Layers readout shows the worst visible mesh error (ε≤…%) without
requiring an energy pair, while mixed-geometry Q·e remains unchanged and never averages
the two currencies.
The ADDITIVE ladder row above is untouched and still correct — as a row about LOD.
The lod capability flag gates kind=lod groups, whose levels REPLACE one another; an
additive ladder is additive_<i>/ subgroups inside a leaf, and as a level of detail it
stays refused permanently, for the reason the row gives.
What has since shipped is the other thing the row points at: the reveal effect of §9.1.
add_mesh(additive_lod=…) writes additive_<i>/ subgroups for method="radial" and for
nothing else — the method set is a one-element frozenset, so the refusal of every
arbitrary-order prefix is enforced by the vocabulary rather than by a separate guard. So the
distinction this section draws is now load-bearing in code: same subgroup layout, admitted
for the effect, still refused for the LOD.
9.1 Reveal ladders — an additive prefix as an EFFECT, never as a LOD
An additive prefix over a mesh is a legitimate thing to offer, as a deliberate progressive-draw effect: a surface that grows in. It is not a level of detail, and the distinction has to survive into the naming, because the existing additive machinery assumes prefix ≈ approximation and that assumption is false for a surface.
Superseded mechanism (2026-08-09/10). This section previously specified the reveal as successive
drawRangeextents over a reordered index buffer. That is not how it is built. The reveal is authoring only: an additive ladder whose ordering is concentric shells around the node’s own bounding-box centre. The existing streaming machinery then loads the prefixes progressively and the object visibly grows outward. No viewer changes, no new display machinery — nothing about how the data is DISPLAYED, only how it is LOADED. §9.1’s reasoning below was right about the hazard and is preserved and generalised; only the mechanism claim was wrong.
The mechanism is one new ordering option per additive implementation. Ordering was
already a pluggable choice, so the method — named radial — is a single new member
of each method registry plus a scorer. It has shipped for GSplats, Points, Lines and
Mesh (-m radial on gsplat lod, additive_lod={"method": "radial"} on add_points /
add_lines / add_mesh), with reveal_center / spatial_dims overrides. Two properties are worth
stating because they are what make it read as a reveal:
the centre is the node’s own bbox centre, not the scene origin, so a dataset far from the origin grows from its own middle rather than in from one corner;
the distance spans the spatial axes only, so a stacked time or channel column cannot become a shell dimension — otherwise the elements furthest in time land at the end of the ladder and an off-centre timepoint’s slice paints last instead of growing outward. How that set is found differs by geometry, and it is worth knowing which you get: gsplats use the axes with real covariance extent (
_nondegenerate_axes), which a stacked axis fails by construction (it is built withsigma=0); element geometries have no covariance, soadd_points/add_linestake the scene’s displayed dims, falling back to non-zero positional extent when the positions are not scene-aligned. Either wayspatial_dimsoverrides it.
On Lines it orders whole polylines by their own centre, so every prefix keeps valid
segment topology. On mesh it orders whole faces by their centroid, and each level
re-indexes its own gathered vertex table through luxar.mesh.split.split_mesh_by_faces —
whose contract is “these face groups are a true partition”, which is exactly what an
additive ladder’s levels are, so the ladder needed no new re-indexer.
The mesh half is where the reveal restriction becomes the whole vocabulary rather than one
option among several: MESH_ADDITIVE_METHODS is {"radial"}. Points and Lines also accept
random, salience and the two samplers, because a prefix of an element cloud is a sparser
SAMPLE of the same object. A random half of a mesh’s triangles is not a coarser surface, it
is confetti — so for mesh, “reveal” is not a mode, it is the only thing a prefix can honestly
be. Two things fall out of that, each independently worth the restriction: the energy stamps
below cannot arise (every reveal method is excluded from them), and vertex duplication at the
level boundaries stays far off the 3x unwelded ceiling a scattered order approaches.
The ordering is best-first growth through face adjacency, keyed on radius from the reveal
centre — not a radius sort. A radius sort delivers the contiguity claim on a convex blob and
breaks it on a closed surface, which is what §Target data names: every centroid sits at nearly
the same radius, so the order is decided by noise over the whole shell. Measured edge-connected
components of each cumulative prefix, n_lods=4, under a plain radius sort: 20 / 20 / 1 / 1
on a 1280-face icosphere. Growing through shared edges makes it structural — the frontier only
admits a face touching one already admitted — so every prefix is one connected patch on a
sphere, a torus or a non-convex dumbbell, and the duplication argument gets its single boundary
curve. Measured at 4 levels, reveal vs random over the same faces: 288-face plane 1.66 vs 2.95,
320-face icosphere 2.67 vs 3.31, 1280-face icosphere 1.69 vs 3.25 (2.81 / 2.17 for the reveal
under the radius sort — the connectivity fix is what moved them).
Authoring landed with add_mesh(additive_lod=…) and write_mesh_multi_lod; the viewer’s
second half landed with it — createProgressiveMeshLoader opens a mesh node declaring
n_additive_sublods > 1 and MeshProgressiveLoader fetches its levels in order,
concatenating each revealed prefix into the buffers the node was sized for.
The CLI reaches it through luxar mesh lod --recipe reveal, whose -m/--add-method,
--n-lods, --counts, --reveal-center and --spatial-dims map onto the additive_lod=
keys above. --recipe selects rather than the knobs composing, because add_mesh refuses
an additive ladder alongside a substitutive one — a mesh has no coarse prefix, so the two
are different products rather than two axes of one. --reveal-center / --spatial-dims
share their parser with gsplat lod (cli/reveal_options.py), so the two commands cannot
drift on what a centre or an axis order means.
Labels are cleared on a laddered mesh — one source vertex maps into every level that touches
it, so a union CSR spanning levels has no well-defined index space; substitutive_lod= and
partition= both keep theirs.
One argument FOR the ladder route that the drawRange design missed: depth sorting
permutes geometry.index, so a reveal expressed as a drawRange over a reveal-ordered
index buffer would be scrambled by the first sort. A ladder of separate subgroups is
immune, because membership and draw order are independent concerns.
The hard rule: a reveal ladder must NOT carry energy_fraction_cum stamps.
This is now enforced by construction for all four shipped types, not merely
specified: luxar.utils.lod_methods.REVEAL_METHODS names which orderings are reveals, and
both the gsplat ladder (gsplats/lod/additive.py) and the element ladder
(core/group/lod/group.py::additive_level_stats) consult it and omit the stamps. The
element path gets it from the single flag that already governed the per-level
energy_fraction_cum and the parent reference_energy together, so the both-or-neither
contract holds without a second coordinated edit. lod_method and the count fields are
still written — they are provenance, and nothing keys brightness off them.
Accepted consequence, stated rather than buried: cross-fade and the e >= 0.6
early-upgrade release are gated on the same stamps, so a reveal hard-switches between
shells. That is the right trade — a wrong brightness is far more visible than a missing
fade — but it is a real loss.
The reasoning, which generalises to every geometry:
energyCompensation (scene/lod-blend.ts) multiplies a leaf’s brightness by 1/e(k)
while an additive ladder is incomplete, and it is gated on
BLENDABLE_MODES = {additive, luminous, volumetric} — not on geometry type. Mesh
supports additive and luminous (§6.3), so a stamped mesh reveal in either mode would
be brightened. That is right for a splat prefix, which genuinely is dimmer than the whole,
and backwards for a mesh prefix, which is not dimmer but holed: the result is a surface
with gaps that also glows. Mesh’s opaque default escapes this today by luck, not design.
Omitting the stamp is both the cheapest fix and the honest encoding, because the stamp’s
meaning — “this prefix is dim, compensate for it” — is a false statement about a holed
surface. energyCompensation already returns exactly 1 for an absent e, leaving the
leaf byte-identical. LOD cross-fade is gated on the same set and follows the same rule.
Measured scope of the hazard (2026-08-10). The compensation is applied in exactly one
place — applyLodFade (scene/lod-fade.ts), whose only caller is the kind=lod group
registry (scene/lod-group-registry.ts). So the stamps are consulted for a ladder inside
a lod group and are inert on a bare stream/flat leaf. Verified by rendering the same
radial ladder twice, stamped and unstamped, under a throttled server: as a bare leaf the
two are byte-identical frame for frame, while inside a levels group the stamped arm is
1.87× brighter in mean luma (p99 luma 109 → 155) for as long as the ladder is
incomplete, converging to identical once it completes.
The rule stays unconditional, and the reason is not that a bare ladder might drift into a
lod group — it cannot do so silently. Every ladder-producing path rebuilds through
make_additive_lod / additive_level_stats and therefore re-consults REVEAL_METHODS:
both gsplat additive and lod --recipe levels were run on an already-radial ladder and
both discarded it, re-deriving from the method they were given (default → a greedy
ladder, correctly stamped; -m radial → still unstamped). The real reason is simpler: the
method is the only thing an authoring call keys on, and it already covers both cases —
--recipe levels|adaptive|overview -m radial writes reveal ladders directly inside a lod
group, where the stamps bite, and --recipe stream -m radial writes a bare one, where they
are inert. One predicate, both cases, no scope test needed.
The rule is enforced at write time: add_mesh raises when either container carries
reference_energy or energy_fraction_cum (_reject_energy_stamps in
packages/luxar/src/luxar/core/group/adders/mesh.py). The containers themselves remain legal
because substitutive levels now stamp the separate level_stats.geometric_error currency.
It has to be the adder that refuses,
because the write path’s allow-list _ALLOWED_NODE_ATTRS in
packages/luxar/src/luxar/io/_compiler/node_common.py is geometry-blind and would let either key
through on any node type. The refusal is prophylactic rather than a fix for a live bug: one latch
still holds — the mesh commit never stamps committedEnergyFraction, so the factor is 1. The other
(mesh could not sit in a kind=lod group) is gone: substitutive LOD landed, so the fade pass
does visit a mesh level now. A reveal ladder would remove the remaining one.
Not a concern: coverage_fraction auto-selection. That selector chooses between
substitutive levels; an additive ladder inside a leaf streams to completion and is never
distance-selected. A reveal cannot be picked as a distant stand-in because nothing picks it.
Consequences to state plainly rather than bury:
Every intermediate frame is a wrong picture, not an approximate one. Under
opaquewith depth writes you see interior back faces through the gaps — correctly lit by thegl_FrontFacingflip, so it reads as a hollow shell. For a deliberate reveal that is arguably the appeal; as a quality ladder it is indefensible.It must converge to 100%. A splat ladder may legitimately stop early against a bandwidth budget. A mesh reveal that stops early leaves a permanently broken model.
The reveal ORDER is the whole effect. Area-descending reads as blocky-then-refined; region-growing from a seed reads as the surface growing; contribution-ordered — the splat metric — reads as confetti, because a mesh triangle has no “contribution” to order by. Whoever ships this is choosing an aesthetic, not an error metric, which is a different kind of decision from QEM’s and should not reuse its vocabulary.
radialis the shipped choice, and for mesh it has a second, non-aesthetic advantage: concentric shells keep vertex duplication near the lower bound (each shell boundary is one closed surface), where a confetti order approaches the 3× worst case.
Naming. The method is radial, and it sits inside the additive vocabulary rather
than beside it — the earlier plan to keep it off that code path entirely
(reveal / progressive_draw) was dropped once the ordering turned out to be the only
thing that differs. What it must NOT inherit is the energy-stamp machinery, and that is
handled by REVEAL_METHODS above, which is a sharper boundary than a separate code path
would have been: one predicate, consulted by every implementation, rather than a parallel
set of builders that could drift.
Pre-existing gap noticed during this spec’s review, since closed by #1220:
POINTS_/LINES_/GSPLATS_RESERVED_ATTRS had omitted has_image_labels even though all three writers
stamp it. No clobber ever resulted — validate_render_attrs’s reject-unknown gate already failed such a
write, just with the unknown-attr message instead of the reserved one — so it was only an
error-message gap. #1220 added the key to all three sibling sets, matching MESH_RESERVED_ATTRS (§3.3).
9.2 kind=partition — lifted
Shipped. add_mesh(partition=True | {"max_elements": N, "rule": …}) writes a
kind=partition wrapper with one independently-drawable Mesh child per BSP part, and
GEOMETRY_CAPABILITIES.mesh.partition is true on both sides of the contract.
This row was always the weakest of the structural exclusions, for the reason the old table gave: it was bookkeeping, not correctness. What the bookkeeping had to do:
Split on face CENTROIDS, never through a face. A triangle is the indivisible unit, so
max_elementscounts FACES. No geometry is cut and no vertex is invented.Re-index, do not slice. The sibling adders hand each part a slice of their element arrays because their elements are independent rows. A triangle is three references into a shared vertex table, so each part gathers the vertices its own faces use and renumbers those faces against the gathered table (
luxar/mesh/split.py).Duplicate across the cut. A vertex referenced from both sides appears in both parts. That is the cost, and it is what makes each part stand alone as a drawable leaf. It is bounded by
3Fin the pathological case and is a few percent in practice — only the cut surface duplicates. The writer reports the measured factor.Gather every per-vertex attribute, including the per-vertex label CSR the old table called out:
normals,colors,scalarsandlabelsall travel with their vertices (via the sharedslice_optional_array, which gathers on length and so leaves a uniform RGB triple or a colormap name alone).Stamp ONE scalar display window on every part. Derived from the whole field before the split, unioned with an explicit
_scalar_data_rangewhen one is given, because the viewer windows each node’s colormap on that node’s own stamped range: per-part min/max recoloured the same value either side of a cut, and a part whose subset is constant landed on the LUT midpoint. Same rule §9’s substitutive ladder applies to its levels, via the same helper. The pair is also each part’s quantization range, so a field with one extreme outlier now spends its codes on the global span rather than per part — the display the file is meant to be viewed at is unchanged, and the ladder path already made that trade.
The seam question resolves the way the old row predicted. With stored normals split verbatim, a duplicated boundary vertex carries an identical position AND an identical normal in both parts, so nothing seams under §6.2’s shading; the derivative variant is per-fragment off the rasterized triangle and is part-agnostic by construction. Revisit this the moment shading gains anything RECOMPUTED per part — area-averaged normals, tangent frames, UVs, baked AO — because each of those is computed from a part’s own contents and would differ across the cut.
The wrapper records the recursive split planes as bsp_tree. The tree is built from the
same face-centroid BSP whose leaves become the mesh parts, then pruned and renumbered against
the parts actually written. Because faces are assigned by centroid while position_bounds
cover all three vertices, a triangle may cross its assigning plane; the resulting traversal is
stable and localizes ambiguity to the overlap, but is approximate rather than an exact painter’s
order. It still avoids the coarser whole-part centroid fallback for translucent mesh.
render-order.ts::traverseBspBackToFront also orders opaque mesh parts back-to-front, which is
correct but forfeits the front-to-back early-Z order an opaque pass would prefer. That is a known
performance tradeoff, not a correctness defect; keeping one partition metadata contract across
all four geometry types is more important than special-casing opaque mesh authoring.
One refusal survives the lift. A mesh may go under a kind=partition group whose
display_type is 'mesh' — nothing else. A partition is homogeneous by definition, and
validate_partition_group’s homogeneity check has no production caller, so declaring a
points partition and dropping a mesh into it would write clean and load as a layer
claiming to be points. reject_mismatched_partition_parent (in core/group/partition.py)
refuses that pairing fail-fast, before any array reaches disk — and it is called by all
four leaf adders, so the rule is symmetric: a points leaf under a display_type='mesh'
partition is refused the same way. That direction only became reachable here, since a mesh
partition could not be built at all before mesh became partition-capable.
Still excluded, and unaffected by this: a partition of a mesh LOD ladder. A substitutive
ladder now exists (§9), but partition= and substitutive_lod= cannot be combined in one
add_mesh call — the same refusal add_lines carries. Points composes that pair into a
global-coarse overview above partitioned fine detail; Mesh does not write that topology yet.
10. Architecture: what the consolidation changed for mesh
Mesh was the forcing function for a question the codebase had been deferring — whether a fourth type should be added to ~30 hand-written dispatch sites, or whether the dispatch should be table-driven first. An earlier draft of this section argued for the second answer. It shipped, before any mesh code, so this section now records the result rather than the argument.
10.1 What landed
PR |
Change |
|---|---|
#1079 |
|
#1099 |
Points given the same |
#1150 |
Points metadata brought level with lines ( |
10.2 What that means for a fourth type
The vocabulary is now single-sourced from the format contract, so adding mesh to
contract.yaml propagates to every consumer, and the places that must still be taught about it
fail the build rather than going quiet. Note the trigger: these three errors fire on adding mesh to
loader_types (the Phase-3 switch-on, §3.1), not to node_types/geometry_types — those two
already contain mesh (#1220) and compile clean. The errors are the map of what Phase 3 must implement:
Missing loader entry →
TS2339: Property 'mesh' does not exist on type 'LoaderByKind'atloader-registry.ts, plus threeTS2536follow-ons inside the generic accessorsMissing descriptor row →
TS2741: Property 'mesh' is missing … required in type 'Record<GeometryKind, GeometryDescriptor>'Missing tolerance arm →
TS2366: Function lacks ending return statementintolerance-computer.ts
The descriptor row is the one that matters most. Before the consolidation, lifecycle/retry.ts
expressed a per-type capability as the presence of an if: a kind with no arm there was not a
compile error, it was a load that could never be retried, manually or on reconnect. Nothing in the
type system said so, and it was found only by reading. It is now a declared field on a row that
cannot be omitted.
Three dispatch sites therefore need no mesh edit at all: load-scene-nodes.ts,
lifecycle/retry.ts and prefetch/slice-prefetcher.ts all read the descriptor table.
10.3 What deliberately stayed specialized
Not everything should be unified, and the consolidation did not try. Materials, geometry assembly, projection kernels and storage layout are genuinely different per type — §2.1 and §6 argue mesh differs from the other three more than they differ from each other. Forcing those behind one interface would be worse architecture, not better.
The descriptor table holds only capabilities with a real consumer. It deliberately carries no
“supports X” flag that every kind currently answers identically, on the grounds that a field no
branch reads is indistinguishable from a field that is wrong. Mesh should respect that when it adds
its row: declare applyPartialExtendTolerance and the factories because those are read, and express
“no slice prefetch” (§7) by not registering a prefetch call site, not by adding an unread flag.
10.4 One safety rule mesh must follow
SceneNode.type is an unvalidated string — build-scene-graph.ts copies attrs.type verbatim out of
the store. A bare GEOMETRY_DESCRIPTORS[node.type] therefore resolves through Object.prototype for
values like constructor or toString and returns a truthy non-descriptor; the resulting TypeError
is not a LoaderError, and loadLeafNode re-throws anything else, so a single such node sinks the
whole scene load. This was a real regression caught in review of #1099.
Use geometryDescriptorFor(node.type), which gates the lookup with Object.hasOwn. Code holding a
GeometryKind from a trusted source (the loader registry, a literal) may index the table directly.
11. Phased delivery
Phase |
Contents |
Verifiable outcome |
|---|---|---|
0 ✅ (done — §10.1) |
Single-source |
Landed as #1079 / #1099 / #1150, all behaviour-preserving. The |
1 ✅ (done — #1220) |
Writable contract ( |
Landed as #1220, all in one PR: |
2 ✅ (done — #1232) |
Rust + TS cull kernels with parity tests |
Kernels green in isolation, no viewer changes |
3 ✅ (done) |
|
Mesh loads and renders unshaded (flat vertex color); E2E smoke green |
4 ✅ (done) |
GLSL + TSL material pair + codegen snapshots + shading model |
Shaded surface, both backends pixel-equivalent. Landed as the |
5 ✅ (done) |
Picking pair, layers panel, monitor, stats, camera framing, debug |
Full parity with the other three at the UI level. The pick pair landed as |
6 ✅ (done — real-WebGPU A/B run; two claims remain reasoned, see the cell) |
Fixture + E2E spec + demo + docs + CHANGELOG |
Shippable. |
The A/B also corrected an overstatement in the §6.2 notes, which is the more useful half. Those notes claimed an unforced derivative normal “would collapse to uAmbient everywhere on WebGPU”. That is a spec-derived RISK, not an observed behaviour: test_mesh.luxar.zarr gained a flat_facing node — a flat-shaded quad FACE-ON, where N.z ≈ ±1 makes the flip the difference between full brightness and the ambient floor — and with the z >= 0 flip REMOVED, real WebGPU still renders it identically to WebGL. The metric is demonstrably sensitive (a control run with every mesh hidden drops from 93,851 lit pixels to 56,700, so the quad contributes 37,151 and lifts the mean from 40 to 116), so this is a measurement rather than a blind test. Conclusion: on Chrome + Apple Silicon the two derivative conventions COINCIDE and the flip is inert. It is kept because it costs one instruction, is correct under either convention, and neither shading-language spec promises they agree — insurance, not a fix for an observed bug. The shader comments now say exactly that.
(The earlier edge-on-only flat_patch could not have shown this either way: N.z ≈ 0 there, and flipping the sign of ~0 leaves wrap = clamp(0 · 0.5 + 0.5) = 0.5 unchanged. That is why the fixture needed the face-on node.)
Still arguments rather than measurements: the provoking-vertex convention and the surface-depth VALUE, both because reading the pick buffer’s ids and depth from outside the app is not cheaply reachable |
Post-Phase-6: the near-plane fade (#1431). The Phase 6 audit above swept the “all three geometry
types” claims and left the two that were genuinely about volumetric physics and chunk-bounds queries.
It missed a third asymmetry that was real: mesh had no perspectiveNearFade in either backend, so a
triangle clipped hard against the near plane while the other three faded out. That is now closed —
§6.2 for the visual pair, §6.5 for the pick pair — and the change reaches further than the shaders:
all four mesh material wrappers became CameraAwareMaterials (consuming isOrtho / nearCull and
ignoring fov / resolution), mesh joined the material manager’s camera broadcast, and the
near-plane floor derivation in scene/scene-manager/clipping/bounds-math.ts — which called mesh “the
one type the floor can clip” — now holds for all four. Parity is covered by the harness’s two
perspective entries (mesh-near-fade, mesh-pick-near-fade); the ORTHO default camera every other
entry uses makes the fade the identity, which is why those two exist.
Phase 0 landed alone, with no mesh code, so any regression it caused would have been unambiguous.
Phase 1 (the Python writable side) landed next, as #1220; Phase 2 (the cull kernels) was independent
of the remaining viewer phases and landed on its own as #1232. The drawable half of the contract
edit — adding mesh to loader_types — was deliberately not part of Phase 1: it fires the three
§10.2 compile errors, which can only be cleared by real Phase-3 code, so it belonged with Phase 3.
Phase 3 would once have been the widest diff — a sweep across every dispatch site — and turned out to
be one of the narrower ones: the three dispatch files needed no mesh edit, leaving the loader_types
switch-on, the loader, the geometry builder, and two table entries. Phase 4 was the deepest single
piece of work.
Estimate (as written up front): 5–6 PRs, roughly 4–5.5K LOC including tests — against ~14K LOC for the full Lines vertical, the difference being everything in §9 plus the dispatch work Phase 0 already absorbed.