Skip to content

glTF 2.1 - #62

Draft
jim-ec wants to merge 35 commits into
mainfrom
gltf-2.1
Draft

jim-ec wants to merge 35 commits into
mainfrom
gltf-2.1

Conversation

@jim-ec

@jim-ec jim-ec commented Oct 25, 2026

Copy link
Copy Markdown
Contributor

jim-ec added 30 commits October 7, 2026 16:49
glTF 2.1 promotes KHR_materials_emissive_strength, KHR_node_visibility,
KHR_mesh_quantization and EXT_texture_webp into the core specification.

Both spellings must keep working: a 2.0 asset using the extension object and a
2.1 asset using the core property must behave identically, and both must stay
reachable by KHR_animation_pointer and KHR_interactivity. Since those JSON
pointers are derived from the object graph by recurseAllAnimatedProperties, the
two are bound to a single AnimatableProperty instance via the new
linkPromotedProperty helper, so neither copy can drift. The core property wins
when an asset specifies both.

- material.emissiveStrength: new animated property, adopted from the extension
  when absent. hasEmissiveStrength is now decided in fromJson, where the
  original JSON is visible, instead of initGl.
- node.visible: new animated property; scene.gatherNodes reads it directly.
- KHR_mesh_quantization needed no change. Accessors already build typed views
  for the quantized component types and enableAttribute already forwards
  accessor.normalized, with nothing gated on the extension being declared.
- EXT_texture_webp mostly worked already. Added magic-byte mime sniffing so a
  WebP image stored in a bufferView without a mimeType decodes, and so an
  unrecognised data URI prefix falls through to sniffing rather than failing.

Also adds the version-gating foundation the later 2.1 phases need: asset parses
version into major/minor, exposes isAtLeast(), and rejects assets whose
minVersion exceeds what this renderer implements. No behaviour is gated on the
version yet; core 2.1 properties are accepted on any asset.

The four extensions stay in allowedExtensions so 2.0 assets that list them in
extensionsRequired keep loading.

The core property names are inferred: the draft 2.1 spec text does not define
them yet, stating only that the syntax will be settled later.
The renderer had no unit test runner: @playwright/test was a dev dependency
with no tests directory. Vitest costs nothing to adopt here because it uses
Vite's resolver, so the extensionless imports throughout source/ resolve the
same way they do in the viewer's bundle. Plain Node cannot resolve them.

Scope is deliberately narrow. Unit tests cover parsing and container logic;
anything needing a GL context stays in the viewer's Playwright suite. That
keeps the suite under a second, so it can run on every edit.

Tests cover the step 1 promotions (both spellings, write-through in either
direction, precedence, defaults), version parsing including the spec's
no-leading-zeros rule, and image magic-byte sniffing. The sniffing tests pin
down that a .wav file also starts with RIFF, so WebP detection has to check
the form type at offset 8 as well.

Phase 0 also parses the new glTF 2.1 top-level arrays. files and
externalAssets get their own objects, with the validation the schema implies:
mimeType is required, and exactly one of uri or bufferView must be present.
Nothing consumes them yet.

json.shapes is parsed, but deliberately kept separate from
extensions.KHR_implicit_shapes.shapes rather than merged into one array.
KHR_physics_rigid_bodies colliders index into the extension's array, so
concatenating core shapes in front of them would silently shift every
collider's shape. Unifying the two is left until physics is migrated to read
gltf.shapes directly.
glTF 2.1 adds GLB container version 3, which widens the length fields to 64
bits, adds a per-chunk encoding field, moves chunk alignment from 4 to 8
bytes, and no longer requires the glTF JSON to be the first chunk. Version 2
stays valid and importers must read both. The container version is
independent of the glTF version in the JSON, so asset.version is still
checked separately.

The parser is rewritten rather than extended. It counted in Uint32Array units
(glbHeaderInts = 3), which cannot express 64-bit fields, and getJsonFromChunk
ignored chunkInfo.start and recomputed the offset assuming the JSON chunk was
chunk 0 -- correct by luck in v2, wrong in v3.

Note the chunk header fields are reordered between versions: v2 is
(length, type), v3 is (type, encoding, length). Reading one with the other's
offsets yields plausible garbage rather than an obvious failure, so both
layouts are tested against byte-exact fixtures.

