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 (