{
try {
const parsedScene: unknown = JSON.parse(this.editor.value);
const validatedScene = ANARISceneSchema.safeParse(parsedScene);
@@ -324,15 +327,24 @@ export default class ANARIPlayground extends AnimationLoopTemplate {
}
setElementText('editor-feedback', 'Exporting ' + format.toUpperCase() + ' scene…');
const scene = validatedScene.data as ANARIJSONScene;
- const contents =
- format === 'gltf'
- ? await exportANARIJSONSceneToGLTF(scene)
- : exportANARIJSONSceneToUSD(scene);
- downloadTextFile(
- contents,
- makeExportFilename(scene.name, format === 'gltf' ? 'gltf' : 'usda'),
- format === 'gltf' ? 'model/gltf+json' : 'model/vnd.usda'
- );
+ if (format === 'glb') {
+ const binaryContents = await exportANARIJSONSceneToGLTF(scene, {binary: true});
+ downloadSceneFile(
+ binaryContents,
+ makeExportFilename(scene.name, 'glb'),
+ 'model/gltf-binary'
+ );
+ } else {
+ const contents =
+ format === 'gltf'
+ ? await exportANARIJSONSceneToGLTF(scene)
+ : exportANARIJSONSceneToUSD(scene);
+ downloadSceneFile(
+ contents,
+ makeExportFilename(scene.name, format === 'gltf' ? 'gltf' : 'usda'),
+ format === 'gltf' ? 'model/gltf+json' : 'model/vnd.usda'
+ );
+ }
setElementText('editor-feedback', format.toUpperCase() + ' scene downloaded');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
@@ -488,7 +500,11 @@ function makeExportFilename(name: string, extension: string): string {
return (normalized || 'anari-scene') + '.' + extension;
}
-function downloadTextFile(contents: string, filename: string, mimeType: string): void {
+function downloadSceneFile(
+ contents: string | ArrayBuffer,
+ filename: string,
+ mimeType: string
+): void {
const url = URL.createObjectURL(new Blob([contents], {type: mimeType}));
const link = document.createElement('a');
link.href = url;
diff --git a/examples/showcase/anari/public/gltf/ASSET-LICENSE.md b/examples/showcase/anari/public/gltf/ASSET-LICENSE.md
index f179f369d2..9cb0c40176 100644
--- a/examples/showcase/anari/public/gltf/ASSET-LICENSE.md
+++ b/examples/showcase/anari/public/gltf/ASSET-LICENSE.md
@@ -6,8 +6,14 @@ These example assets are distributed under the Creative Commons Zero (CC0 1.0) p
- `AnimatedMorphCube.glb` — Animated Morph Cube by Microsoft.
- `AntiqueCamera.glb` — Antique Camera by Maximillan Kamps.
- `Lantern.glb` — Lantern by sbtron and Frank Galligan.
+- `RobotExpressive.glb` — Robot Expressive by Tomás Laulhé, with facial morph targets and glTF conversion by Don McCurdy.
- `SimpleSkin.gltf` — Simple Skin by Marco Hutter.
- `ToyCar.glb` — Toy Car by Guido Odendahl and Eric Chadwick.
The files originate from the Khronos Group glTF Sample Assets collection:
https://github.com/KhronosGroup/glTF-Sample-Assets
+
+`RobotExpressive.glb` originates from the three.js sample collection at commit
+`24595fb65bb662ea1e70984bb18301af06637b07`. Its upstream documentation explicitly
+identifies the asset as CC0 1.0:
+https://github.com/mrdoob/three.js/blob/24595fb65bb662ea1e70984bb18301af06637b07/examples/models/gltf/RobotExpressive/README.md
diff --git a/examples/showcase/anari/public/gltf/RobotExpressive.glb b/examples/showcase/anari/public/gltf/RobotExpressive.glb
new file mode 100644
index 0000000000..6fec9cfb4b
Binary files /dev/null and b/examples/showcase/anari/public/gltf/RobotExpressive.glb differ
diff --git a/examples/showcase/anari/usd-samples.ts b/examples/showcase/anari/usd-samples.ts
index 5cf4b14cfd..8ce927ffef 100644
--- a/examples/showcase/anari/usd-samples.ts
+++ b/examples/showcase/anari/usd-samples.ts
@@ -22,6 +22,24 @@ function resolveSceneAssetUrl(relativePath: string): string {
}
export const SCENE_SAMPLES: readonly SceneSample[] = [
+ {
+ identifier: 'gltf-expressive-robot',
+ label: 'glTF · Expressive Robot · 14 Animated Clips',
+ url: resolveSceneAssetUrl('./gltf/RobotExpressive.glb'),
+ format: 'gltf'
+ },
+ {
+ identifier: 'gltf-animated-morphs',
+ label: 'glTF · Animated Morph Targets',
+ url: resolveSceneAssetUrl('./gltf/AnimatedMorphCube.glb'),
+ format: 'gltf'
+ },
+ {
+ identifier: 'gltf-animated-skin',
+ label: 'glTF · Animated Skeleton',
+ url: resolveSceneAssetUrl('./gltf/SimpleSkin.gltf'),
+ format: 'gltf'
+ },
{
identifier: 'gltf-animated-colors',
label: 'glTF · Animated Colors',
diff --git a/examples/showcase/gltf/app.ts b/examples/showcase/gltf/app.ts
index 4ac1fc1ffc..1aaba6610e 100644
--- a/examples/showcase/gltf/app.ts
+++ b/examples/showcase/gltf/app.ts
@@ -2,16 +2,16 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors
-import {RenderPass, log} from '@luma.gl/core';
-import {AnimationProps, ClipSpace} from '@luma.gl/engine';
-import {loadPBREnvironment, type PBREnvironment} from '@luma.gl/gltf';
-import {type LightingProps} from '@luma.gl/shadertools';
import {
ColumnPanel,
type Panel,
type SettingsChangeDescriptor,
type SettingsSchema
} from '@deck.gl-community/panels';
+import {log, RenderPass} from '@luma.gl/core';
+import {type AnimationLoopMode, AnimationProps, ClipSpace} from '@luma.gl/engine';
+import {loadPBREnvironment, type PBREnvironment} from '@luma.gl/gltf';
+import {type LightingProps, type PBRMaterialUniforms} from '@luma.gl/shadertools';
import {
ExamplePanelManager,
ExampleSettingsPanelManager,
@@ -19,19 +19,25 @@ import {
makeExamplePanelHostHtml,
makeHtmlCustomPanel
} from '../../example-panels';
+import {GLTF_STUDIO_DEFAULT_VARIANT, type GLTFAnimationStudioState} from './gltf-animation-studio';
import GLTFCatalogApp, {
+ GLTF_ANIMATION_INFO_ID,
GLTF_MODEL_INFO_ID,
- saveOptions,
type GLTFCatalogModel,
- type GLTFModelReference
+ type GLTFModelReference,
+ saveOptions
} from './gltf-catalog-app';
import {GLTF_EXTENSION_DEMOS, type GLTFExtensionDemo} from './gltf-extension-demos';
+import {getFeaturedGLTFAsset} from './gltf-featured-assets';
const PBR_ENVIRONMENT_BASE_URL =
'https://raw.githubusercontent.com/uber-common/deck.gl-data/master/luma.gl/examples/gltf';
const SHOWCASE_EXTENSION_STORAGE_KEY = 'showcase-gltf-extension-filter';
const ALL_EXTENSIONS_FILTER = 'all';
+const FEATURED_ASSETS_FILTER = 'featured';
const LOADING_MODEL_VALUE = 'loading-models';
+const NO_ANIMATION_CLIP = '__no-animation__';
+const ORBIT_CAMERA = '__orbit-camera__';
const CUBE_FACE_TO_DIRECTION = ['right', 'left', 'top', 'bottom', 'front', 'back'] as const;
const SHOWCASE_FALLBACK_LIGHTING = {
ambientLight: {
@@ -89,31 +95,34 @@ void main(void) {
`;
const GLTF_DESCRIPTION_HTML = `\
-Browse production-quality glTF sample assets with interactive camera and animation controls.
+Explore curated glTF assets with independent clip playback, skeletal animation, facial morphs,
+material variants, and standards-native extension diagnostics.
Drag to orbit. Use the mouse wheel or trackpad to zoom.
+
`;
export default class AppAnimationLoopTemplate extends GLTFCatalogApp {
- static info = makeExamplePanelHostHtml();
+ static override info = makeExamplePanelHostHtml();
backgroundModel: ClipSpace;
readonly settingsPanel: ExampleSettingsPanelManager;
readonly panels: ExamplePanelManager;
extensionDemos: GLTFExtensionDemo[] = [];
modelOptions: ShowcaseModelMenuOption[] = [];
- extensionName = ALL_EXTENSIONS_FILTER;
+ extensionName = FEATURED_ASSETS_FILTER;
selectedModelValue = '';
imageBasedLightingEnvironment?: PBREnvironment;
imageBasedLightingEnvironmentPromise?: Promise;
- constructor({device}: AnimationProps) {
- super({device});
+ constructor(animationProps: AnimationProps) {
+ super(animationProps);
+ const {device} = animationProps;
this.settingsPanel = new ExampleSettingsPanelManager({
id: 'gltf-settings',
schema: makeGltfSettingsSchema([], [], this.extensionName),
@@ -137,15 +146,15 @@ export default class AppAnimationLoopTemplate extends GLTFCatalogApp {
canvas.style.background = 'linear-gradient(180deg, #9b9b97 0%, #6f6d64 100%)';
}
- getDefaultModelName(): string {
- return 'DamagedHelmet';
+ override getDefaultModelName(): string {
+ return 'RobotExpressive';
}
- getModelStorageKey(): string {
+ override getModelStorageKey(): string {
return 'showcase-last-gltf-model-v2';
}
- getClearColor(): [number, number, number, number] {
+ override getClearColor(): [number, number, number, number] {
return [0.53, 0.52, 0.49, 1];
}
@@ -155,9 +164,10 @@ export default class AppAnimationLoopTemplate extends GLTFCatalogApp {
this.extensionDemos.map(extensionDemo => extensionDemo.extensionName)
);
const storedExtension = window.localStorage[SHOWCASE_EXTENSION_STORAGE_KEY];
- this.extensionName = activeExtensionNames.has(storedExtension)
- ? storedExtension
- : ALL_EXTENSIONS_FILTER;
+ this.extensionName =
+ storedExtension === FEATURED_ASSETS_FILTER || activeExtensionNames.has(storedExtension)
+ ? storedExtension
+ : FEATURED_ASSETS_FILTER;
this.modelOptions = getModelOptionsForExtension(
this.extensionName,
getAllModelOptions(models),
@@ -166,17 +176,20 @@ export default class AppAnimationLoopTemplate extends GLTFCatalogApp {
const selectedModelOption = getInitialModelOption(this.modelOptions, currentModelName);
this.selectedModelValue = encodeModelOption(selectedModelOption);
this.syncSettingsPanel();
- if (this.extensionName !== ALL_EXTENSIONS_FILTER) {
+ if (
+ this.extensionName !== ALL_EXTENSIONS_FILTER &&
+ this.extensionName !== FEATURED_ASSETS_FILTER
+ ) {
this.loadModelOption(selectedModelOption);
}
return () => {};
}
- drawBackground(renderPass: RenderPass): void {
+ override drawBackground(renderPass: RenderPass): void {
this.backgroundModel.draw(renderPass);
}
- async getImageBasedLightingEnvironment(): Promise {
+ override async getImageBasedLightingEnvironment(): Promise {
if (this.imageBasedLightingEnvironment) {
return this.imageBasedLightingEnvironment;
}
@@ -196,7 +209,7 @@ export default class AppAnimationLoopTemplate extends GLTFCatalogApp {
return SHOWCASE_FALLBACK_LIGHTING;
}
- override getPBRMaterialProps() {
+ override getPBRMaterialProps(): Partial {
if (!this.imageBasedLightingEnvironment) {
return {};
}
@@ -216,6 +229,10 @@ export default class AppAnimationLoopTemplate extends GLTFCatalogApp {
this.imageBasedLightingEnvironmentPromise = undefined;
}
+ override onScenegraphsChanged(): void {
+ this.syncSettingsPanel();
+ }
+
private async loadImageBasedLightingEnvironment(): Promise {
try {
log.log(0, 'Loading showcase PBR environment')();
@@ -263,18 +280,42 @@ export default class AppAnimationLoopTemplate extends GLTFCatalogApp {
}
private getSettingsState(): GltfSettingsState {
+ const animationState = this.animationStudio.getState();
+ const morphValues = Object.fromEntries(
+ animationState.morphTargets.map(target => [
+ makeMorphSettingName(target.identifier),
+ target.value
+ ])
+ );
+
return {
extensionName: this.extensionName,
modelValue: this.selectedModelValue || LOADING_MODEL_VALUE,
+ animationClip: animationState.selectedClip || NO_ANIMATION_CLIP,
+ animationSpeed: animationState.speed,
+ animationCrossFade: animationState.crossFadeDuration,
+ animationTime: animationState.time,
+ animationLoop: animationState.loop,
+ characterInstances: this.getAnimationInstanceCount(),
+ materialVariant: animationState.selectedVariant,
+ cameraSelection:
+ this.selectedCameraIndex === null ? ORBIT_CAMERA : String(this.selectedCameraIndex),
useModelLights: this.options['useModelLights'],
cameraAnimation: this.options['cameraAnimation'],
- gltfAnimation: this.options['gltfAnimation']
+ gltfAnimation: this.options['gltfAnimation'],
+ ...morphValues
};
}
private syncSettingsPanel(): void {
this.settingsPanel.setSchemaAndSettings(
- makeGltfSettingsSchema(this.extensionDemos, this.modelOptions, this.extensionName),
+ makeGltfSettingsSchema(
+ this.extensionDemos,
+ this.modelOptions,
+ this.extensionName,
+ this.animationStudio.getState(),
+ this.scenegraphsFromGLTF?.cameras || []
+ ),
this.getSettingsState()
);
this.panels.setPanel(this.makePanel());
@@ -294,6 +335,57 @@ export default class AppAnimationLoopTemplate extends GLTFCatalogApp {
this.selectModel(modelValue);
return;
}
+ const animationClip = getChangedSetting(changedSettings, 'animationClip')?.nextValue;
+ if (typeof animationClip === 'string' && animationClip !== NO_ANIMATION_CLIP) {
+ this.animationStudio.selectClip(animationClip);
+ this.syncSettingsPanel();
+ return;
+ }
+ const materialVariant = getChangedSetting(changedSettings, 'materialVariant')?.nextValue;
+ if (typeof materialVariant === 'string') {
+ this.animationStudio.selectVariant(materialVariant);
+ return;
+ }
+ const cameraSelection = getChangedSetting(changedSettings, 'cameraSelection')?.nextValue;
+ if (typeof cameraSelection === 'string') {
+ this.selectedCameraIndex = cameraSelection === ORBIT_CAMERA ? null : Number(cameraSelection);
+ return;
+ }
+ const animationSpeed = getChangedSetting(changedSettings, 'animationSpeed')?.nextValue;
+ if (typeof animationSpeed === 'number') {
+ this.animationStudio.setSpeed(animationSpeed);
+ return;
+ }
+ const animationCrossFade = getChangedSetting(changedSettings, 'animationCrossFade')?.nextValue;
+ if (typeof animationCrossFade === 'number') {
+ this.animationStudio.setCrossFadeDuration(animationCrossFade);
+ return;
+ }
+ const animationTime = getChangedSetting(changedSettings, 'animationTime')?.nextValue;
+ if (typeof animationTime === 'number') {
+ this.animationStudio.seek(animationTime);
+ return;
+ }
+ const animationLoop = getChangedSetting(changedSettings, 'animationLoop')?.nextValue;
+ if (animationLoop === 'repeat' || animationLoop === 'once' || animationLoop === 'ping-pong') {
+ this.animationStudio.setLoop(animationLoop);
+ return;
+ }
+ const characterInstances = getChangedSetting(changedSettings, 'characterInstances')?.nextValue;
+ if (typeof characterInstances === 'number') {
+ this.setAnimationInstanceCount(characterInstances);
+ return;
+ }
+ for (const target of this.animationStudio.getState().morphTargets) {
+ const value = getChangedSetting(
+ changedSettings,
+ makeMorphSettingName(target.identifier)
+ )?.nextValue;
+ if (typeof value === 'number') {
+ this.animationStudio.setMorphWeight(target.identifier, value);
+ return;
+ }
+ }
for (const optionName of ['useModelLights', 'cameraAnimation', 'gltfAnimation'] as const) {
const nextValue = getChangedSetting(changedSettings, optionName)?.nextValue;
if (typeof nextValue === 'boolean') {
@@ -306,7 +398,7 @@ export default class AppAnimationLoopTemplate extends GLTFCatalogApp {
private selectExtension(extensionName: string): void {
this.extensionName = isGltfExtensionName(extensionName, this.extensionDemos)
? extensionName
- : ALL_EXTENSIONS_FILTER;
+ : FEATURED_ASSETS_FILTER;
window.localStorage[SHOWCASE_EXTENSION_STORAGE_KEY] = this.extensionName;
this.modelOptions = getModelOptionsForExtension(
this.extensionName,
@@ -400,6 +492,9 @@ function getModelOptionsForExtension(
allModels: ShowcaseModelMenuOption[],
extensionDemos: GLTFExtensionDemo[]
): ShowcaseModelMenuOption[] {
+ if (extensionName === FEATURED_ASSETS_FILTER) {
+ return allModels.filter(model => Boolean(getFeaturedGLTFAsset(model.name)));
+ }
if (extensionName === ALL_EXTENSIONS_FILTER) {
return allModels;
}
@@ -417,16 +512,31 @@ function encodeModelOption(modelOption: GLTFModelReference): string {
type GltfSettingsState = {
extensionName: string;
modelValue: string;
+ animationClip: string;
+ animationSpeed: number;
+ animationCrossFade: number;
+ animationTime: number;
+ animationLoop: AnimationLoopMode;
+ characterInstances: number;
+ materialVariant: string;
+ cameraSelection: string;
useModelLights: boolean;
cameraAnimation: boolean;
gltfAnimation: boolean;
+ [settingName: string]: boolean | number | string;
};
export function makeGltfSettingsSchema(
extensionDemos: GLTFExtensionDemo[] = [],
modelOptions: ShowcaseModelMenuOption[] = [],
- extensionName = ALL_EXTENSIONS_FILTER
+ extensionName = FEATURED_ASSETS_FILTER,
+ animationState?: GLTFAnimationStudioState,
+ cameras: readonly {name?: string}[] = []
): SettingsSchema {
+ const clipOptions = animationState?.clipNames.length
+ ? animationState.clipNames.map(name => ({label: name, value: name}))
+ : [{label: 'No animation clips', value: NO_ANIMATION_CLIP}];
+
return {
title: 'Settings',
sections: [
@@ -441,6 +551,7 @@ export function makeGltfSettingsSchema(
type: 'select',
persist: 'none',
options: [
+ {label: 'Curated Highlights', value: FEATURED_ASSETS_FILTER},
{label: 'All Extensions', value: ALL_EXTENSIONS_FILTER},
...extensionDemos.map(extensionDemo => ({
label: extensionDemo.extensionName,
@@ -466,24 +577,122 @@ export function makeGltfSettingsSchema(
},
{
id: 'animation',
- name: 'Animation',
+ name: 'Animation Studio',
initiallyCollapsed: false,
settings: [
{
- name: 'useModelLights',
- label: 'Use Model Lights',
+ name: 'gltfAnimation',
+ label: 'Play Animation',
type: 'boolean',
persist: 'none'
},
{
- name: 'cameraAnimation',
- label: 'Camera Animation',
+ name: 'animationClip',
+ label: 'Animation Clip',
+ type: 'select',
+ options: clipOptions,
+ persist: 'none'
+ },
+ {
+ name: 'animationSpeed',
+ label: 'Playback Speed',
+ type: 'number',
+ min: 0,
+ max: 3,
+ step: 0.05,
+ persist: 'none'
+ },
+ {
+ name: 'animationCrossFade',
+ label: 'Crossfade Seconds',
+ type: 'number',
+ min: 0,
+ max: 2,
+ step: 0.05,
+ persist: 'none'
+ },
+ {
+ name: 'animationTime',
+ label: 'Animation Time',
+ type: 'number',
+ min: 0,
+ max: Math.max(animationState?.duration || 0, 0.01),
+ step: 0.01,
+ persist: 'none'
+ },
+ {
+ name: 'animationLoop',
+ label: 'Loop Mode',
+ type: 'select',
+ options: [
+ {label: 'Repeat', value: 'repeat'},
+ {label: 'Ping-pong', value: 'ping-pong'},
+ {label: 'Play once', value: 'once'}
+ ],
+ persist: 'none'
+ },
+ {
+ name: 'characterInstances',
+ label: 'Independent Characters',
+ type: 'number',
+ min: 1,
+ max: 6,
+ step: 1,
+ persist: 'none'
+ }
+ ]
+ },
+ {
+ id: 'morph-targets',
+ name: 'Facial Expressions',
+ initiallyCollapsed: false,
+ settings: (animationState?.morphTargets || []).map(target => ({
+ name: makeMorphSettingName(target.identifier),
+ label: target.label,
+ type: 'number',
+ min: 0,
+ max: 1,
+ step: 0.01,
+ persist: 'none'
+ }))
+ },
+ {
+ id: 'materials',
+ name: 'Materials and Cameras',
+ initiallyCollapsed: false,
+ settings: [
+ {
+ name: 'materialVariant',
+ label: 'Material Variant',
+ type: 'select',
+ options: [
+ {label: 'Original materials', value: GLTF_STUDIO_DEFAULT_VARIANT},
+ ...(animationState?.variants || []).map(name => ({label: name, value: name}))
+ ],
+ persist: 'none'
+ },
+ {
+ name: 'cameraSelection',
+ label: 'Camera Lens',
+ type: 'select',
+ options: [
+ {label: 'Studio orbit camera', value: ORBIT_CAMERA},
+ ...cameras.map((camera, index) => ({
+ label: camera.name || `Source camera ${index + 1}`,
+ value: String(index)
+ }))
+ ],
+ persist: 'none'
+ },
+ {
+ name: 'useModelLights',
+ label: 'Use Model Lights',
type: 'boolean',
persist: 'none'
},
{
- name: 'gltfAnimation',
- label: 'glTF Animation',
+ name: 'cameraAnimation',
+ label: 'Orbit Animation',
type: 'boolean',
persist: 'none'
}
@@ -495,7 +704,12 @@ export function makeGltfSettingsSchema(
function isGltfExtensionName(extensionName: string, extensionDemos: GLTFExtensionDemo[]): boolean {
return (
+ extensionName === FEATURED_ASSETS_FILTER ||
extensionName === ALL_EXTENSIONS_FILTER ||
extensionDemos.some(extensionDemo => extensionDemo.extensionName === extensionName)
);
}
+
+function makeMorphSettingName(identifier: string): string {
+ return `morph__${identifier.replace(':', '__')}`;
+}
diff --git a/examples/showcase/gltf/gltf-animation-studio.ts b/examples/showcase/gltf/gltf-animation-studio.ts
new file mode 100644
index 0000000000..43c91fa29a
--- /dev/null
+++ b/examples/showcase/gltf/gltf-animation-studio.ts
@@ -0,0 +1,225 @@
+// luma.gl
+// SPDX-License-Identifier: MIT
+// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors
+
+import type {AnimationLoopMode} from '@luma.gl/engine';
+import {type GLTFScenegraphs, setGLTFMorphWeights} from '@luma.gl/gltf';
+
+/** An authored facial-expression or morph-target control. */
+export type GLTFStudioMorphTarget = {
+ identifier: string;
+ nodeIndex: number;
+ targetIndex: number;
+ label: string;
+ value: number;
+};
+
+/** Readable capabilities and the current application-controlled playback state. */
+export type GLTFAnimationStudioState = {
+ clipNames: readonly string[];
+ selectedClip: string;
+ duration: number;
+ time: number;
+ playing: boolean;
+ speed: number;
+ crossFadeDuration: number;
+ loop: AnimationLoopMode;
+ variants: readonly string[];
+ selectedVariant: string;
+ morphTargets: readonly GLTFStudioMorphTarget[];
+ skinCount: number;
+ jointCount: number;
+ cameraCount: number;
+};
+
+const DEFAULT_VARIANT = '__default__';
+
+/** Small application controller built entirely on the public glTF animation APIs. */
+export class GLTFAnimationStudio {
+ private scenegraphs: GLTFScenegraphs | undefined;
+ private previousFrameTimeMilliseconds: number | undefined;
+ private readonly morphTargets = new Map();
+ private playing = true;
+ private speed = 1;
+ private crossFadeDuration = 0.35;
+ private loop: AnimationLoopMode = 'repeat';
+
+ attach(scenegraphs: GLTFScenegraphs): void {
+ this.scenegraphs = scenegraphs;
+ this.previousFrameTimeMilliseconds = undefined;
+ this.morphTargets.clear();
+ this.discoverMorphTargets();
+
+ const firstClip = scenegraphs.animator.clips[0];
+ const initialClip = scenegraphs.animator.clips.find(clip => clip.name === 'Idle') || firstClip;
+ if (initialClip) {
+ scenegraphs.animator.selectClip(initialClip.name);
+ initialClip.action.setLoop(this.loop);
+ }
+ scenegraphs.animator.mixer.timeScale = this.speed;
+ }
+
+ detach(): void {
+ this.scenegraphs = undefined;
+ this.previousFrameTimeMilliseconds = undefined;
+ this.morphTargets.clear();
+ }
+
+ update(timeMilliseconds: number): void {
+ const previousTimeMilliseconds = this.previousFrameTimeMilliseconds;
+ this.previousFrameTimeMilliseconds = timeMilliseconds;
+ if (!this.scenegraphs || !this.playing || previousTimeMilliseconds === undefined) {
+ return;
+ }
+
+ const elapsedSeconds = Math.max(0, (timeMilliseconds - previousTimeMilliseconds) / 1000);
+ this.scenegraphs.animator.update(elapsedSeconds);
+ }
+
+ selectClip(clipName: string): void {
+ if (!this.scenegraphs || !clipName) {
+ return;
+ }
+
+ const clip = this.scenegraphs.animator.selectClip(clipName, {
+ crossFadeDuration: this.crossFadeDuration
+ });
+ clip.action.setLoop(this.loop);
+ this.previousFrameTimeMilliseconds = undefined;
+ }
+
+ setPlaying(playing: boolean): void {
+ if (this.playing === playing) {
+ return;
+ }
+ this.playing = playing;
+ this.previousFrameTimeMilliseconds = undefined;
+ }
+
+ setSpeed(speed: number): void {
+ this.speed = Math.max(0, Math.min(4, speed));
+ if (this.scenegraphs) {
+ this.scenegraphs.animator.mixer.timeScale = this.speed;
+ }
+ }
+
+ setCrossFadeDuration(duration: number): void {
+ this.crossFadeDuration = Math.max(0, Math.min(2, duration));
+ }
+
+ setLoop(loop: AnimationLoopMode): void {
+ this.loop = loop;
+ const action = this.getActiveAction();
+ action?.setLoop(loop);
+ }
+
+ seek(timeSeconds: number): void {
+ const action = this.getActiveAction();
+ if (!action || !this.scenegraphs) {
+ return;
+ }
+
+ action.setTime(Math.max(0, Math.min(action.clip.duration, timeSeconds)));
+ this.scenegraphs.animator.update(0);
+ this.previousFrameTimeMilliseconds = undefined;
+ }
+
+ selectVariant(variant: string): void {
+ if (!this.scenegraphs) {
+ return;
+ }
+ if (variant === DEFAULT_VARIANT) {
+ this.scenegraphs.variants.resetVariant();
+ } else {
+ this.scenegraphs.variants.selectVariant(variant);
+ }
+ }
+
+ setMorphWeight(identifier: string, value: number): void {
+ const target = this.morphTargets.get(identifier);
+ const node = target
+ ? this.scenegraphs?.gltfNodeIndexToNodeMap.get(target.nodeIndex)
+ : undefined;
+ if (!target || !node) {
+ return;
+ }
+
+ const existingWeights = (node.userData['morphWeights'] as readonly number[] | undefined) || [];
+ const targetCount = this.getMorphTargetCount(target.nodeIndex);
+ const weights = Array.from({length: targetCount}, (_, index) => existingWeights[index] || 0);
+ target.value = Math.max(0, Math.min(1, value));
+ weights[target.targetIndex] = target.value;
+ setGLTFMorphWeights(node, weights);
+ }
+
+ getState(): GLTFAnimationStudioState {
+ const scenegraphs = this.scenegraphs;
+ const activeAction = this.getActiveAction();
+
+ return {
+ clipNames: scenegraphs?.animator.clips.map(clip => clip.name) || [],
+ selectedClip: scenegraphs?.animator.activeClip || '',
+ duration: activeAction?.clip.duration || 0,
+ time: activeAction?.time || 0,
+ playing: this.playing,
+ speed: this.speed,
+ crossFadeDuration: this.crossFadeDuration,
+ loop: this.loop,
+ variants: scenegraphs?.variants.names || [],
+ selectedVariant: scenegraphs?.variants.activeVariant || DEFAULT_VARIANT,
+ morphTargets: Array.from(this.morphTargets.values()),
+ skinCount: scenegraphs?.skins.bindings.length || 0,
+ jointCount:
+ scenegraphs?.skins.bindings.reduce(
+ (jointCount, skin) => jointCount + skin.joints.length,
+ 0
+ ) || 0,
+ cameraCount: scenegraphs?.cameras?.length || 0
+ };
+ }
+
+ private getActiveAction() {
+ const activeClip = this.scenegraphs?.animator.activeClip;
+ return activeClip ? this.scenegraphs?.animator.mixer.getAction(activeClip) : undefined;
+ }
+
+ private getMorphTargetCount(nodeIndex: number): number {
+ const sourceNode = this.scenegraphs?.gltf.nodes[nodeIndex];
+ const sourceMesh = sourceNode?.mesh;
+ return (
+ sourceMesh?.primitives.reduce(
+ (targetCount, primitive) => Math.max(targetCount, primitive.targets?.length || 0),
+ 0
+ ) || 0
+ );
+ }
+
+ private discoverMorphTargets(): void {
+ for (const [nodeIndex, sourceNode] of this.scenegraphs?.gltf.nodes.entries() || []) {
+ const targetCount = this.getMorphTargetCount(nodeIndex);
+ if (targetCount === 0) {
+ continue;
+ }
+
+ const sourceMesh = sourceNode.mesh;
+ const targetNames = sourceMesh?.extras?.['targetNames'];
+ const sourceWeights = sourceNode.weights || sourceMesh?.weights || [];
+ for (let targetIndex = 0; targetIndex < targetCount; targetIndex++) {
+ const authoredName = Array.isArray(targetNames) ? targetNames[targetIndex] : undefined;
+ const target = {
+ identifier: `${nodeIndex}:${targetIndex}`,
+ nodeIndex,
+ targetIndex,
+ label:
+ typeof authoredName === 'string'
+ ? authoredName
+ : `${sourceNode.name || 'Morph'} ${targetIndex + 1}`,
+ value: sourceWeights[targetIndex] || 0
+ };
+ this.morphTargets.set(target.identifier, target);
+ }
+ }
+ }
+}
+
+export {DEFAULT_VARIANT as GLTF_STUDIO_DEFAULT_VARIANT};
diff --git a/examples/showcase/gltf/gltf-catalog-app.ts b/examples/showcase/gltf/gltf-catalog-app.ts
index 5437aa1675..2196c881b0 100644
--- a/examples/showcase/gltf/gltf-catalog-app.ts
+++ b/examples/showcase/gltf/gltf-catalog-app.ts
@@ -2,13 +2,19 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors
-import {AnimationLoopTemplate, AnimationProps, ModelNode} from '@luma.gl/engine';
-import {Color, Device, RenderPass, log} from '@luma.gl/core';
import {load} from '@loaders.gl/core';
-import {Light, LightingProps, type PBRMaterialUniforms} from '@luma.gl/shadertools';
-import {createScenegraphsFromGLTF, type PBREnvironment} from '@luma.gl/gltf';
import {GLTFLoader, postProcessGLTF} from '@loaders.gl/gltf';
+import {Device, log, RenderPass} from '@luma.gl/core';
+import {AnimationLoopTemplate, AnimationProps, ModelNode} from '@luma.gl/engine';
+import {createScenegraphsFromGLTF, type PBREnvironment} from '@luma.gl/gltf';
+import {Light, LightingProps, type PBRMaterialUniforms} from '@luma.gl/shadertools';
import {Matrix4} from '@math.gl/core';
+import {GLTFAnimationStudio, type GLTFAnimationStudioState} from './gltf-animation-studio';
+import {
+ GLTF_FEATURED_ASSETS,
+ getBundledGLTFAssetUrl,
+ getFeaturedGLTFAsset
+} from './gltf-featured-assets';
/* eslint-disable camelcase */
@@ -19,6 +25,7 @@ const LAST_GLTF_MODEL_STORAGE_KEY = 'last-gltf-model';
const GLTF_OPTIONS_STORAGE_KEY = 'showcase-gltf-options';
const GLTF_LOADING_STYLE_ID = 'gltf-loading-indicator-style';
export const GLTF_MODEL_INFO_ID = 'model-info';
+export const GLTF_ANIMATION_INFO_ID = 'animation-info';
export const GLTF_CONTROL_PANEL_STYLE = 'display: grid; gap: 8px;';
export const GLTF_CONTROL_ROW_STYLE =
'display: grid; grid-template-columns: 7rem minmax(0, 1fr); align-items: center; column-gap: 0.75rem;';
@@ -69,6 +76,7 @@ const INFO_HTML = `\
+
`;
@@ -86,8 +94,14 @@ export type GLTFCatalogModel = {
screenshot?: string;
tags?: string[];
variants?: Record;
+ features?: readonly string[];
+ license?: string;
};
type GLTFModelMetadata = Pick;
+type GLTFStudioActor = {
+ scenegraphs: ReturnType;
+ studio: GLTFAnimationStudio;
+};
const GLTF_MODEL_METADATA_OVERRIDES: Record = {
PotOfCoalsAnimationPointer: {
@@ -107,7 +121,11 @@ export default class AppAnimationLoopTemplate extends AnimationLoopTemplate {
device: Device;
availableModels: GLTFCatalogModel[] = [];
scenegraphsFromGLTF?: ReturnType;
+ readonly animationStudio = new GLTFAnimationStudio();
+ private readonly animatedActors: GLTFStudioActor[] = [];
+ private activeScenegraphOptions: Parameters[2];
modelLights: Light[] = [];
+ selectedCameraIndex: number | null = null;
center = [0, 0, 0];
cameraHeight = 0;
cameraOrbitDistance = 1;
@@ -156,6 +174,10 @@ export default class AppAnimationLoopTemplate extends AnimationLoopTemplate {
if (this.isFinalized) {
return;
}
+ this.availableModels = getBundledFeaturedModels();
+ this.cleanupCallbacks.push(
+ this.initializeModelMenus(this.availableModels, initialModelName)
+ );
this.loadGLTF(initialModelName);
});
@@ -197,12 +219,15 @@ export default class AppAnimationLoopTemplate extends AnimationLoopTemplate {
cleanupCallback();
}
this.cleanupCallbacks = [];
+ this.clearAnimatedActors();
+ this.animationStudio.detach();
destroyScenegraphs(this.scenegraphsFromGLTF);
this.scenegraphsFromGLTF = undefined;
this.modelLights = [];
this.setViewerLoadingState(false);
updateModelInfoBox();
updateExtensionSupportTable();
+ updateAnimationStudioInfo();
}
getDefaultModelName(): string {
@@ -213,7 +238,7 @@ export default class AppAnimationLoopTemplate extends AnimationLoopTemplate {
return LAST_GLTF_MODEL_STORAGE_KEY;
}
- getClearColor(): Color {
+ getClearColor(): [number, number, number, number] {
return [0, 0, 0, 1];
}
@@ -248,6 +273,43 @@ export default class AppAnimationLoopTemplate extends AnimationLoopTemplate {
drawBackground(_renderPass: RenderPass): void {}
+ /** Lets richer showcase surfaces refresh controls after an asset has loaded. */
+ onScenegraphsChanged(_scenegraphs: ReturnType): void {}
+
+ getAnimationInstanceCount(): number {
+ return this.scenegraphsFromGLTF ? this.animatedActors.length + 1 : 1;
+ }
+
+ /** Keeps authored geometry shared by the source document while giving each actor its own pose. */
+ setAnimationInstanceCount(instanceCount: number): void {
+ this.clearAnimatedActors();
+ const sourceScenegraphs = this.scenegraphsFromGLTF;
+ if (!sourceScenegraphs) {
+ return;
+ }
+
+ const clampedCount = Math.max(1, Math.min(6, Math.floor(instanceCount)));
+ const preferredClips = ['Walking', 'Running', 'Dance', 'Wave', 'Idle'];
+ for (let instanceIndex = 1; instanceIndex < clampedCount; instanceIndex++) {
+ const scenegraphs = createScenegraphsFromGLTF(
+ this.device,
+ sourceScenegraphs.gltf,
+ this.activeScenegraphOptions
+ );
+ const studio = new GLTFAnimationStudio();
+ studio.attach(scenegraphs);
+ const availableClip = preferredClips
+ .map((_, index) => preferredClips[(index + instanceIndex) % preferredClips.length])
+ .find(clip => studio.getState().clipNames.includes(clip));
+ if (availableClip) {
+ studio.selectClip(availableClip);
+ studio.seek(instanceIndex * 0.22);
+ }
+ studio.setSpeed(0.78 + instanceIndex * 0.14);
+ this.animatedActors.push({scenegraphs, studio});
+ }
+ }
+
onRender({aspect, device, time}: AnimationProps): void {
const renderPass = device.beginRenderPass({clearColor: this.getClearColor(), clearDepth: 1});
this.drawBackground(renderPass);
@@ -259,10 +321,31 @@ export default class AppAnimationLoopTemplate extends AnimationLoopTemplate {
updateModelLightIndicator(this.modelLights, this.options['useModelLights']);
- const orbitDistance = this.cameraOrbitDistance;
+ const actorCount = this.getAnimationInstanceCount();
+ const orbitDistance = this.cameraOrbitDistance + this.sceneRadius * Math.max(0, actorCount - 1);
const far = Math.max(orbitDistance + this.sceneRadius * 8, 10);
const near = Math.max(this.sceneRadius / 1000, 0.01);
- const projectionMatrix = new Matrix4().perspective({fovy: Math.PI / 3, aspect, near, far});
+ const authoredCamera =
+ this.selectedCameraIndex === null
+ ? undefined
+ : this.scenegraphsFromGLTF.cameras?.[this.selectedCameraIndex];
+ const perspective = authoredCamera?.perspective;
+ const orthographic = authoredCamera?.orthographic;
+ const projectionMatrix = orthographic
+ ? new Matrix4().ortho({
+ left: -orthographic.xmag,
+ right: orthographic.xmag,
+ bottom: -orthographic.ymag,
+ top: orthographic.ymag,
+ near: orthographic.znear,
+ far: orthographic.zfar
+ })
+ : new Matrix4().perspective({
+ fovy: perspective?.yfov || Math.PI / 3,
+ aspect: perspective?.aspectRatio || aspect,
+ near: perspective?.znear || near,
+ far: perspective?.zfar || far
+ });
const cameraTime = this.options['cameraAnimation'] ? time : this.mouseCameraTime;
const orbitAngle = 0.001 * cameraTime;
const horizontalOrbitScale = Math.cos(this.mouseCameraTilt);
@@ -274,56 +357,74 @@ export default class AppAnimationLoopTemplate extends AnimationLoopTemplate {
orbitDistance * horizontalOrbitScale * Math.cos(orbitAngle)
];
- if (this.options['gltfAnimation']) {
- this.scenegraphsFromGLTF.animator?.setTime(time);
+ this.animationStudio.setPlaying(this.options['gltfAnimation']);
+ this.animationStudio.update(time);
+ for (const actor of this.animatedActors) {
+ actor.studio.setPlaying(this.options['gltfAnimation']);
+ actor.studio.update(time);
}
+ updateAnimationStudioInfo(this.animationStudio.getState(), actorCount);
const viewMatrix = new Matrix4().lookAt({eye: cameraPos, center: this.center});
const pbrMaterialProps = this.getPBRMaterialProps();
const hasPBRMaterialProps = Object.keys(pbrMaterialProps).length > 0;
- this.scenegraphsFromGLTF.scenes[0].traverse((node, {worldMatrix: modelMatrix}) => {
- const {model} = node as ModelNode;
-
- const modelViewProjectionMatrix = new Matrix4(projectionMatrix)
- .multiplyRight(viewMatrix)
- .multiplyRight(modelMatrix);
-
- const sceneShaderInputProps: Record = {
- lighting: this.getLightingProps(),
- pbrProjection: {
- camera: cameraPos,
- modelViewProjectionMatrix,
- modelMatrix,
- normalMatrix: new Matrix4(modelMatrix).invert().transpose()
- },
- skin: {
- scenegraphsFromGLTF: this.scenegraphsFromGLTF
+ const actors: readonly GLTFStudioActor[] = [
+ {scenegraphs: this.scenegraphsFromGLTF, studio: this.animationStudio},
+ ...this.animatedActors
+ ];
+ const horizontalSpacing = this.sceneRadius * 1.9;
+ for (const [actorIndex, actor] of actors.entries()) {
+ const horizontalOffset = (actorIndex - (actors.length - 1) * 0.5) * horizontalSpacing;
+ actor.scenegraphs.scenes[0].traverse((node, {worldMatrix}) => {
+ if (!(node instanceof ModelNode)) {
+ return;
}
- };
-
- if (hasPBRMaterialProps) {
- if (model.material?.ownsModule('pbrMaterial')) {
+ const {model} = node;
+ const modelMatrix = new Matrix4()
+ .translate([horizontalOffset, 0, 0])
+ .multiplyRight(worldMatrix);
+
+ const modelViewProjectionMatrix = new Matrix4(projectionMatrix)
+ .multiplyRight(viewMatrix)
+ .multiplyRight(modelMatrix);
+
+ const sceneShaderInputProps = {
+ lighting: this.getLightingProps(),
+ pbrProjection: {
+ camera: cameraPos,
+ modelViewProjectionMatrix,
+ modelMatrix,
+ normalMatrix: new Matrix4(modelMatrix).invert().transpose()
+ },
+ skin: {
+ scenegraphsFromGLTF: actor.scenegraphs
+ },
+ ...(hasPBRMaterialProps && !model.material?.ownsModule('pbrMaterial')
+ ? {pbrMaterial: pbrMaterialProps}
+ : {})
+ };
+
+ if (hasPBRMaterialProps && model.material?.ownsModule('pbrMaterial')) {
model.material.setProps({pbrMaterial: pbrMaterialProps});
- } else {
- sceneShaderInputProps.pbrMaterial = pbrMaterialProps;
}
- }
- model.shaderInputs.setProps(sceneShaderInputProps);
- model.draw(renderPass);
- });
+ model.shaderInputs.setProps(sceneShaderInputProps);
+ model.draw(renderPass);
+ });
+ }
renderPass.end();
}
async fetchModelList(): Promise {
const response = await fetch(MODEL_LIST_URL);
const models = (await response.json()) as GLTFCatalogModel[];
- return models.map(model => ({
+ const catalogModels = models.map(model => ({
...model,
hasGLBVariant: Boolean(model.variants?.['glTF-Binary'])
}));
+ return mergeFeaturedModels(catalogModels);
}
async loadGLTF(modelReference: string | GLTFModelReference) {
@@ -354,12 +455,17 @@ export default class AppAnimationLoopTemplate extends AnimationLoopTemplate {
)();
const processedGLTF = postProcessGLTF(gltf);
- const scenegraphsFromGLTF = createScenegraphsFromGLTF(this.device, processedGLTF, {
+ const scenegraphOptions = {
lights: true,
imageBasedLightingEnvironment,
pbrDebug: false,
useTangents: true
- });
+ };
+ const scenegraphsFromGLTF = createScenegraphsFromGLTF(
+ this.device,
+ processedGLTF,
+ scenegraphOptions
+ );
log.log(0, `Created glTF scenegraphs: ${modelDescription}`)();
if (this.isLoadStale(loadGeneration)) {
@@ -367,11 +473,16 @@ export default class AppAnimationLoopTemplate extends AnimationLoopTemplate {
return;
}
+ this.clearAnimatedActors();
destroyScenegraphs(this.scenegraphsFromGLTF);
this.scenegraphsFromGLTF = scenegraphsFromGLTF;
+ this.activeScenegraphOptions = scenegraphOptions;
+ this.animationStudio.attach(scenegraphsFromGLTF);
+ this.selectedCameraIndex = null;
this.modelLights = scenegraphsFromGLTF.lights;
this.updateModelInfo(resolvedModelReference, loadGeneration);
updateExtensionSupportTable(scenegraphsFromGLTF.extensionSupport);
+ updateAnimationStudioInfo(this.animationStudio.getState());
const activeSceneBounds =
scenegraphsFromGLTF.sceneBounds[0] || scenegraphsFromGLTF.modelBounds;
@@ -392,6 +503,8 @@ export default class AppAnimationLoopTemplate extends AnimationLoopTemplate {
MAX_CAMERA_TILT
);
+ this.onScenegraphsChanged(scenegraphsFromGLTF);
+
showError();
} catch (error) {
if (this.isLoadStale(loadGeneration)) {
@@ -453,6 +566,14 @@ export default class AppAnimationLoopTemplate extends AnimationLoopTemplate {
return this.modelMetadataCache.get(modelName)!;
}
+
+ private clearAnimatedActors(): void {
+ for (const actor of this.animatedActors) {
+ actor.studio.detach();
+ destroyScenegraphs(actor.scenegraphs);
+ }
+ this.animatedActors.length = 0;
+ }
}
function setModelMenu(
@@ -577,9 +698,50 @@ async function loadPreferredGLTF(candidateModelReferences: Required): string {
+ const featuredAsset = getFeaturedGLTFAsset(modelReference.name);
+ const localAssetUrl = featuredAsset
+ ? getBundledGLTFAssetUrl(
+ featuredAsset,
+ window.location,
+ Boolean((window as Window & {website?: boolean}).website)
+ )
+ : undefined;
+ if (localAssetUrl) {
+ return localAssetUrl;
+ }
return `${MODEL_DIRECTORY_URL}/${modelReference.name}/${modelReference.variant}/${modelReference.fileName}`;
}
+function mergeFeaturedModels(catalogModels: GLTFCatalogModel[]): GLTFCatalogModel[] {
+ const modelsByName = new Map(catalogModels.map(model => [model.name, model]));
+ const featuredModels = GLTF_FEATURED_ASSETS.flatMap(asset => {
+ const catalogModel = modelsByName.get(asset.name);
+ if (!catalogModel && !asset.bundledFilename) {
+ return [];
+ }
+
+ return [
+ {
+ ...catalogModel,
+ name: asset.name,
+ label: asset.label,
+ description: asset.description,
+ features: asset.features,
+ license: asset.license,
+ hasGLBVariant:
+ catalogModel?.hasGLBVariant || Boolean(asset.bundledFilename?.endsWith('.glb'))
+ }
+ ];
+ });
+ const featuredNames = new Set(featuredModels.map(model => model.name));
+
+ return [...featuredModels, ...catalogModels.filter(model => !featuredNames.has(model.name))];
+}
+
+function getBundledFeaturedModels(): GLTFCatalogModel[] {
+ return mergeFeaturedModels([]);
+}
+
function setOptionsUI(options: Record): Array<() => void> {
const cleanupCallbacks: Array<() => void> = [];
for (const id of Object.keys(options)) {
@@ -748,7 +910,10 @@ function setLoadingState(isLoading: boolean, message?: string): void {
}
function updateModelInfoBox(
- model?: Pick,
+ model?: Pick<
+ GLTFCatalogModel,
+ 'label' | 'name' | 'summary' | 'description' | 'features' | 'license'
+ >,
modelReference?: Required
): void {
const container = document.getElementById(GLTF_MODEL_INFO_ID) as HTMLDivElement | null;
@@ -795,6 +960,47 @@ function updateModelInfoBox(
descriptionParagraph.textContent = description;
container.append(descriptionParagraph);
}
+
+ if (model?.features?.length) {
+ const featureSummary = document.createElement('div');
+ featureSummary.style.fontSize = '12px';
+ featureSummary.style.marginTop = '8px';
+ featureSummary.style.opacity = '0.78';
+ featureSummary.textContent = model.features.join(' · ');
+ container.append(featureSummary);
+ }
+
+ if (model?.license) {
+ const license = document.createElement('div');
+ license.style.fontSize = '11px';
+ license.style.marginTop = '5px';
+ license.style.opacity = '0.68';
+ license.textContent = `Asset license: ${model.license}`;
+ container.append(license);
+ }
+}
+
+function updateAnimationStudioInfo(state?: GLTFAnimationStudioState, instanceCount = 1): void {
+ const container = document.getElementById(GLTF_ANIMATION_INFO_ID) as HTMLDivElement | null;
+ if (!container) {
+ return;
+ }
+
+ if (!state) {
+ container.textContent = '';
+ return;
+ }
+
+ const capabilities = [
+ instanceCount > 1 ? `${instanceCount} independent character instances` : undefined,
+ state.selectedClip ? `${state.selectedClip} ${state.time.toFixed(2)}s` : undefined,
+ state.clipNames.length ? `${state.clipNames.length} animation clips` : undefined,
+ state.skinCount ? `${state.skinCount} skeletons · ${state.jointCount} joints` : undefined,
+ state.morphTargets.length ? `${state.morphTargets.length} facial morphs` : undefined,
+ state.variants.length ? `${state.variants.length} material variants` : undefined,
+ state.cameraCount ? `${state.cameraCount} cameras` : undefined
+ ].filter(Boolean);
+ container.textContent = capabilities.join(' · ');
}
function showError(error?: unknown) {
@@ -855,7 +1061,8 @@ function updateExtensionSupportTable(extensionSupport?: GLTFExtensionSupportMap)
table.append(
createTableRow([
{text: 'Extension', header: true},
- {text: 'Built-in', header: true, align: 'center'},
+ {text: 'Maturity', header: true},
+ {text: 'Runtime', header: true},
{text: 'Notes', header: true}
])
);
@@ -864,9 +1071,11 @@ function updateExtensionSupportTable(extensionSupport?: GLTFExtensionSupportMap)
table.append(
createTableRow([
{text: supportInfo.extensionName, code: true},
+ {text: getExtensionMaturityLabel(supportInfo.extensionName)},
{
- text: supportInfo.supported ? '✓' : '✕',
- align: 'center',
+ text: supportInfo.supported
+ ? getSupportLevelLabel(supportInfo.supportLevel)
+ : 'Unavailable',
color: supportInfo.supported ? '#0b8457' : '#b00020',
title: getSupportLevelLabel(supportInfo.supportLevel)
},
@@ -878,6 +1087,20 @@ function updateExtensionSupportTable(extensionSupport?: GLTFExtensionSupportMap)
container.append(table);
}
+function getExtensionMaturityLabel(extensionName: string): string {
+ if (extensionName === 'KHR_materials_diffuse_transmission') {
+ return 'Release candidate';
+ }
+ if (
+ extensionName === 'KHR_materials_volume_scatter' ||
+ extensionName === 'KHR_materials_scatter' ||
+ extensionName === 'EXT_materials_bump'
+ ) {
+ return 'Experimental';
+ }
+ return extensionName.startsWith('KHR_') ? 'Khronos' : 'Extension';
+}
+
function createTableRow(
cells: Array<{
text: string;
@@ -952,10 +1175,21 @@ function getModelLightSummary(modelLights: Light[]): string {
}
function destroyScenegraphs(scenegraphsFromGLTF?: ReturnType) {
+ const disposableScenegraphs = scenegraphsFromGLTF as
+ | (ReturnType & {destroy?: () => void})
+ | undefined;
+ if (disposableScenegraphs?.destroy) {
+ disposableScenegraphs.destroy();
+ return;
+ }
+
+ const destroyedNodes = new Set();
for (const scene of scenegraphsFromGLTF?.scenes || []) {
scene.traverse(node => {
- const model = (node as Partial).model;
- model?.destroy();
+ if (node instanceof ModelNode && !destroyedNodes.has(node)) {
+ destroyedNodes.add(node);
+ node.destroy();
+ }
});
}
}
diff --git a/examples/showcase/gltf/gltf-featured-assets.ts b/examples/showcase/gltf/gltf-featured-assets.ts
new file mode 100644
index 0000000000..fc3dcaf1cc
--- /dev/null
+++ b/examples/showcase/gltf/gltf-featured-assets.ts
@@ -0,0 +1,135 @@
+// luma.gl
+// SPDX-License-Identifier: MIT
+// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors
+
+/** A deliberately selected, freely reusable asset in the glTF Animation Studio. */
+export type GLTFFeaturedAsset = {
+ name: string;
+ label: string;
+ category: 'animation' | 'materials' | 'native-extensions';
+ description: string;
+ features: readonly string[];
+ license: 'CC0-1.0';
+ bundledFilename?: string;
+ upstreamUrl?: string;
+};
+
+const THREE_SAMPLE_REVISION = '24595fb65bb662ea1e70984bb18301af06637b07';
+const KHRONOS_SAMPLE_REVISION = '2bac6f8c57bf471df0d2a1e8a8ec023c7801dddf';
+
+/** Small CC0 hero assets are bundled; larger Khronos examples remain lazy-loaded. */
+export const GLTF_FEATURED_ASSETS: readonly GLTFFeaturedAsset[] = [
+ {
+ name: 'RobotExpressive',
+ label: 'Expressive Robot · 14 Clips',
+ category: 'animation',
+ description: 'Fourteen named clips, two animated skeletons, and three facial expressions.',
+ features: ['14 animation clips', 'two skeletons', 'three facial morphs', 'crossfades'],
+ license: 'CC0-1.0',
+ bundledFilename: 'RobotExpressive.glb',
+ upstreamUrl: `https://raw.githubusercontent.com/mrdoob/three.js/${THREE_SAMPLE_REVISION}/examples/models/gltf/RobotExpressive/RobotExpressive.glb`
+ },
+ {
+ name: 'AnimatedMorphCube',
+ label: 'Animated Morph Targets',
+ category: 'animation',
+ description: 'A compact authored glTF morph-target and animation-channel example.',
+ features: ['morph targets', 'animated weights'],
+ license: 'CC0-1.0',
+ bundledFilename: 'AnimatedMorphCube.glb',
+ upstreamUrl: `https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/${KHRONOS_SAMPLE_REVISION}/Models/AnimatedMorphCube/glTF-Binary/AnimatedMorphCube.glb`
+ },
+ {
+ name: 'SimpleSkin',
+ label: 'Animated Skeleton',
+ category: 'animation',
+ description: 'A minimal joint hierarchy with inverse-bind matrices and skeletal animation.',
+ features: ['skeletal animation', 'joint palettes'],
+ license: 'CC0-1.0',
+ bundledFilename: 'SimpleSkin.gltf',
+ upstreamUrl: `https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/${KHRONOS_SAMPLE_REVISION}/Models/SimpleSkin/glTF-Embedded/SimpleSkin.gltf`
+ },
+ {
+ name: 'SimpleInstancing',
+ label: 'GPU Instancing',
+ category: 'native-extensions',
+ description: 'Source-authored transforms rendered with native GPU instancing.',
+ features: ['EXT_mesh_gpu_instancing'],
+ license: 'CC0-1.0'
+ },
+ {
+ name: 'CubeVisibility',
+ label: 'Animated Visibility',
+ category: 'native-extensions',
+ description: 'Recursive node visibility driven by typed glTF animation pointers.',
+ features: ['KHR_node_visibility', 'KHR_animation_pointer'],
+ license: 'CC0-1.0'
+ },
+ {
+ name: 'TransmissionTest',
+ label: 'Physical Transmission',
+ category: 'materials',
+ description: 'Transparent physical surfaces with authored transmission factors.',
+ features: ['KHR_materials_transmission'],
+ license: 'CC0-1.0'
+ },
+ {
+ name: 'IridescenceSuzanne',
+ label: 'Spectral Iridescence',
+ category: 'materials',
+ description: 'Thin-film interference on the familiar Suzanne reference model.',
+ features: ['KHR_materials_iridescence'],
+ license: 'CC0-1.0'
+ },
+ {
+ name: 'SheenChair',
+ label: 'Fabric Sheen',
+ category: 'materials',
+ description: 'Fabric fibers represented by the authored physical sheen extension.',
+ features: ['KHR_materials_sheen'],
+ license: 'CC0-1.0'
+ },
+ {
+ name: 'DiffuseTransmissionTeacup',
+ label: 'Translucent Porcelain · Preview',
+ category: 'materials',
+ description: 'A freely licensed diffuse-transmission release-candidate reference asset.',
+ features: ['KHR_materials_diffuse_transmission', 'release candidate'],
+ license: 'CC0-1.0'
+ },
+ {
+ name: 'ScatteringSkull',
+ label: 'Subsurface Scattering · Experimental',
+ category: 'materials',
+ description: 'An experimental volume-scattering and diffuse-transmission reference model.',
+ features: ['KHR_materials_volume_scatter', 'experimental proposal'],
+ license: 'CC0-1.0'
+ }
+];
+
+export function getFeaturedGLTFAsset(name: string): GLTFFeaturedAsset | undefined {
+ return GLTF_FEATURED_ASSETS.find(asset => asset.name === name);
+}
+
+/** Resolves assets shared with the independently built ANARI showcase. */
+export function getBundledGLTFAssetUrl(
+ asset: GLTFFeaturedAsset,
+ location: Pick,
+ website: boolean
+): string | undefined {
+ if (!asset.bundledFilename) {
+ return undefined;
+ }
+
+ if (!website) {
+ return asset.upstreamUrl;
+ }
+
+ const examplesPathIndex = location.pathname.indexOf('/examples/');
+ const websiteBasePath =
+ examplesPathIndex < 0 ? '/' : `${location.pathname.slice(0, examplesPathIndex)}/`;
+ return new URL(
+ `${websiteBasePath}standalone-examples/anari/gltf/${asset.bundledFilename}`,
+ location.href
+ ).href;
+}
diff --git a/examples/showcase/gltf/index.html b/examples/showcase/gltf/index.html
index 555ac05e6b..e5a1d92c40 100644
--- a/examples/showcase/gltf/index.html
+++ b/examples/showcase/gltf/index.html
@@ -1,5 +1,7 @@
+
+ glTF Animation Studio
-
+
+
+
diff --git a/modules/gltf/src/index.ts b/modules/gltf/src/index.ts
index 4468cc0c9d..22d0480fd4 100644
--- a/modules/gltf/src/index.ts
+++ b/modules/gltf/src/index.ts
@@ -75,6 +75,7 @@ export {
convertSamplerToGLTF,
type GLTFSampler
} from './webgl-to-webgpu/convert-webgl-sampler';
+export {type GLTFMorphTargetState, setGLTFMorphWeights} from './gltf/morph-targets';
// Standards-native glTF extension runtime helpers.
export {
diff --git a/test/examples/example-panels.spec.ts b/test/examples/example-panels.spec.ts
index 5331a078d3..6efa2c0eef 100644
--- a/test/examples/example-panels.spec.ts
+++ b/test/examples/example-panels.spec.ts
@@ -1,6 +1,10 @@
-import {describe, expect, test, vi} from 'vitest';
import type {SettingsChangeDescriptor, SettingsSchema} from '@deck.gl-community/panels';
import * as arrow from 'apache-arrow';
+import {describe, expect, test, vi} from 'vitest';
+import {
+ ArrowExamplePanelManager,
+ makeArrowExamplePanelHostHtml
+} from '../../examples/arrow/arrow-example-panels';
import {
configurePanelHostElement,
ExamplePanelManager,
@@ -12,24 +16,20 @@ import {
makeInlineSettingsSchema
} from '../../examples/example-panels';
import {applyExampleTheme, EXAMPLE_THEME_TOKENS} from '../../examples/example-theme';
-import {
- ArrowExamplePanelManager,
- makeArrowExamplePanelHostHtml
-} from '../../examples/arrow/arrow-example-panels';
-import {
- getTextSpaceCrawlColorKind,
- setTextSpaceCrawlColorKind
-} from '../../examples/text-space-crawl-color';
import {makeGltfSettingsSchema} from '../../examples/showcase/gltf/app';
import {
+ type EffectState,
flattenEffectSettings,
getEffectResolutionScale,
makePostprocessingUniforms,
reorderEffectPassNames,
unflattenEffectSettings,
- updateEffectPassNames,
- type EffectState
+ updateEffectPassNames
} from '../../examples/showcase/postprocessing/app';
+import {
+ getTextSpaceCrawlColorKind,
+ setTextSpaceCrawlColorKind
+} from '../../examples/text-space-crawl-color';
const TEST_SETTINGS_SCHEMA: SettingsSchema = {
title: 'Settings',
@@ -766,6 +766,57 @@ describe('glTF controls', () => {
})
);
});
+
+ test('exposes named clips, crossfades, expressions, material variants, and independent actors', () => {
+ const definitions = getSettingDefinitions(
+ makeGltfSettingsSchema(
+ [],
+ [],
+ 'featured',
+ {
+ clipNames: ['Idle', 'Walking'],
+ selectedClip: 'Idle',
+ duration: 2,
+ time: 0,
+ playing: true,
+ speed: 1,
+ crossFadeDuration: 0.35,
+ loop: 'repeat',
+ variants: ['Midnight'],
+ selectedVariant: '__default__',
+ morphTargets: [
+ {identifier: '13:0', nodeIndex: 13, targetIndex: 0, label: 'Angry', value: 0}
+ ],
+ skinCount: 2,
+ jointCount: 86,
+ cameraCount: 1
+ },
+ [{name: 'Studio Camera'}]
+ )
+ );
+
+ expect(definitions.get('animationClip')?.options).toEqual([
+ {label: 'Idle', value: 'Idle'},
+ {label: 'Walking', value: 'Walking'}
+ ]);
+ expect(definitions.get('animationCrossFade')).toEqual(
+ expect.objectContaining({label: 'Crossfade Seconds', max: 2})
+ );
+ expect(definitions.get('characterInstances')).toEqual(
+ expect.objectContaining({label: 'Independent Characters', max: 6})
+ );
+ expect(definitions.get('morph__13__0')).toEqual(
+ expect.objectContaining({label: 'Angry', max: 1})
+ );
+ expect(definitions.get('materialVariant')?.options).toContainEqual({
+ label: 'Midnight',
+ value: 'Midnight'
+ });
+ expect(definitions.get('cameraSelection')?.options).toContainEqual({
+ label: 'Studio Camera',
+ value: '0'
+ });
+ });
});
function makeMemoryStorage(initialValues: Record = {}): Storage {
diff --git a/test/examples/gltf-animation-studio.node.spec.ts b/test/examples/gltf-animation-studio.node.spec.ts
new file mode 100644
index 0000000000..4ca52e65a1
--- /dev/null
+++ b/test/examples/gltf-animation-studio.node.spec.ts
@@ -0,0 +1,179 @@
+// luma.gl
+// SPDX-License-Identifier: MIT
+// SPDX-FileCopyrightText: Copyright (c) vis.gl contributors
+
+import {readFile} from 'node:fs/promises';
+import {parse} from '@loaders.gl/core';
+import {GLTFLoader, postProcessGLTF} from '@loaders.gl/gltf';
+import {ANARISceneSchema} from '@luma.gl/anari/schemas';
+import {createScenegraphsFromGLTF} from '@luma.gl/gltf';
+import {NullDevice} from '@luma.gl/test-utils';
+import {describe, expect, test} from 'vitest';
+import {makeANARIJSONSceneFromGLTF} from '../../examples/showcase/anari/gltf-to-anari';
+import {GLTFAnimationStudio} from '../../examples/showcase/gltf/gltf-animation-studio';
+import {
+ GLTF_FEATURED_ASSETS,
+ getBundledGLTFAssetUrl,
+ getFeaturedGLTFAsset
+} from '../../examples/showcase/gltf/gltf-featured-assets';
+
+const ROBOT_PATH = new URL(
+ '../../examples/showcase/anari/public/gltf/RobotExpressive.glb',
+ import.meta.url
+);
+
+async function loadExpressiveRobot() {
+ const bytes = await readFile(ROBOT_PATH);
+ return postProcessGLTF(await parse(bytes, GLTFLoader, {gltf: {loadImages: false}}));
+}
+
+describe('curated glTF Animation Studio', () => {
+ test('vendors the real reusable robot with authored clips, skins, and facial targets', async () => {
+ const bytes = await readFile(ROBOT_PATH);
+ const robot = await loadExpressiveRobot();
+
+ expect(bytes.byteLength).toBe(463988);
+ expect(robot.animations).toHaveLength(14);
+ expect(robot.animations.map(animation => animation.name)).toEqual(
+ expect.arrayContaining(['Dance', 'Idle', 'Running', 'Walking', 'Wave'])
+ );
+ expect(robot.skins?.map(skin => skin.joints.length)).toEqual([43, 43]);
+ const face = robot.nodes.find(node => node.name === 'Head' && node.mesh);
+ expect(face?.mesh?.extras?.['targetNames']).toEqual(['Angry', 'Surprised', 'Sad']);
+ });
+
+ test('drives real named clip transitions, loops, speed, seek, skin palettes, and facial morphs', async () => {
+ const source = await loadExpressiveRobot();
+ const device = new NullDevice({});
+ const scenegraphs = createScenegraphsFromGLTF(device, source);
+ const studio = new GLTFAnimationStudio();
+
+ try {
+ studio.attach(scenegraphs);
+ expect(studio.getState()).toEqual(
+ expect.objectContaining({
+ selectedClip: 'Idle',
+ skinCount: 2,
+ jointCount: 86,
+ playing: true
+ })
+ );
+ expect(studio.getState().clipNames).toHaveLength(14);
+ expect(studio.getState().morphTargets.map(target => target.label)).toEqual([
+ 'Angry',
+ 'Surprised',
+ 'Sad'
+ ]);
+
+ studio.setCrossFadeDuration(0.4);
+ studio.selectClip('Walking');
+ studio.update(0);
+ studio.update(250);
+ const firstTime = studio.getState().time;
+ expect(firstTime).toBeGreaterThan(0);
+
+ studio.setSpeed(2);
+ studio.update(500);
+ expect(studio.getState().time).toBeGreaterThan(firstTime);
+ expect(scenegraphs.animator.mixer.timeScale).toBe(2);
+
+ studio.setLoop('ping-pong');
+ expect(scenegraphs.animator.mixer.getAction('Walking')?.loop).toBe('ping-pong');
+ studio.seek(0.15);
+ expect(studio.getState().time).toBeCloseTo(0.15, 4);
+
+ const face = studio.getState().morphTargets[0];
+ studio.setMorphWeight(face.identifier, 0.75);
+ const faceNode = scenegraphs.gltfNodeIndexToNodeMap.get(face.nodeIndex);
+ expect(faceNode?.userData['morphWeights']).toEqual([0.75, 0, 0]);
+
+ studio.setPlaying(false);
+ const pausedTime = studio.getState().time;
+ studio.update(750);
+ expect(studio.getState().time).toBe(pausedTime);
+
+ studio.detach();
+ expect(studio.getState().clipNames).toEqual([]);
+ } finally {
+ for (const scene of scenegraphs.scenes) {
+ scene.destroy();
+ }
+ device.destroy();
+ }
+ });
+
+ test('imports all real robot clips and skin palettes through the retained ANARI showcase', async () => {
+ const source = await loadExpressiveRobot();
+ const retainedScene = await makeANARIJSONSceneFromGLTF(source, 'EXPRESSIVE ROBOT');
+ const skinSurfaces = Object.values(retainedScene.surfaces).filter(surface => surface.skin);
+
+ expect(retainedScene.clips).toHaveLength(14);
+ expect(skinSurfaces.length).toBeGreaterThan(1);
+ expect(skinSurfaces.every(surface => surface.skin?.joints.length === 43)).toBe(true);
+ expect(ANARISceneSchema.safeParse(retainedScene).success).toBe(true);
+ });
+
+ test('keeps the complete curated collection explicitly CC0 and resolves website base paths', () => {
+ expect(GLTF_FEATURED_ASSETS.every(asset => asset.license === 'CC0-1.0')).toBe(true);
+ expect(GLTF_FEATURED_ASSETS.map(asset => asset.name)).toEqual(
+ expect.arrayContaining([
+ 'RobotExpressive',
+ 'AnimatedMorphCube',
+ 'SimpleSkin',
+ 'SimpleInstancing',
+ 'DiffuseTransmissionTeacup',
+ 'ScatteringSkull'
+ ])
+ );
+
+ const robot = getFeaturedGLTFAsset('RobotExpressive');
+ expect(robot).toBeDefined();
+ expect(
+ getBundledGLTFAssetUrl(
+ robot!,
+ {
+ href: 'https://luma.gl/luma.gl/examples/showcase/gltf',
+ pathname: '/luma.gl/examples/showcase/gltf'
+ },
+ true
+ )
+ ).toBe('https://luma.gl/luma.gl/standalone-examples/anari/gltf/RobotExpressive.glb');
+ expect(
+ getBundledGLTFAssetUrl(robot!, {href: 'http://localhost:5173/', pathname: '/'}, false)
+ ).toContain('raw.githubusercontent.com/mrdoob/three.js/');
+ });
+
+ test('surfaces bundled skeletal, morph, and robot samples in both retained-scene surfaces', async () => {
+ const samples = await readFile(
+ new URL('../../examples/showcase/anari/usd-samples.ts', import.meta.url),
+ 'utf8'
+ );
+ const playground = await readFile(
+ new URL('../../examples/showcase/anari/playground.html', import.meta.url),
+ 'utf8'
+ );
+ const license = await readFile(
+ new URL('../../examples/showcase/anari/public/gltf/ASSET-LICENSE.md', import.meta.url),
+ 'utf8'
+ );
+
+ for (const assetName of ['RobotExpressive.glb', 'AnimatedMorphCube.glb', 'SimpleSkin.gltf']) {
+ expect(samples).toContain(assetName);
+ expect(license).toContain(assetName);
+ }
+ expect(license).toContain('CC0');
+ expect(playground).toContain('id="export-glb"');
+ });
+
+ test('provides the settings host when Animation Studio runs outside the website shell', async () => {
+ const standaloneStudio = await readFile(
+ new URL('../../examples/showcase/gltf/index.html', import.meta.url),
+ 'utf8'
+ );
+
+ expect(standaloneStudio).toContain('glTF Animation Studio');
+ expect(standaloneStudio).toContain('aria-label="glTF Animation Studio controls"');
+ expect(standaloneStudio).toContain('data-info-box-appearance="cinematic"');
+ expect(standaloneStudio).toContain('id="example-panel-host" data-example-panel-host');
+ });
+});
diff --git a/test/examples/gltf-animation-studio.spec.ts b/test/examples/gltf-animation-studio.spec.ts
new file mode 100644
index 0000000000..9edf198b9b
--- /dev/null
+++ b/test/examples/gltf-animation-studio.spec.ts
@@ -0,0 +1,106 @@
+// luma.gl
+// SPDX-License-Identifier: MIT
+// SPDX-FileCopyrightText: 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 {createScenegraphsFromGLTF} from '@luma.gl/gltf';
+import {getTestDevices, getWebGLTestDevice} from '@luma.gl/test-utils';
+import {Matrix4} from '@math.gl/core';
+import test from 'test/utils/vitest-tape';
+import {GLTFAnimationStudio} from '../../examples/showcase/gltf/gltf-animation-studio';
+
+test('Animation Studio renders expressive robot skinning and facial morphs on WebGL and WebGPU', async testCase => {
+ const source = postProcessGLTF(
+ await load('/examples/showcase/anari/public/gltf/RobotExpressive.glb', GLTFLoader, {
+ gltf: {loadImages: false}
+ })
+ );
+ const [webglDevice, webgpuDevices] = await Promise.all([
+ getWebGLTestDevice(),
+ getTestDevices(['webgpu'])
+ ]);
+ const devices = webglDevice ? [webglDevice, ...webgpuDevices] : webgpuDevices;
+
+ for (const device of devices) {
+ const scenegraphs = createScenegraphsFromGLTF(device, source);
+ const studio = new GLTFAnimationStudio();
+ const color = device.createTexture({
+ width: 32,
+ height: 32,
+ format: device.preferredColorFormat,
+ usage: Texture.RENDER | Texture.COPY_SRC
+ });
+ const depth = device.createTexture({
+ width: 32,
+ height: 32,
+ format: 'depth24plus',
+ usage: Texture.RENDER
+ });
+ const framebuffer = device.createFramebuffer({
+ width: 32,
+ height: 32,
+ colorAttachments: [color],
+ depthStencilAttachment: depth
+ });
+
+ try {
+ studio.attach(scenegraphs);
+ studio.selectClip('Walking');
+ studio.update(0);
+ studio.update(400);
+ const firstPose = Array.from(scenegraphs.skins.bindings[0].jointMatrices);
+ studio.selectClip('Running');
+ studio.update(500);
+ studio.update(900);
+
+ testCase.equal(scenegraphs.skins.bindings.length, 2, `${device.type} binds both robot skins`);
+ testCase.notDeepEqual(
+ Array.from(scenegraphs.skins.bindings[0].jointMatrices),
+ firstPose,
+ `${device.type} updates a real 43-joint skin during crossfades`
+ );
+
+ const expression = studio.getState().morphTargets[0];
+ studio.setMorphWeight(expression.identifier, 0.8);
+ testCase.equal(
+ scenegraphs.gltfNodeIndexToNodeMap.get(expression.nodeIndex)?.userData['morphWeights']?.[0],
+ 0.8,
+ `${device.type} applies authored Angry facial morph`
+ );
+
+ const binding = scenegraphs.skins.bindings[0];
+ const modelNode = binding.models[0];
+ const identity = Array.from(new Matrix4());
+ modelNode.model.shaderInputs.setProps({
+ pbrProjection: {
+ modelViewProjectionMatrix: identity,
+ modelMatrix: identity,
+ normalMatrix: identity,
+ camera: [0, 0, 4]
+ },
+ skin: {jointMatrices: binding.jointMatrices}
+ });
+ const renderPass = device.beginRenderPass({
+ framebuffer,
+ clearColor: [0, 0, 0, 0],
+ clearDepth: 1
+ });
+ testCase.ok(modelNode.model.draw(renderPass), `${device.type} draws the animated robot`);
+ renderPass.end();
+ device.submit();
+ } finally {
+ studio.detach();
+ for (const scene of scenegraphs.scenes) {
+ scene.destroy();
+ }
+ framebuffer.destroy();
+ color.destroy();
+ depth.destroy();
+ }
+ }
+
+ testCase.ok(devices.length > 0, 'at least one live graphics backend is exercised');
+ testCase.end();
+});
diff --git a/website/content/examples/showcase/gltf.mdx b/website/content/examples/showcase/gltf.mdx
index 59c5241b76..db82404459 100644
--- a/website/content/examples/showcase/gltf.mdx
+++ b/website/content/examples/showcase/gltf.mdx
@@ -12,3 +12,53 @@ sidebar_custom_props:
import {GLTFExample} from '@site/src/examples';
+
+## Animation Studio
+
+The default **Expressive Robot** is a compact, CC0-licensed production asset containing 14 named
+animation clips, two 43-joint skeletons, and three facial-expression morph targets. Use the Animation
+Studio controls to select `Idle`, `Walking`, `Running`, `Dance`, and other clips; adjust playback
+speed; crossfade between actions; scrub through a clip; and choose repeat, ping-pong, or single-shot
+playback.
+
+Increase **Independent Characters** to place several copies of the same asset side by side. Each
+character receives its own authored clip, animation phase, playback speed, skinning palette, and
+mutable scenegraph while continuing to reuse the original decoded glTF document.
+
+The **Facial Expressions** controls directly update the robot's `Angry`, `Surprised`, and `Sad`
+morph targets. These controls use the same public scenegraph, animation, skeletal, and morph-target
+APIs that an application can use directly:
+
+```ts
+import {load} from '@loaders.gl/core';
+import {GLTFLoader, postProcessGLTF} from '@loaders.gl/gltf';
+import {createScenegraphsFromGLTF, setGLTFMorphWeights} from '@luma.gl/gltf';
+
+const source = postProcessGLTF(await load('/models/RobotExpressive.glb', GLTFLoader));
+const scenegraphs = createScenegraphsFromGLTF(device, source);
+
+scenegraphs.animator.selectClip('Walking');
+scenegraphs.animator.selectClip('Running', {crossFadeDuration: 0.35});
+scenegraphs.animator.mixer.timeScale = 1.25;
+
+const faceNode = scenegraphs.gltfNodeIndexToNodeMap.get(13);
+if (faceNode) {
+ setGLTFMorphWeights(faceNode, [0.8, 0, 0]);
+}
+```
+
+## Curated, reusable assets
+
+The **Curated Highlights** collection starts with small offline-capable CC0 skeletal and morph
+fixtures, then includes representative GPU instancing, visibility, transmission, sheen,
+iridescence, diffuse-transmission, and experimental scattering samples from Khronos. Larger samples
+are loaded on demand and each selected asset displays its feature tags and license.
+
+Use the materials controls to select authored `KHR_materials_variants` without rebuilding the scene,
+and choose source-authored camera projections when present. The extension inspector distinguishes
+implemented runtime support from release-candidate and experimental specifications, so draft
+materials are not presented as ratified glTF features.
+
+The companion [ANARI Playground](/examples/experimental/anari-playground) exposes the same Expressive
+Robot, Animated Morph Cube, and Simple Skin fixtures. Its retained-scene editor can export both
+textual `.gltf` and binary `.glb` files while preserving compatible animation clips and skin data.