BigInt is confined to a single readLength method. Mixing BigInt and Number
throws at runtime, so nothing downstream ever sees one; a length past
Number.MAX_SAFE_INTEGER is rejected with a specific message because it cannot
be used as an offset.

Validation that was missing: files too short for a header, a v2 file past the
2^32-4 limit its end alignment implies, a chunk running past end of file, a
chunk layout that fails to advance (previously an infinite loop), a missing
JSON chunk, and unparseable JSON (previously an uncaught throw).

buffer.chunk is what makes multiple binary chunks usable. The glTF 2.0
fallback where buffer 0 implicitly means chunk 1 is kept, but only for the
exact layout 2.0 could produce: buffer index 0, no uri, JSON at chunk 0, and
chunk 1 is a BIN chunk. A 2.1 buffer that omits chunk by mistake should fail
visibly rather than silently binding to whatever is at chunk 1. A bound chunk
must be at least as large as byteLength, not equal, so GLB padding does not
force byteLength to be rewritten.

The appendix argument to gltfLoader.load previously duck-typed its contents.
With chunk metadata to carry that no longer stretches, so a GLB container is
passed explicitly as { glb: ... } and the dropped-file path keeps its array.
ResourceLoader now throws when extractGlbData returns undefined instead of
dereferencing it and reporting a confusing property access error.

The GLB writer lives next to the parser so a format change cannot update one
without the other, and so the sample viewer's asset generator can share it.
asset.thumbnail points at an image that previews the asset. The point of the
feature is that an application can show it without loading the scene, so the
interesting part is not parsing the index but reading the image on its own.

ResourceLoader.loadThumbnail reads only the JSON (or the GLB JSON chunk) and
the one image it names. It builds no glTF document and touches no WebGL. Data
URIs are returned as-is; everything else becomes an object URL the caller owns
and must revoke.

A thumbnail stored in a bufferView still needs that buffer. Inside a GLB the
chunk is sliced directly, but a .gltf with an external .bin has to fetch the
whole buffer, which is the slow case and could only be avoided with range
requests.

The media type is passed through to the Blob untouched rather than validated,
because the spec deliberately allows any type here and lets the application
decide what it can display.

During a normal load a thumbnail that no texture references is now skipped,
since loadThumbnail fetches it separately and decoding it twice would be
wasted work. An image used as both thumbnail and material texture is still
loaded, which is why the marking records usedByTexture rather than just
isThumbnail.
glTF 2.0 required TEXCOORD_n and COLOR_n to start at 0 and be consecutive, so
the set index in the file and the shader slot were always the same number.
glTF 2.1 relaxes that, so a primitive may carry TEXCOORD_1 and TEXCOORD_3 and
nothing else.

The shaders still declare a fixed number of varyings, so rather than widening
them, a primitive now maps the set indices it has onto contiguous slots in
ascending order. That keeps the lowest-numbered set primary, which is what the
spec recommends, and leaves conventional 2.0 assets mapping to themselves.

Attribute parsing is now driven by a regex over the semantic rather than a
switch listing every name, so no code change is needed to accept a set index
nobody has used before.

Three places had to follow:

- The ~22 u_*UVSet uniforms are written per draw call, so each one routes its
  material's texCoord through the primitive's map. A material asking for a set
  the primitive does not provide warns once and falls back to the first set,
  where it previously sampled whatever happened to be in v_texcoord_1.
- Morph target semantics are remapped the same way. Without this, a
  displacement on TEXCOORD_3 would emit a HAS_MORPH_TARGET_TEXCOORD_3 define
  no shader knows about, and the displacement would be silently dropped.
- Sets beyond the shader's slot budget are dropped with a warning naming them,
  rather than being silently ignored.

Only the lowest COLOR_n is used, since the shaders carry a single vertex
colour varying.
glTF 2.1 moves the draft KHR_implicit_shapes extension into a core `shapes`
array, adds mesh-based shape types, and gives nodes a `boundingVolume`.

The defaults in the 2.1 schema differ from the draft extension this renderer
implemented, and the old values were wrong in a way physics would inherit:
cylinder height was 0.5 rather than 2.0, capsule height 0.5 rather than 1.0,
and every radius 0.25 rather than 0.5. Assets that omit these properties will
therefore collide differently than before. plane.doubleSided is dropped
because the spec defines a plane as one-sided with its normal along +Y.

