Skip to main content

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

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.

ExtensionRuntime behaviorScenegraph access
KHR_materials_variantsSelects authored materials without replacing scene nodes.scenegraphs.variants
EXT_mesh_gpu_instancingDraws authored mesh instances in a single instanced draw per primitive.getGLTFNodeInstancing(gltf, node)
KHR_node_visibilityRecursively hides descendants and attached punctual lights.scenegraphs.gltfNodeIndexToNodeMap
KHR_animation_pointerDrives node, material, texture, camera, and punctual-light properties.scenegraphs.animations and scenegraphs.animator
KHR_materials_dispersionPreserves 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:

{
"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:

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:

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:

{
"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:

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:

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:

{
"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:

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:

{
"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.

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 familyExample pointerResult
Node transform/nodes/2/translationUpdates the existing node transform.
Morph weights/nodes/2/weightsUpdates existing morph vertex buffers.
Recursive visibility/nodes/2/extensions/KHR_node_visibility/visibleUpdates display and refreshes punctual lights.
Perspective camera/cameras/0/perspective/yfovUpdates a runtime projection copy.
Orthographic camera/cameras/1/orthographic/xmagUpdates a runtime projection copy.
Light intensity/extensions/KHR_lights_punctual/lights/0/intensityRefreshes the corresponding runtime light.
Light RGB channel/extensions/KHR_lights_punctual/lights/0/color/2Updates one authored linear color component.
Spotlight cone/extensions/KHR_lights_punctual/lights/0/spot/innerConeAnglePreserves distinct inner and outer cones.
Chromatic dispersion/materials/0/extensions/KHR_materials_dispersion/dispersionUpdates the canonical physical-material uniform.
Texture transform/materials/0/pbrMetallicRoughness/baseColorTexture/extensions/KHR_texture_transform/offset/0Updates 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

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 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.

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 levelMeaningAccepted when required?
built-inA complete decoder or runtime path handles the feature.Yes
parsed-and-wiredParsed source data is connected to the existing runtime.Yes
loader-onlyLoader data survives, but device/application support is not guaranteed.No
noneNo 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:

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 fixtureSizeWhat is checked
SimpleInstancing.glb7.2 KBReal WebGL/WebGPU instanced draws, source TRS accessors, instance counts, matrices, and aggregate bounds.
CubeVisibility.glb3.2 KBRecursive hidden meshes, boolean STEP animation, strict capability checks, and derived variant/dispersion mutation cases.
LightVisibility.glb2.9 KBRecursive 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

PackageResponsibility
@loaders.gl/gltfContainer decoding, accessor/image decoding, and explicit glTF postprocessing.
@luma.gl/gltfExtension interpretation, source-material mappings, typed pointers, skin ownership, and capability reporting.
@luma.gl/engineGeneric GroupNode visibility, reusable Model instancing, animation mixing, and deformation utilities.
@luma.gl/shadertoolsCanonical PBR material uniforms, physical shading, and reusable skinning.
@luma.gl/experimentalOptional format-independent physical scene rendering and captured-scene transmission.
@luma.gl/anariOptional 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.