Skip to content

Refactor conversion of glTF nodes to luma.gl nodes, add skin capability #2391

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 28 additions & 18 deletions examples/tutorials/hello-gltf/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,19 @@
// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors

import {AnimationLoopTemplate, AnimationProps, GroupNode, ModelNode} from '@luma.gl/engine';
import {AnimationLoopTemplate, AnimationProps, ModelNode} from '@luma.gl/engine';
import {Device} from '@luma.gl/core';
import {load} from '@loaders.gl/core';
import {LightingProps} from '@luma.gl/shadertools';
import {createScenegraphsFromGLTF, GLTFAnimator} from '@luma.gl/gltf';
import {createScenegraphsFromGLTF} from '@luma.gl/gltf';
import {GLTFLoader, postProcessGLTF} from '@loaders.gl/gltf';
import {Matrix4} from '@math.gl/core';

/* eslint-disable camelcase */

const MODEL_DIRECTORY_URL =
'https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/main/Models/';
const MODEL_LIST_URL =
'https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/main/Models/model-index.json';
'https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Assets/main/Models';
const MODEL_LIST_URL = `${MODEL_DIRECTORY_URL}/model-index.json`;

const lightSources = {
ambientLight: {
Expand Down Expand Up @@ -43,11 +42,10 @@ const lightSources = {

export default class AppAnimationLoopTemplate extends AnimationLoopTemplate {
device: Device;
scenes: GroupNode[] = [];
animator?: GLTFAnimator;
scenegraphsFromGLTF?: ReturnType<typeof createScenegraphsFromGLTF>;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: That is an ugly type, maybe we can import a type instead of brute force extracting from the function?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's what I did before but you said that type is not useful.

center = [0, 0, 0];
cameraPos = [0, 0, 0];
time: number = 0;
mouseCameraTime: number = 0;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Maybe a TSDoc on this line (what is mouse camera time)?

options: Record<string, boolean> = {
cameraAnimation: true,
gltfAnimation: false,
Expand Down Expand Up @@ -79,33 +77,40 @@ export default class AppAnimationLoopTemplate extends AnimationLoopTemplate {
}
);
});

this.device.getDefaultCanvasContext().canvas.addEventListener('mousemove', event => {
const e = event as MouseEvent;
if (e.buttons) {
this.mouseCameraTime -= e.movementX * 3.5;
}
});
}

onFinalize() {
this.scenes[0].traverse(node => (node as ModelNode).model.destroy());
this.scenegraphsFromGLTF?.scenes[0].traverse(node => (node as ModelNode).model.destroy());
}

onRender({aspect, device, time}: AnimationProps): void {
if (!this.scenes?.length) return;
if (!this.scenegraphsFromGLTF?.scenes?.length) return;
const renderPass = device.beginRenderPass({clearColor: [0, 0, 0, 1], clearDepth: 1});

const far = 2 * this.cameraPos[0];
const near = far / 1000;
const projectionMatrix = new Matrix4().perspective({fovy: Math.PI / 3, aspect, near, far});
const cameraTime = this.options['cameraAnimation'] ? time : 0;
const cameraTime = this.options['cameraAnimation'] ? time : this.mouseCameraTime;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Can we type the options so that we don't need index signatures?

const cameraPos = [
this.cameraPos[0] * Math.sin(0.001 * cameraTime),
this.cameraPos[1],
this.cameraPos[2] * Math.cos(0.001 * cameraTime)
];

if (this.options['gltfAnimation']) {
this.animator?.setTime(time);
this.scenegraphsFromGLTF.animator?.setTime(time);
}

const viewMatrix = new Matrix4().lookAt({eye: cameraPos, center: this.center});

this.scenes[0].traverse((node, {worldMatrix: modelMatrix}) => {
this.scenegraphsFromGLTF.scenes[0].traverse((node, {worldMatrix: modelMatrix}) => {
const {model} = node as ModelNode;

const modelViewProjectionMatrix = new Matrix4(projectionMatrix)
Expand All @@ -119,6 +124,11 @@ export default class AppAnimationLoopTemplate extends AnimationLoopTemplate {
modelViewProjectionMatrix,
modelMatrix,
normalMatrix: new Matrix4(modelMatrix).invert().transpose()
},
skin: {
// TODO: This is required to trigger getUniforms() of skin.
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add to glTF tracker task?

// Fix it, then remove this.
scenegraphsFromGLTF: this.scenegraphsFromGLTF
}
});
model.draw(renderPass);
Expand All @@ -143,20 +153,17 @@ export default class AppAnimationLoopTemplate extends AnimationLoopTemplate {
);
const processedGLTF = postProcessGLTF(gltf);

const {scenes, animator} = createScenegraphsFromGLTF(this.device, processedGLTF, {
this.scenegraphsFromGLTF = createScenegraphsFromGLTF(this.device, processedGLTF, {
lights: true,
imageBasedLightingEnvironment: undefined,
pbrDebug: false
});

this.scenes = scenes;
this.animator = animator;

// Calculate nice camera view
// TODO move to utility in gltf module
let min = [Infinity, Infinity, Infinity];
let max = [0, 0, 0];
this.scenes[0].traverse(node => {
this.scenegraphsFromGLTF?.scenes[0].traverse(node => {
const {bounds} = node as ModelNode;
min = min.map((n, i) => Math.min(n, bounds[0][i], bounds[1][i]));
max = max.map((n, i) => Math.max(n, bounds[0][i], bounds[1][i]));
Expand All @@ -167,6 +174,8 @@ export default class AppAnimationLoopTemplate extends AnimationLoopTemplate {
canvas.style.opacity = '1';
showError();
} catch (error) {
// eslint-disable-next-line no-console
console.error(error);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Do we need both console and showError?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes! because the showError() doesn't have stack trace.

showError(error as Error);
}
}
Expand Down Expand Up @@ -194,6 +203,7 @@ function setModelMenu(
});

modelSelector.append(...options);
modelSelector.value = currentItem;
}

function setOptionsUI(options: Record<string, boolean>) {
Expand Down
16 changes: 16 additions & 0 deletions modules/engine/src/scenegraph/group-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,4 +108,20 @@ export class GroupNode extends ScenegraphNode {
}
}
}

preorderTraversal(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is a preorderTraversal? Should we have an option parameter to traverse instead?

traverse(... {..., traversalOrder: 'breadth-first' | 'depth-first'})

or something like that?

visitor: (node: ScenegraphNode, context: {worldMatrix: Matrix4}) => void,
{worldMatrix = new Matrix4()} = {}
) {
const modelMatrix = new Matrix4(worldMatrix).multiplyRight(this.matrix);
visitor(this, {worldMatrix: modelMatrix});

for (const child of this.children) {
if (child instanceof GroupNode) {
child.preorderTraversal(visitor, {worldMatrix: modelMatrix});
} else {
visitor(child, {worldMatrix: modelMatrix});
}
}
}
}
43 changes: 27 additions & 16 deletions modules/engine/src/scenegraph/scenegraph-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@
import {Vector3, Matrix4, NumericArray} from '@math.gl/core';
import {uid} from '../utils/uid';

function assert(condition: boolean, message?: string): asserts condition {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't luma.gl/core export an assert function?

if (!condition) {
throw new Error(message);
}
}

/** Properties for creating a new Scenegraph */
export type ScenegraphNodeProps = {
id?: string;
Expand Down Expand Up @@ -57,19 +63,19 @@ export class ScenegraphNode {
}

setPosition(position: any): this {
// assert(position.length === 3, 'setPosition requires vector argument');
assert(position.length === 3, 'setPosition requires vector argument');
this.position = position;
return this;
}

setRotation(rotation: any): this {
// assert(rotation.length === 3, 'setRotation requires vector argument');
assert(rotation.length === 3 || rotation.length === 4, 'setRotation requires vector argument');
this.rotation = rotation;
return this;
}

setScale(scale: any): this {
// assert(scale.length === 3, 'setScale requires vector argument');
assert(scale.length === 3, 'setScale requires vector argument');
this.scale = scale;
return this;
}
Expand Down Expand Up @@ -105,19 +111,20 @@ export class ScenegraphNode {
}

updateMatrix(): this {
const pos = this.position;
const rot = this.rotation;
const scale = this.scale;

this.matrix.identity();
this.matrix.translate(pos);
this.matrix.rotateXYZ(rot);
this.matrix.scale(scale);
this.matrix.translate(this.position);
if (this.rotation.length === 4) {
const rotationMatrix = new Matrix4().fromQuaternion(this.rotation);
this.matrix.multiplyRight(rotationMatrix);
} else {
this.matrix.rotateXYZ(this.rotation);
}
this.matrix.scale(this.scale);

return this;
}

update(options: {position?: any; rotation?: any; scale?: any} = {}): this {
const {position, rotation, scale} = options;
update({position, rotation, scale}: {position?: any; rotation?: any; scale?: any} = {}): this {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Maybe add TSDoc to each method?

if (position) {
this.setPosition(position);
}
Expand All @@ -127,7 +134,9 @@ export class ScenegraphNode {
if (scale) {
this.setScale(scale);
}

this.updateMatrix();

return this;
}

Expand Down Expand Up @@ -188,18 +197,20 @@ export class ScenegraphNode {
// this.display = props.display;
// }

if ('position' in props) {
if (props?.position) {
this.setPosition(props.position);
}
if ('rotation' in props) {
if (props?.rotation) {
this.setRotation(props.rotation);
}
if ('scale' in props) {
if (props?.scale) {
this.setScale(props.scale);
}

this.updateMatrix();

// Matrix overwrites other props
if ('matrix' in props) {
if (props?.matrix) {
this.setMatrix(props.matrix);
}

Expand Down
10 changes: 6 additions & 4 deletions modules/gltf/src/gltf/animations/animations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,23 @@
// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors

import {GLTFNodePostprocessed} from '@loaders.gl/gltf';
import {GroupNode} from '@luma.gl/engine';

export type GLTFAnimation = {
name: string;
channels: GLTFAnimationChannel[];
};

export type GLTFAnimationPath = 'translation' | 'rotation' | 'scale' | 'weights';

export type GLTFAnimationChannel = {
path: 'translation' | 'rotation' | 'scale' | 'weights';
path: GLTFAnimationPath;
sampler: GLTFAnimationSampler;
target: GLTFNodePostprocessed;
target: GroupNode;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q: could the target be by node key instead of Node?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using GroupNode makes this generic and detaches it from glTF. What is node key?

Copy link
Collaborator

@ibgreen ibgreen May 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using GroupNode makes this generic and detaches it from glTF. What is node key?

It detaches it from glTF but links it to the "dynamic" Node structure, forcing apps to keep track of generated nodes.
Referencing a node by key would decouple it further, in much more declarative way, if that is possible.
Since the glTF keys are not guaranteed to be unique, one option is that we mint our own unique keys on nodes, or maybe defined a path concept of keys through the hierarcy.
That would be for a separate PR, obviously.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, I'll keep GroupNode for this PR.

};

export type GLTFAnimationSampler = {
input: number[];
interpolation: string;
output: number[] | number[][];
output: number[][];
};
Loading
Loading