Two new shape types are parsed: `mesh`, which may be concave, and
`convexMesh`, which is the convex hull of its mesh. Nothing computes hulls
yet; that matters only once shapes are rendered or used for collision.

node.boundingVolume is a shape instance with its own TRS. Per
KhronosGroup/glTF#2666 that TRS applies first, taking the shape into the
node's local space, and only then does the node's global transform apply. The
order is only observable once a node has non-uniform scale or the volume is
rotated, so it is pinned by a test rather than left to inspection.

Physics now reads gltf.shapes directly instead of reaching into the extension
object, which is what makes it safe to have the extension populate the core
array. An asset using both spellings is rejected with a warning rather than
merged, because concatenating them would shift the indices colliders use.

The interactivity graph pointer is registered under both /shapes.length and
the old extension path so existing graphs keep working.
Both only surface with glTF 2.1 assets, and both were invisible to the unit
tests because they involve ordering and consumers outside the slot mapping.

Slot assignment ran before Draco decompression, which is what populates
this.attributes for a compressed primitive. A Draco mesh would therefore have
had its slots computed from an empty attribute set, losing every texture
coordinate and vertex colour. Moved after the decode.

Tangent generation checked this.attributes.TEXCOORD_0 literally, so a 2.1
primitive whose lowest set is, say, TEXCOORD_5 would silently skip generating
tangents and render unlit-looking where a normal map was expected. It now asks
for whichever set occupies shader slot 0.

The second bug was found by the new browser test comparing an asset using
TEXCOORD_5 against the same geometry using TEXCOORD_0: they render identically
only because that asset has no normal map, so the missing tangents do not
affect its shading. Worth noting the equality test did not catch this one on
its own, it just made the difference in behaviour visible while investigating.
Preparation for external assets, with no recursion yet. glTF 2.0 only ever
needed "join the uri onto the folder the .gltf came from", which buffer.js and
image.js each implemented for themselves. glTF 2.1 adds two wrinkles that
neither could express:

  - A parent asset can alias a URI appearing inside a child, redirecting it to
    a file the parent holds. Matching is on the exact literal string, with no
    normalisation, and aliases are not inherited by that child's own children.
  - An asset embedded in a bufferView or data URI has no location of its own,
    so its relative URIs resolve against its parent's base, not its own.

FileResolver owns that policy. One instance belongs to one asset; forEmbedded
and forLocation derive a child's resolver with the right base, deliberately
dropping the parent's aliases, and share a cache so a file reached from
several places is fetched once.

This collapses four image loading paths into one. Images previously took
separate routes for bufferView, dropped file, URI and data URI, each with its
own mime type handling, and the URI path let the browser fetch and decode by
handing a URL to an <img>. Everything that is not a bufferView now resolves to
bytes and goes through setImageFromBytes, which is the path bufferView images
already used. A KTX2 image loaded by URI therefore decodes from a buffer
rather than a URL.

setMimetypeFromFilename no longer guesses JPEG for an unrecognised extension.
It falls back to the server's Content-Type if there is one, and otherwise
leaves the type undefined so the magic-byte sniffing added earlier can decide.
Guessing JPEG meant a mis-detected image failed somewhere inside the decoder
instead of at the point the type was unknown.

The environment's LUT images are loaded through their own resolver with an
empty base, since their paths are relative to the page rather than to any
glTF asset.
Recursion, cycle detection and alias resolution. Documents are loaded and
cached but nothing instantiates them into the scene yet, so this changes no
rendering; a referenced asset is parsed and its resources resolved, and the
result hangs off externalAsset.document.

Ordering matters inside a document: buffers have to land before the recursion,
because a referenced asset may live in a bufferView of its parent, and images
load afterwards so they can come from either.

Two rules from the spec drive the design.

A cycle is decided by the chain from the root to here, not by whether a file
has been seen before. Two siblings referencing one child is a diamond, which
is legal and must load, with the shared file parsed once. Tracking "visited"
instead of "on the current path" would reject it.

An asset's URIs are resolved by whichever asset declared its files entry, and
by nobody else. A child therefore gets exactly the aliases on its own entry,
and passes none of them further down. A child named by uri gets a base of its
own; an embedded child borrows its parent's, because data in a bufferView has
no location to be relative to.

