/**
* LittleJS glTF Plugin
* - Loads glTF 2.0 models: a .gltf with its .bin and images beside it, or a .glb with everything in one file
* - A model comes back as parts, one Mesh per primitive of every node placed by the node tree, each with its
* material's color and base color texture, plus everything combined into one Mesh
* - Geometry: positions, normals, uvs, vertex colors and indices; skins and morph targets are not read
* - Node animations play: parts that move, turn and scale, like doors, wheels and propellers, through the
* GLTFObject that createObject makes; a skinned character's walk is not read
* - Materials give a base color and texture and whether they blend; glass made with KHR_materials_transmission blends too,
* and a KHR_materials_unlit material comes in emissive, its own color with no shading
* - A material's normal map and emissive map load too, with its normal scale and emissive factor, read at the base
* color texture's uvs; roughness, metalness and occlusion maps are not loaded
* - The base color texture reads the uv set its texCoord names, moved by KHR_texture_transform as gltfpack and
* Blender write it
* - An OPAQUE material, the default, ignores its texture's alpha as the format says: a texture only such materials
* use loads with its alpha set to 1, so the 3D pass cuts no holes in it; MASK always cuts at half, alphaCutoff
* is not read
* - Material and vertex colors are linear in glTF and are converted to sRGB at load, the space textures are in
* - glTF and LittleJS agree on the axes, y up and -z forward, on counter clockwise triangles and on uvs running down
* - Requires the Render3D plugin
* @namespace GLTF
* @example
* const model = await loadGLTF('ship.glb'); // in an async gameInit
* const ship = model.createObject(vec3(0, 1, 0)); // an object with a child per part, textures and all
* ship.play('fly'); // and its animation, by name or number
* new EngineObject3D(vec3(), model.mesh); // or the whole thing as one mesh, still
*/
'use strict';
///////////////////////////////////////////////////////////////////////////////
/**
* GLTFPart - One primitive of a model, placed where its node put it
* @memberof GLTF
*/
class GLTFPart
{
/** @param {string} name @param {Mesh} mesh @param {Color} color @param {TextureInfo|undefined} textureInfo @param {boolean} transparent */
constructor(name, mesh, color, textureInfo, transparent)
{
/** @property {string} - The node's name, or its mesh's */
this.name = name;
/** @property {Mesh} - The geometry in model space, the node transforms applied, with the vertex colors the file had;
* a node resting at scale 0 is applied at scale 1 there, so an animation can grow it from nothing */
this.mesh = mesh;
/** @property {Color} - The material's base color, to draw the mesh tinted with */
this.color = color;
/** @property {TextureInfo|undefined} - The material's base color texture, undefined without one or without WebGL
* @type {TextureInfo|undefined} */
this.textureInfo = textureInfo;
/** @property {boolean} - The material blends or is glass, so the part belongs in the transparent stage */
this.transparent = transparent;
/** @property {boolean} - Its texture's sampler asks for nearest filtering, hard edged pixels, as pixel art
* and voxel tools export; the object createObject makes draws it pixelated */
this.pixelated = false;
/** @property {number} - The node it came from, which an animation moves it with */
this.node = 0;
/** @property {boolean} - The material is unlit (KHR_materials_unlit), its own color with no shading; the object
* createObject makes draws it with emissive 1 */
this.unlit = false;
/** @property {TextureInfo|undefined} - The material's normal map, drawn with the base color texture's uvs
* @type {TextureInfo|undefined} */
this.normalMap = undefined;
/** @property {number} - The normal map's strength, its scale in the file */
this.normalScale = 1;
/** @property {TextureInfo|undefined} - The material's emissive map, or a white texture when it has an
* emissiveFactor and no texture, so it glows all over
* @type {TextureInfo|undefined} */
this.emissiveMap = undefined;
/** @property {Color} - The emissiveFactor, which multiplies the emissive map */
this.emissiveMapColor = WHITE;
}
}
/**
* GLTFAnimation - One animation of a model: keys that move, turn and scale its nodes over time
* - Play it through the GLTFObject that model.createObject makes
* @memberof GLTF
*/
class GLTFAnimation
{
/** @param {string} name @param {Array<Object>} channels */
constructor(name, channels)
{
/** @property {string} - Its name in the file, or 'animation' and its number when it has none */
this.name = name;
/** @property {Array<Object>} - What it moves: for each, a node, which of its translation, rotation or scale,
* the key times and values, and how to go between keys, LINEAR, STEP or CUBICSPLINE
* @type {Array<Object>} */
this.channels = channels;
/** @property {number} - Length in seconds, the time of its last key
* @type {number} */
this.duration = channels.reduce((d, c)=> max(d, c.times[c.times.length - 1] || 0), 0);
}
}
/**
* GLTFModel - A loaded model: its parts, and everything as one mesh
* @memberof GLTF
*/
class GLTFModel
{
/** @param {Array<GLTFPart>} parts @param {Array<GLTFAnimation>} [animations] @param {Object} [nodeTree] */
constructor(parts, animations=[], nodeTree)
{
/** @property {Array<GLTFPart>} - One per primitive of every node that has a mesh */
this.parts = parts;
/** @property {Array<GLTFAnimation>} - The animations, play one through createObject's GLTFObject
* @type {Array<GLTFAnimation>} */
this.animations = animations;
this.nodeTree = nodeTree; // each node's parent and resting place, for animation
this.modelMatrix = new Matrix4; // what center, fit and transform did to the parts, animation works through it
/** @property {Mesh} - Every part combined, each tinted with its material color; the texture is textureInfo,
* and blending and unlit stay with the parts, which createObject draws */
this.mesh = new Mesh;
for (const part of parts) // a part baked at scale 1 from a node resting at 0 goes in as it rests
this.mesh.combine(part.mesh, nodeTree?.restPose?.[part.node] || RENDER3D_IDENTITY, part.color);
// the one texture every part uses, when they all do; a part without one has no uvs into it, so a model
// that mixes plain and textured parts, or uses several textures, is drawn through createObject instead
const textures = new Set(parts.map(p=> p.textureInfo));
/** @property {TextureInfo|undefined} - The texture to draw mesh with, when every part uses the same one
* @type {TextureInfo|undefined} */
this.textureInfo = textures.size === 1 ? textures.values().next().value : undefined;
/** @type {{min: Vector3, max: Vector3}|undefined} */
this.bounds = undefined; // the box around every part, kept once measured, see getBounds
}
/** The box around every part, measured once and again after transform, so change the model through that
* @return {{min: Vector3, max: Vector3}} */
getBounds()
{
const bounds = this.bounds ||= this.mesh.getBounds();
return { min: bounds.min.copy(), max: bounds.max.copy() }; // a copy, so a change to it leaves the model's
}
/** Move every part so the center of the model's bounds is on the origin, like Mesh.center
* @return {GLTFModel} */
center()
{
const bounds = this.getBounds();
return this.transform(bounds.min.add(bounds.max).scale(-.5));
}
/** Scale every part evenly so the model's largest extent is a size, like Mesh.fit, for models of unknown units
* @param {number} [size]
* @return {GLTFModel} */
fit(size=1)
{
const bounds = this.getBounds(), extent = bounds.max.subtract(bounds.min);
return this.transform(Matrix4.scaling(vec3(size / (max(extent.x, extent.y, extent.z) || 1))));
}
/** Move, turn or scale every part and the combined mesh together
* @param {Matrix4|Vector3} matrix - Transform, or just an offset to move by
* @return {GLTFModel} */
transform(matrix)
{
matrix = render3DMatrix(matrix);
for (const part of this.parts)
part.mesh.transform(matrix);
this.mesh.transform(matrix);
this.bounds = undefined; // measured again when next asked for
this.modelMatrix = matrix.copy().multiply(this.modelMatrix);
return this;
}
/** Find an animation by name or number
* @param {string|number|GLTFAnimation} animation
* @return {GLTFAnimation|undefined} */
getAnimation(animation)
{
return animation instanceof GLTFAnimation ? animation : isNumber(animation) ? this.animations[animation]
: this.animations.find(a=> a.name === animation);
}
/** How far each part has moved from its resting place at a time in an animation, one matrix per part
* - createObject's GLTFObject calls this as it plays, a game only needs it to pose something by hand
* @param {GLTFAnimation} animation
* @param {number} time - Seconds into it
* @return {Array<Matrix4>} */
getPose(animation, time)
{
const tree = this.nodeTree;
if (!tree) return this.parts.map(()=> new Matrix4);
// the nodes the animation moves get its values at this time, every other node keeps its own
const moved = new Map;
for (const channel of animation.channels)
{
let node = moved.get(channel.node);
node || moved.set(channel.node, node = gltfNodeTRS(tree.nodes[channel.node]));
gltfSample(channel, time, node[channel.path]);
}
// then each node's place in the model through its parents, and each part's move from where it rests,
// through what center and fit did: modelMatrix * now * rest inverse * modelMatrix inverse
const world = [];
const worldOf = (i)=>
{
if (world[i]) return world[i];
const local = gltfNodeMatrix(moved.get(i) || tree.nodes[i]), parent = tree.parents[i];
return world[i] = parent === undefined ? local : worldOf(parent).copy().multiply(local);
};
const model = this.modelMatrix, modelInverse = model.copy().invert();
return this.parts.map(part=> model.copy().multiply(worldOf(part.node)).multiply(tree.restInverse[part.node]).multiply(modelInverse));
}
/** Free the GPU buffers of every part's mesh and of the combined mesh, and the textures, for a model that is
* done with, like a level's models when the next level loads
* - Destroy the objects createObject made from it first; a mesh drawn again only uploads again, but a freed
* texture is gone */
dispose()
{
for (const part of this.parts)
part.mesh.dispose();
this.mesh.dispose();
for (const textureInfo of new Set(this.parts.flatMap(p=> [p.textureInfo, p.normalMap, p.emissiveMap])))
textureInfo !== gltfWhiteTextureInfo && textureInfo?.destroyWebGLTexture(); // the white one is shared
}
/** Make an object at a position with a child per part, so each keeps its own texture, color and blending, and
* the model's animations can play on it; the way to show a model with windows or other see through parts,
* which the combined mesh draws solid, or one that moves
* @param {Vector3} [pos3D]
* @return {GLTFObject} - The root, move and turn it and the parts follow */
createObject(pos3D=vec3()) { return new GLTFObject(this, pos3D); }
}
///////////////////////////////////////////////////////////////////////////////
/**
* GLTFObject - A model as an object with a child per part, which plays the model's animations
* - model.createObject makes one; move, turn and scale it like any EngineObject3D and the parts follow
* - play starts an animation by name or number, and the object moves its parts each frame as it runs
* - The parts' meshes stay where they rest, an animation moves the child objects that draw them
* @extends EngineObject3D
* @memberof GLTF
* @example
* const door = model.createObject(vec3(0, 0, 5));
* door.play('open', false); // once, holding the last pose
*/
class GLTFObject extends EngineObject3D
{
/** Make the object and its parts, model.createObject is the usual way
* @param {GLTFModel} model
* @param {Vector3} [pos3D] */
constructor(model, pos3D=vec3())
{
super(pos3D);
// the size of the whole model, as an object made from model.mesh would have, measured once for the model
const bounds = model.mesh.points.length ? model.getBounds() : undefined;
if (bounds)
this.size3D = bounds.max.subtract(bounds.min);
/** @property {GLTFModel} - The model it shows */
this.model = model;
/** @property {GLTFAnimation|undefined} - The animation playing, or the last one, undefined for none
* @type {GLTFAnimation|undefined} */
this.animation = undefined;
/** @property {number} - Seconds into the animation */
this.animationTime = 0;
/** @property {number} - How fast it plays, 1 is as made, negative plays it backward */
this.animationSpeed = 1;
/** @property {boolean} - Start again at the end, or stop there and hold the last pose */
this.animationLoop = true;
/** @property {boolean} - Whether it is moving through the animation now */
this.animationPlaying = false;
/** @property {Array<EngineObject3D>} - The child that draws each of the model's parts, in the order of
* model.parts, which an animation poses; one destroyed or taken off the object is left alone
* @type {Array<EngineObject3D>} */
this.parts = [];
for (const part of model.parts)
{
const o = new EngineObject3D(vec3(), part.mesh, part.textureInfo, part.color);
o.transparent = part.transparent;
o.pixelated = part.pixelated;
o.emissive = part.unlit ? 1 : 0;
o.normalMap = part.normalMap, o.normalScale = part.normalScale;
o.emissiveMap = part.emissiveMap, o.emissiveMapColor = part.emissiveMapColor.copy(); // its own, as color is
const rest = model.nodeTree?.restPose?.[part.node];
if (rest)
{
// baked at scale 1 from a node resting at 0, it starts as it rests, where a pose would put it
const modelMatrix = model.modelMatrix, m = modelMatrix.copy().multiply(rest).multiply(modelMatrix.copy().invert());
o.localMatrix = m; // whole, as a pose is
o.pos3D = m.getTranslation();
o.rotation3D = m.getRotation();
o.scale3D = m.getScale();
}
this.addChild(o);
this.parts.push(o);
}
}
/** Play an animation from its start
* @param {string|number|GLTFAnimation} [animation] - Its name, its number in model.animations, or itself
* @param {boolean} [loop] - Start again at the end, or stop there
* @param {number} [speed] - 1 is as made, negative plays it backward from its end */
play(animation=0, loop=true, speed=1)
{
const found = this.model.getAnimation(animation);
ASSERT(found, 'the model has no animation ' + animation, this.model.animations.map(a=> a.name));
if (!found) return;
this.animation = found;
this.animationLoop = loop;
this.animationSpeed = speed;
this.animationPlaying = true;
this.setAnimationTime(speed < 0 ? found.duration : 0);
}
/** Stop the animation where it is, the parts hold that pose */
stop() { this.animationPlaying = false; }
/** Put the parts where the animation has them at a time, playing or not
* @param {number} time - Seconds into the animation */
setAnimationTime(time)
{
this.animationTime = time;
if (!this.animation) return;
// its own list of the part objects, so a child removed or added does not hand a part another's pose
const pose = this.model.getPose(this.animation, time), parts = this.parts;
for (let i = 0; i < pose.length && i < parts.length; ++i)
{
const o = parts[i], m = pose[i];
if (o.destroyed || o.parent !== this) continue;
// drawn with the whole pose, which a parent's uneven scale can shear, the parts kept for what reads them
o.localMatrix = m;
o.pos3D = m.getTranslation();
o.rotation3D = m.getRotation();
o.scale3D = m.getScale();
}
}
/** Move through the animation, called automatically each frame */
update()
{
super.update();
const animation = this.animation;
if (!this.animationPlaying || !animation) return;
const duration = animation.duration;
let t = this.animationTime + timeDelta * this.animationSpeed;
if (this.animationLoop)
t = duration ? mod(t, duration) : 0;
else if (t >= duration || t <= 0)
{
// the end, or the start when playing backward: hold the last pose there
t = clamp(t, 0, duration);
this.animationPlaying = false;
}
this.setAnimationTime(t);
}
}
///////////////////////////////////////////////////////////////////////////////
/** Load a glTF or GLB model, the .bin and images of a .gltf from beside it
* - A texture only OPAQUE materials use loads with its alpha set to 1, so the 3D pass cuts no holes in it
* @param {string} url
* @return {Promise<GLTFModel>}
* @memberof GLTF */
async function loadGLTF(url)
{
const response = await fetch(url);
if (!response.ok)
throw new Error('loadGLTF failed: ' + url);
return parseGLTF(await response.arrayBuffer(), url.slice(0, url.lastIndexOf('/') + 1));
}
/** Parse a model from GLB bytes or glTF JSON, fetching the buffers and images it refers to
* @param {ArrayBuffer|Object|string} data - GLB bytes, or the glTF JSON as bytes, text or an object
* @param {string} [baseUrl] - Where the .bin and image files are, with its trailing slash; loadGLTF passes the file's folder
* @return {Promise<GLTFModel>}
* @memberof GLTF */
async function parseGLTF(data, baseUrl='')
{
let json = data, glbBuffer;
if (data instanceof ArrayBuffer)
{
const view = new DataView(data);
if (data.byteLength >= 12 && view.getUint32(0, true) === 0x46546C67) // 'glTF', the binary container
{
if (view.getUint32(4, true) !== 2)
throw new Error('only GLB version 2 is read');
for (let offset = 12; offset + 8 <= data.byteLength;)
{
const length = view.getUint32(offset, true), type = view.getUint32(offset + 4, true);
const chunk = data.slice(offset + 8, offset + 8 + length);
if (type === 0x4E4F534A) json = new TextDecoder().decode(chunk); // JSON
else if (type === 0x004E4942) glbBuffer = chunk; // BIN
offset += 8 + length;
}
}
else
json = new TextDecoder().decode(data);
}
if (typeof json == 'string')
json = JSON.parse(json);
if (!json.asset || !String(json.asset.version).startsWith('2'))
throw new Error('only glTF 2.0 is read'); // a file problem, not a code one, so it throws in every build
// compressed geometry has no plain accessors to read; the other extensions add to what is here, and can be left out
for (const name of json.extensionsRequired || [])
if (name === 'KHR_draco_mesh_compression' || name === 'EXT_meshopt_compression')
throw new Error(`glTF with ${name} is not read, export it uncompressed`);
// the buffers: the GLB's own, a data uri, or a file beside the model
const buffers = await Promise.all((json.buffers || []).map((buffer, i)=>
{
if (!buffer.uri)
{
ASSERT(glbBuffer && !i, 'a buffer without a uri is the GLB chunk, and only the first can be');
return glbBuffer;
}
return gltfFetch(buffer.uri, baseUrl).then(r=> r.arrayBuffer());
}));
// the textures, decoded together first; none without WebGL, and a failed image only logs; the base color, normal
// and emissive textures are drawn with, the roughness and occlusion maps are not loaded at all
const usedTextures = new Set((json.materials || []).flatMap(m=> [m.pbrMetallicRoughness?.baseColorTexture?.index,
m.normalTexture?.index, m.emissiveTexture?.index]));
const normalTextures = new Set((json.materials || []).map(m=> m.normalTexture?.index));
const opaqueTextures = gltfOpaqueTextures(json);
const textures = await Promise.all((json.textures || []).map(async (texture, index)=>
{
if (!glContext || typeof createImageBitmap == 'undefined' || !usedTextures.has(index)) return;
try
{
// a WebP or AVIF image is named by its extension, the browser decodes those like any other
const source = texture.source ?? texture.extensions?.EXT_texture_webp?.source ?? texture.extensions?.EXT_texture_avif?.source;
const image = json.images[source], sampler = json.samplers?.[texture.sampler] || {};
let blob;
if (image.uri)
blob = await gltfFetch(image.uri, baseUrl).then(r=> r.blob());
else
{
const view = json.bufferViews[image.bufferView];
blob = new Blob([new Uint8Array(buffers[view.buffer], view.byteOffset || 0, view.byteLength)], {type: image.mimeType});
}
// an OPAQUE material ignores its texture's alpha, which the 3D pass would cut holes by, so a texture only
// those use has it set to 1; decoded as stored for that, and a jpeg has no alpha to set
const opaque = opaqueTextures.has(index) && blob.type !== 'image/jpeg';
// a normal map is directions, not colors, so it is decoded as stored; one a material also uses as a
// color, an odd file, is decoded that way too
const bitmap = normalTextures.has(index) ?
await createImageBitmap(blob, {colorSpaceConversion: 'none', premultiplyAlpha: 'none'}) :
opaque ? await createImageBitmap(blob, {premultiplyAlpha: 'none'}).then(gltfOpaqueImage) : await createImageBitmap(blob);
return new TextureInfo(bitmap, true, [sampler.wrapS ?? 10497, sampler.wrapT ?? 10497]); // REPEAT by default
}
catch (e) { LOG('glTF image not loaded', e); }
}));
// the parts: the scene's nodes walked with their transforms, every primitive of a node's mesh placed by it;
// each node's parent and resting place are kept, so an animation can move a part from where it rests
const parts = [], parents = [], restInverse = [], restPose = [];
const visit = (index, parentMatrix, parentIndex, parentRest)=>
{
const node = json.nodes[index], local = gltfNodeMatrix(node);
let matrix = parentMatrix ? parentMatrix.copy().multiply(local) : local;
let rest = parentRest && parentRest.copy().multiply(local); // where it rests, when that is not where it is baked
if (!node.matrix && node.scale?.includes(0))
{
// resting at scale 0, like a pop in exported at its first frame, would bake its parts onto a point and
// leave no inverse to pose them from; it and its children are baked at scale 1 on that axis instead,
// and a pose takes them back to 0, or up from there as an animation says
rest ||= matrix;
const unscaled = gltfNodeMatrix({...node, scale: node.scale.map(s=> s || 1)});
matrix = parentMatrix ? parentMatrix.copy().multiply(unscaled) : unscaled;
}
parents[index] = parentIndex;
restInverse[index] = matrix.copy().invert();
if (rest)
restPose[index] = rest.copy().multiply(restInverse[index]); // from where it is baked to where it rests
if (node.mesh !== undefined)
{
const mesh = json.meshes[node.mesh];
for (const primitive of mesh.primitives)
{
const part = gltfPart(json, buffers, textures, primitive, matrix, node.name || mesh.name || 'part ' + parts.length);
if (!part) continue;
part.node = index;
parts.push(part);
}
}
for (const child of node.children || [])
visit(child, matrix, index, rest);
};
let animations;
try
{
const scene = json.scenes?.[json.scene ?? 0];
if (scene)
(scene.nodes || []).forEach(i=> visit(i)); // a scene may be empty
else if (json.nodes)
{
// no scene: every node that is not another's child is a root
const children = new Set(json.nodes.flatMap(n=> n.children || []));
json.nodes.forEach((n, i)=> children.has(i) || visit(i));
}
// the animations: each channel that moves a node's translation, rotation or scale, with its keys; morph
// weights are not read, and a node given as a matrix cannot be animated, the format says
animations = (json.animations || []).map((animation, i)=> new GLTFAnimation(animation.name || 'animation ' + i,
animation.channels.filter(c=> c.target.node !== undefined &&
['translation', 'rotation', 'scale'].includes(c.target.path) && !json.nodes[c.target.node].matrix).map(c=>
{
const sampler = animation.samplers[c.sampler];
return {node: c.target.node, path: c.target.path, interpolation: sampler.interpolation || 'LINEAR',
times: gltfAccessor(json, buffers, sampler.input).data,
values: gltfAccessor(json, buffers, sampler.output).data,
components: c.target.path === 'rotation' ? 4 : 3};
})));
}
catch (error)
{
// a file that fails partway leaves no model to dispose, so the textures it made go now
for (const texture of textures)
texture?.destroyWebGLTexture();
throw error;
}
// a texture none of the parts draws with, another scene's, goes now too, since dispose finds them by the parts
const used = new Set(parts.flatMap(p=> [p.textureInfo, p.normalMap, p.emissiveMap]));
for (const texture of textures)
texture && !used.has(texture) && texture.destroyWebGLTexture();
return new GLTFModel(parts, animations, {nodes: json.nodes, parents, restInverse, restPose});
}
// a node's translation, rotation and scale as arrays to animate, copies so the file's stay as they rest
function gltfNodeTRS(node)
{
return {translation: [...(node.translation || [0, 0, 0])], rotation: [...(node.rotation || [0, 0, 0, 1])],
scale: [...(node.scale || [1, 1, 1])]};
}
// the base color textures that only OPAQUE materials use, the default mode, which the format says ignores alpha;
// one a MASK or BLEND material also uses keeps its alpha for that
function gltfOpaqueTextures(json)
{
const opaque = new Set, cut = new Set;
for (const material of json.materials || [])
{
const index = material.pbrMetallicRoughness?.baseColorTexture?.index;
if (index !== undefined)
(!material.alphaMode || material.alphaMode === 'OPAQUE' ? opaque : cut).add(index);
}
for (const index of cut)
opaque.delete(index);
return opaque;
}
// an image decoded without premultiplied alpha, given alpha 1 all over and its colors as they are; read back through
// a framebuffer, since a 2D canvas would multiply the colors by the alpha and lose those under a clear texel
function gltfOpaqueImage(image)
{
const gl = glContext, {width, height} = image;
if (gl.isContextLost()) return image; // nothing to read back through, it keeps its alpha
const texture = gl.createTexture(), framebuffer = gl.createFramebuffer(), bound = gl.getParameter(gl.FRAMEBUFFER_BINDING);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
const data = new Uint8ClampedArray(width * height * 4);
gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, data); // row 0 is the image's first row, as uploaded
gl.bindFramebuffer(gl.FRAMEBUFFER, bound);
gl.bindTexture(gl.TEXTURE_2D, glActiveTexture);
gl.deleteFramebuffer(framebuffer);
gl.deleteTexture(texture);
for (let i = 3; i < data.length; i += 4)
data[i] = 255;
image.close();
return createImageBitmap(new ImageData(data, width, height));
}
// write a channel's value at a time into out: its keys held before the first and after the last, stepped, straight
// between, or on the curve their tangents make; a rotation goes the short way round and stays a unit quaternion
function gltfSample(channel, time, out)
{
const {times, values, components: n, interpolation} = channel, last = times.length - 1;
const cubic = interpolation === 'CUBICSPLINE', stride = cubic ? 3 * n : n, at = cubic ? n : 0; // a cubic key is in, value, out
// the last key at or before the time, found by halving, since a baked animation has thousands of keys
let k = 0, high = last;
while (k < high)
{
const mid = k + high + 1 >> 1;
times[mid] <= time ? k = mid : high = mid - 1;
}
if (k >= last || time <= times[0] || interpolation === 'STEP')
{
for (let j = 0; j < n; ++j)
out[j] = values[k * stride + at + j];
return;
}
const dt = times[k + 1] - times[k], t = (time - times[k]) / dt, a = k * stride, b = a + stride;
if (cubic)
{
// hermite between the two values, with the out tangent of the first and the in tangent of the second
const t2 = t * t, t3 = t2 * t;
const h00 = 2*t3 - 3*t2 + 1, h10 = t3 - 2*t2 + t, h01 = -2*t3 + 3*t2, h11 = t3 - t2;
for (let j = 0; j < n; ++j)
out[j] = h00 * values[a + n + j] + h10 * dt * values[a + 2*n + j] + h01 * values[b + n + j] + h11 * dt * values[b + j];
}
else if (n === 4)
{
// a rotation turns along the arc between the two, the shorter way round
let dot = 0;
for (let j = 0; j < 4; ++j)
dot += values[a + j] * values[b + j];
const flip = dot < 0 ? -1 : 1;
dot *= flip;
let wa = 1 - t, wb = t * flip;
if (dot < .9995) // close together the straight line is as good and does not divide by nothing
{
const angle = Math.acos(dot), s = sin(angle);
wa = sin((1 - t) * angle) / s, wb = sin(t * angle) / s * flip;
}
for (let j = 0; j < 4; ++j)
out[j] = wa * values[a + j] + wb * values[b + j];
}
else
for (let j = 0; j < n; ++j)
out[j] = values[a + j] + (values[b + j] - values[a + j]) * t;
if (n === 4)
{
const l = hypot(out[0], out[1], out[2], out[3]) || 1;
for (let j = 0; j < 4; ++j)
out[j] /= l;
}
}
// fetch a uri beside the model, or decode a data uri without going out
function gltfFetch(uri, baseUrl)
{
if (uri.startsWith('data:'))
{
const comma = uri.indexOf(','), bytes = atob(uri.slice(comma + 1)), data = new Uint8Array(bytes.length);
for (let i = 0; i < bytes.length; ++i)
data[i] = bytes.charCodeAt(i);
return Promise.resolve(new Response(data.buffer, {headers: {'Content-Type': uri.slice(5, uri.indexOf(';'))}}));
}
// resolved as a link in the model is, from the model's folder: a leading slash from the site root, a leading
// // from the page's scheme, ../ up a folder; with no page to start from, a relative folder is only prefixed
const page = typeof location !== 'undefined' && location.href;
const base = page ? new URL(baseUrl, page) : /^[a-z][a-z0-9+.-]*:/i.test(baseUrl) ? baseUrl : undefined;
const url = base ? new URL(uri, base).href : baseUrl + uri;
return fetch(url).then(r=>
{
if (!r.ok) throw new Error('glTF file not found: ' + url);
return r;
});
}
// a node's local transform: its matrix, or translation, rotation quaternion and scale composed
function gltfNodeMatrix(node)
{
if (node.matrix)
return new Matrix4(node.matrix); // column major, like ours
const [x, y, z, w] = node.rotation || [0, 0, 0, 1], [sx, sy, sz] = node.scale || [1, 1, 1], t = node.translation || [0, 0, 0];
return new Matrix4([
(1 - 2*(y*y + z*z)) * sx, 2*(x*y + z*w) * sx, 2*(x*z - y*w) * sx, 0,
2*(x*y - z*w) * sy, (1 - 2*(x*x + z*z)) * sy, 2*(y*z + x*w) * sy, 0,
2*(x*z + y*w) * sz, 2*(y*z - x*w) * sz, (1 - 2*(x*x + y*y)) * sz, 0,
t[0], t[1], t[2], 1]);
}
// an accessor's values as floats, one row per element, normalized integer types brought to 0 to 1; a sparse one
// starts from its bufferView, or from zeros without one, and then has the listed elements replaced
function gltfAccessor(json, buffers, index)
{
const a = json.accessors[index], view = json.bufferViews?.[a.bufferView];
const components = {SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT4: 16}[a.type];
const types = {5120: Int8Array, 5121: Uint8Array, 5122: Int16Array, 5123: Uint16Array, 5125: Uint32Array, 5126: Float32Array};
const Type = types[a.componentType];
if (!components || !Type) // a file problem, so it throws in every build
throw new Error(`glTF accessor of ${a.type} ${a.componentType} is not read`);
const size = Type.BYTES_PER_ELEMENT;
const scales = /** @type {Array<[Object, number]>} */ ([[Int8Array, 127], [Uint8Array, 255], [Int16Array, 32767], [Uint16Array, 65535]]);
const scale = a.normalized ? new Map(scales).get(Type) || 1 : 1;
const out = new Float32Array(a.count * components);
if (view)
{
const buffer = buffers[view.buffer], offset = (view.byteOffset || 0) + (a.byteOffset || 0);
const stride = view.byteStride || components * size;
if (stride === components * size)
out.set(new Type(buffer, offset, a.count * components)); // packed, one view over all of it
else
for (let i = 0; i < a.count; ++i) // interleaved with other attributes, an element at each stride
out.set(new Type(buffer, offset + i * stride, components), i * components);
}
const sparse = a.sparse;
if (sparse)
{
// which elements change, and their new values packed in the accessor's own type
const {indices, values} = sparse, IndexType = types[indices.componentType];
const indexView = json.bufferViews?.[indices.bufferView], valueView = json.bufferViews?.[values.bufferView];
if (!IndexType || !indexView || !valueView)
throw new Error('glTF sparse accessor is missing its indices or values');
const at = new IndexType(buffers[indexView.buffer], (indexView.byteOffset || 0) + (indices.byteOffset || 0), sparse.count);
const data = new Type(buffers[valueView.buffer], (valueView.byteOffset || 0) + (values.byteOffset || 0), sparse.count * components);
for (let i = 0; i < sparse.count; ++i)
out.set(data.subarray(i * components, (i + 1) * components), at[i] * components);
}
if (scale !== 1)
for (let i = 0; i < out.length; ++i)
out[i] /= scale;
return {data: out, components, count: a.count};
}
// one primitive as a part: its attributes into a mesh, the indices as triangles, then placed and given its material
function gltfPart(json, buffers, textures, primitive, matrix, name)
{
const mode = primitive.mode ?? 4; // triangles
if (mode < 4)
{
LOG('glTF points and lines are not drawn:', name);
return;
}
const attributes = primitive.attributes;
ASSERT(attributes.POSITION !== undefined, 'a glTF primitive needs positions');
const read = (index, make)=>
{
const {data, components, count} = gltfAccessor(json, buffers, index), list = [];
for (let i = 0; i < count; ++i)
list.push(make(data, i * components, components));
return list;
};
const points = read(attributes.POSITION, (d, k)=> vec3(d[k], d[k+1], d[k+2]));
const normals = attributes.NORMAL !== undefined ? read(attributes.NORMAL, (d, k)=> vec3(d[k], d[k+1], d[k+2])) : undefined;
// the uv set the base color texture names, moved by its KHR_texture_transform once here, offset + rotation * scale
const material = json.materials?.[primitive.material] || {}, pbr = material.pbrMetallicRoughness || {};
const textureRef = pbr.baseColorTexture, uvTransform = textureRef?.extensions?.KHR_texture_transform;
const uvAccessor = attributes['TEXCOORD_' + (uvTransform?.texCoord ?? textureRef?.texCoord ?? 0)];
const [ox, oy] = uvTransform?.offset || [0, 0], [sx, sy] = uvTransform?.scale || [1, 1], r = uvTransform?.rotation || 0;
const c = cos(r), s = sin(r);
const uvs = uvAccessor !== undefined ? read(uvAccessor, (d, k)=>
vec2(c*sx*d[k] + s*sy*d[k+1] + ox, c*sy*d[k+1] - s*sx*d[k] + oy)) : undefined;
const colors = attributes.COLOR_0 !== undefined ? read(attributes.COLOR_0, (d, k, n)=>
rgb(gltfSRGB(d[k]), gltfSRGB(d[k+1]), gltfSRGB(d[k+2]), n > 3 ? d[k+3] : 1)) : undefined;
let indices = primitive.indices !== undefined ? Array.from(gltfAccessor(json, buffers, primitive.indices).data) : points.map((_, i)=> i);
if (mode === 5) // a strip: triangle i is the three entries up to i, the odd ones read the other way
indices = indices.flatMap((_, i, s)=> i < 2 ? [] : i & 1 ? [s[i-1], s[i-2], s[i]] : [s[i-2], s[i-1], s[i]]);
else if (mode === 6) // a fan around the first entry
indices = indices.flatMap((_, i, s)=> i < 2 ? [] : [s[0], s[i-1], s[i]]);
const mesh = new Mesh().addTriangles(points, indices, normals, uvs, colors);
normals || mesh.computeNormals(false); // flat when the file gives none, as the format says
mesh.transform(matrix);
const factor = pbr.baseColorFactor || [1, 1, 1, 1];
mesh.doubleSided = !!material.doubleSided;
// glass is usually made with transmission, an opaque white material the light passes through, which would
// draw solid white; it comes in blended instead, a faint tint of its color that lets the rest show through
// only a blending material reads its alpha, an opaque or masked one is solid whatever the factor says
const transmission = material.extensions?.KHR_materials_transmission?.transmissionFactor || 0;
const blend = material.alphaMode === 'BLEND', alpha = (blend ? factor[3] : 1) * (1 - .8 * transmission);
const texture = textureRef && json.textures?.[textureRef.index];
const part = new GLTFPart(name, mesh, rgb(gltfSRGB(factor[0]), gltfSRGB(factor[1]), gltfSRGB(factor[2]), alpha),
textureRef ? textures[textureRef.index] : undefined, blend || transmission > 0);
part.pixelated = json.samplers?.[texture?.sampler]?.magFilter === 9728; // NEAREST
part.unlit = !!material.extensions?.KHR_materials_unlit;
// the normal and emissive maps, read at the base color texture's uvs; the emissive texture is multiplied by the
// factor, which is black by default as the format says, so a texture alone does not glow; a factor with no
// texture glows all over
const normalRef = material.normalTexture, emissiveRef = material.emissiveTexture;
part.normalMap = normalRef && textures[normalRef.index];
part.normalScale = normalRef?.scale ?? 1;
const [er, eg, eb] = material.emissiveFactor || [0, 0, 0];
if (er || eg || eb)
{
part.emissiveMap = emissiveRef ? textures[emissiveRef.index] : gltfWhiteTexture();
part.emissiveMapColor = rgb(gltfSRGB(er), gltfSRGB(eg), gltfSRGB(eb));
}
return part;
}
// a 1 by 1 white texture, the emissive map of a material that glows all over with no texture; made once and never
// freed, like the engine's own white texture, undefined without WebGL
let gltfWhiteTextureInfo;
function gltfWhiteTexture()
{
if (gltfWhiteTextureInfo || !glContext || typeof OffscreenCanvas == 'undefined') return gltfWhiteTextureInfo;
const context = createCanvasContext(1);
context.fillStyle = '#fff';
context.fillRect(0, 0, 1, 1);
return gltfWhiteTextureInfo = new TextureInfo(context.canvas);
}
// a glTF color factor or vertex color is linear, where the renderer works in sRGB like the textures, so it is
// brought across at load or a mid gray material would come out nearly black; 0 and 1 stay exact
function gltfSRGB(c)
{ return c <= .0031308 ? c * 12.92 : c >= 1 ? c : 1.055 * c ** (1 / 2.4) - .055; }