diff --git a/docs/api-reference/gltf/gltf-animation.md b/docs/api-reference/gltf/gltf-animation.md
index 00f43f776c..4dcb88a20e 100644
--- a/docs/api-reference/gltf/gltf-animation.md
+++ b/docs/api-reference/gltf/gltf-animation.md
@@ -27,7 +27,9 @@ console.log(scenegraphs.animator.getAnimations().map(clip => clip.name));
`parseGLTFAnimations()` owns glTF accessor decoding and pointer interpretation. The returned
channels are converted to shared `AnimationTrack`, `AnimationClip`, and `AnimationMixer` objects
-by `GLTFAnimator`.
+by `GLTFAnimator`. `scenegraphs.animations` retains the parsed source channels;
+`scenegraphs.animator` owns their shared runtime actions; `scenegraphs.skins` owns reusable,
+automatically updated source skin palettes.
## `GLTFAnimator`
@@ -42,7 +44,36 @@ requestAnimationFrame(renderFrame);
```
`GLTFAnimator.setTime(timeMilliseconds)` accepts an **absolute timestamp in milliseconds**,
-matching `requestAnimationFrame()`. It evaluates all active clips in one shared mixer pass.
+matching `requestAnimationFrame()`. It evaluates all active clips in one shared mixer pass, then
+updates dependent skin palettes once.
+
+Applications that already maintain an animation delta can use seconds directly:
+
+```ts
+scenegraphs.animator.update(deltaSeconds);
+```
+
+Unlike calling the underlying `mixer.update()` manually, `animator.update()` also refreshes the
+automatically managed glTF skin bindings after every animation frame.
+
+### Select and crossfade clips
+
+```ts
+const animator = scenegraphs.animator;
+
+animator.selectClip('Walk');
+console.log(animator.activeClip); // 'Walk'
+
+animator.selectClip('Run', {
+ crossFadeDuration: 0.35
+});
+
+console.log(animator.activeClip); // 'Run'
+```
+
+`selectClip()` stops unrelated actions, activates the selected source clip, and optionally
+crossfades from the previously active clip. Durations are measured in **seconds**. Unknown clip
+names are rejected without changing the active selection.
Individual `GLTFAnimationClip` instances expose their format-independent `clip`, their shared
`mixer`, and their playback `action`:
@@ -56,9 +87,9 @@ walk.action.crossFadeTo(run.action, 0.35);
```
`AnimationAction` and `AnimationMixer` measure clip time, fade duration, and update deltas in
-**seconds**. If an application takes direct control of `animator.mixer.update(deltaSeconds)`, use
-that mixer as the animation clock instead of simultaneously advancing the same actions through
-`animator.setTime()`.
+**seconds**. Do not simultaneously advance the same actions through `animator.setTime()` and
+`animator.update()`. If an application directly controls `animator.mixer.update(deltaSeconds)`, it
+must also update dependent source skin palettes explicitly with `scenegraphs.skins.update()`.
See the [engine animation guide](/docs/api-guide/engine/animation) and
[AnimationMixer API reference](/docs/api-reference/engine/animation/animation-mixer) for pause,
@@ -70,17 +101,62 @@ seek, reverse playback, once/repeat/ping-pong loops, weighted blending, and cros
| --- | --- |
| Node `translation`, `rotation`, and `scale` | The corresponding retained `GroupNode` transform. |
| Node `weights` | Node-local mesh morph-target weights and existing GPU vertex buffers. |
+| `KHR_node_visibility.visible` | Recursive scenegraph visibility and an in-place punctual-light refresh. |
| Supported material-factor pointers | Shared canonical PBR material uniforms. |
-| Supported texture-transform pointers | Per-slot UV offset, rotation, or scale. |
+| Supported texture-transform pointers | Per-slot UV offset, rotation, or scale across all 17 map slots. |
+| Perspective or orthographic camera pointers | Independent runtime projection definitions. |
+| Punctual-light pointers | Authored linear color, intensity, range, and spotlight cone angles. |
`STEP`, `LINEAR`, and `CUBICSPLINE` interpolation are supported. Quaternion rotation tracks use
shortest-path interpolation, and cubic quaternion results are normalized. Morph channels unpack
all source target weights, including cubic spline tangent/value/tangent groups.
-`KHR_animation_pointer` supports the node transforms and morph weights above, selected
-scalar/vector PBR factors, and `KHR_texture_transform` offset/rotation/scale on all 17 supported
-texture slots. Camera pointers, extras, structural material switches such as `alphaMode`,
-`doubleSided`, or `unlit`, animated `texCoord`, and `TEXCOORD_2+` are not supported.
+### Typed `KHR_animation_pointer` targets
+
+Source pointers preserve their original JSON paths while being represented as typed node,
+material, texture-transform, camera, or light channels:
+
+```ts
+for (const animation of scenegraphs.animations) {
+ for (const channel of animation.channels) {
+ switch (channel.type) {
+ case 'node':
+ console.log(channel.targetNodeId, channel.path);
+ break;
+ case 'material':
+ console.log(channel.targetMaterialIndex, channel.property);
+ break;
+ case 'textureTransform':
+ console.log(channel.textureSlot, channel.path);
+ break;
+ case 'camera':
+ console.log(channel.targetCameraIndex, channel.projection, channel.property);
+ break;
+ case 'light':
+ console.log(channel.targetLightIndex, channel.property, channel.component);
+ break;
+ }
+ }
+}
+```
+
+Supported source pointers include:
+
+- `/nodes/1/extensions/KHR_node_visibility/visible` with `STEP` interpolation.
+- `/cameras/0/perspective/yfov` and `/cameras/0/orthographic/xmag`.
+- `/extensions/KHR_lights_punctual/lights/0/intensity` and individual `color/0` components.
+- `/extensions/KHR_lights_punctual/lights/0/spot/innerConeAngle` and `outerConeAngle`.
+- `/materials/0/extensions/KHR_materials_dispersion/dispersion` and supported physical factors.
+- `KHR_texture_transform` offset, rotation, and scale across all 17 supported material map slots.
+
+Camera channels update `scenegraphs.cameras`, which contains independent copies of source
+projection definitions. Light and visibility channels refresh the existing `scenegraphs.lights`
+array in place. Original postprocessed camera and light source data remains unchanged.
+
+Extras, structural material switches such as `alphaMode`, `doubleSided`, or `unlit`, animated
+`texCoord`, and `TEXCOORD_2+` are not supported. See
+[native glTF extensions](/docs/api-reference/gltf/gltf-native-extensions) for the full target
+matrix and strict extension diagnostics.
## Skeletal animation and skinning
@@ -89,15 +165,44 @@ integer joint weights retain their intended normalized interpretation at the con
The existing shared `skin` shader module in `@luma.gl/shadertools` applies joint palettes; its
current uniform-array capacity is 64 joints.
-The module accepts either its existing glTF scenegraph-based inputs or a format-independent
-`jointMatrices` palette. The shared
+`createScenegraphsFromGLTF()` automatically builds a source-aware `GLTFSkinController`. Each
+binding maps one authored mesh node to its source skin, animated joints, optional inverse bind
+matrices, reusable mesh-local joint palette, and existing primitive models:
+
+```ts
+for (const binding of scenegraphs.skins.bindings) {
+ console.log({
+ sourceNode: binding.nodeIndex,
+ sourceSkin: binding.skinIndex,
+ jointCount: binding.joints.length,
+ palette: binding.jointMatrices
+ });
+}
+
+const skinBinding = scenegraphs.skins.getBinding(2);
+console.log(skinBinding?.models.length);
+```
+
+`animator.setTime()` and `animator.update()` refresh all bindings once after their animation
+channels evaluate. Multiple independent source skins, authored inverse-bind transforms, mesh-local
+motion, and shared source nodes reuse the existing GPU models and skin shader instead of creating a
+parallel skeletal runtime.
+
+If application code changes a joint manually outside the animation controller, refresh the existing
+palettes explicitly:
+
+```ts
+scenegraphs.skins.update();
+```
+
+The shared
[experimental SceneRenderer](/docs/api-reference/experimental/scene-renderer) consumes the
format-independent palette through its surface skin descriptor.
-The optional ANARI integration can import source joint attributes and render an explicitly
-provided surface joint palette, but its showcase importer does not automatically create or update
-that palette from glTF skins. Imported skeletal playback through ANARI therefore still requires
-application-provided skin-palette integration.
+The optional ANARI glTF integration also maps retained source skin bindings to the same generic
+joint-palette helper and updates its palettes after each animation frame. It remains an optional
+`@luma.gl/anari/gltf` adapter: the ANARI core does not own a loader, animation mixer, or skinning
+shader.
## Morph targets
diff --git a/docs/api-reference/gltf/gltf-extensions.mdx b/docs/api-reference/gltf/gltf-extensions.mdx
index c158d2b174..10f74ce0e7 100644
--- a/docs/api-reference/gltf/gltf-extensions.mdx
+++ b/docs/api-reference/gltf/gltf-extensions.mdx
@@ -100,6 +100,11 @@ default loader-to-scenegraph path.
Each extension name links to its official Khronos-managed specification page in
the glTF extension registry repository.
+For a complete application walkthrough, including source JSON, public TypeScript APIs, real
+instanced draws, material selection, recursive mesh/light visibility, typed camera and light
+animation, capability diagnostics, and official sample coverage, see
+[native glTF extensions](/docs/api-reference/gltf/gltf-native-extensions).
+
Status meanings:
- `✅`: works end-to-end in the default luma.gl glTF pipeline.
@@ -132,8 +137,10 @@ Status meanings:
Quantized accessors are unpacked during load before geometry creation.
-
- GPU instancing data is not yet converted into luma.gl instanced draw setup.
+
+ Accessor-backed translation, rotation, and scale become one real instanced draw
+ per source primitive on WebGL and WebGPU. Aggregate bounds include every
+ instance, and application-defined _NAME attributes remain available.
Directional, point, and spot lights preserve authored intensity, range,
@@ -170,6 +177,7 @@ Status meanings:
Authored dispersion is parsed into the canonical PBR material. Shared experimental and ANARI
rendering separate transmitted RGB wavelengths using the ratified wavelength-dependent IOR.
+ A supported material animation pointer can update the same physical dispersion uniform.
Volume scattering is not implemented in the stock PBR shader.
@@ -194,9 +202,10 @@ Status meanings:
loaders.gl can preserve the extension data, but @luma.gl/gltf does
not translate it into the default metallic-roughness shader path.
-
- Variant metadata can be loaded, but applications must choose and apply variants
- themselves.
+
+ scenegraphs.variants.selectVariant(name) switches authored primitive
+ materials without replacing scenegraph nodes; resetVariant()
+ restores source defaults and pipeline state.
BasisU / KTX2 textures are passed through as compressed textures when supported
@@ -215,9 +224,14 @@ Status meanings:
and authored TEXCOORD_0 / TEXCOORD_1 selection.
- Node TRS and morph-weight pointers, selected material factor pointers, and
- animated KHR_texture_transform offset/rotation/scale pointers across
- all 17 supported slots are wired to runtime updates. Still unsupported: cameras, extras,
+ Node TRS, morph-weight, and recursive boolean visibility pointers; selected
+ material factor pointers; and animated KHR_texture_transform
+ offset/rotation/scale pointers across all 17 supported slots are wired to
+ runtime updates. Perspective/orthographic camera properties and typed punctual-light
+ color, intensity, range, spotlight angles, and physical chromatic-dispersion factors are also
+ supported. Boolean
+ visibility uses authored STEP interpolation and refreshes punctual
+ lights. Still unsupported: extras,
structural material switches such as alphaMode /
doubleSided / unlit, animated
KHR_texture_transform.texCoord, and texture slots that resolve to
@@ -226,9 +240,10 @@ Status meanings:
the shared experimental renderer and ANARI can refract captured scene color,
while the standalone glTF scenegraph path still uses its alpha approximation.
-
- Node-visibility animations and toggles are not mapped onto runtime scenegraph
- state.
+
+ Source-authored visibility recursively hides mesh descendants and punctual
+ lights. Visibility-pointer animation updates existing scenegraph nodes and
+ preserves the identity of the exported light array.
Metadata payloads remain in the loaded glTF, but luma.gl does not interpret
@@ -252,8 +267,83 @@ Status meanings:
+## Inspect runtime capabilities
+
+Source extension declarations can be inspected before scenegraph creation:
+
+```ts
+import {
+ assertSupportedGLTFExtensions,
+ createScenegraphsFromGLTF,
+ getGLTFExtensionSupport,
+ getUnsupportedRequiredGLTFExtensions
+} from '@luma.gl/gltf';
+
+for (const capability of getGLTFExtensionSupport(gltf).values()) {
+ console.log(capability.extensionName, {
+ required: capability.required,
+ supported: capability.supported,
+ level: capability.supportLevel,
+ explanation: capability.comment
+ });
+}
+
+const unsupportedRequired = getUnsupportedRequiredGLTFExtensions(gltf);
+
+if (unsupportedRequired.length > 0) {
+ assertSupportedGLTFExtensions(gltf);
+}
+
+const scenegraphs = createScenegraphsFromGLTF(device, gltf, {
+ strictExtensions: true
+});
+```
+
+`built-in` and `parsed-and-wired` capabilities satisfy required-extension checks. `loader-only`
+capabilities, including browser-dependent WebP and AVIF image decoding, do not promise complete
+portable runtime support. Unknown or `none` capabilities also fail when required. Optional
+unsupported extensions remain visible in the report without preventing scene creation.
+
+The returned `scenegraphs.extensionSupport` preserves the document-specific capability report.
+Strict checks run before model creation, so an unsupported required feature does not leave a
+partially constructed GPU scene behind.
+
+## Use authored runtime extensions
+
+```ts
+import {getGLTFNodeInstancing} from '@luma.gl/gltf';
+
+scenegraphs.variants.selectVariant('Midnight');
+scenegraphs.variants.resetVariant();
+
+const instancing = getGLTFNodeInstancing(gltf, gltf.nodes[0]);
+console.log(instancing?.matrices.length);
+
+scenegraphs.animator.selectClip('Night lighting', {
+ crossFadeDuration: 0.3
+});
+scenegraphs.animator.setTime(1000);
+
+console.log(scenegraphs.cameras[0]);
+console.log(scenegraphs.lights);
+```
+
+Variant selection preserves existing scenegraph node/model identities and restores unmapped
+primitives to their authored default materials. Instancing submits one GPU draw per source
+primitive on both WebGL and WebGPU. Visibility and punctual-light pointers update stable retained
+scene objects through the existing shared animation mixer. Automatic source skin palettes and morph
+targets are evaluated in the same frame.
+
## Notes
+- `getGLTFExtensionSupport(gltf)` distinguishes optional and required extensions.
+ Use `getUnsupportedRequiredGLTFExtensions(gltf)`,
+ `assertSupportedGLTFExtensions(gltf)`, or
+ `createScenegraphsFromGLTF(device, gltf, {strictExtensions: true})` to reject
+ unsupported required features instead of silently degrading them.
+- `getGLTFNodeInstancing(gltf, node)` exposes resolved source matrices and all
+ authored instance-accessor semantics, including application-defined `_NAME`
+ attributes.
- The built-in material extension rows reuse the shared `pbrMaterial` shader.
The experimental `SceneRenderer` and ANARI capture opaque scene color for
screen-space transmission/refraction; the standalone `createScenegraphsFromGLTF()`
@@ -266,5 +356,7 @@ Status meanings:
- `@luma.gl/gltf` relies on `@loaders.gl/gltf` for low-level extension decoding,
decompression, and glTF post-processing.
- Core glTF skin attributes and animated morph targets are supported independently
- of these extension rows. See [glTF animation and deformation](/docs/api-reference/gltf/gltf-animation)
- and [glTF materials and textures](/docs/api-reference/gltf/gltf-materials).
+ of these extension rows; source skin palettes are updated automatically once per animation
+ frame. See [glTF animation and deformation](/docs/api-reference/gltf/gltf-animation),
+ [native glTF extensions](/docs/api-reference/gltf/gltf-native-extensions), and
+ [glTF materials and textures](/docs/api-reference/gltf/gltf-materials).
diff --git a/docs/api-reference/gltf/gltf-native-extensions.md b/docs/api-reference/gltf/gltf-native-extensions.md
new file mode 100644
index 0000000000..9dd6d8dc2e
--- /dev/null
+++ b/docs/api-reference/gltf/gltf-native-extensions.md
@@ -0,0 +1,452 @@
+import {GltfDocsTabs} from '@site/src/components/docs/gltf-docs-tabs';
+
+# Native glTF Extensions
+
+
+
+glTF extensions should describe what an asset actually does, not merely which JSON properties
+survived loading. `@luma.gl/gltf` connects authored material variants, GPU instance transforms,
+recursive node visibility, typed animation pointers, and required-extension capabilities to the
+existing luma.gl scenegraph and animation runtime.
+
+The implementation remains format-owned: `@loaders.gl/gltf` reads and postprocesses the asset,
+`@luma.gl/gltf` resolves glTF extension semantics, and `@luma.gl/engine` owns generic scenegraph,
+model, and animation behavior. There is no parallel loader, material system, or renderer.
+
+## Load a standards-native scene
+
+```ts
+import {load} from '@loaders.gl/core';
+import {GLTFLoader, postProcessGLTF} from '@loaders.gl/gltf';
+import {createScenegraphsFromGLTF} from '@luma.gl/gltf';
+
+const asset = await load('/models/product.glb', GLTFLoader);
+const gltf = postProcessGLTF(asset);
+
+const scenegraphs = createScenegraphsFromGLTF(device, gltf, {
+ strictExtensions: true,
+ useByteColors: false
+});
+
+console.log(scenegraphs.variants.names);
+console.log(scenegraphs.extensionSupport);
+console.log(scenegraphs.animations.map(animation => animation.name));
+```
+
+`postProcessGLTF()` is explicit: loaders.gl v4 does not support the historical
+`{gltf: {postProcess: true}}` loader option. Use `useByteColors: false` when consuming punctual
+light colors as glTF-authored linear RGB values. Strict extension handling is described below.
+
+| Extension | Runtime behavior | Scenegraph access |
+| --- | --- | --- |
+| `KHR_materials_variants` | Selects authored materials without replacing scene nodes. | `scenegraphs.variants` |
+| `EXT_mesh_gpu_instancing` | Draws authored mesh instances in a single instanced draw per primitive. | `getGLTFNodeInstancing(gltf, node)` |
+| `KHR_node_visibility` | Recursively hides descendants and attached punctual lights. | `scenegraphs.gltfNodeIndexToNodeMap` |
+| `KHR_animation_pointer` | Drives node, material, texture, camera, and punctual-light properties. | `scenegraphs.animations` and `scenegraphs.animator` |
+| `KHR_materials_dispersion` | Preserves and animates physically based chromatic dispersion. | Canonical material uniforms and material-pointer channels. |
+
+## Material variants
+
+`KHR_materials_variants` stores application-visible variant names at the document root and maps
+individual mesh primitives to alternate source materials:
+
+```json
+{
+ "extensions": {
+ "KHR_materials_variants": {
+ "variants": [{"name": "Midnight"}, {"name": "Sunrise"}]
+ }
+ },
+ "meshes": [
+ {
+ "primitives": [
+ {
+ "material": 0,
+ "extensions": {
+ "KHR_materials_variants": {
+ "mappings": [
+ {"material": 1, "variants": [0]},
+ {"material": 2, "variants": [1]}
+ ]
+ }
+ }
+ }
+ ]
+ }
+ ]
+}
+```
+
+The scenegraph exposes one format-aware controller:
+
+```ts
+const {variants} = scenegraphs;
+
+console.log(variants.names); // ['Midnight', 'Sunrise']
+console.log(variants.activeVariant); // null
+
+variants.selectVariant('Midnight');
+console.log(variants.activeVariant); // 'Midnight'
+
+variants.selectVariant('Sunrise');
+variants.resetVariant();
+
+console.log(variants.activeVariant); // null
+```
+
+Selection preserves existing `GroupNode`, `ModelNode`, and `Model` identities. Each mapped
+primitive receives its authored material and source-derived pipeline parameters; primitives without
+a mapping for the selected variant return to their original material. Unknown names are rejected
+before any primitive changes.
+
+The parsed definitions are also available in authored order:
+
+```ts
+for (const variant of scenegraphs.variants.variants) {
+ console.log(variant.index, variant.name);
+}
+```
+
+**Material-layout constraint:** selection updates existing material and pipeline state; it does not
+rebuild an existing model's shader feature layout. Alternate materials should remain compatible with
+the primitive's original shader specialization, particularly when introducing a previously absent
+texture or alpha-cutoff define.
+
+## Mesh GPU instancing
+
+`EXT_mesh_gpu_instancing` associates accessor-backed transforms with a source mesh node. Each
+source primitive becomes one real instanced model on WebGL and WebGPU:
+
+```json
+{
+ "nodes": [
+ {
+ "mesh": 0,
+ "extensions": {
+ "EXT_mesh_gpu_instancing": {
+ "attributes": {
+ "TRANSLATION": 3,
+ "ROTATION": 4,
+ "SCALE": 5,
+ "_FEATURE_ID": 6
+ }
+ }
+ }
+ }
+ ]
+}
+```
+
+Inspect resolved instance transforms and exact authored accessor metadata:
+
+```ts
+import {getGLTFNodeInstancing} from '@luma.gl/gltf';
+
+const instancing = getGLTFNodeInstancing(gltf, gltf.nodes[0]);
+
+if (instancing) {
+ console.log(instancing.matrices.length);
+ console.log(instancing.matrices[0]);
+
+ for (const [semantic, attribute] of Object.entries(instancing.attributes)) {
+ console.log(semantic, {
+ values: attribute.value,
+ components: attribute.size,
+ count: attribute.count,
+ normalized: attribute.normalized
+ });
+ }
+}
+```
+
+Omitted translation, rotation, and scale components receive their glTF identity defaults. Signed
+and unsigned normalized integer accessors are decoded correctly, and authored quaternions are
+normalized before matrix composition. Mismatched accessor counts fail instead of producing partial
+or incorrectly indexed draws.
+
+Generated model nodes expose the same underlying instance data:
+
+```ts
+import {ModelNode} from '@luma.gl/engine';
+
+scenegraphs.scenes[0].traverse(node => {
+ if (node instanceof ModelNode && node.model.isInstanced) {
+ console.log(node.model.instanceCount);
+ console.log(node.instanceMatrices);
+ }
+});
+```
+
+Each instance matrix is uploaded through four per-instance vector attributes. Bounds include every
+instance, so `scenegraphs.modelBounds` and `scenegraphs.sceneBounds` remain useful for initial
+camera framing. Custom `_NAME` accessors are preserved in `instancing.attributes`; applications
+must explicitly bind custom semantics if their own shaders consume them.
+
+Instancing reduces repeated mesh draws, not the number of distinct source primitives: a mesh with
+three primitives still produces three instanced draws.
+
+## Recursive node visibility
+
+`KHR_node_visibility` contributes its authored boolean directly to the existing generic
+`GroupNode.display` state:
+
+```json
+{
+ "nodes": [
+ {
+ "children": [1, 2],
+ "extensions": {
+ "KHR_node_visibility": {"visible": false}
+ }
+ },
+ {"mesh": 0},
+ {"extensions": {"KHR_lights_punctual": {"light": 0}}}
+ ]
+}
+```
+
+The entire descendant subtree is hidden during rendering. Punctual lights attached anywhere inside
+that subtree are omitted from `scenegraphs.lights`:
+
+```ts
+const node = scenegraphs.gltfNodeIndexToNodeMap.get(0);
+
+console.log(node?.display); // false
+console.log(scenegraphs.lights.length); // excludes hidden descendants
+```
+
+`GroupNode.traverse()` skips hidden nodes and descendants. `preorderTraversal()` deliberately
+retains structural traversal, allowing applications and internal controllers to inspect or update
+hidden nodes. `GroupNode.getBounds()` follows visible traversal; the initial
+`scenegraphs.sceneBounds` and `scenegraphs.modelBounds` are load-time snapshots.
+
+### Animate visibility
+
+The ratified pointer targets the extension's boolean field:
+
+```json
+{
+ "samplers": [{"input": 0, "output": 1, "interpolation": "STEP"}],
+ "channels": [
+ {
+ "sampler": 0,
+ "target": {
+ "path": "pointer",
+ "extensions": {
+ "KHR_animation_pointer": {
+ "pointer": "/nodes/3/extensions/KHR_node_visibility/visible"
+ }
+ }
+ }
+ }
+ ]
+}
+```
+
+Boolean visibility requires `STEP` interpolation. When a visibility channel evaluates,
+`GLTFAnimator` updates the existing node and refreshes punctual lights in place: the
+`scenegraphs.lights` array keeps its identity while its contents reflect the newly visible scene.
+
+```ts
+const originalLights = scenegraphs.lights;
+
+scenegraphs.animator.setTime(1000);
+
+console.log(scenegraphs.gltfNodeIndexToNodeMap.get(3)?.display);
+console.log(scenegraphs.lights === originalLights); // true
+```
+
+Changing `node.display` directly affects generic scenegraph traversal, but does not automatically
+refresh a previously parsed punctual-light array. The glTF animation controller handles that
+refresh for source-authored visibility channels.
+
+## Typed animation pointers
+
+`KHR_animation_pointer` targets precise JSON properties while reusing the existing shared
+`AnimationSampler`, `AnimationTrack`, `AnimationClip`, and `AnimationMixer` implementations.
+
+| Target family | Example pointer | Result |
+| --- | --- | --- |
+| Node transform | `/nodes/2/translation` | Updates the existing node transform. |
+| Morph weights | `/nodes/2/weights` | Updates existing morph vertex buffers. |
+| Recursive visibility | `/nodes/2/extensions/KHR_node_visibility/visible` | Updates `display` and refreshes punctual lights. |
+| Perspective camera | `/cameras/0/perspective/yfov` | Updates a runtime projection copy. |
+| Orthographic camera | `/cameras/1/orthographic/xmag` | Updates a runtime projection copy. |
+| Light intensity | `/extensions/KHR_lights_punctual/lights/0/intensity` | Refreshes the corresponding runtime light. |
+| Light RGB channel | `/extensions/KHR_lights_punctual/lights/0/color/2` | Updates one authored linear color component. |
+| Spotlight cone | `/extensions/KHR_lights_punctual/lights/0/spot/innerConeAngle` | Preserves distinct inner and outer cones. |
+| Chromatic dispersion | `/materials/0/extensions/KHR_materials_dispersion/dispersion` | Updates the canonical physical-material uniform. |
+| Texture transform | `/materials/0/pbrMetallicRoughness/baseColorTexture/extensions/KHR_texture_transform/offset/0` | Updates the existing material's UV transform. |
+
+Supported perspective properties are `aspectRatio`, `yfov`, `znear`, and `zfar`; orthographic
+properties are `xmag`, `ymag`, `znear`, and `zfar`. Punctual lights support `color`, individual
+RGB components, `intensity`, `range`, `innerConeAngle`, and `outerConeAngle`.
+
+Camera projections are cloned into `scenegraphs.cameras` before animation. Light definitions are
+also copied, and the exported light array is refreshed in place. Animation therefore never mutates
+the original postprocessed glTF camera or light document.
+
+### Inspect discriminated channel types
+
+```ts
+import {
+ parseGLTFAnimations,
+ type GLTFCameraAnimationChannel,
+ type GLTFLightAnimationChannel
+} from '@luma.gl/gltf';
+
+for (const animation of parseGLTFAnimations(gltf)) {
+ for (const channel of animation.channels) {
+ if (channel.type === 'camera') {
+ const cameraChannel: GLTFCameraAnimationChannel = channel;
+ console.log(cameraChannel.targetCameraIndex, cameraChannel.projection, cameraChannel.property);
+ }
+
+ if (channel.type === 'light') {
+ const lightChannel: GLTFLightAnimationChannel = channel;
+ console.log(lightChannel.targetLightIndex, lightChannel.property, lightChannel.component);
+ }
+
+ if (channel.type === 'material' && channel.property === 'dispersion') {
+ console.log(channel.targetMaterialIndex, channel.pointer);
+ }
+ }
+}
+```
+
+Physical dispersion is meaningful when the source material enables its authored
+`KHR_materials_dispersion` extension. The shared experimental `SceneRenderer` and ANARI facade
+combine the animated canonical uniform with captured opaque-scene refraction. The standalone glTF
+model path preserves and animates the same factor but does not capture scene color.
+
+See [glTF animation and deformation](/docs/api-reference/gltf/gltf-animation) for clip selection,
+crossfading, automatic skin palettes, and morph-target playback.
+
+## Strict extension capability checks
+
+The glTF distinction between `extensionsUsed` and `extensionsRequired` matters: optional
+unsupported features may degrade gracefully, while required unsupported features should reject the
+asset before GPU models are created.
+
+```ts
+import {
+ assertSupportedGLTFExtensions,
+ getGLTFExtensionSupport,
+ getUnsupportedRequiredGLTFExtensions
+} from '@luma.gl/gltf';
+
+const support = getGLTFExtensionSupport(gltf);
+
+for (const extension of support.values()) {
+ console.log({
+ name: extension.extensionName,
+ required: extension.required,
+ supported: extension.supported,
+ level: extension.supportLevel,
+ explanation: extension.comment
+ });
+}
+
+const unsupportedRequired = getUnsupportedRequiredGLTFExtensions(gltf);
+
+if (unsupportedRequired.length > 0) {
+ assertSupportedGLTFExtensions(gltf);
+}
+```
+
+`createScenegraphsFromGLTF(device, gltf, {strictExtensions: true})` performs the same assertion
+**before** parsing GPU resources. The returned `scenegraphs.extensionSupport` is the same
+document-specific capability model.
+
+| Support level | Meaning | Accepted when required? |
+| --- | --- | --- |
+| `built-in` | A complete decoder or runtime path handles the feature. | Yes |
+| `parsed-and-wired` | Parsed source data is connected to the existing runtime. | Yes |
+| `loader-only` | Loader data survives, but device/application support is not guaranteed. | No |
+| `none` | No complete built-in runtime behavior is available. | No |
+
+For example, required `KHR_node_visibility`, `EXT_mesh_gpu_instancing`, material variants, and
+physically implemented `KHR_materials_dispersion` pass strict checks. A required unknown vendor
+extension fails. Required WebP or AVIF texture extensions remain conservative because image decode
+support depends on the browser/device combination.
+
+Capability collection includes declared used/required extensions, source root extension entries,
+extensions moved during loaders.gl postprocessing, and detected punctual lights.
+
+## Automatic animation and deformation integration
+
+Native pointers participate in the same animation frame as transforms, skinning, and morphing:
+
+```ts
+scenegraphs.animator.selectClip('Walk');
+scenegraphs.animator.selectClip('Run', {crossFadeDuration: 0.35});
+
+function renderFrame(timestampMilliseconds: number): void {
+ scenegraphs.animator.setTime(timestampMilliseconds);
+
+ for (const binding of scenegraphs.skins.bindings) {
+ console.log(binding.nodeIndex, binding.joints.length, binding.jointMatrices);
+ }
+
+ requestAnimationFrame(renderFrame);
+}
+```
+
+`setTime()` accepts absolute milliseconds; `selectClip()` crossfade duration and
+`animator.update(deltaSeconds)` use seconds. Imported skin palettes are updated automatically once
+after all channels evaluate, using existing mesh-local joint matrices and the canonical shared skin
+shader. `scenegraphs.skins.getBinding(nodeIndex)` exposes a specific reusable skin binding.
+
+## Official fixture coverage
+
+The runtime is tested against compact, unmodified CC0 assets from Khronos glTF Sample Assets,
+pinned to source commit `2bac6f8c57bf471df0d2a1e8a8ec023c7801dddf`:
+
+| Official fixture | Size | What is checked |
+| --- | --- | --- |
+| `SimpleInstancing.glb` | 7.2 KB | Real WebGL/WebGPU instanced draws, source TRS accessors, instance counts, matrices, and aggregate bounds. |
+| `CubeVisibility.glb` | 3.2 KB | Recursive hidden meshes, boolean `STEP` animation, strict capability checks, and derived variant/dispersion mutation cases. |
+| `LightVisibility.glb` | 2.9 KB | Recursive hidden punctual lights, stable light arrays, animated camera projections, and authored light properties. |
+
+CPU tests inspect parsed source behavior and scenegraph identity using `NullDevice`. Browser tests
+execute real WebGL and WebGPU draw calls against the same official instancing fixture. Mutation
+tests derive material-variant and camera/light/dispersion-pointer cases from the small official
+documents instead of adding large synthetic assets.
+
+The fixture attribution and pinned source revision are recorded in
+`modules/gltf/test/data/README.md`.
+
+## Architecture and ownership
+
+| Package | Responsibility |
+| --- | --- |
+| `@loaders.gl/gltf` | Container decoding, accessor/image decoding, and explicit glTF postprocessing. |
+| `@luma.gl/gltf` | Extension interpretation, source-material mappings, typed pointers, skin ownership, and capability reporting. |
+| `@luma.gl/engine` | Generic `GroupNode` visibility, reusable `Model` instancing, animation mixing, and deformation utilities. |
+| `@luma.gl/shadertools` | Canonical PBR material uniforms, physical shading, and reusable skinning. |
+| `@luma.gl/experimental` | Optional format-independent physical scene rendering and captured-scene transmission. |
+| `@luma.gl/anari` | Optional thin retained-object orchestration through `@luma.gl/anari/gltf`; no loader or BRDF ownership. |
+
+The core ANARI package does not import glTF. Its optional glTF adapter imports the existing glTF
+parsers and engine animation primitives. Camera, punctual-light, and visibility pointer playback
+currently belongs to the canonical glTF scenegraph; the thin ANARI animation adapter safely ignores
+unsupported target families instead of creating an independent extension runtime.
+
+## Boundaries and current limitations
+
+- Visibility booleans require `STEP`; interpolating a boolean channel is invalid.
+- Texture transforms support `TEXCOORD_0` and `TEXCOORD_1`, not animated `texCoord` or
+ `TEXCOORD_2+`.
+- Structural material switches such as `alphaMode`, `doubleSided`, and `unlit` are not animation
+ pointer targets.
+- Material variants do not construct a new shader feature layout when the alternate material adds
+ previously absent texture bindings.
+- Custom `_NAME` instance accessors remain available without being automatically bound to a custom
+ shader.
+- Standalone glTF scenegraphs do not own an opaque-scene capture pass; true transmitted scene-color
+ refraction is provided by the shared experimental renderer and ANARI facade.
+- Generic `extras`, unsupported vendor metadata, automatic image-based-light extension ingestion,
+ and video-texture extensions are not silently presented as supported required features.
+
+For the complete extension-by-extension capability matrix, see
+[glTF extension support](/docs/api-reference/gltf/gltf-extensions).
diff --git a/docs/table-of-contents.json b/docs/table-of-contents.json
index fec25a6082..65900a78a2 100644
--- a/docs/table-of-contents.json
+++ b/docs/table-of-contents.json
@@ -324,6 +324,7 @@
"items": [
"api-reference/gltf/README",
"api-reference/gltf/gltf-materials",
+ "api-reference/gltf/gltf-native-extensions",
"api-reference/gltf/gltf-animation",
"api-reference/gltf/gltf-extensions"
]
diff --git a/modules/anari/src/gltf.ts b/modules/anari/src/gltf.ts
index 9e4a445118..a42bc40915 100644
--- a/modules/anari/src/gltf.ts
+++ b/modules/anari/src/gltf.ts
@@ -611,7 +611,7 @@ function makeAnimationTracks(
path,
...(channel.component !== undefined && !isPackedScalar ? {component: channel.component} : {})
};
- } else {
+ } else if (channel.type === 'textureTransform') {
const identifier =
mappings.samplerIdentifiers?.[`${channel.targetMaterialIndex}:${channel.textureSlot}`];
if (!identifier) {
@@ -628,6 +628,9 @@ function makeAnimationTracks(
rotation: channel.baseTransform.rotation,
scale: [...channel.baseTransform.scale]
};
+ } else {
+ // Camera and punctual-light pointers remain format-owned until explicitly adapted.
+ return [];
}
const interpolation = channel.sampler.interpolation as ANARIAnimationInterpolation;
diff --git a/modules/engine/src/scenegraph/group-node.ts b/modules/engine/src/scenegraph/group-node.ts
index 4cbc87c038..5e5b33f31b 100644
--- a/modules/engine/src/scenegraph/group-node.ts
+++ b/modules/engine/src/scenegraph/group-node.ts
@@ -98,9 +98,16 @@ export class GroupNode extends ScenegraphNode {
visitor: (node: ScenegraphNode, context: {worldMatrix: Matrix4}) => void,
{worldMatrix = new Matrix4()} = {}
) {
+ if (!this.display) {
+ return;
+ }
+
const modelMatrix = new Matrix4(worldMatrix).multiplyRight(this.matrix);
for (const child of this.children) {
+ if (!child.display) {
+ continue;
+ }
if (child instanceof GroupNode) {
child.traverse(visitor, {worldMatrix: modelMatrix});
} else {
diff --git a/modules/engine/src/scenegraph/scenegraph-node.ts b/modules/engine/src/scenegraph/scenegraph-node.ts
index 544f3fd425..eeecfbb95f 100644
--- a/modules/engine/src/scenegraph/scenegraph-node.ts
+++ b/modules/engine/src/scenegraph/scenegraph-node.ts
@@ -193,9 +193,9 @@ export class ScenegraphNode {
*/
_setScenegraphNodeProps(props: ScenegraphNodeProps): void {
- // if ('display' in props) {
- // this.display = props.display;
- // }
+ if (props.display !== undefined) {
+ this.display = props.display;
+ }
if (props?.position) {
this.setPosition(props.position);
diff --git a/modules/engine/test/scenegraph/scenegraph-visibility.node.spec.ts b/modules/engine/test/scenegraph/scenegraph-visibility.node.spec.ts
new file mode 100644
index 0000000000..ec0ca9473c
--- /dev/null
+++ b/modules/engine/test/scenegraph/scenegraph-visibility.node.spec.ts
@@ -0,0 +1,60 @@
+// luma.gl
+// SPDX-License-Identifier: MIT
+// Copyright (c) vis.gl contributors
+
+import {GroupNode, ScenegraphNode} from '@luma.gl/engine';
+import {describe, expect, test} from 'vitest';
+
+describe('scenegraph hierarchical visibility', () => {
+ test('honors display at construction and through runtime property updates', () => {
+ const node = new ScenegraphNode({display: false});
+ expect(node.display).toBe(false);
+
+ node.setProps({display: true});
+ expect(node.display).toBe(true);
+ });
+
+ test('skips hidden branches during rendering while preserving structural preorder traversal', () => {
+ const visibleLeaf = new ScenegraphNode({id: 'visible'});
+ const hiddenLeaf = new ScenegraphNode({id: 'hidden-leaf', display: false});
+ const hiddenDescendant = new ScenegraphNode({id: 'hidden-descendant'});
+ const hiddenParent = new GroupNode({
+ id: 'hidden-parent',
+ display: false,
+ children: [hiddenDescendant]
+ });
+ const scene = new GroupNode({
+ id: 'scene',
+ children: [visibleLeaf, hiddenLeaf, hiddenParent]
+ });
+
+ const renderedNodes: string[] = [];
+ scene.traverse(node => renderedNodes.push(node.id));
+ expect(renderedNodes).toEqual(['visible']);
+
+ const structuralNodes: string[] = [];
+ scene.preorderTraversal(node => structuralNodes.push(node.id));
+ expect(structuralNodes).toEqual([
+ 'scene',
+ 'visible',
+ 'hidden-leaf',
+ 'hidden-parent',
+ 'hidden-descendant'
+ ]);
+
+ hiddenParent.setProps({display: true});
+ renderedNodes.length = 0;
+ scene.traverse(node => renderedNodes.push(node.id));
+ expect(renderedNodes).toEqual(['visible', 'hidden-descendant']);
+ });
+
+ test('hides an entire scene when its root is not displayed', () => {
+ const scene = new GroupNode({
+ display: false,
+ children: [new ScenegraphNode({id: 'child'})]
+ });
+ const nodes: string[] = [];
+ scene.traverse(node => nodes.push(node.id));
+ expect(nodes).toEqual([]);
+ });
+});
diff --git a/modules/experimental/src/lugraph/lu-graph-degree-internals.ts b/modules/experimental/src/lugraph/lu-graph-degree-internals.ts
index 7d47af5436..6f5a0ae980 100644
--- a/modules/experimental/src/lugraph/lu-graph-degree-internals.ts
+++ b/modules/experimental/src/lugraph/lu-graph-degree-internals.ts
@@ -1,6 +1,7 @@
// luma.gl
// SPDX-License-Identifier: MIT
-// Copyright (c) vis.gl contributors
+// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors
+// SPDX-FileComment: Independently implemented for WebGPU; inspired by NVIDIA RAPIDS cuGraph.
import {type Binding} from '@luma.gl/core';
import {Computation} from '@luma.gl/engine';
diff --git a/modules/experimental/src/lugraph/lu-graph-degree.ts b/modules/experimental/src/lugraph/lu-graph-degree.ts
index c6ba3c4e0d..a6b7984e71 100644
--- a/modules/experimental/src/lugraph/lu-graph-degree.ts
+++ b/modules/experimental/src/lugraph/lu-graph-degree.ts
@@ -1,6 +1,7 @@
// luma.gl
// SPDX-License-Identifier: MIT
-// Copyright (c) vis.gl contributors
+// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors
+// SPDX-FileComment: Independently implemented for WebGPU; inspired by NVIDIA RAPIDS cuGraph.
import type {Buffer} from '@luma.gl/core';
import {DynamicBuffer} from '@luma.gl/engine';
diff --git a/modules/gltf/src/gltf/animations/animations.ts b/modules/gltf/src/gltf/animations/animations.ts
index 54d5ed6cd6..5597a2c4d5 100644
--- a/modules/gltf/src/gltf/animations/animations.ts
+++ b/modules/gltf/src/gltf/animations/animations.ts
@@ -13,7 +13,7 @@ export type GLTFAnimation = {
};
/** Supported glTF animation target paths. */
-export type GLTFAnimationPath = 'translation' | 'rotation' | 'scale' | 'weights';
+export type GLTFAnimationPath = 'translation' | 'rotation' | 'scale' | 'weights' | 'visibility';
/** Parsed glTF animation channel that targets a scenegraph node. */
export type GLTFNodeAnimationChannel = {
@@ -37,6 +37,7 @@ export type GLTFMaterialAnimationProperty =
| 'baseColorFactor'
| 'clearcoatFactor'
| 'clearcoatRoughnessFactor'
+ | 'dispersion'
| 'emissiveFactor'
| 'emissiveStrength'
| 'ior'
@@ -89,11 +90,51 @@ export type GLTFTextureTransformAnimationChannel = {
baseTransform: import('../../pbr/texture-transform').PBRTextureTransform;
};
+/** Camera projection property targeted by `KHR_animation_pointer`. */
+export type GLTFCameraAnimationProperty =
+ | 'aspectRatio'
+ | 'yfov'
+ | 'znear'
+ | 'zfar'
+ | 'xmag'
+ | 'ymag';
+
+/** Parsed glTF animation channel that updates an authored camera projection. */
+export type GLTFCameraAnimationChannel = {
+ type: 'camera';
+ sampler: GLTFAnimationSampler;
+ pointer: string;
+ targetCameraIndex: number;
+ projection: 'perspective' | 'orthographic';
+ property: GLTFCameraAnimationProperty;
+};
+
+/** Punctual-light property targeted by `KHR_animation_pointer`. */
+export type GLTFLightAnimationProperty =
+ | 'color'
+ | 'intensity'
+ | 'range'
+ | 'innerConeAngle'
+ | 'outerConeAngle';
+
+/** Parsed glTF animation channel that updates an authored punctual-light definition. */
+export type GLTFLightAnimationChannel = {
+ type: 'light';
+ sampler: GLTFAnimationSampler;
+ pointer: string;
+ targetLightIndex: number;
+ property: GLTFLightAnimationProperty;
+ /** Individual RGB component index, when the pointer addresses one color element. */
+ component?: number;
+};
+
/** Parsed glTF animation channel. */
export type GLTFAnimationChannel =
| GLTFNodeAnimationChannel
| GLTFMaterialAnimationChannel
- | GLTFTextureTransformAnimationChannel;
+ | GLTFTextureTransformAnimationChannel
+ | GLTFCameraAnimationChannel
+ | GLTFLightAnimationChannel;
/** Parsed glTF animation sampler. */
export type GLTFAnimationSampler = AnimationSampler & {
diff --git a/modules/gltf/src/gltf/create-gltf-model.ts b/modules/gltf/src/gltf/create-gltf-model.ts
index fc384016ce..ca8150f1ee 100644
--- a/modules/gltf/src/gltf/create-gltf-model.ts
+++ b/modules/gltf/src/gltf/create-gltf-model.ts
@@ -9,6 +9,7 @@ import {
Texture,
TextureView,
type Binding,
+ type BufferLayout,
type RenderPipelineParameters,
log
} from '@luma.gl/core';
@@ -20,8 +21,10 @@ import {
MaterialFactory,
Model,
ModelNode,
- type ModelProps
+ type ModelProps,
+ type ScenegraphBounds
} from '@luma.gl/engine';
+import type {NumericArray} from '@math.gl/core';
import {type ParsedPBRMaterial} from '../pbr/pbr-material';
const SHADER = /* WGSL */ `
@@ -43,6 +46,12 @@ struct VertexInputs {
@location(5) JOINTS_0: vec4u,
@location(6) WEIGHTS_0: vec4f,
#endif
+#ifdef HAS_GLTF_INSTANCING
+ @location(8) instanceModelMatrixCol0: vec4f,
+ @location(9) instanceModelMatrixCol1: vec4f,
+ @location(10) instanceModelMatrixCol2: vec4f,
+ @location(11) instanceModelMatrixCol3: vec4f,
+#endif
};
struct FragmentInputs {
@@ -56,6 +65,18 @@ struct FragmentInputs {
#endif
};
+#ifdef HAS_GLTF_INSTANCING
+fn getGLTFInstanceNormalMatrix(matrix: mat3x3f) -> mat3x3f {
+ let firstCofactor = cross(matrix[1], matrix[2]);
+ let inverseDeterminant = 1.0 / dot(matrix[0], firstCofactor);
+ return mat3x3f(
+ firstCofactor,
+ cross(matrix[2], matrix[0]),
+ cross(matrix[0], matrix[1])
+ ) * inverseDeterminant;
+}
+#endif
+
@vertex
fn vertexMain(inputs: VertexInputs) -> FragmentInputs {
var outputs: FragmentInputs;
@@ -84,6 +105,24 @@ fn vertexMain(inputs: VertexInputs) -> FragmentInputs {
#ifdef HAS_TANGENTS
tangent = vec4f(normalize((skinMatrix * vec4f(tangent.xyz, 0.0)).xyz), tangent.w);
#endif
+#endif
+
+#ifdef HAS_GLTF_INSTANCING
+ let instanceMatrix = mat4x4f(
+ inputs.instanceModelMatrixCol0,
+ inputs.instanceModelMatrixCol1,
+ inputs.instanceModelMatrixCol2,
+ inputs.instanceModelMatrixCol3
+ );
+ position = instanceMatrix * position;
+ normal = normalize(getGLTFInstanceNormalMatrix(mat3x3f(
+ instanceMatrix[0].xyz,
+ instanceMatrix[1].xyz,
+ instanceMatrix[2].xyz
+ )) * normal);
+#ifdef HAS_TANGENTS
+ tangent = vec4f(normalize((instanceMatrix * vec4f(tangent.xyz, 0.0)).xyz), tangent.w);
+#endif
#endif
let worldPosition = pbrProjection.modelMatrix * position;
@@ -150,6 +189,13 @@ const vs = /* glsl */ `\
in vec4 WEIGHTS_0;
#endif
+ #ifdef HAS_GLTF_INSTANCING
+ in vec4 instanceModelMatrixCol0;
+ in vec4 instanceModelMatrixCol1;
+ in vec4 instanceModelMatrixCol2;
+ in vec4 instanceModelMatrixCol3;
+ #endif
+
void main(void) {
vec4 _NORMAL = vec4(0.);
vec4 _TANGENT = vec4(0.);
@@ -181,6 +227,18 @@ const vs = /* glsl */ `\
_TANGENT = vec4((skinMat * vec4(_TANGENT.xyz, 0.)).xyz, _TANGENT.w);
#endif
+ #ifdef HAS_GLTF_INSTANCING
+ mat4 instanceMatrix = mat4(
+ instanceModelMatrixCol0,
+ instanceModelMatrixCol1,
+ instanceModelMatrixCol2,
+ instanceModelMatrixCol3
+ );
+ pos = instanceMatrix * pos;
+ _NORMAL = vec4(normalize(transpose(inverse(mat3(instanceMatrix))) * _NORMAL.xyz), 0.0);
+ _TANGENT = vec4(normalize(mat3(instanceMatrix) * _TANGENT.xyz), _TANGENT.w);
+ #endif
+
pbr_setPositionNormalTangentUV(pos, _NORMAL, _TANGENT, _TEXCOORD_0, _TEXCOORD_1);
gl_Position = pbrProjection.modelViewProjectionMatrix * pos;
}
@@ -210,6 +268,10 @@ export type CreateGLTFModelOptions = {
material?: Material | null;
/** Additional model props merged into the generated model. */
modelOptions?: Partial;
+ /** Source primitive bounds before node and optional instance transforms. */
+ bounds?: ScenegraphBounds;
+ /** Source-authored local transforms for `EXT_mesh_gpu_instancing`. */
+ instanceMatrices?: readonly NumericArray[];
};
export type CreateGLTFMaterialOptions = {
@@ -244,7 +306,14 @@ export function createGLTFMaterial(device: Device, options: CreateGLTFMaterialOp
/** Creates a luma.gl Model from GLTF data*/
export function createGLTFModel(device: Device, options: CreateGLTFModelOptions): ModelNode {
- const {id, geometry, parsedPPBRMaterial, vertexCount, modelOptions = {}} = options;
+ const {
+ id,
+ geometry,
+ parsedPPBRMaterial,
+ vertexCount,
+ modelOptions = {},
+ instanceMatrices
+ } = options;
log.info(4, 'createGLTFModel defines: ', parsedPPBRMaterial.defines)();
@@ -262,6 +331,28 @@ export function createGLTFModel(device: Device, options: CreateGLTFModelOptions)
cullMode: 'back'
};
+ const instanceAttributes: Record = {};
+ const instanceBufferLayout: BufferLayout[] = [];
+ if (instanceMatrices) {
+ for (let columnIndex = 0; columnIndex < 4; columnIndex++) {
+ const values = new Float32Array(instanceMatrices.length * 4);
+ instanceMatrices.forEach((matrix, instanceIndex) => {
+ for (let rowIndex = 0; rowIndex < 4; rowIndex++) {
+ values[instanceIndex * 4 + rowIndex] = matrix[columnIndex * 4 + rowIndex];
+ }
+ });
+ const attributeName = `instanceModelMatrixCol${columnIndex}`;
+ const buffer = device.createBuffer({
+ id: `${id || 'gltf'}-${attributeName}`,
+ data: values,
+ usage: Buffer.VERTEX | Buffer.COPY_DST
+ });
+ instanceAttributes[attributeName] = buffer;
+ instanceBufferLayout.push({name: attributeName, format: 'float32x4', stepMode: 'instance'});
+ managedResources.push(buffer);
+ }
+ }
+
const modelProps: ModelProps = {
id,
source: SHADER,
@@ -273,7 +364,19 @@ export function createGLTFModel(device: Device, options: CreateGLTFModelOptions)
modules: [pbrMaterial, skin],
...modelOptions,
- defines: {...parsedPPBRMaterial.defines, ...modelOptions.defines},
+ ...(instanceMatrices
+ ? {
+ attributes: {...modelOptions.attributes, ...instanceAttributes},
+ bufferLayout: [...(modelOptions.bufferLayout || []), ...instanceBufferLayout],
+ instanceCount: instanceMatrices.length,
+ isInstanced: true
+ }
+ : {}),
+ defines: {
+ ...parsedPPBRMaterial.defines,
+ ...(instanceMatrices ? {HAS_GLTF_INSTANCING: true} : {}),
+ ...modelOptions.defines
+ },
parameters: {...parameters, ...parsedPPBRMaterial.parameters, ...modelOptions.parameters}
};
@@ -299,7 +402,12 @@ export function createGLTFModel(device: Device, options: CreateGLTFModelOptions)
sceneShaderInputValues
);
model.shaderInputs.setProps(sceneShaderInputProps);
- return new ModelNode({managedResources, model});
+ return new ModelNode({
+ managedResources,
+ model,
+ bounds: options.bounds,
+ instanceMatrices
+ });
}
function isMaterialBindingResource(value: unknown): boolean {
diff --git a/modules/gltf/src/gltf/create-scenegraph-from-gltf.ts b/modules/gltf/src/gltf/create-scenegraph-from-gltf.ts
index 46fb8da2a1..43c394623a 100644
--- a/modules/gltf/src/gltf/create-scenegraph-from-gltf.ts
+++ b/modules/gltf/src/gltf/create-scenegraph-from-gltf.ts
@@ -12,7 +12,12 @@ import {GLTFAnimator} from './gltf-animator';
import {GLTFSkinController} from './gltf-skin';
import {parseGLTFAnimations} from '../parsers/parse-gltf-animations';
import type {GLTFAnimation} from './animations/animations';
-import {getGLTFExtensionSupport, type GLTFExtensionSupport} from './gltf-extension-support';
+import {
+ assertSupportedGLTFExtensions,
+ getGLTFExtensionSupport,
+ type GLTFExtensionSupport
+} from './gltf-extension-support';
+import {GLTFMaterialVariants} from './gltf-material-variants';
export type GLTFScenegraphBounds = {
/** World-space axis-aligned bounds for the scene or model. */
@@ -33,6 +38,10 @@ export type GLTFScenegraphs = {
scenes: GroupNode[];
/** Materials aligned with the source glTF `materials` array. */
materials: Material[];
+ /** Runtime controller for source-authored `KHR_materials_variants` mappings. */
+ variants: GLTFMaterialVariants;
+ /** Independent runtime camera projections updated by typed glTF animation pointers. */
+ cameras: GLTFPostprocessed['cameras'];
/** Animation controller for glTF animations. */
animator: GLTFAnimator;
/** Parsed source animations, including supported material and texture-transform pointers. */
@@ -66,12 +75,52 @@ export function createScenegraphsFromGLTF(
gltf: GLTFPostprocessed,
options?: ParseGLTFOptions
): GLTFScenegraphs {
+ if (options?.strictExtensions) {
+ assertSupportedGLTFExtensions(gltf);
+ }
+
const {scenes, materials, gltfMeshIdToNodeMap, gltfNodeIdToNodeMap, gltfNodeIndexToNodeMap} =
parseGLTF(device, gltf, options);
const animations = parseGLTFAnimations(gltf);
- const animator = new GLTFAnimator({animations, gltfNodeIdToNodeMap, materials});
- const lights = parseGLTFLights(gltf, {useByteColors: options?.useByteColors ?? true});
+ const sourceLights =
+ (gltf as GLTFPostprocessed & {lights?: Record[]}).lights ||
+ (gltf.extensions?.['KHR_lights_punctual']?.['lights'] as Record[] | undefined) ||
+ [];
+ const lightDefinitions = sourceLights.map(light => ({
+ ...light,
+ ...(Array.isArray(light['color']) ? {color: [...light['color']]} : {}),
+ ...(light['spot'] ? {spot: {...light['spot']}} : {})
+ }));
+ const cameras: GLTFPostprocessed['cameras'] = (gltf.cameras || []).map(camera => {
+ const runtimeCamera = {...camera};
+ if (camera.perspective) {
+ runtimeCamera.perspective = {...camera.perspective};
+ }
+ if (camera.orthographic) {
+ runtimeCamera.orthographic = {...camera.orthographic};
+ }
+ return runtimeCamera;
+ });
+ const lightOptions = {
+ useByteColors: options?.useByteColors ?? true,
+ nodeVisibility: gltfNodeIdToNodeMap,
+ lightDefinitions
+ };
+ const lights = parseGLTFLights(gltf, lightOptions);
+ const refreshLights = () => {
+ lights.splice(0, lights.length, ...parseGLTFLights(gltf, lightOptions));
+ };
+ const animator = new GLTFAnimator({
+ onVisibilityChange: refreshLights,
+ cameras,
+ lightDefinitions,
+ onLightChange: refreshLights,
+ animations,
+ gltfNodeIdToNodeMap,
+ materials
+ });
+ const variants = new GLTFMaterialVariants(gltf, scenes);
const extensionSupport = getGLTFExtensionSupport(gltf);
const sceneBounds = scenes.map(scene => getScenegraphBounds(scene.getBounds()));
const modelBounds = getCombinedScenegraphBounds(sceneBounds);
@@ -81,6 +130,8 @@ export function createScenegraphsFromGLTF(
return {
scenes,
materials,
+ variants,
+ cameras,
animator,
animations,
lights,
diff --git a/modules/gltf/src/gltf/gltf-animator.ts b/modules/gltf/src/gltf/gltf-animator.ts
index 39e0e2a0df..284a09b779 100644
--- a/modules/gltf/src/gltf/gltf-animator.ts
+++ b/modules/gltf/src/gltf/gltf-animator.ts
@@ -3,6 +3,7 @@
// Copyright (c) vis.gl contributors
import {log} from '@luma.gl/core';
+import type {GLTFPostprocessed} from '@loaders.gl/gltf';
import {
type AnimationAction,
AnimationClip,
@@ -24,6 +25,8 @@ import {
GLTFAnimation,
GLTFAnimationChannel,
GLTFAnimationPath,
+ GLTFCameraAnimationChannel,
+ GLTFLightAnimationChannel,
GLTFMaterialAnimationChannel,
GLTFMaterialAnimationProperty,
GLTFTextureTransformAnimationChannel
@@ -36,6 +39,14 @@ export type GLTFAnimationClipProps = {
animation: GLTFAnimation;
/** Mapping from glTF node ids to scenegraph nodes. */
gltfNodeIdToNodeMap: Map;
+ /** Refreshes runtime punctual lights after a node-visibility channel changes. */
+ onVisibilityChange?: () => void;
+ /** Runtime camera projection definitions aligned with the source camera array. */
+ cameras?: GLTFPostprocessed['cameras'];
+ /** Mutable runtime punctual-light definitions aligned with the source extension array. */
+ lightDefinitions?: Record[];
+ /** Refreshes derived punctual lights after one source light property changes. */
+ onLightChange?: () => void;
/** Materials aligned with the source glTF materials array. */
materials?: Material[];
/** Optional shared playback mixer for clips belonging to the same scene. */
@@ -48,6 +59,10 @@ export class GLTFAnimationClip extends AnimationClipController {
animation: GLTFAnimation;
/** Target scenegraph lookup table. */
gltfNodeIdToNodeMap: Map;
+ private readonly onVisibilityChange?: () => void;
+ private readonly cameras: GLTFPostprocessed['cameras'];
+ private readonly lightDefinitions: Record[];
+ private readonly onLightChange?: () => void;
/** Materials aligned with the source glTF materials array. */
materials: Material[];
/** Format-independent engine clip generated from the parsed glTF channels. */
@@ -67,11 +82,17 @@ export class GLTFAnimationClip extends AnimationClipController {
super({name: props.animation.name || 'unnamed'});
this.animation = props.animation;
this.gltfNodeIdToNodeMap = props.gltfNodeIdToNodeMap;
+ this.onVisibilityChange = props.onVisibilityChange;
+ this.cameras = props.cameras || [];
+ this.lightDefinitions = props.lightDefinitions || [];
+ this.onLightChange = props.onLightChange;
this.materials = props.materials || [];
this.animation.name ||= 'unnamed';
this.name = this.animation.name;
if (
- this.animation.channels.some(channel => channel.type !== 'node') &&
+ this.animation.channels.some(
+ channel => channel.type === 'material' || channel.type === 'textureTransform'
+ ) &&
!this.materials.length
) {
throw new Error(
@@ -110,6 +131,20 @@ export class GLTFAnimationClip extends AnimationClipController {
});
}
+ if (channel.type === 'camera' || channel.type === 'light') {
+ return new AnimationTrack({
+ name: channel.pointer,
+ times: channel.sampler.input,
+ values: channel.sampler.output,
+ interpolation,
+ binding: {
+ id: channel.pointer,
+ getValue: () => this.getSceneAnimationValue(channel),
+ setValue: value => this.applySceneAnimationValue(channel, value)
+ }
+ });
+ }
+
const material = this.materials[channel.targetMaterialIndex];
if (!material) {
throw new Error(
@@ -157,6 +192,8 @@ export class GLTFAnimationClip extends AnimationClipController {
return Array.from(
(targetNode.userData['morphWeights'] as readonly number[] | undefined) || []
);
+ case 'visibility':
+ return [targetNode.display ? 1 : 0];
default:
return [];
}
@@ -181,6 +218,10 @@ export class GLTFAnimationClip extends AnimationClipController {
case 'weights':
setGLTFMorphWeights(targetNode, value);
break;
+ case 'visibility':
+ targetNode.setProps({display: value[0] !== 0});
+ this.onVisibilityChange?.();
+ break;
default:
log.warn(`Bad animation path ${path}`)();
}
@@ -193,10 +234,67 @@ export class GLTFAnimationClip extends AnimationClipController {
}
return targetNode;
}
+
+ private getSceneAnimationValue(
+ channel: GLTFCameraAnimationChannel | GLTFLightAnimationChannel
+ ): number[] {
+ if (channel.type === 'camera') {
+ const camera = this.cameras[channel.targetCameraIndex] as Record | undefined;
+ const value = camera?.[channel.projection]?.[channel.property];
+ return typeof value === 'number' ? [value] : [];
+ }
+
+ const light = this.lightDefinitions[channel.targetLightIndex];
+ const value =
+ channel.property === 'innerConeAngle' || channel.property === 'outerConeAngle'
+ ? light?.['spot']?.[channel.property]
+ : light?.[channel.property];
+ if (Array.isArray(value)) {
+ return channel.component === undefined ? [...value] : [value[channel.component]];
+ }
+ return typeof value === 'number' ? [value] : [];
+ }
+
+ private applySceneAnimationValue(
+ channel: GLTFCameraAnimationChannel | GLTFLightAnimationChannel,
+ value: number[]
+ ): void {
+ if (channel.type === 'camera') {
+ const camera = this.cameras[channel.targetCameraIndex] as Record | undefined;
+ if (camera?.[channel.projection]) {
+ camera[channel.projection][channel.property] = value[0];
+ }
+ return;
+ }
+
+ const light = this.lightDefinitions[channel.targetLightIndex];
+ if (!light) {
+ return;
+ }
+ if (channel.property === 'innerConeAngle' || channel.property === 'outerConeAngle') {
+ light['spot'] ||= {};
+ light['spot'][channel.property] = value[0];
+ } else if (channel.component !== undefined) {
+ const color = [...(light[channel.property] || [1, 1, 1])];
+ color[channel.component] = value[0];
+ light[channel.property] = color;
+ } else {
+ light[channel.property] = value.length === 1 ? value[0] : [...value];
+ }
+ this.onLightChange?.();
+ }
}
/** Construction props for {@link GLTFAnimator}. */
export type GLTFAnimatorProps = {
+ /** Refreshes runtime punctual lights after a node-visibility channel changes. */
+ onVisibilityChange?: () => void;
+ /** Runtime camera projection definitions aligned with the source camera array. */
+ cameras?: GLTFPostprocessed['cameras'];
+ /** Mutable runtime punctual-light definitions aligned with the source extension array. */
+ lightDefinitions?: Record[];
+ /** Refreshes derived punctual lights after one source light property changes. */
+ onLightChange?: () => void;
/** Parsed animations from the source glTF. */
animations: GLTFAnimation[];
/** Mapping from glTF node ids to scenegraph nodes. */
@@ -234,6 +332,10 @@ export class GLTFAnimator extends Animator {
const name = animation.name || `Animation-${index}`;
return new GLTFAnimationClip({
gltfNodeIdToNodeMap: props.gltfNodeIdToNodeMap,
+ onVisibilityChange: props.onVisibilityChange,
+ cameras: props.cameras,
+ lightDefinitions: props.lightDefinitions,
+ onLightChange: props.onLightChange,
materials: props.materials,
mixer,
animation: {name, channels: animation.channels}
diff --git a/modules/gltf/src/gltf/gltf-extension-support.ts b/modules/gltf/src/gltf/gltf-extension-support.ts
index d6cb42ca0a..22da093d10 100644
--- a/modules/gltf/src/gltf/gltf-extension-support.ts
+++ b/modules/gltf/src/gltf/gltf-extension-support.ts
@@ -8,12 +8,17 @@ export type GLTFExtensionSupportLevel = 'built-in' | 'parsed-and-wired' | 'loade
export type GLTFExtensionSupport = {
extensionName: string;
+ /** Whether the source document declares this extension in `extensionsRequired`. */
+ required: boolean;
supported: boolean;
supportLevel: GLTFExtensionSupportLevel;
comment: string;
};
-type GLTFExtensionSupportDefinition = Omit;
+type GLTFExtensionSupportDefinition = Omit<
+ GLTFExtensionSupport,
+ 'extensionName' | 'required' | 'supported'
+>;
type GLTFPostprocessedWithRemovedExtensions = GLTFPostprocessed & {
extensionsRemoved?: string[];
@@ -109,21 +114,21 @@ const GLTF_EXTENSION_SUPPORT_REGISTRY: Record {
const extensionNames = Array.from(collectGLTFExtensionNames(gltf)).sort();
+ const requiredExtensionNames = new Set(gltf.extensionsRequired || []);
const extensionSupportEntries: [string, GLTFExtensionSupport][] = extensionNames.map(
extensionName => {
const extensionSupportDefinition =
@@ -173,6 +179,7 @@ export function getGLTFExtensionSupport(
extensionName,
{
extensionName,
+ required: requiredExtensionNames.has(extensionName),
supported:
extensionSupportDefinition.supportLevel === 'built-in' ||
extensionSupportDefinition.supportLevel === 'parsed-and-wired',
@@ -186,6 +193,27 @@ export function getGLTFExtensionSupport(
return new Map(extensionSupportEntries);
}
+/** Returns required extensions that have no complete runtime implementation. */
+export function getUnsupportedRequiredGLTFExtensions(
+ gltf: GLTFPostprocessed
+): GLTFExtensionSupport[] {
+ return Array.from(getGLTFExtensionSupport(gltf).values()).filter(
+ extension => extension.required && !extension.supported
+ );
+}
+
+/** Rejects documents whose required extensions cannot be honored by the runtime. */
+export function assertSupportedGLTFExtensions(gltf: GLTFPostprocessed): void {
+ const unsupportedExtensions = getUnsupportedRequiredGLTFExtensions(gltf);
+ if (unsupportedExtensions.length) {
+ throw new Error(
+ `Unsupported required glTF extensions: ${unsupportedExtensions
+ .map(extension => extension.extensionName)
+ .join(', ')}`
+ );
+ }
+}
+
export function getRegisteredGLTFExtensionSupport(
extensionName: string
): GLTFExtensionSupportDefinition | null {
diff --git a/modules/gltf/src/gltf/gltf-instancing.ts b/modules/gltf/src/gltf/gltf-instancing.ts
new file mode 100644
index 0000000000..10de26883c
--- /dev/null
+++ b/modules/gltf/src/gltf/gltf-instancing.ts
@@ -0,0 +1,130 @@
+// luma.gl
+// SPDX-License-Identifier: MIT
+// Copyright (c) vis.gl contributors
+
+import type {GLTFNodePostprocessed, GLTFPostprocessed} from '@loaders.gl/gltf';
+import {Matrix4} from '@math.gl/core';
+
+/** Accessor values authored on an `EXT_mesh_gpu_instancing` node. */
+export type GLTFInstanceAttribute = {
+ /** Flattened typed accessor values, preserved without CPU aliases. */
+ value: ArrayBufferView;
+ /** Number of scalar components in one instance value. */
+ size: number;
+ /** Number of source instances. */
+ count: number;
+ /** Whether integer components are normalized by the glTF accessor. */
+ normalized: boolean;
+};
+
+/** Source-authored glTF instance data and resolved local transforms. */
+export type GLTFGPUInstancing = {
+ /** Local instance matrices in source accessor order. */
+ matrices: Matrix4[];
+ /** All authored instance attributes, including application-specific `_NAME` semantics. */
+ attributes: Readonly>;
+};
+
+/** Resolves accessor-backed `EXT_mesh_gpu_instancing` transforms for one glTF node. */
+export function getGLTFNodeInstancing(
+ gltf: GLTFPostprocessed,
+ node: GLTFNodePostprocessed
+): GLTFGPUInstancing | null {
+ const sourceAttributes = node.extensions?.['EXT_mesh_gpu_instancing']?.attributes;
+ if (!sourceAttributes || typeof sourceAttributes !== 'object') {
+ return null;
+ }
+
+ const attributes: Record = {};
+ let instanceCount: number | undefined;
+
+ for (const [attributeName, accessorReference] of Object.entries(sourceAttributes)) {
+ const accessor =
+ typeof accessorReference === 'number'
+ ? gltf.accessors[accessorReference]
+ : (accessorReference as GLTFPostprocessed['accessors'][number]);
+
+ if (!accessor || !ArrayBuffer.isView(accessor.value)) {
+ throw new Error(`Invalid glTF instance accessor for ${attributeName}`);
+ }
+ if (instanceCount !== undefined && accessor.count !== instanceCount) {
+ throw new Error('glTF instance attributes must have matching accessor counts');
+ }
+
+ instanceCount = accessor.count;
+ attributes[attributeName] = {
+ value: accessor.value,
+ size: accessor.components || getAccessorComponentCount(accessor.type),
+ count: accessor.count,
+ normalized: Boolean(accessor.normalized)
+ };
+ }
+
+ const matrices: Matrix4[] = [];
+ for (let instanceIndex = 0; instanceIndex < (instanceCount || 0); instanceIndex++) {
+ const translation = getInstanceValues(attributes['TRANSLATION'], instanceIndex, [0, 0, 0]);
+ const rotation = getInstanceValues(attributes['ROTATION'], instanceIndex, [0, 0, 0, 1]);
+ const scale = getInstanceValues(attributes['SCALE'], instanceIndex, [1, 1, 1]);
+ const rotationLength = Math.hypot(...rotation);
+ if (rotationLength > 0) {
+ for (let component = 0; component < rotation.length; component++) {
+ rotation[component] /= rotationLength;
+ }
+ }
+ matrices.push(
+ new Matrix4()
+ .translate(translation)
+ .multiplyRight(new Matrix4().fromQuaternion(rotation))
+ .scale(scale)
+ );
+ }
+
+ return {matrices, attributes};
+}
+
+function getInstanceValues(
+ attribute: GLTFInstanceAttribute | undefined,
+ instanceIndex: number,
+ defaultValues: number[]
+): number[] {
+ if (!attribute) {
+ return [...defaultValues];
+ }
+
+ const values = attribute.value as unknown as ArrayLike;
+ return defaultValues.map((defaultValue, componentIndex) => {
+ const component = values[instanceIndex * attribute.size + componentIndex];
+ if (component === undefined) {
+ return defaultValue;
+ }
+ if (!attribute.normalized) {
+ return component;
+ }
+ if (attribute.value instanceof Int8Array) {
+ return Math.max(component / 127, -1);
+ }
+ if (attribute.value instanceof Int16Array) {
+ return Math.max(component / 32767, -1);
+ }
+ if (attribute.value instanceof Uint8Array) {
+ return component / 255;
+ }
+ if (attribute.value instanceof Uint16Array) {
+ return component / 65535;
+ }
+ return component;
+ });
+}
+
+function getAccessorComponentCount(type: string | undefined): number {
+ switch (type) {
+ case 'VEC2':
+ return 2;
+ case 'VEC3':
+ return 3;
+ case 'VEC4':
+ return 4;
+ default:
+ return 1;
+ }
+}
diff --git a/modules/gltf/src/gltf/gltf-material-variants.ts b/modules/gltf/src/gltf/gltf-material-variants.ts
new file mode 100644
index 0000000000..f7e73ee64d
--- /dev/null
+++ b/modules/gltf/src/gltf/gltf-material-variants.ts
@@ -0,0 +1,83 @@
+// luma.gl
+// SPDX-License-Identifier: MIT
+// Copyright (c) vis.gl contributors
+
+import type {RenderPipelineParameters} from '@luma.gl/core';
+import {GroupNode, Material, ModelNode} from '@luma.gl/engine';
+import type {GLTFPostprocessed} from '@loaders.gl/gltf';
+
+/** One source-authored `KHR_materials_variants` definition. */
+export type GLTFMaterialVariant = {
+ /** Application-visible variant name. */
+ name: string;
+ /** Variant index in the source glTF extension. */
+ index: number;
+};
+
+/** Material and pipeline state captured for one source primitive. */
+export type GLTFPrimitiveMaterialVariants = {
+ defaultMaterial: Material | null;
+ defaultParameters: RenderPipelineParameters;
+ mappings: ReadonlyMap;
+};
+
+/** Selects authored glTF material variants without rebuilding scenegraph topology. */
+export class GLTFMaterialVariants {
+ /** Source variants in their authored order. */
+ readonly variants: readonly GLTFMaterialVariant[];
+ /** Application-visible source variant names. */
+ readonly names: readonly string[];
+ /** Currently selected variant, or `null` when source defaults are active. */
+ activeVariant: string | null = null;
+
+ private readonly modelNodes: ModelNode[];
+
+ constructor(gltf: GLTFPostprocessed, scenes: readonly GroupNode[]) {
+ const sourceVariants = gltf.extensions?.['KHR_materials_variants']?.['variants'] || [];
+ this.variants = sourceVariants.map((variant: {name?: string}, index: number) => ({
+ name: variant.name || `Variant-${index}`,
+ index
+ }));
+ this.names = this.variants.map(variant => variant.name);
+
+ const visitedModelNodes = new Set();
+ for (const scene of scenes) {
+ scene.preorderTraversal(node => {
+ if (node instanceof ModelNode && node.userData['gltfMaterialVariants']) {
+ visitedModelNodes.add(node);
+ }
+ });
+ }
+ this.modelNodes = Array.from(visitedModelNodes);
+ }
+
+ /** Applies one named variant atomically and restores unmapped primitives to source defaults. */
+ selectVariant(variantName: string): void {
+ const variant = this.variants.find(candidate => candidate.name === variantName);
+ if (!variant) {
+ throw new Error(`Unknown glTF material variant: ${variantName}`);
+ }
+
+ for (const modelNode of this.modelNodes) {
+ const sourceVariants = modelNode.userData[
+ 'gltfMaterialVariants'
+ ] as GLTFPrimitiveMaterialVariants;
+ const mapping = sourceVariants.mappings.get(variant.index);
+ modelNode.model.setMaterial(mapping?.material || sourceVariants.defaultMaterial);
+ modelNode.model.setParameters(mapping?.parameters || sourceVariants.defaultParameters);
+ }
+ this.activeVariant = variantName;
+ }
+
+ /** Restores every primitive's authored default material and pipeline parameters. */
+ resetVariant(): void {
+ for (const modelNode of this.modelNodes) {
+ const sourceVariants = modelNode.userData[
+ 'gltfMaterialVariants'
+ ] as GLTFPrimitiveMaterialVariants;
+ modelNode.model.setMaterial(sourceVariants.defaultMaterial);
+ modelNode.model.setParameters(sourceVariants.defaultParameters);
+ }
+ this.activeVariant = null;
+ }
+}
diff --git a/modules/gltf/src/index.ts b/modules/gltf/src/index.ts
index 14a99bc63f..88716ee933 100644
--- a/modules/gltf/src/index.ts
+++ b/modules/gltf/src/index.ts
@@ -59,3 +59,25 @@ export {
convertSamplerToGLTF,
type GLTFSampler
} from './webgl-to-webgpu/convert-webgl-sampler';
+
+// Standards-native glTF extension runtime helpers.
+export {
+ assertSupportedGLTFExtensions,
+ getUnsupportedRequiredGLTFExtensions
+} from './gltf/gltf-extension-support';
+export {
+ getGLTFNodeInstancing,
+ type GLTFGPUInstancing,
+ type GLTFInstanceAttribute
+} from './gltf/gltf-instancing';
+export {
+ GLTFMaterialVariants,
+ type GLTFMaterialVariant,
+ type GLTFPrimitiveMaterialVariants
+} from './gltf/gltf-material-variants';
+export type {
+ GLTFCameraAnimationChannel,
+ GLTFCameraAnimationProperty,
+ GLTFLightAnimationChannel,
+ GLTFLightAnimationProperty
+} from './gltf/animations/animations';
diff --git a/modules/gltf/src/parsers/parse-gltf-animations.ts b/modules/gltf/src/parsers/parse-gltf-animations.ts
index 4ea4102faf..8aaab47238 100644
--- a/modules/gltf/src/parsers/parse-gltf-animations.ts
+++ b/modules/gltf/src/parsers/parse-gltf-animations.ts
@@ -9,6 +9,10 @@ import {
type GLTFAnimationChannel,
GLTFAnimationPath,
type GLTFAnimationSampler,
+ type GLTFCameraAnimationChannel,
+ type GLTFCameraAnimationProperty,
+ type GLTFLightAnimationChannel,
+ type GLTFLightAnimationProperty,
type GLTFMaterialAnimationChannel,
type GLTFMaterialAnimationProperty,
type GLTFNodeAnimationChannel,
@@ -118,13 +122,113 @@ function parseAnimationPointerChannel(
case 'materials':
return parseMaterialPointerAnimationChannel(gltf, pointerSegments, sampler, pointer);
+ case 'cameras':
+ return parseCameraPointerAnimationChannel(gltf, pointerSegments, sampler, pointer);
+
+ case 'extensions':
+ if (pointerSegments[1] === 'KHR_lights_punctual') {
+ return parseLightPointerAnimationChannel(gltf, pointerSegments, sampler, pointer);
+ }
+ break;
+
default:
- warnUnsupportedAnimationPointer(
- pointer,
- `top-level target "${pointerSegments[0]}" has no runtime animation mapping`
- );
- return null;
+ break;
}
+
+ warnUnsupportedAnimationPointer(
+ pointer,
+ `top-level target "${pointerSegments[0]}" has no runtime animation mapping`
+ );
+ return null;
+}
+
+function parseCameraPointerAnimationChannel(
+ gltf: GLTFPostprocessed,
+ pointerSegments: string[],
+ sampler: GLTFAnimationSampler,
+ pointer: string
+): GLTFCameraAnimationChannel | null {
+ const cameraIndex = Number(pointerSegments[1]);
+ const camera = gltf.cameras?.[cameraIndex];
+ const projection = pointerSegments[2];
+ const property = pointerSegments[3];
+ const perspectiveProperties = ['aspectRatio', 'yfov', 'znear', 'zfar'];
+ const orthographicProperties = ['xmag', 'ymag', 'znear', 'zfar'];
+
+ if (
+ pointerSegments.length !== 4 ||
+ !Number.isInteger(cameraIndex) ||
+ !camera ||
+ (projection !== 'perspective' && projection !== 'orthographic') ||
+ camera.type !== projection ||
+ !(projection === 'perspective' ? perspectiveProperties : orthographicProperties).includes(
+ property
+ )
+ ) {
+ warnUnsupportedAnimationPointer(
+ pointer,
+ 'camera pointers must target a supported projection property'
+ );
+ return null;
+ }
+
+ return {
+ type: 'camera',
+ sampler,
+ pointer,
+ targetCameraIndex: cameraIndex,
+ projection,
+ property: property as GLTFCameraAnimationProperty
+ };
+}
+
+function parseLightPointerAnimationChannel(
+ gltf: GLTFPostprocessed,
+ pointerSegments: string[],
+ sampler: GLTFAnimationSampler,
+ pointer: string
+): GLTFLightAnimationChannel | null {
+ const lightIndex = Number(pointerSegments[3]);
+ const lightDefinitions =
+ (gltf as GLTFPostprocessed & {lights?: unknown[]}).lights ||
+ gltf.extensions?.['KHR_lights_punctual']?.['lights'];
+ const isSpotProperty = pointerSegments[4] === 'spot';
+ const property = isSpotProperty ? pointerSegments[5] : pointerSegments[4];
+ const component = !isSpotProperty && property === 'color' ? pointerSegments[5] : undefined;
+ const allowedProperties: readonly GLTFLightAnimationProperty[] = [
+ 'color',
+ 'intensity',
+ 'range',
+ 'innerConeAngle',
+ 'outerConeAngle'
+ ];
+ const expectedLength = isSpotProperty || component !== undefined ? 6 : 5;
+
+ if (
+ pointerSegments[2] !== 'lights' ||
+ pointerSegments.length !== expectedLength ||
+ !Number.isInteger(lightIndex) ||
+ !Array.isArray(lightDefinitions) ||
+ !lightDefinitions[lightIndex] ||
+ !allowedProperties.includes(property as GLTFLightAnimationProperty) ||
+ (isSpotProperty && property !== 'innerConeAngle' && property !== 'outerConeAngle') ||
+ (component !== undefined && (!/^[0-2]$/.test(component) || property !== 'color'))
+ ) {
+ warnUnsupportedAnimationPointer(
+ pointer,
+ 'punctual-light pointers must target supported typed light properties'
+ );
+ return null;
+ }
+
+ return {
+ type: 'light',
+ sampler,
+ pointer,
+ targetLightIndex: lightIndex,
+ property: property as GLTFLightAnimationProperty,
+ ...(component === undefined ? {} : {component: Number(component)})
+ };
}
function parseNodePointerAnimationChannel(
@@ -133,10 +237,15 @@ function parseNodePointerAnimationChannel(
sampler: GLTFAnimationSampler,
pointer: string
): GLTFNodeAnimationChannel | null {
- if (pointerSegments.length !== 3) {
+ const isVisibilityPointer =
+ pointerSegments.length === 5 &&
+ pointerSegments[2] === 'extensions' &&
+ pointerSegments[3] === 'KHR_node_visibility' &&
+ pointerSegments[4] === 'visible';
+ if (pointerSegments.length !== 3 && !isVisibilityPointer) {
warnUnsupportedAnimationPointer(
pointer,
- 'node pointers must use /nodes/{index}/{translation|rotation|scale|weights}'
+ 'node pointers must target transforms, morph weights, or KHR_node_visibility.visible'
);
return null;
}
@@ -150,7 +259,15 @@ function parseNodePointerAnimationChannel(
return null;
}
- const path = getNodeAnimationPath(pointerSegments[2]);
+ if (isVisibilityPointer && sampler.interpolation !== 'STEP') {
+ warnUnsupportedAnimationPointer(
+ pointer,
+ 'boolean visibility animation requires STEP interpolation'
+ );
+ return null;
+ }
+
+ const path = isVisibilityPointer ? 'visibility' : getNodeAnimationPath(pointerSegments[2]);
if (!path) {
warnUnsupportedAnimationPointer(
pointer,
@@ -343,6 +460,11 @@ function resolveMaterialAnimationTarget(
? {type: 'material', property: 'ior'}
: {reason: getUnsupportedMaterialPointerReason(pointerSegments)};
+ case 'extensions/KHR_materials_dispersion/dispersion':
+ return material['extensions']?.['KHR_materials_dispersion']
+ ? {type: 'material', property: 'dispersion'}
+ : {reason: getUnsupportedMaterialPointerReason(pointerSegments)};
+
case 'extensions/KHR_materials_transmission/transmissionFactor':
return material['extensions']?.['KHR_materials_transmission']
? {type: 'material', property: 'transmissionFactor'}
diff --git a/modules/gltf/src/parsers/parse-gltf-lights.ts b/modules/gltf/src/parsers/parse-gltf-lights.ts
index af227757aa..12a3bfcbc2 100644
--- a/modules/gltf/src/parsers/parse-gltf-lights.ts
+++ b/modules/gltf/src/parsers/parse-gltf-lights.ts
@@ -11,6 +11,10 @@ import {
export type ParseGLTFLightsOptions = {
/** When true, parsed light colors are converted into luma.gl's legacy byte-style range. */
useByteColors?: boolean;
+ /** Optional live scenegraph visibility used for animated `KHR_node_visibility` updates. */
+ nodeVisibility?: ReadonlyMap;
+ /** Optional mutable light definitions used by typed punctual-light animation pointers. */
+ lightDefinitions?: readonly Record[];
};
/** Parse KHR_lights_punctual extension into luma.gl light definitions */
@@ -19,6 +23,7 @@ export function parseGLTFLights(
options: ParseGLTFLightsOptions = {}
): Light[] {
const lightDefs =
+ options.lightDefinitions ||
// `postProcessGLTF()` moves KHR_lights_punctual into `gltf.lights`.
(gltf as GLTFPostprocessed & {lights?: any[]}).lights ||
gltf.extensions?.['KHR_lights_punctual']?.['lights'];
@@ -31,6 +36,10 @@ export function parseGLTFLights(
const worldMatrixByNodeId = new Map();
for (const node of gltf.nodes || []) {
+ if (!isNodeVisible(node, parentNodeById, options.nodeVisibility)) {
+ continue;
+ }
+
const lightIndex =
(node as GLTFNodePostprocessed & {light?: number}).light ??
node.extensions?.KHR_lights_punctual?.light;
@@ -71,6 +80,27 @@ export function parseGLTFLights(
return lights;
}
+/** Applies source-authored or live visibility recursively through a punctual light's parent chain. */
+function isNodeVisible(
+ node: GLTFNodePostprocessed,
+ parentNodeById: ReadonlyMap,
+ liveNodeVisibility?: ReadonlyMap
+): boolean {
+ let currentNode: GLTFNodePostprocessed | undefined = node;
+ while (currentNode) {
+ const liveNode = liveNodeVisibility?.get(currentNode.id);
+ if (
+ liveNode
+ ? !liveNode.display
+ : currentNode.extensions?.['KHR_node_visibility']?.visible === false
+ ) {
+ return false;
+ }
+ currentNode = parentNodeById.get(currentNode.id);
+ }
+ return true;
+}
+
/**
* Converts glTF colors from the 0-1 spec range to the configured luma light convention.
*/
diff --git a/modules/gltf/src/parsers/parse-gltf.ts b/modules/gltf/src/parsers/parse-gltf.ts
index bf27c11956..da7545fff6 100644
--- a/modules/gltf/src/parsers/parse-gltf.ts
+++ b/modules/gltf/src/parsers/parse-gltf.ts
@@ -21,6 +21,8 @@ import {
} from '@luma.gl/engine';
import {pbrMaterial} from '@luma.gl/shadertools';
import {createGLTFMaterial, createGLTFModel} from '../gltf/create-gltf-model';
+import {getGLTFNodeInstancing, type GLTFGPUInstancing} from '../gltf/gltf-instancing';
+import type {GLTFPrimitiveMaterialVariants} from '../gltf/gltf-material-variants';
import {type GLTFMorphTargetState, setGLTFMorphWeights} from '../gltf/morph-targets';
import {type PBREnvironment} from '../pbr/pbr-environment';
import {convertGLDrawModeToTopology} from '../webgl-to-webgpu/convert-webgl-topology';
@@ -41,6 +43,8 @@ export type ParseGLTFOptions = {
useTangents?: boolean;
/** When true, parsed semantic light colors are converted into luma.gl's legacy byte-style range. */
useByteColors?: boolean;
+ /** Reject documents whose required extensions have no complete runtime implementation. */
+ strictExtensions?: boolean;
};
const defaultOptions: Required = {
@@ -49,7 +53,8 @@ const defaultOptions: Required = {
imageBasedLightingEnvironment: undefined!,
lights: true,
useTangents: false,
- useByteColors: true
+ useByteColors: true,
+ strictExtensions: false
};
/**
@@ -131,17 +136,19 @@ export function parseGLTF(
// Nodes can have children nodes and one optional child mesh at the same time.
if (gltfNode.mesh) {
const sourceMesh = gltfNode.mesh;
+ const instancing = getGLTFNodeInstancing(gltf, gltfNode);
const hasMorphTargets = sourceMesh.primitives.some(primitive =>
Boolean(primitive.targets?.length)
);
const mesh =
- hasMorphTargets && assignedMorphMeshes.has(sourceMesh.id)
+ instancing || (hasMorphTargets && assignedMorphMeshes.has(sourceMesh.id))
? createNodeForGLTFMesh(
device,
sourceMesh,
gltf,
gltfMaterialIdToMaterialMap,
- combinedOptions
+ combinedOptions,
+ instancing || undefined
)
: gltfMeshIdToNodeMap.get(sourceMesh.id);
if (!mesh) {
@@ -187,6 +194,7 @@ function createNodeForGLTFNode(
id: gltfNode.name || gltfNode.id,
children: [],
matrix: gltfNode.matrix,
+ display: gltfNode.extensions?.['KHR_node_visibility']?.visible !== false,
position: gltfNode.translation,
rotation: gltfNode.rotation,
scale: gltfNode.scale
@@ -199,7 +207,8 @@ function createNodeForGLTFMesh(
gltfMesh: GLTFMeshPostprocessed,
gltf: GLTFPostprocessed,
gltfMaterialIdToMaterialMap: Map,
- options: Required
+ options: Required,
+ instancing?: GLTFGPUInstancing
): GroupNode {
const gltfPrimitives = gltfMesh.primitives || [];
const primitives = gltfPrimitives.map((gltfPrimitive, i) =>
@@ -210,7 +219,8 @@ function createNodeForGLTFMesh(
gltfMesh,
gltf,
gltfMaterialIdToMaterialMap,
- options
+ options,
+ instancing
})
);
const mesh = new GroupNode({
@@ -230,6 +240,7 @@ type CreateNodeForGLTFPrimitiveOptions = {
gltf: GLTFPostprocessed;
gltfMaterialIdToMaterialMap: Map;
options: Required;
+ instancing?: GLTFGPUInstancing;
};
/** Creates a renderable model node for one glTF primitive. */
@@ -240,7 +251,8 @@ function createNodeForGLTFPrimitive({
gltfMesh,
gltf,
gltfMaterialIdToMaterialMap,
- options
+ options,
+ instancing
}: CreateNodeForGLTFPrimitiveOptions): ModelNode {
const id = gltfPrimitive.name || `${gltfMesh.name || gltfMesh.id}-primitive-${primitiveIndex}`;
const topology = convertGLDrawModeToTopology(gltfPrimitive.mode ?? 4);
@@ -263,9 +275,52 @@ function createNodeForGLTFPrimitive({
: null,
parsedPPBRMaterial,
modelOptions: options.modelOptions,
- vertexCount
+ vertexCount,
+ bounds: [gltfPrimitive.attributes.POSITION.min, gltfPrimitive.attributes.POSITION.max],
+ instanceMatrices: instancing?.matrices
});
+ if (instancing) {
+ modelNode.userData['gltfInstancing'] = instancing;
+ }
+
+ const sourceVariantMappings =
+ gltfPrimitive.extensions?.['KHR_materials_variants']?.mappings || [];
+ if (sourceVariantMappings.length) {
+ const mappings = new Map<
+ number,
+ {material: Material; parameters: GLTFPrimitiveMaterialVariants['defaultParameters']}
+ >();
+ for (const mapping of sourceVariantMappings) {
+ const sourceMaterial =
+ typeof mapping.material === 'number' ? gltf.materials[mapping.material] : mapping.material;
+ const material = sourceMaterial && gltfMaterialIdToMaterialMap.get(sourceMaterial.id);
+ if (!material) {
+ continue;
+ }
+ const variantMaterial = parsePBRMaterial(device, sourceMaterial as any, geometry.attributes, {
+ ...options,
+ gltf
+ });
+ for (const variantIndex of mapping.variants || []) {
+ mappings.set(variantIndex, {
+ material,
+ parameters: {
+ ...modelNode.model.parameters,
+ ...variantMaterial.parameters,
+ depthWriteEnabled: sourceMaterial.alphaMode !== 'BLEND',
+ cullMode: sourceMaterial.doubleSided ? 'none' : 'back'
+ }
+ });
+ }
+ }
+ modelNode.userData['gltfMaterialVariants'] = {
+ defaultMaterial: modelNode.model.material,
+ defaultParameters: {...modelNode.model.parameters},
+ mappings
+ } satisfies GLTFPrimitiveMaterialVariants;
+ }
+
if (gltfPrimitive.targets?.length) {
const baseAttributes: MorphTargetAttributes = {};
for (const attributeName of ['POSITION', 'NORMAL', 'TANGENT'] as const) {
@@ -299,7 +354,6 @@ function createNodeForGLTFPrimitive({
} satisfies GLTFMorphTargetState;
}
- modelNode.bounds = [gltfPrimitive.attributes.POSITION.min, gltfPrimitive.attributes.POSITION.max];
// TODO this holds on to all the CPU side texture and attribute data
// modelNode.material = gltfPrimitive.material;
diff --git a/modules/gltf/test/data/CubeVisibility.glb b/modules/gltf/test/data/CubeVisibility.glb
new file mode 100644
index 0000000000..48e06900a6
Binary files /dev/null and b/modules/gltf/test/data/CubeVisibility.glb differ
diff --git a/modules/gltf/test/data/LightVisibility.glb b/modules/gltf/test/data/LightVisibility.glb
new file mode 100644
index 0000000000..861a523587
Binary files /dev/null and b/modules/gltf/test/data/LightVisibility.glb differ
diff --git a/modules/gltf/test/data/README.md b/modules/gltf/test/data/README.md
new file mode 100644
index 0000000000..a465c48421
--- /dev/null
+++ b/modules/gltf/test/data/README.md
@@ -0,0 +1,11 @@
+# glTF Native Extension Fixtures
+
+These compact binary assets come from the Khronos glTF Sample Assets repository at
+commit `2bac6f8c57bf471df0d2a1e8a8ec023c7801dddf`:
+
+- `SimpleInstancing.glb`: `EXT_mesh_gpu_instancing` transforms and accessor data.
+- `CubeVisibility.glb`: recursive `KHR_node_visibility` mesh visibility.
+- `LightVisibility.glb`: recursive `KHR_node_visibility` punctual-light visibility.
+
+Each source asset is released under CC0-1.0. See the corresponding model directories in
+https://github.com/KhronosGroup/glTF-Sample-Assets/tree/2bac6f8c57bf471df0d2a1e8a8ec023c7801dddf/Models.
diff --git a/modules/gltf/test/data/SimpleInstancing.glb b/modules/gltf/test/data/SimpleInstancing.glb
new file mode 100644
index 0000000000..0b717bdb30
Binary files /dev/null and b/modules/gltf/test/data/SimpleInstancing.glb differ
diff --git a/modules/gltf/test/gltf/gltf-native-extensions.node.spec.ts b/modules/gltf/test/gltf/gltf-native-extensions.node.spec.ts
new file mode 100644
index 0000000000..5fc7c51c4f
--- /dev/null
+++ b/modules/gltf/test/gltf/gltf-native-extensions.node.spec.ts
@@ -0,0 +1,381 @@
+// luma.gl
+// SPDX-License-Identifier: MIT
+// Copyright (c) vis.gl contributors
+
+import {readFile} from 'node:fs/promises';
+import {parse} from '@loaders.gl/core';
+import {GLTFLoader, type GLTFPostprocessed, postProcessGLTF} from '@loaders.gl/gltf';
+import {ModelNode} from '@luma.gl/engine';
+import {
+ assertSupportedGLTFExtensions,
+ createScenegraphsFromGLTF,
+ getGLTFExtensionSupport,
+ getGLTFNodeInstancing,
+ getUnsupportedRequiredGLTFExtensions,
+ parseGLTFAnimations,
+ parseGLTFLights
+} from '@luma.gl/gltf';
+import {NullDevice} from '@luma.gl/test-utils';
+import {describe, expect, test} from 'vitest';
+
+async function loadNativeExtensionFixture(name: string): Promise {
+ const data = await readFile(new URL(`../data/${name}.glb`, import.meta.url));
+ return postProcessGLTF(await parse(data, GLTFLoader, {gltf: {loadImages: false}}));
+}
+
+function getVisibleModelNodes(gltf: ReturnType): ModelNode[] {
+ const modelNodes: ModelNode[] = [];
+ for (const scene of gltf.scenes) {
+ scene.traverse(node => {
+ if (node instanceof ModelNode) {
+ modelNodes.push(node);
+ }
+ });
+ }
+ return modelNodes;
+}
+
+function destroyScenegraphs(
+ device: NullDevice,
+ scenegraphs: ReturnType
+): void {
+ for (const scene of scenegraphs.scenes) {
+ scene.destroy();
+ }
+ device.destroy();
+}
+
+describe('standards-native glTF extension runtime', () => {
+ test('draws the official instancing asset with one source primitive and authored matrices', async () => {
+ const source = await loadNativeExtensionFixture('SimpleInstancing');
+ const device = new NullDevice({});
+ const scenegraphs = createScenegraphsFromGLTF(device, source, {strictExtensions: true});
+
+ try {
+ const instancing = getGLTFNodeInstancing(source, source.nodes[0]);
+ const modelNodes = getVisibleModelNodes(scenegraphs);
+
+ expect(instancing).not.toBeNull();
+ expect(instancing?.matrices.length).toBeGreaterThan(1);
+ expect(Object.keys(instancing?.attributes || {})).toEqual([
+ 'TRANSLATION',
+ 'ROTATION',
+ 'SCALE'
+ ]);
+ expect(modelNodes).toHaveLength(1);
+ expect(modelNodes[0].model.instanceCount).toBe(instancing?.matrices.length);
+ expect(modelNodes[0].model.isInstanced).toBe(true);
+ expect(modelNodes[0].instanceMatrices).toHaveLength(instancing?.matrices.length || 0);
+ expect(
+ modelNodes[0].model.bufferLayout.filter(layout => layout.stepMode === 'instance')
+ ).toHaveLength(4);
+ expect(scenegraphs.extensionSupport.get('EXT_mesh_gpu_instancing')).toMatchObject({
+ supportLevel: 'built-in',
+ supported: true
+ });
+ expect(scenegraphs.modelBounds.size[0]).toBeGreaterThan(1);
+ } finally {
+ destroyScenegraphs(device, scenegraphs);
+ }
+ });
+
+ test('preserves custom instance semantics and normalized signed rotation accessors', async () => {
+ const source = await loadNativeExtensionFixture('SimpleInstancing');
+ const extension = source.nodes[0].extensions!['EXT_mesh_gpu_instancing'];
+ const originalRotation = source.accessors[extension.attributes.ROTATION];
+ const normalizedRotation = new Int8Array(originalRotation.count * 4);
+ for (let index = 0; index < originalRotation.count; index++) {
+ normalizedRotation[index * 4 + 3] = 127;
+ }
+ source.accessors.push({
+ ...originalRotation,
+ componentType: 5120,
+ normalized: true,
+ value: normalizedRotation
+ });
+ extension.attributes.ROTATION = source.accessors.length - 1;
+ extension.attributes._FEATURE_ID = extension.attributes.TRANSLATION;
+
+ const instancing = getGLTFNodeInstancing(source, source.nodes[0]);
+ expect(instancing?.attributes['_FEATURE_ID']).toBeDefined();
+ expect(instancing?.attributes['ROTATION'].normalized).toBe(true);
+ expect(instancing?.matrices[0].every(Number.isFinite)).toBe(true);
+ });
+
+ test('recursively hides official visibility descendants and evaluates boolean STEP pointers', async () => {
+ const source = await loadNativeExtensionFixture('CubeVisibility');
+ const device = new NullDevice({});
+ const scenegraphs = createScenegraphsFromGLTF(device, source, {strictExtensions: true});
+
+ try {
+ expect(scenegraphs.gltfNodeIndexToNodeMap.get(1)?.display).toBe(false);
+ expect(getVisibleModelNodes(scenegraphs)).toHaveLength(2);
+ expect(scenegraphs.animations[0].channels[0]).toMatchObject({
+ type: 'node',
+ path: 'visibility'
+ });
+
+ const channel = scenegraphs.animations[0].channels[0];
+ const hiddenKeyframe = channel.sampler.output.findIndex(value => value[0] === 0);
+ expect(hiddenKeyframe).toBeGreaterThanOrEqual(0);
+ scenegraphs.animator.setTime(channel.sampler.input[hiddenKeyframe] * 1000);
+
+ expect(scenegraphs.gltfNodeIndexToNodeMap.get(5)?.display).toBe(false);
+ expect(getVisibleModelNodes(scenegraphs)).toHaveLength(1);
+
+ const visibleKeyframe = channel.sampler.output.findIndex(value => value[0] === 1);
+ scenegraphs.animator.setTime(channel.sampler.input[visibleKeyframe] * 1000);
+ expect(scenegraphs.gltfNodeIndexToNodeMap.get(5)?.display).toBe(true);
+ expect(getVisibleModelNodes(scenegraphs)).toHaveLength(2);
+ } finally {
+ destroyScenegraphs(device, scenegraphs);
+ }
+ });
+
+ test('filters hidden authored lights and refreshes the stable light array when visibility animates', async () => {
+ const source = await loadNativeExtensionFixture('LightVisibility');
+ expect(parseGLTFLights(source, {useByteColors: false})).toHaveLength(2);
+
+ const device = new NullDevice({});
+ const scenegraphs = createScenegraphsFromGLTF(device, source, {
+ strictExtensions: true,
+ useByteColors: false
+ });
+
+ try {
+ const originalLights = scenegraphs.lights;
+ expect(originalLights).toHaveLength(2);
+
+ const channel = scenegraphs.animations[0].channels[0];
+ const hiddenKeyframe = channel.sampler.output.findIndex(value => value[0] === 0);
+ scenegraphs.animator.setTime(channel.sampler.input[hiddenKeyframe] * 1000);
+
+ expect(scenegraphs.lights).toBe(originalLights);
+ expect(scenegraphs.lights).toHaveLength(1);
+ expect(scenegraphs.gltfNodeIndexToNodeMap.get(5)?.display).toBe(false);
+ } finally {
+ destroyScenegraphs(device, scenegraphs);
+ }
+ });
+
+ test('animates camera projections and typed punctual-light properties without mutating source data', async () => {
+ const source = await loadNativeExtensionFixture('LightVisibility');
+ source.cameras = [
+ {
+ id: 'camera-0',
+ type: 'perspective',
+ perspective: {id: 'perspective-0', yfov: 1, znear: 0.1}
+ }
+ ] as GLTFPostprocessed['cameras'];
+
+ function addScalarAccessor(values: number[]): number {
+ const typedValues = new Float32Array(values);
+ source.accessors.push({
+ id: `animation-accessor-${source.accessors.length}`,
+ componentType: 5126,
+ count: values.length,
+ type: 'SCALAR',
+ components: 1,
+ value: typedValues,
+ bufferView: {data: {buffer: typedValues.buffer}}
+ } as GLTFPostprocessed['accessors'][number]);
+ return source.accessors.length - 1;
+ }
+
+ const timeAccessor = addScalarAccessor([0, 1]);
+ const fieldOfViewAccessor = addScalarAccessor([1, 2]);
+ const intensityAccessor = addScalarAccessor([1, 5]);
+ const redAccessor = addScalarAccessor([1, 0.2]);
+ source.animations = [
+ {
+ id: 'typed-scene-animation',
+ name: 'Typed scene animation',
+ channels: [
+ {
+ sampler: 0,
+ target: {
+ path: 'pointer',
+ extensions: {KHR_animation_pointer: {pointer: '/cameras/0/perspective/yfov'}}
+ }
+ },
+ {
+ sampler: 1,
+ target: {
+ path: 'pointer',
+ extensions: {
+ KHR_animation_pointer: {
+ pointer: '/extensions/KHR_lights_punctual/lights/1/intensity'
+ }
+ }
+ }
+ },
+ {
+ sampler: 2,
+ target: {
+ path: 'pointer',
+ extensions: {
+ KHR_animation_pointer: {
+ pointer: '/extensions/KHR_lights_punctual/lights/1/color/0'
+ }
+ }
+ }
+ }
+ ],
+ samplers: [
+ {input: timeAccessor, output: fieldOfViewAccessor, interpolation: 'LINEAR'},
+ {input: timeAccessor, output: intensityAccessor, interpolation: 'LINEAR'},
+ {input: timeAccessor, output: redAccessor, interpolation: 'LINEAR'}
+ ]
+ }
+ ] as GLTFPostprocessed['animations'];
+
+ const originalIntensity = (source as GLTFPostprocessed & {lights: {intensity: number}[]})
+ .lights[1].intensity;
+ const device = new NullDevice({});
+ const scenegraphs = createScenegraphsFromGLTF(device, source, {useByteColors: false});
+
+ try {
+ expect(scenegraphs.animations[0].channels.map(channel => channel.type)).toEqual([
+ 'camera',
+ 'light',
+ 'light'
+ ]);
+ const originalLights = scenegraphs.lights;
+ scenegraphs.animator.setTime(500);
+
+ expect(scenegraphs.cameras[0].perspective?.yfov).toBeCloseTo(1.5);
+ expect(source.cameras[0].perspective?.yfov).toBe(1);
+ expect(scenegraphs.lights).toBe(originalLights);
+ expect(scenegraphs.lights[0].intensity).toBeCloseTo(3);
+ expect(scenegraphs.lights[0].color?.[0]).toBeCloseTo(0.6);
+ expect(
+ (source as GLTFPostprocessed & {lights: {intensity: number}[]}).lights[1].intensity
+ ).toBe(originalIntensity);
+ } finally {
+ destroyScenegraphs(device, scenegraphs);
+ }
+ });
+
+ test('reports required extension capabilities and rejects unsupported required features', async () => {
+ const source = await loadNativeExtensionFixture('CubeVisibility');
+ expect(getGLTFExtensionSupport(source).get('KHR_node_visibility')).toMatchObject({
+ required: true,
+ supported: true
+ });
+ expect(getUnsupportedRequiredGLTFExtensions(source)).toEqual([]);
+ expect(() => assertSupportedGLTFExtensions(source)).not.toThrow();
+
+ source.extensionsRequired = [...(source.extensionsRequired || []), 'VENDOR_unimplemented'];
+ expect(
+ getUnsupportedRequiredGLTFExtensions(source).map(extension => extension.extensionName)
+ ).toEqual(['VENDOR_unimplemented']);
+ expect(() => assertSupportedGLTFExtensions(source)).toThrow('VENDOR_unimplemented');
+
+ const device = new NullDevice({});
+ try {
+ expect(() => createScenegraphsFromGLTF(device, source, {strictExtensions: true})).toThrow(
+ 'VENDOR_unimplemented'
+ );
+ } finally {
+ device.destroy();
+ }
+ });
+
+ test('accepts required physically implemented dispersion in strict extension mode', async () => {
+ const source = await loadNativeExtensionFixture('CubeVisibility');
+ source.extensionsRequired = [...(source.extensionsRequired || []), 'KHR_materials_dispersion'];
+
+ expect(getGLTFExtensionSupport(source).get('KHR_materials_dispersion')).toMatchObject({
+ required: true,
+ supported: true,
+ supportLevel: 'parsed-and-wired'
+ });
+ expect(getUnsupportedRequiredGLTFExtensions(source)).toEqual([]);
+ expect(() => assertSupportedGLTFExtensions(source)).not.toThrow();
+
+ const device = new NullDevice({});
+ const scenegraphs = createScenegraphsFromGLTF(device, source, {strictExtensions: true});
+ destroyScenegraphs(device, scenegraphs);
+ });
+
+ test('preserves authored KHR_materials_dispersion animation pointers as canonical material channels', async () => {
+ const source = await loadNativeExtensionFixture('CubeVisibility');
+ source.materials[0].extensions = {
+ ...source.materials[0].extensions,
+ KHR_materials_dispersion: {dispersion: 0.25}
+ };
+ source.animations[0].channels[0].target.extensions = {
+ KHR_animation_pointer: {
+ pointer: '/materials/0/extensions/KHR_materials_dispersion/dispersion'
+ }
+ };
+
+ const parsedAnimations = parseGLTFAnimations(source);
+
+ expect(parsedAnimations[0].channels[0]).toMatchObject({
+ type: 'material',
+ targetMaterialIndex: 0,
+ property: 'dispersion',
+ pointer: '/materials/0/extensions/KHR_materials_dispersion/dispersion'
+ });
+ expect(parsedAnimations[0].channels[0].sampler.output.length).toBeGreaterThan(1);
+ });
+
+ test('switches and restores authored material variants without replacing model nodes', async () => {
+ const source = await loadNativeExtensionFixture('CubeVisibility');
+ const sourceMaterial = source.materials[0];
+ const alternateMaterial = {
+ ...sourceMaterial,
+ id: 'variant-material',
+ alphaMode: 'BLEND',
+ doubleSided: true,
+ pbrMetallicRoughness: {
+ ...sourceMaterial.pbrMetallicRoughness,
+ baseColorFactor: [0, 1, 0, 0.5]
+ }
+ } as GLTFPostprocessed['materials'][number];
+ source.materials.push(alternateMaterial);
+ source.extensions = {
+ ...source.extensions,
+ KHR_materials_variants: {variants: [{name: 'Emerald'}, {name: 'Unmapped'}]}
+ } as GLTFPostprocessed['extensions'];
+ source.extensionsUsed = [...(source.extensionsUsed || []), 'KHR_materials_variants'];
+ const primitive = source.meshes[1].primitives[0];
+ primitive.extensions = {
+ ...primitive.extensions,
+ KHR_materials_variants: {
+ mappings: [{material: source.materials.length - 1, variants: [0]}]
+ }
+ };
+
+ const device = new NullDevice({});
+ const scenegraphs = createScenegraphsFromGLTF(device, source);
+ try {
+ const target = getVisibleModelNodes(scenegraphs).find(
+ node => node.userData['gltfMaterialVariants']
+ );
+ expect(target).toBeDefined();
+ const originalModel = target!.model;
+ const originalMaterial = target!.model.material;
+ expect(scenegraphs.variants.names).toEqual(['Emerald', 'Unmapped']);
+
+ scenegraphs.variants.selectVariant('Emerald');
+ expect(target!.model).toBe(originalModel);
+ expect(target!.model.material).toBe(scenegraphs.materials.at(-1));
+ expect(target!.model.parameters.depthWriteEnabled).toBe(false);
+ expect(scenegraphs.variants.activeVariant).toBe('Emerald');
+
+ scenegraphs.variants.selectVariant('Unmapped');
+ expect(target!.model.material).toBe(originalMaterial);
+ expect(() => scenegraphs.variants.selectVariant('Missing')).toThrow('Missing');
+ expect(scenegraphs.variants.activeVariant).toBe('Unmapped');
+
+ scenegraphs.variants.selectVariant('Emerald');
+ scenegraphs.variants.resetVariant();
+ expect(target!.model.material).toBe(originalMaterial);
+ expect(scenegraphs.variants.activeVariant).toBeNull();
+ } finally {
+ destroyScenegraphs(device, scenegraphs);
+ }
+ });
+});
diff --git a/modules/gltf/test/gltf/gltf-native-extensions.spec.ts b/modules/gltf/test/gltf/gltf-native-extensions.spec.ts
new file mode 100644
index 0000000000..42eb2169f2
--- /dev/null
+++ b/modules/gltf/test/gltf/gltf-native-extensions.spec.ts
@@ -0,0 +1,87 @@
+// luma.gl
+// SPDX-License-Identifier: MIT
+// Copyright (c) vis.gl contributors
+
+import {load} from '@loaders.gl/core';
+import {GLTFLoader, postProcessGLTF} from '@loaders.gl/gltf';
+import {Texture} from '@luma.gl/core';
+import {ModelNode} from '@luma.gl/engine';
+import {createScenegraphsFromGLTF} from '@luma.gl/gltf';
+import {getTestDevices} from '@luma.gl/test-utils';
+import {Matrix4} from '@math.gl/core';
+import test from 'test/utils/vitest-tape';
+
+test('glTF renders official EXT_mesh_gpu_instancing through one real draw on available backends', async testCase => {
+ const source = postProcessGLTF(
+ await load(new URL('../data/SimpleInstancing.glb', import.meta.url).href, GLTFLoader, {
+ gltf: {loadImages: false}
+ })
+ );
+
+ for (const device of await getTestDevices()) {
+ const scenegraphs = createScenegraphsFromGLTF(device, source, {strictExtensions: true});
+ const colorTexture = device.createTexture({
+ width: 16,
+ height: 16,
+ format: device.preferredColorFormat,
+ usage: Texture.RENDER | Texture.COPY_SRC
+ });
+ const depthTexture = device.createTexture({
+ width: 16,
+ height: 16,
+ format: 'depth24plus',
+ usage: Texture.RENDER
+ });
+ const framebuffer = device.createFramebuffer({
+ width: 16,
+ height: 16,
+ colorAttachments: [colorTexture],
+ depthStencilAttachment: depthTexture
+ });
+
+ try {
+ let modelNode: ModelNode | undefined;
+ scenegraphs.scenes[0].traverse(node => {
+ if (node instanceof ModelNode) {
+ modelNode = node;
+ }
+ });
+
+ testCase.ok(modelNode, `${device.type} creates one instanced primitive`);
+ if (modelNode) {
+ const identity = Array.from(new Matrix4());
+ modelNode.model.shaderInputs.setProps({
+ pbrProjection: {
+ modelViewProjectionMatrix: identity,
+ modelMatrix: identity,
+ normalMatrix: identity,
+ camera: [0, 0, 4]
+ }
+ });
+ const renderPass = device.beginRenderPass({
+ framebuffer,
+ clearColor: [0, 0, 0, 0],
+ clearDepth: 1
+ });
+ testCase.ok(modelNode.model.draw(renderPass), `${device.type} executes an instanced draw`);
+ renderPass.end();
+ device.submit();
+
+ testCase.ok(modelNode.model.isInstanced, `${device.type} enables GPU instancing`);
+ testCase.ok(
+ modelNode.model.instanceCount > 1,
+ `${device.type} submits every authored source instance`
+ );
+ }
+ } finally {
+ for (const scene of scenegraphs.scenes) {
+ scene.destroy();
+ }
+ framebuffer.destroy();
+ colorTexture.destroy();
+ depthTexture.destroy();
+ }
+ }
+
+ testCase.end();
+});
diff --git a/modules/gltf/test/index.ts b/modules/gltf/test/index.ts
index a59cccf461..6e4cf6d5e7 100644
--- a/modules/gltf/test/index.ts
+++ b/modules/gltf/test/index.ts
@@ -13,3 +13,4 @@ import './parsers/parse-gltf.spec';
import './parsers/parse-pbr-compressed-texture.spec';
import './parsers/parse-pbr-material.spec';
import './parsers/parse-pbr-sampler.spec';
+import './gltf/gltf-native-extensions.spec';
diff --git a/modules/gltf/test/parsers/parse-gltf-animations.spec.ts b/modules/gltf/test/parsers/parse-gltf-animations.spec.ts
index fc898797e6..77a1997b74 100644
--- a/modules/gltf/test/parsers/parse-gltf-animations.spec.ts
+++ b/modules/gltf/test/parsers/parse-gltf-animations.spec.ts
@@ -331,7 +331,7 @@ test('gltf#parseGLTFAnimations warns specifically for unsupported top-level poin
path: 'pointer',
extensions: {
KHR_animation_pointer: {
- pointer: '/cameras/0/perspective/yfov'
+ pointer: '/asset/version'
}
}
}
@@ -347,7 +347,7 @@ test('gltf#parseGLTFAnimations warns specifically for unsupported top-level poin
t.ok(
warnings.some(warning =>
- warning.includes('top-level target "cameras" has no runtime animation mapping')
+ warning.includes('top-level target "asset" has no runtime animation mapping')
),
'warning explains the unsupported top-level target'
);
diff --git a/website/src/components/docs/gltf-docs-tabs.tsx b/website/src/components/docs/gltf-docs-tabs.tsx
index 3dc356d69b..46b03293b8 100644
--- a/website/src/components/docs/gltf-docs-tabs.tsx
+++ b/website/src/components/docs/gltf-docs-tabs.tsx
@@ -1,20 +1,27 @@
import React, {type ReactNode} from 'react';
import Link from '@docusaurus/Link';
-type GltfDocsTab = {id: GltfDocsTabId; label: string; href: string};
+type GltfDocsTab = {id: NativeGltfDocsTabId; label: string; href: string};
/** glTF documentation tab identifiers. */
export type GltfDocsTabId = 'overview' | 'materials' | 'animation' | 'extensions';
+type NativeGltfDocsTabId = GltfDocsTabId | 'native-extensions';
+
const GLTF_DOCS_TABS: GltfDocsTab[] = [
{id: 'overview', label: 'Overview', href: '/docs/api-reference/gltf'},
{id: 'materials', label: 'Materials', href: '/docs/api-reference/gltf/gltf-materials'},
+ {
+ id: 'native-extensions',
+ label: 'Native Extensions',
+ href: '/docs/api-reference/gltf/gltf-native-extensions'
+ },
{id: 'animation', label: 'Animation', href: '/docs/api-reference/gltf/gltf-animation'},
{id: 'extensions', label: 'Extensions', href: '/docs/api-reference/gltf/gltf-extensions'}
];
/** Renders page links with the same visual treatment as tabs for glTF documentation pages. */
-export function GltfDocsTabs({active}: {active: GltfDocsTabId}): ReactNode {
+export function GltfDocsTabs({active}: {active: NativeGltfDocsTabId}): ReactNode {
return (