Bounds are imposed on depth and total document count, since the reference
graph comes from the asset rather than from us.
Dropping a file into the viewer warned "Unsupported source passed to the
thumbnail loader" even for an asset with no thumbnail at all. loadThumbnail
handled a path, an ArrayBuffer and a Blob, but the viewer hands a dropped file
over as a [path, File] pair, so reading the source failed before anything had
looked for a thumbnail.

Thumbnails are optional, so the failure mode was wrong in two ways. A source
the loader cannot read now yields no thumbnail rather than raising: whatever
is wrong with it is reported by the real load, and duplicating that on the
thumbnail path is just noise. The same applies to a source that is not
parseable glTF. A thumbnail the asset does declare but that fails to resolve
is a genuine fault and still throws.

URI resolution now goes through FileResolver instead of bare fetch, so a
thumbnail referenced from a dropped asset resolves against its dropped
siblings. That is also why droppedFiles is threaded through.

FileResolver reads dropped files with Blob.arrayBuffer rather than FileReader.
The callback form predates the promise API, and FileReader does not exist
outside a browser, which kept the dropped-file path out of reach of tests.
planning/shapes_all.gltf exercises every shape type, the 2.1 defaults and a
bounding volume with its own transform. None of that had coverage, and the
defaults in particular changed when shapes moved out of KHR_implicit_shapes,
so a debug renderer written against the old values would look plausible and
be wrong.

The asset lives outside the repo, so the test skips when the planning
checkout is absent.
glTF 2.1 promotes shapes to the core, but nothing in the viewer drew them, so
an authoring mistake in a bounding volume was invisible. This adds the
geometry, a minimal pass and the diagnostics.

debug_geometry.js generates indexed geometry with both triangle and line
indices, so one buffer set serves the solid and the wireframe style. Sphere,
cylinder, cone and capsule are one surface of revolution with different
profiles. A tapered capsule joins its caps along their common tangent rather
than stacking two different end caps on a cylinder, which is the shape the
spec actually describes. Infinite planes get a clamped quad with a grid, and
every plane gets a normal arrow, because a one-sided plane is otherwise
impossible to orient by eye.

Mesh and convexMesh shapes are drawn from the source mesh with load-time
morph weights applied, matching what the physics engines build colliders
from. Convex hulls are not computed yet, so a hull is drawn as its source
mesh: that over-reports concavity but never misplaces the shape.

The pass is deliberately independent of the physics engines, so shapes are
visible whether or not PhysX loaded. Its fragment shader writes the tonemap
flag as passthrough, so a debug colour reaches the screen as written instead
of being tonemapped into something else.

Geometry is cached per shape under a key built from the current parameter
values rather than the dirty flags, which avoids depending on who resets
them; an animated shape rebuilds itself and a static one never does.

Bounding volumes that fail to enclose their node geometry are drawn in a
warning colour. The comparison is world-space AABB against world-space AABB,
so it is conservative: it flags volumes that are genuinely too small and
stays quiet on a tight volume over a rotated mesh.
The lateral surface of a capsule is the common tangent of its two cap
spheres. Tangency requires height*sin(phi) + (top - bottom) = 0, so
sin(phi) = (bottom - top) / height. The sign was inverted, which put the
contact ring below each sphere equator instead of above it: the smaller
sphere visibly started shrinking before the lateral band reached it, and the
silhouette had a crease where the two met.

The previous test derived its expected angle with the same inverted sign, so
it confirmed the bug rather than catching it. The replacements assert
properties instead: that the profile stays convex, that the band passes
outside the smaller equator, and that the larger equator is the widest
circle. Each of those fails against the old sign.

With the correct angle a cap can reach past its own equator, and that circle
is then the widest point of the whole shape, so it is inserted into the
profile exactly rather than left to wherever the sampling lands.
Each node referencing an external asset gets its own copy of the child
document, so transforms, animation playheads, morph weights, poses and
visibility are independent per instance.

The per-array policy is written out rather than being a generic deep clone.
A generic clone duplicates uploaded buffers and decoded images, which looks
like it works and then costs a multiple of the memory it should. Resource
arrays share their elements but get their own container, because a skin
allocates its joint texture by appending to images, samplers and textures,
and that append must not reach the template or a sibling instance.

Meshes are copied for their morph weights but their primitives are shared:
a primitive owns the uploaded vertex buffers and the generated tangents and
holds nothing that differs per instance.

