@loaders.gl/gltf
Version:
Framework-independent loader for the glTF format
684 lines (591 loc) • 20.5 kB
text/typescript
// loaders.gl
// SPDX-License-Identifier: MIT
// Copyright (c) vis.gl contributors
import type {GLTFWithBuffers} from '../types/gltf-types';
import type {
GLTF,
GLTFScene,
GLTFNode,
GLTFMesh,
GLTFSkin,
GLTFMaterial,
GLTFAccessor,
GLTFSampler,
GLTFTexture,
GLTFImage,
GLTFBuffer,
GLTFBufferView
} from '../types/gltf-json-schema';
import {getBinaryImageMetadata} from '@loaders.gl/images';
import {padToNBytes, copyToArray} from '@loaders.gl/loader-utils';
import {assert} from '../utils/assert';
import {getAccessorTypeFromSize, getComponentTypeFromArray} from '../gltf-utils/gltf-utils';
import {getTypedArrayForAccessor as _getTypedArrayForAccessor} from '../gltf-utils/get-typed-array';
type Extension = {[key: string]: any};
function makeDefaultGLTFJson(): GLTF {
return {
asset: {
version: '2.0',
generator: 'loaders.gl'
},
buffers: [],
extensions: {},
extensionsRequired: [],
extensionsUsed: []
};
}
/**
* Class for structured access to GLTF data
*/
export class GLTFScenegraph {
// internal
gltf: GLTFWithBuffers;
sourceBuffers: any[];
byteLength: number;
// TODO - why is this not GLTFWithBuffers - what happens to images?
constructor(gltf?: {json: GLTF; buffers?: any[]; images?: any[]}) {
// Declare locally so
this.gltf = {
json: gltf?.json || makeDefaultGLTFJson(),
buffers: gltf?.buffers || [],
images: gltf?.images || []
};
this.sourceBuffers = [];
this.byteLength = 0;
// Initialize buffers
if (this.gltf.buffers && this.gltf.buffers[0]) {
this.byteLength = this.gltf.buffers[0].byteLength;
this.sourceBuffers = [this.gltf.buffers[0]];
}
}
// Accessors
get json(): GLTF {
return this.gltf.json;
}
getApplicationData(key: string): unknown {
// TODO - Data is already unpacked by GLBParser
const data = this.json[key];
return data;
}
getExtraData(key: string): unknown {
// TODO - Data is already unpacked by GLBParser
const extras = (this.json.extras || {}) as Record<string, unknown>;
return extras[key];
}
hasExtension(extensionName: string): boolean {
const isUsedExtension = this.getUsedExtensions().find((name) => name === extensionName);
const isRequiredExtension = this.getRequiredExtensions().find((name) => name === extensionName);
return typeof isUsedExtension === 'string' || typeof isRequiredExtension === 'string';
}
getExtension<T = Extension>(extensionName: string): T | null {
const isExtension = this.getUsedExtensions().find((name) => name === extensionName);
const extensions = this.json.extensions || {};
return isExtension ? (extensions[extensionName] as T) : null;
}
getRequiredExtension<T = Extension>(extensionName: string): T | null {
const isRequired = this.getRequiredExtensions().find((name) => name === extensionName);
return isRequired ? this.getExtension(extensionName) : null;
}
getRequiredExtensions(): string[] {
return this.json.extensionsRequired || [];
}
getUsedExtensions(): string[] {
return this.json.extensionsUsed || [];
}
getRemovedExtensions(): string[] {
return (this.json.extensionsRemoved || []) as string[];
}
getObjectExtension<T = Extension>(object: {[key: string]: any}, extensionName: string): T | null {
const extensions = object.extensions || {};
return extensions[extensionName];
}
getScene(index: number): GLTFScene {
return this.getObject('scenes', index) as GLTFScene;
}
getNode(index: number): GLTFNode {
return this.getObject('nodes', index) as GLTFNode;
}
getSkin(index: number): GLTFSkin {
return this.getObject('skins', index) as GLTFSkin;
}
getMesh(index: number): GLTFMesh {
return this.getObject('meshes', index) as GLTFMesh;
}
getMaterial(index: number): GLTFMaterial {
return this.getObject('materials', index) as GLTFMaterial;
}
getAccessor(index: number): GLTFAccessor {
return this.getObject('accessors', index) as GLTFAccessor;
}
// getCamera(index: number): object | null {
// return null; // TODO: fix thi: object as null;
// }
getTexture(index: number): GLTFTexture {
return this.getObject('textures', index) as GLTFTexture;
}
getSampler(index: number): GLTFSampler {
return this.getObject('samplers', index) as GLTFSampler;
}
getImage(index: number): GLTFImage {
return this.getObject('images', index) as GLTFImage;
}
getBufferView(index: number | object): GLTFBufferView {
return this.getObject('bufferViews', index) as GLTFBufferView;
}
getBuffer(index: number): GLTFBuffer {
return this.getObject('buffers', index) as GLTFBuffer;
}
getObject(array: string, index: number | object): Record<string, unknown> {
// check if already resolved
if (typeof index === 'object') {
return index as Record<string, unknown>;
}
const object = this.json[array] && (this.json[array] as {}[])[index];
if (!object) {
throw new Error(`glTF file error: Could not find ${array}[${index}]`); // eslint-disable-line
}
return object as Record<string, unknown>;
}
/**
* Accepts buffer view index or buffer view object
* @returns a `Uint8Array`
*/
getTypedArrayForBufferView(bufferView: number | object): Uint8Array {
bufferView = this.getBufferView(bufferView);
// @ts-ignore
const bufferIndex = bufferView.buffer;
// Get hold of the arrayBuffer
// const buffer = this.getBuffer(bufferIndex);
const binChunk = this.gltf.buffers[bufferIndex];
assert(binChunk);
// @ts-ignore
const byteOffset = (bufferView.byteOffset || 0) + binChunk.byteOffset;
// @ts-ignore
return new Uint8Array(binChunk.arrayBuffer, byteOffset, bufferView.byteLength);
}
/** Accepts accessor index or accessor object
* @returns a typed array with type that matches the types
*/
getTypedArrayForAccessor(accessor: number | object): any {
// @ts-ignore
const gltfAccessor = this.getAccessor(accessor);
return _getTypedArrayForAccessor(this.gltf.json, this.gltf.buffers, gltfAccessor);
}
/** accepts accessor index or accessor object
* returns a `Uint8Array`
*/
getTypedArrayForImageData(image: number | object): Uint8Array {
// @ts-ignore
image = this.getAccessor(image);
// @ts-ignore
const bufferView = this.getBufferView(image.bufferView);
const buffer = this.getBuffer(bufferView.buffer);
// @ts-ignore
const arrayBuffer = buffer.data;
const byteOffset = bufferView.byteOffset || 0;
return new Uint8Array(arrayBuffer, byteOffset, bufferView.byteLength);
}
// MODIFERS
/**
* Add an extra application-defined key to the top-level data structure
*/
addApplicationData(key: string, data: object): GLTFScenegraph {
this.json[key] = data;
return this;
}
/**
* `extras` - Standard GLTF field for storing application specific data
*/
addExtraData(key: string, data: object): GLTFScenegraph {
this.json.extras = this.json.extras || {};
(this.json.extras as Record<string, unknown>)[key] = data;
return this;
}
addObjectExtension(object: object, extensionName: string, data: object): GLTFScenegraph {
// @ts-ignore
object.extensions = object.extensions || {};
// TODO - clobber or merge?
// @ts-ignore
object.extensions[extensionName] = data;
this.registerUsedExtension(extensionName);
return this;
}
setObjectExtension(object: any, extensionName: string, data: object): void {
const extensions = object.extensions || {};
extensions[extensionName] = data;
// TODO - add to usedExtensions...
}
removeObjectExtension(object: any, extensionName: string): void {
const extensions = object?.extensions || {};
if (extensions[extensionName]) {
this.json.extensionsRemoved = this.json.extensionsRemoved || [];
const extensionsRemoved = this.json.extensionsRemoved as string[];
if (!extensionsRemoved.includes(extensionName)) {
extensionsRemoved.push(extensionName);
}
}
delete extensions[extensionName];
}
/**
* Add to standard GLTF top level extension object, mark as used
*/
addExtension(extensionName: string, extensionData: object = {}): object {
assert(extensionData);
this.json.extensions = this.json.extensions || {};
this.json.extensions[extensionName] = extensionData;
this.registerUsedExtension(extensionName);
return extensionData;
}
/**
* Standard GLTF top level extension object, mark as used and required
*/
addRequiredExtension(extensionName, extensionData: object = {}): object {
assert(extensionData);
this.addExtension(extensionName, extensionData);
this.registerRequiredExtension(extensionName);
return extensionData;
}
/**
* Add extensionName to list of used extensions
*/
registerUsedExtension(extensionName: string): void {
this.json.extensionsUsed = this.json.extensionsUsed || [];
if (!this.json.extensionsUsed.find((ext) => ext === extensionName)) {
this.json.extensionsUsed.push(extensionName);
}
}
/**
* Add extensionName to list of required extensions
*/
registerRequiredExtension(extensionName: string): void {
this.registerUsedExtension(extensionName);
this.json.extensionsRequired = this.json.extensionsRequired || [];
if (!this.json.extensionsRequired.find((ext) => ext === extensionName)) {
this.json.extensionsRequired.push(extensionName);
}
}
/**
* Removes an extension from the top-level list
*/
removeExtension(extensionName: string): void {
if (this.json.extensions?.[extensionName]) {
this.json.extensionsRemoved = this.json.extensionsRemoved || [];
const extensionsRemoved = this.json.extensionsRemoved as string[];
if (!extensionsRemoved.includes(extensionName)) {
extensionsRemoved.push(extensionName);
}
}
if (this.json.extensions) {
delete this.json.extensions[extensionName];
}
if (this.json.extensionsRequired) {
this._removeStringFromArray(this.json.extensionsRequired, extensionName);
}
if (this.json.extensionsUsed) {
this._removeStringFromArray(this.json.extensionsUsed, extensionName);
}
}
/**
* Set default scene which is to be displayed at load time
*/
setDefaultScene(sceneIndex: number): void {
this.json.scene = sceneIndex;
}
/**
* @todo: add more properties for scene initialization:
* name`, `extensions`, `extras`
* https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#reference-scene
*/
addScene(scene: {nodeIndices: number[]}): number {
const {nodeIndices} = scene;
this.json.scenes = this.json.scenes || [];
this.json.scenes.push({nodes: nodeIndices});
return this.json.scenes.length - 1;
}
/**
* @todo: add more properties for node initialization:
* `name`, `extensions`, `extras`, `camera`, `children`, `skin`, `rotation`, `scale`, `translation`, `weights`
* https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#node
*/
addNode(node: {meshIndex: number; matrix?: number[]}): number {
const {meshIndex, matrix} = node;
this.json.nodes = this.json.nodes || [];
const nodeData = {mesh: meshIndex};
if (matrix) {
// @ts-ignore
nodeData.matrix = matrix;
}
this.json.nodes.push(nodeData);
return this.json.nodes.length - 1;
}
/** Adds a mesh to the json part */
addMesh(mesh: {attributes: object; indices?: object; material?: number; mode?: number}): number {
const {attributes, indices, material, mode = 4} = mesh;
const accessors = this._addAttributes(attributes);
const glTFMesh = {
primitives: [
{
attributes: accessors,
mode
}
]
};
if (indices) {
const indicesAccessor = this._addIndices(indices);
// @ts-ignore
glTFMesh.primitives[0].indices = indicesAccessor;
}
if (Number.isFinite(material)) {
// @ts-ignore
glTFMesh.primitives[0].material = material;
}
this.json.meshes = this.json.meshes || [];
this.json.meshes.push(glTFMesh);
return this.json.meshes.length - 1;
}
addPointCloud(attributes: object): number {
// @ts-ignore
const accessorIndices = this._addAttributes(attributes);
const glTFMesh = {
primitives: [
{
attributes: accessorIndices,
mode: 0 // GL.POINTS
}
]
};
this.json.meshes = this.json.meshes || [];
this.json.meshes.push(glTFMesh);
return this.json.meshes.length - 1;
}
/**
* Adds a binary image. Builds glTF "JSON metadata" and saves buffer reference
* Buffer will be copied into BIN chunk during "pack"
* Currently encodes as glTF image
* @param imageData
* @param mimeType
*/
addImage(imageData: any, mimeTypeOpt?: string): number {
// If image is referencing a bufferView instead of URI, mimeType must be defined:
// https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#images
// "a reference to a bufferView; in that case mimeType must be defined."
const metadata = getBinaryImageMetadata(imageData);
const mimeType = mimeTypeOpt || metadata?.mimeType;
const bufferViewIndex = this.addBufferView(imageData);
const glTFImage = {
bufferView: bufferViewIndex,
mimeType
};
this.json.images = this.json.images || [];
this.json.images.push(glTFImage);
return this.json.images.length - 1;
}
/**
* Add one untyped source buffer, create a matching glTF `bufferView`, and return its index
* @param buffer
*/
addBufferView(buffer: any, bufferIndex = 0, byteOffset = this.byteLength): number {
const byteLength = buffer.byteLength;
assert(Number.isFinite(byteLength));
// Add this buffer to the list of buffers to be written to the body.
this.sourceBuffers = this.sourceBuffers || [];
this.sourceBuffers.push(buffer);
const glTFBufferView = {
buffer: bufferIndex,
// Write offset from the start of the binary body
byteOffset,
byteLength
};
// We've now added the contents to the body, so update the total length
// Every sub-chunk needs to be 4-byte align ed
this.byteLength += padToNBytes(byteLength, 4);
// Add a bufferView indicating start and length of this binary sub-chunk
this.json.bufferViews = this.json.bufferViews || [];
this.json.bufferViews.push(glTFBufferView);
return this.json.bufferViews.length - 1;
}
/**
* Adds an accessor to a bufferView
* @param bufferViewIndex
* @param accessor
*/
addAccessor(bufferViewIndex: number, accessor: object): number {
const glTFAccessor = {
bufferView: bufferViewIndex,
// @ts-ignore
type: getAccessorTypeFromSize(accessor.size),
// @ts-ignore
componentType: accessor.componentType,
// @ts-ignore
count: accessor.count,
// @ts-ignore
max: accessor.max,
// @ts-ignore
min: accessor.min
};
this.json.accessors = this.json.accessors || [];
this.json.accessors.push(glTFAccessor);
return this.json.accessors.length - 1;
}
/**
* Add a binary buffer. Builds glTF "JSON metadata" and saves buffer reference
* Buffer will be copied into BIN chunk during "pack"
* Currently encodes buffers as glTF accessors, but this could be optimized
* @param sourceBuffer
* @param accessor
*/
addBinaryBuffer(sourceBuffer: any, accessor: object = {size: 3}): number {
const bufferViewIndex = this.addBufferView(sourceBuffer);
// @ts-ignore
let minMax = {min: accessor.min, max: accessor.max};
if (!minMax.min || !minMax.max) {
// @ts-ignore
minMax = this._getAccessorMinMax(sourceBuffer, accessor.size);
}
const accessorDefaults = {
// @ts-ignore
size: accessor.size,
componentType: getComponentTypeFromArray(sourceBuffer),
// @ts-ignore
count: Math.round(sourceBuffer.length / accessor.size),
min: minMax.min,
max: minMax.max
};
return this.addAccessor(bufferViewIndex, Object.assign(accessorDefaults, accessor));
}
/**
* Adds a texture to the json part
* @todo: add more properties for texture initialization
* `sampler`, `name`, `extensions`, `extras`
* https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#texture
*/
addTexture(texture: {imageIndex: number}): number {
const {imageIndex} = texture;
const glTFTexture = {
source: imageIndex
};
this.json.textures = this.json.textures || [];
this.json.textures.push(glTFTexture);
return this.json.textures.length - 1;
}
/** Adds a material to the json part */
addMaterial(pbrMaterialInfo: Object): number {
this.json.materials = this.json.materials || [];
this.json.materials.push(pbrMaterialInfo);
return this.json.materials.length - 1;
}
/** Pack the binary chunk */
createBinaryChunk(): void {
// Allocate total array
const totalByteLength = this.byteLength;
const arrayBuffer = new ArrayBuffer(totalByteLength);
const targetArray = new Uint8Array(arrayBuffer);
// Copy each array into
let dstByteOffset = 0;
for (const sourceBuffer of this.sourceBuffers || []) {
dstByteOffset = copyToArray(sourceBuffer, targetArray, dstByteOffset);
}
// Update the glTF BIN CHUNK byte length
if (this.json?.buffers?.[0]) {
this.json.buffers[0].byteLength = totalByteLength;
} else {
this.json.buffers = [{byteLength: totalByteLength}];
}
// Save generated arrayBuffer
this.gltf.binary = arrayBuffer;
// Put arrayBuffer to sourceBuffers for possible additional writing data in the chunk
this.sourceBuffers = [arrayBuffer];
this.gltf.buffers = [{arrayBuffer, byteOffset: 0, byteLength: arrayBuffer.byteLength}];
}
// PRIVATE
_removeStringFromArray(array, string) {
let found = true;
while (found) {
const index = array.indexOf(string);
if (index > -1) {
array.splice(index, 1);
} else {
found = false;
}
}
}
/**
* Add attributes to buffers and create `attributes` object which is part of `mesh`
*/
_addAttributes(attributes = {}) {
const result = {};
for (const attributeKey in attributes) {
const attributeData = attributes[attributeKey];
const attrName = this._getGltfAttributeName(attributeKey);
const accessor = this.addBinaryBuffer(attributeData.value, attributeData);
result[attrName] = accessor;
}
return result;
}
/**
* Add indices to buffers
*/
_addIndices(indices) {
return this.addBinaryBuffer(indices, {size: 1});
}
/**
* Deduce gltf specific attribue name from input attribute name
*/
_getGltfAttributeName(attributeName) {
switch (attributeName.toLowerCase()) {
case 'position':
case 'positions':
case 'vertices':
return 'POSITION';
case 'normal':
case 'normals':
return 'NORMAL';
case 'color':
case 'colors':
return 'COLOR_0';
case 'texcoord':
case 'texcoords':
return 'TEXCOORD_0';
default:
return attributeName;
}
}
/**
* Calculate `min` and `max` arrays of accessor according to spec:
* https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#reference-accessor
*/
_getAccessorMinMax(buffer, size) {
const result = {min: null, max: null};
if (buffer.length < size) {
return result;
}
// @ts-ignore
result.min = [];
// @ts-ignore
result.max = [];
const initValues = buffer.subarray(0, size);
for (const value of initValues) {
// @ts-ignore
result.min.push(value);
// @ts-ignore
result.max.push(value);
}
for (let index = size; index < buffer.length; index += size) {
for (let componentIndex = 0; componentIndex < size; componentIndex++) {
// @ts-ignore
result.min[0 + componentIndex] = Math.min(
// @ts-ignore
result.min[0 + componentIndex],
buffer[index + componentIndex]
);
// @ts-ignore
result.max[0 + componentIndex] = Math.max(
// @ts-ignore
result.max[0 + componentIndex],
buffer[index + componentIndex]
);
}
}
return result;
}
}