Parent links are object references, so they are rebuilt on the clone rather
than remapped. Everything else in glTF is index-based and stays valid
because the arrays keep their order.

Documents now carry an id. A mesh index only means something together with
the document it belongs to, which the renderer will need when it groups
draw calls across a tree.

The loader already rejects cycles, so instantiation terminates. The budget
guards the case that is not a cycle: a diamond doubling the instance count
at every level.
Scene traversal now descends from a node into the document it instantiates,
and the renderer resolves indices against the document that owns each node
rather than against the root. A mesh or material index only means something
together with its document, and a tree of external assets has many.

Nodes carry their owning document, so the drawables the renderer already
builds did not need a new shape: every index lookup that was node-relative
reads node.ownerDocument instead. Picking, hover and skin updates now walk
the gathered node list, which spans the tree, rather than the root nodes.

Two things outside the renderer made the same assumption and had to follow:
the statistics pass, and the camera framing. The camera was the visible
failure, because scene extents walked node indices against the root
document, found nothing, and framed an empty box with all the content off
screen.

This also fixes uploading a parent whose child is already uploaded. The
default walk over members reached the child through externalAssets and
called its initGl with a document where a GL context belongs, which failed
deep inside primitive upload with no hint of the cause. The walk now stops
at the document boundary.
An instantiated asset has no entry in the animation UI, so everything it
declares runs on the shared clock. Each instance owns its animation objects,
so two instances hold independent state even though they run in lockstep
here.

The viewer also had to learn that something is moving. It only kept
redrawing while the root had animations selected, so an asset whose motion
came entirely from a child rendered one frame and went still.
The hierarchy depth map was keyed by node index and walked only the root
document. Both are wrong once external assets are instantiated: node 0 of a
child is not node 0 of the root, so a node either borrowed a depth from an
unrelated node or fell back to zero, and every instance came out the colour
of the root.

Keying by the node itself removes the ambiguity, and the walk now descends
into instantiated assets the way the rest of the traversal does.

Also records the decision on interactivity: only the root document graph
runs. A graph addresses the world through JSON pointers naming one document,
and the spec does not say whether an event raised by one graph should reach
another or whether two instances share variables. Rather than invent
semantics that may later be contradicted, graphs inside an instance are
skipped and the asset is named in a warning so the author is not left
guessing.
uid costs nothing to support: fromKeys already copies unknown JSON keys onto
the object, so a 2.1 asset carrying uids round trips untouched. These tests
exist so that stays true.

Nothing further is implemented on purpose. uid does not appear in the 2.1
spec draft, and the explainer is still weighing a separate uid property
against requiring name to be unique, and core against an extension. Nothing
in the format references a uid either, so there is no behaviour to add.

That makes the uniqueness validator in the plan premature: the constraint
against name, the character set and the name fallback all belong to one of
the two competing designs, and warning an author for breaking a rule that
may not survive is worse than staying quiet.
This is the first thing that makes a glTF 2.1 bounding volume do something
rather than merely be inspectable.

Only nodes that declare a volume are considered. A node without one says
nothing about where its geometry is, so it always draws: the feature uses
what the asset states rather than guessing from mesh bounds.

Two cases are deliberately exempt. A node drawn as part of an instanced
group shares one draw call with its siblings, and a GPU-instanced node is
spread over transforms its own volume does not describe; in both, one
volume cannot speak for what is drawn. An infinite plane is exempt too,
since shapeBounds cannot measure it and undefined has to read as "cannot
cull" rather than "empty".

The decision lives in culling.js as a plain function over nodes rather than
in the renderer, because correct culling is invisible by definition -
whatever it removes was off screen - so it can only be tested by asserting
the decision directly. The renderer cannot be imported under vitest, which
has no GLSL plugin and deliberately keeps GL out.

Shape extents are computed analytically. Building a sphere's geometry every
frame to measure it would be absurd.

Toggleable because a volume too small for its node hides geometry that
should be visible. That is an authoring error, not a renderer one, but it
is invisible without help, which is what the debug pass and its
non-enclosing warning colour are for.
glTF 2.1 lets a primitive carry any number of sets and a material address
any of them, but every texture getter picked its set with the same
two-way conditional, which is what capped the renderer at two.

All 22 of them now go through one getTexCoord helper, so raising the limit
is a matter of declaring more varyings rather than editing each getter. Four
is a practical ceiling rather than a spec one; sets beyond it are still
dropped with a warning, and dropping the highest rather than shifting the
others keeps the sets a material does reference where it expects them.

The extra varyings are declared only when the primitive provides them, so
an asset using the usual one or two pays nothing. The guard is the
HAS_TEXCOORD_n_VEC2 define that already existed, which reaches both shader
stages, so vertex and fragment agree on what is declared without a new
define to keep in sync.

Morph targets still only displace the first two sets, which is what
animation.glsl implements.

One existing test encoded the old limit by using three sets to prove
overflow. It now derives the count from maxTexCoordSlots so it keeps
testing overflow rather than silently testing nothing.
The previous commit left the set count spread across the shaders. Adding a
fifth set meant declaring a varying in primitive.vert, declaring it again in
textures.glsl, and extending the ladder of conditionals in getTexCoord: three
edits in three places to change one number, with nothing but care keeping the
three in agreement.

The varyings are now a single sized array, v_texcoord[TEXCOORD_ARRAY_SIZE],
and MAX_TEXCOORD_SLOTS in the new attribute_limits.js is the number that sizes
it. getTexCoord indexes that array rather than walking a chain of early
returns, so the count is a number rather than a shader edit.

The attributes cannot follow, because GLSL ES 3.00 forbids arrays of vertex
inputs. They stay individually named, declared by a ladder of
TEXCOORD_SET_COUNT tests, with an #error for an asset that supplies more sets
than there are attributes to hold them. Generating those declarations from JS
would lift the last limit, but a shader assembled by string concatenation is
harder to read than four #if lines, and the guard makes the ceiling loud
rather than silent. That trade is worth revisiting if the ceiling ever moves.

TEXCOORD_ARRAY_SIZE and TEXCOORD_SET_COUNT are pushed as primitive defines,
which is the one list reaching both the vertex and the fragment stage, so the
two cannot disagree about the size of a shared interface. The array is never
empty: a primitive carrying no texture coordinates still has to compile, and a
material addressing a set the primitive lacks has to sample a zeroed
coordinate rather than fail to link.

The debug view follows the same idea. One DEBUG_UV define and a u_DebugUVSet
uniform replace one define per set, so the number of sets no longer multiplies
shader permutations, and GltfState now lists one channel per slot instead of
the hardcoded two. The fragment shader leaves the checkerboard showing when
the chosen set is one this primitive does not carry, which reads as "nothing
here" rather than as black UVs.

The slot budget is clamped at startup to what the GPU reports it can afford,
never below the two sets a glTF 2.0 asset relies on.
index.html pulled draco_decoder_gltf.js from www.gstatic.com with a blocking
script tag. When that fetch failed the page carried on without a decoder, and
the two console errors it left behind - ERR_SOCKET_NOT_CONNECTED, then
"DracoDecoderModule is not defined" - surfaced in whichever test happened to be
running, because every test asserts a clean console. A full suite run lost a
test to it this week, and the failure was reported against external assets,
which had nothing to do with Draco. A dependency that can fail any test for
reasons unrelated to that test is worse than a slow one.

The file now sits in source/libs next to libktx and mikktspace, so rollup
copies it into dist/libs and the viewer's sync:renderer-assets step puts it in
public/libs. eslint already ignores source/libs, so the minified source is not
linted.

Only the JavaScript is vendored. The decoder does name a companion
draco_decoder_gltf.wasm, but this build never requests it - a Decoder
constructed from the file alone runs and answers calls, with the wasm absent
and no request attempted. Shipping it would have added 192 KB that nothing
fetches. That is also why this is not a change in decoding behaviour: the
bytes are the ones the CDN was already serving.
animation.glsl implemented getTargetTexCoord0 and getTargetTexCoord1 and
nothing further, so a morph target on any set beyond the second was parsed,
uploaded into the morph texture, and then never read. The primitive already
remapped the target's semantic onto a shader slot, so the define was there and
correct; there was simply no code looking at it.

The two near-identical getters become one getTargetTexCoord taking the slot's
target offset as a parameter. The offset has to be a parameter rather than
derived from the slot, because MORPH_TARGET_TEXCOORD_n_OFFSET is a macro and
the preprocessor cannot index it by a variable. That is the same constraint
that keeps the vertex attributes individually named, so the call sites are a
ladder for the same reason the declarations above them are.

Each call is guarded on TEXCOORD_ARRAY_SIZE as well as on the morph define.
That second guard is not redundant: a morph target may name a set the
primitive itself does not provide, in which case the semantic is passed
through unmapped and the define names a slot that does not exist. Without the
bound the shader would write past the end of the varying array.

The test compares the displaced asset against one that bakes the same shift
into its geometry, so it checks that the right set moved by the right amount
rather than merely that something moved - a displacement applied to the wrong
slot would pass the weaker check.

It reads the coordinates back through the texture coordinate debug view rather
than by texturing the quad. That is deliberate: the generated assets' inline
PNG currently fails to upload, so every textured test quad renders black and a
comparison through a texture fetch would pass no matter what the morph did.
The debug view shows the varying itself, which is the value under test.
The debug list offered "Texture Coordinates 0" through 3, one per shader slot.
Slots are an implementation detail: the renderer packs an asset's sets down
into consecutive slots, so an asset whose only set is TEXCOORD_5 offered a
channel called 0, and one with sets 0, 2, 4 and 6 offered 0 through 3. The
author is shown numbers they never wrote, and given no way to ask for the set
they did.

The channels are now built from the loaded asset and named by the index the
file uses. They come from the slots assigned at load rather than from the
attributes, so a set dropped for exceeding the renderer's budget gets no
channel: offering one would promise a view the shaders cannot produce. An asset
with no texture coordinates offers none at all, where before it still listed
four.

Resolving a channel moved from its position in a fixed list to a lookup per
primitive. Each primitive maps the chosen set index through its own slots, so
two primitives that number their sets differently each show the set that was
asked for rather than whichever one happens to share a slot index.

u_DebugUVSet is -1 where a primitive does not carry the chosen set, and the
shaders now test for that rather than for an upper bound. The old test let -1
through as a valid slot, which would have shown set 0 while claiming to be
another.

The naming and gathering live in their own module rather than in GltfState.
GltfState cannot be imported under vitest - it pulls in the physics controller
and with it a wasm module Node will not load - so keeping them apart is what
lets them be unit tested at all.
main made draco initialisation awaitable and awaited it just before
gltf.fromJson. That call now lives in buildDocument, one per document in
the asset tree, so the await moved up into loadGltf next to the mikktspace
init: it has to happen once, before any document is parsed.
Scene extents were gathered from mesh positions alone, so a glTF 2.1 asset
made of nothing but shapes and bounding volumes left them inverted and
infinite. The fitted camera distance and clipping planes became NaN and
nothing drew, which looked like the bounding volume debug pass being
broken rather than the camera pointing nowhere.

Bounding volumes now supply the extents when no geometry does, and a scene
with neither falls back to a unit box. They do not contribute when there is
geometry: a volume states where geometry may be, not what is drawn, and an
oversized one would otherwise zoom every existing asset out.
Rings were sampled with a stride anchored at the first profile point, so
where the pattern landed depended on where the profile happened to start.
On a sphere that drew one ring 11.25 degrees from the equator and left
22.5 degrees between every other pair, and put the lowest ring at -56.25
against +78.75 at the top. The result read as a malformed sphere rather
than as a sampling artefact.

Rings now grow outwards from the middle of the profile, which is
symmetric by construction and places the sphere's equator without it
having to be forced in as a feature ring. The cylinder's rims and the
capsule's tangent rings are feature rings and are unaffected.
glTF 2.1 deprecates multiple scenes and lets a file have zero of them, so
an asset may carry a node hierarchy and no scenes array at all. Nothing
downstream walks nodes directly; every pass starts from a scene, so such a
file loaded without error and drew nothing.

A 2.1 asset without scenes now gets one covering the hierarchy's own
roots, which is the whole file when nothing claims a node as its child.
The same applies to an external asset reached through the files array:
its roots are what it contributes, where before it contributed nothing.
glTF 2.0 is untouched, since that version requires the array.
Pins both halves of the version split: a 2.1 asset without a scenes array
gets one over its root nodes, keeping every root rather than just the
first, while a 2.0 asset is left without one as that version requires. The
same split applies to a child reached through an external asset, so a 2.0
child that declares no scene still contributes nothing.
# Conflicts:
#	source/ResourceLoader/resource_loader.js
#	source/gltf/buffer.js
#	source/gltf/gltf.js
#	source/gltf/image.js

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant