littlejsengine 1.19.3 → 1.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/FAQ.md +841 -820
- package/README.md +29 -25
- package/dist/littlejs.d.ts +2857 -955
- package/dist/littlejs.esm.js +33428 -20995
- package/dist/littlejs.esm.min.js +4 -3
- package/dist/littlejs.js +33386 -20996
- package/dist/littlejs.min.js +2 -2
- package/dist/littlejs.release.js +27841 -20422
- package/package.json +10 -5
- package/plugins/audioEffects.js +28 -24
- package/plugins/box2d.js +2516 -2073
- package/plugins/drawUtilities.js +206 -31
- package/plugins/gltf.js +786 -0
- package/plugins/jsconfig.json +19 -0
- package/plugins/lightSystem.js +719 -332
- package/plugins/math3d.js +148 -27
- package/plugins/medalSystem.js +326 -280
- package/plugins/newgrounds.js +400 -199
- package/plugins/pathFinder.js +208 -175
- package/plugins/pluginExport.js +25 -0
- package/plugins/postProcess.js +96 -24
- package/plugins/render3d.js +2067 -1632
- package/plugins/render3dDebug.js +110 -0
- package/plugins/render3dEditor.js +1812 -0
- package/plugins/render3dExtras.js +1948 -0
- package/plugins/render3dLevel.js +189 -0
- package/plugins/render3dVoxels.js +514 -0
- package/plugins/sceneSystem.js +58 -0
- package/plugins/textureSheet.js +496 -458
- package/plugins/threejs.js +183 -163
- package/plugins/tweakables.js +411 -0
- package/plugins/tweenSystem.js +265 -145
- package/plugins/uiSystem.js +2024 -1687
- package/plugins/zzfxm.js +159 -159
- package/src/engine.js +378 -97
- package/src/engineAudio.js +231 -127
- package/src/engineBuild.mjs +292 -256
- package/src/engineDebug.js +967 -743
- package/src/engineDraw.js +391 -198
- package/src/engineEditor.js +2452 -0
- package/src/engineExport.js +18 -0
- package/src/engineInput.js +1466 -1293
- package/src/engineLogo.js +19 -0
- package/src/engineMath.js +1274 -1252
- package/src/engineObject.js +197 -82
- package/src/engineParticles.js +583 -532
- package/src/engineRelease.js +79 -45
- package/src/engineSettings.js +115 -29
- package/src/engineTileLayer.js +995 -741
- package/src/engineUtilities.js +19 -13
- package/src/engineWebGL.js +239 -82
- package/src/jsconfig.json +17 -13
- package/plugins/box2d.wasm.js +0 -630
- package/plugins/box2d.wasm.wasm +0 -0
package/plugins/gltf.js
ADDED
|
@@ -0,0 +1,786 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LittleJS glTF Plugin
|
|
3
|
+
* - Loads glTF 2.0 models: a .gltf with its .bin and images beside it, or a .glb with everything in one file
|
|
4
|
+
* - A model comes back as parts, one Mesh per primitive of every node placed by the node tree, each with its
|
|
5
|
+
* material's color and base color texture, plus everything combined into one Mesh
|
|
6
|
+
* - Geometry: positions, normals, uvs, vertex colors and indices; skins and morph targets are not read
|
|
7
|
+
* - Node animations play: parts that move, turn and scale, like doors, wheels and propellers, through the
|
|
8
|
+
* GLTFObject that createObject makes; a skinned character's walk is not read
|
|
9
|
+
* - Materials give a base color and texture and whether they blend; glass made with KHR_materials_transmission blends too,
|
|
10
|
+
* and a KHR_materials_unlit material comes in emissive, its own color with no shading
|
|
11
|
+
* - A material's normal map and emissive map load too, with its normal scale and emissive factor, read at the base
|
|
12
|
+
* color texture's uvs; roughness, metalness and occlusion maps are not loaded
|
|
13
|
+
* - The base color texture reads the uv set its texCoord names, moved by KHR_texture_transform as gltfpack and
|
|
14
|
+
* Blender write it
|
|
15
|
+
* - An OPAQUE material, the default, ignores its texture's alpha as the format says: a texture only such materials
|
|
16
|
+
* use loads with its alpha set to 1, so the 3D pass cuts no holes in it; MASK always cuts at half, alphaCutoff
|
|
17
|
+
* is not read
|
|
18
|
+
* - Material and vertex colors are linear in glTF and are converted to sRGB at load, the space textures are in
|
|
19
|
+
* - glTF and LittleJS agree on the axes, y up and -z forward, on counter clockwise triangles and on uvs running down
|
|
20
|
+
* - Requires the Render3D plugin
|
|
21
|
+
* @namespace GLTF
|
|
22
|
+
* @example
|
|
23
|
+
* const model = await loadGLTF('ship.glb'); // in an async gameInit
|
|
24
|
+
* const ship = model.createObject(vec3(0, 1, 0)); // an object with a child per part, textures and all
|
|
25
|
+
* ship.play('fly'); // and its animation, by name or number
|
|
26
|
+
* new EngineObject3D(vec3(), model.mesh); // or the whole thing as one mesh, still
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
'use strict';
|
|
30
|
+
|
|
31
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
32
|
+
/**
|
|
33
|
+
* GLTFPart - One primitive of a model, placed where its node put it
|
|
34
|
+
* @memberof GLTF
|
|
35
|
+
*/
|
|
36
|
+
class GLTFPart
|
|
37
|
+
{
|
|
38
|
+
/** @param {string} name @param {Mesh} mesh @param {Color} color @param {TextureInfo|undefined} textureInfo @param {boolean} transparent */
|
|
39
|
+
constructor(name, mesh, color, textureInfo, transparent)
|
|
40
|
+
{
|
|
41
|
+
/** @property {string} - The node's name, or its mesh's */
|
|
42
|
+
this.name = name;
|
|
43
|
+
/** @property {Mesh} - The geometry in model space, the node transforms applied, with the vertex colors the file had;
|
|
44
|
+
* a node resting at scale 0 is applied at scale 1 there, so an animation can grow it from nothing */
|
|
45
|
+
this.mesh = mesh;
|
|
46
|
+
/** @property {Color} - The material's base color, to draw the mesh tinted with */
|
|
47
|
+
this.color = color;
|
|
48
|
+
/** @property {TextureInfo|undefined} - The material's base color texture, undefined without one or without WebGL
|
|
49
|
+
* @type {TextureInfo|undefined} */
|
|
50
|
+
this.textureInfo = textureInfo;
|
|
51
|
+
/** @property {boolean} - The material blends or is glass, so the part belongs in the transparent stage */
|
|
52
|
+
this.transparent = transparent;
|
|
53
|
+
/** @property {boolean} - Its texture's sampler asks for nearest filtering, hard edged pixels, as pixel art
|
|
54
|
+
* and voxel tools export; the object createObject makes draws it pixelated */
|
|
55
|
+
this.pixelated = false;
|
|
56
|
+
/** @property {number} - The node it came from, which an animation moves it with */
|
|
57
|
+
this.node = 0;
|
|
58
|
+
/** @property {boolean} - The material is unlit (KHR_materials_unlit), its own color with no shading; the object
|
|
59
|
+
* createObject makes draws it with emissive 1 */
|
|
60
|
+
this.unlit = false;
|
|
61
|
+
/** @property {TextureInfo|undefined} - The material's normal map, drawn with the base color texture's uvs
|
|
62
|
+
* @type {TextureInfo|undefined} */
|
|
63
|
+
this.normalMap = undefined;
|
|
64
|
+
/** @property {number} - The normal map's strength, its scale in the file */
|
|
65
|
+
this.normalScale = 1;
|
|
66
|
+
/** @property {TextureInfo|undefined} - The material's emissive map, or a white texture when it has an
|
|
67
|
+
* emissiveFactor and no texture, so it glows all over
|
|
68
|
+
* @type {TextureInfo|undefined} */
|
|
69
|
+
this.emissiveMap = undefined;
|
|
70
|
+
/** @property {Color} - The emissiveFactor, which multiplies the emissive map */
|
|
71
|
+
this.emissiveMapColor = WHITE;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* GLTFAnimation - One animation of a model: keys that move, turn and scale its nodes over time
|
|
77
|
+
* - Play it through the GLTFObject that model.createObject makes
|
|
78
|
+
* @memberof GLTF
|
|
79
|
+
*/
|
|
80
|
+
class GLTFAnimation
|
|
81
|
+
{
|
|
82
|
+
/** @param {string} name @param {Array<Object>} channels */
|
|
83
|
+
constructor(name, channels)
|
|
84
|
+
{
|
|
85
|
+
/** @property {string} - Its name in the file, or 'animation' and its number when it has none */
|
|
86
|
+
this.name = name;
|
|
87
|
+
/** @property {Array<Object>} - What it moves: for each, a node, which of its translation, rotation or scale,
|
|
88
|
+
* the key times and values, and how to go between keys, LINEAR, STEP or CUBICSPLINE
|
|
89
|
+
* @type {Array<Object>} */
|
|
90
|
+
this.channels = channels;
|
|
91
|
+
/** @property {number} - Length in seconds, the time of its last key
|
|
92
|
+
* @type {number} */
|
|
93
|
+
this.duration = channels.reduce((d, c)=> max(d, c.times[c.times.length - 1] || 0), 0);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* GLTFModel - A loaded model: its parts, and everything as one mesh
|
|
99
|
+
* @memberof GLTF
|
|
100
|
+
*/
|
|
101
|
+
class GLTFModel
|
|
102
|
+
{
|
|
103
|
+
/** @param {Array<GLTFPart>} parts @param {Array<GLTFAnimation>} [animations] @param {Object} [nodeTree] */
|
|
104
|
+
constructor(parts, animations=[], nodeTree)
|
|
105
|
+
{
|
|
106
|
+
/** @property {Array<GLTFPart>} - One per primitive of every node that has a mesh */
|
|
107
|
+
this.parts = parts;
|
|
108
|
+
/** @property {Array<GLTFAnimation>} - The animations, play one through createObject's GLTFObject
|
|
109
|
+
* @type {Array<GLTFAnimation>} */
|
|
110
|
+
this.animations = animations;
|
|
111
|
+
this.nodeTree = nodeTree; // each node's parent and resting place, for animation
|
|
112
|
+
this.modelMatrix = new Matrix4; // what center, fit and transform did to the parts, animation works through it
|
|
113
|
+
/** @property {Mesh} - Every part combined, each tinted with its material color; the texture is textureInfo,
|
|
114
|
+
* and blending and unlit stay with the parts, which createObject draws */
|
|
115
|
+
this.mesh = new Mesh;
|
|
116
|
+
for (const part of parts) // a part baked at scale 1 from a node resting at 0 goes in as it rests
|
|
117
|
+
this.mesh.combine(part.mesh, nodeTree?.restPose?.[part.node] || RENDER3D_IDENTITY, part.color);
|
|
118
|
+
// the one texture every part uses, when they all do; a part without one has no uvs into it, so a model
|
|
119
|
+
// that mixes plain and textured parts, or uses several textures, is drawn through createObject instead
|
|
120
|
+
const textures = new Set(parts.map(p=> p.textureInfo));
|
|
121
|
+
/** @property {TextureInfo|undefined} - The texture to draw mesh with, when every part uses the same one
|
|
122
|
+
* @type {TextureInfo|undefined} */
|
|
123
|
+
this.textureInfo = textures.size === 1 ? textures.values().next().value : undefined;
|
|
124
|
+
/** @type {{min: Vector3, max: Vector3}|undefined} */
|
|
125
|
+
this.bounds = undefined; // the box around every part, kept once measured, see getBounds
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The box around every part, measured once and again after transform, so change the model through that
|
|
129
|
+
* @return {{min: Vector3, max: Vector3}} */
|
|
130
|
+
getBounds()
|
|
131
|
+
{
|
|
132
|
+
const bounds = this.bounds ||= this.mesh.getBounds();
|
|
133
|
+
return { min: bounds.min.copy(), max: bounds.max.copy() }; // a copy, so a change to it leaves the model's
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Move every part so the center of the model's bounds is on the origin, like Mesh.center
|
|
137
|
+
* @return {GLTFModel} */
|
|
138
|
+
center()
|
|
139
|
+
{
|
|
140
|
+
const bounds = this.getBounds();
|
|
141
|
+
return this.transform(bounds.min.add(bounds.max).scale(-.5));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Scale every part evenly so the model's largest extent is a size, like Mesh.fit, for models of unknown units
|
|
145
|
+
* @param {number} [size]
|
|
146
|
+
* @return {GLTFModel} */
|
|
147
|
+
fit(size=1)
|
|
148
|
+
{
|
|
149
|
+
const bounds = this.getBounds(), extent = bounds.max.subtract(bounds.min);
|
|
150
|
+
return this.transform(Matrix4.scaling(vec3(size / (max(extent.x, extent.y, extent.z) || 1))));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Move, turn or scale every part and the combined mesh together
|
|
154
|
+
* @param {Matrix4|Vector3} matrix - Transform, or just an offset to move by
|
|
155
|
+
* @return {GLTFModel} */
|
|
156
|
+
transform(matrix)
|
|
157
|
+
{
|
|
158
|
+
matrix = render3DMatrix(matrix);
|
|
159
|
+
for (const part of this.parts)
|
|
160
|
+
part.mesh.transform(matrix);
|
|
161
|
+
this.mesh.transform(matrix);
|
|
162
|
+
this.bounds = undefined; // measured again when next asked for
|
|
163
|
+
this.modelMatrix = matrix.copy().multiply(this.modelMatrix);
|
|
164
|
+
return this;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Find an animation by name or number
|
|
168
|
+
* @param {string|number|GLTFAnimation} animation
|
|
169
|
+
* @return {GLTFAnimation|undefined} */
|
|
170
|
+
getAnimation(animation)
|
|
171
|
+
{
|
|
172
|
+
return animation instanceof GLTFAnimation ? animation : isNumber(animation) ? this.animations[animation]
|
|
173
|
+
: this.animations.find(a=> a.name === animation);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** How far each part has moved from its resting place at a time in an animation, one matrix per part
|
|
177
|
+
* - createObject's GLTFObject calls this as it plays, a game only needs it to pose something by hand
|
|
178
|
+
* @param {GLTFAnimation} animation
|
|
179
|
+
* @param {number} time - Seconds into it
|
|
180
|
+
* @return {Array<Matrix4>} */
|
|
181
|
+
getPose(animation, time)
|
|
182
|
+
{
|
|
183
|
+
const tree = this.nodeTree;
|
|
184
|
+
if (!tree) return this.parts.map(()=> new Matrix4);
|
|
185
|
+
|
|
186
|
+
// the nodes the animation moves get its values at this time, every other node keeps its own
|
|
187
|
+
const moved = new Map;
|
|
188
|
+
for (const channel of animation.channels)
|
|
189
|
+
{
|
|
190
|
+
let node = moved.get(channel.node);
|
|
191
|
+
node || moved.set(channel.node, node = gltfNodeTRS(tree.nodes[channel.node]));
|
|
192
|
+
gltfSample(channel, time, node[channel.path]);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// then each node's place in the model through its parents, and each part's move from where it rests,
|
|
196
|
+
// through what center and fit did: modelMatrix * now * rest inverse * modelMatrix inverse
|
|
197
|
+
const world = [];
|
|
198
|
+
const worldOf = (i)=>
|
|
199
|
+
{
|
|
200
|
+
if (world[i]) return world[i];
|
|
201
|
+
const local = gltfNodeMatrix(moved.get(i) || tree.nodes[i]), parent = tree.parents[i];
|
|
202
|
+
return world[i] = parent === undefined ? local : worldOf(parent).copy().multiply(local);
|
|
203
|
+
};
|
|
204
|
+
const model = this.modelMatrix, modelInverse = model.copy().invert();
|
|
205
|
+
return this.parts.map(part=> model.copy().multiply(worldOf(part.node)).multiply(tree.restInverse[part.node]).multiply(modelInverse));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Free the GPU buffers of every part's mesh and of the combined mesh, and the textures, for a model that is
|
|
209
|
+
* done with, like a level's models when the next level loads
|
|
210
|
+
* - Destroy the objects createObject made from it first; a mesh drawn again only uploads again, but a freed
|
|
211
|
+
* texture is gone */
|
|
212
|
+
dispose()
|
|
213
|
+
{
|
|
214
|
+
for (const part of this.parts)
|
|
215
|
+
part.mesh.dispose();
|
|
216
|
+
this.mesh.dispose();
|
|
217
|
+
for (const textureInfo of new Set(this.parts.flatMap(p=> [p.textureInfo, p.normalMap, p.emissiveMap])))
|
|
218
|
+
textureInfo !== gltfWhiteTextureInfo && textureInfo?.destroyWebGLTexture(); // the white one is shared
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Make an object at a position with a child per part, so each keeps its own texture, color and blending, and
|
|
222
|
+
* the model's animations can play on it; the way to show a model with windows or other see through parts,
|
|
223
|
+
* which the combined mesh draws solid, or one that moves
|
|
224
|
+
* @param {Vector3} [pos3D]
|
|
225
|
+
* @return {GLTFObject} - The root, move and turn it and the parts follow */
|
|
226
|
+
createObject(pos3D=vec3()) { return new GLTFObject(this, pos3D); }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
230
|
+
/**
|
|
231
|
+
* GLTFObject - A model as an object with a child per part, which plays the model's animations
|
|
232
|
+
* - model.createObject makes one; move, turn and scale it like any EngineObject3D and the parts follow
|
|
233
|
+
* - play starts an animation by name or number, and the object moves its parts each frame as it runs
|
|
234
|
+
* - The parts' meshes stay where they rest, an animation moves the child objects that draw them
|
|
235
|
+
* @extends EngineObject3D
|
|
236
|
+
* @memberof GLTF
|
|
237
|
+
* @example
|
|
238
|
+
* const door = model.createObject(vec3(0, 0, 5));
|
|
239
|
+
* door.play('open', false); // once, holding the last pose
|
|
240
|
+
*/
|
|
241
|
+
class GLTFObject extends EngineObject3D
|
|
242
|
+
{
|
|
243
|
+
/** Make the object and its parts, model.createObject is the usual way
|
|
244
|
+
* @param {GLTFModel} model
|
|
245
|
+
* @param {Vector3} [pos3D] */
|
|
246
|
+
constructor(model, pos3D=vec3())
|
|
247
|
+
{
|
|
248
|
+
super(pos3D);
|
|
249
|
+
// the size of the whole model, as an object made from model.mesh would have, measured once for the model
|
|
250
|
+
const bounds = model.mesh.points.length ? model.getBounds() : undefined;
|
|
251
|
+
if (bounds)
|
|
252
|
+
this.size3D = bounds.max.subtract(bounds.min);
|
|
253
|
+
/** @property {GLTFModel} - The model it shows */
|
|
254
|
+
this.model = model;
|
|
255
|
+
/** @property {GLTFAnimation|undefined} - The animation playing, or the last one, undefined for none
|
|
256
|
+
* @type {GLTFAnimation|undefined} */
|
|
257
|
+
this.animation = undefined;
|
|
258
|
+
/** @property {number} - Seconds into the animation */
|
|
259
|
+
this.animationTime = 0;
|
|
260
|
+
/** @property {number} - How fast it plays, 1 is as made, negative plays it backward */
|
|
261
|
+
this.animationSpeed = 1;
|
|
262
|
+
/** @property {boolean} - Start again at the end, or stop there and hold the last pose */
|
|
263
|
+
this.animationLoop = true;
|
|
264
|
+
/** @property {boolean} - Whether it is moving through the animation now */
|
|
265
|
+
this.animationPlaying = false;
|
|
266
|
+
/** @property {Array<EngineObject3D>} - The child that draws each of the model's parts, in the order of
|
|
267
|
+
* model.parts, which an animation poses; one destroyed or taken off the object is left alone
|
|
268
|
+
* @type {Array<EngineObject3D>} */
|
|
269
|
+
this.parts = [];
|
|
270
|
+
for (const part of model.parts)
|
|
271
|
+
{
|
|
272
|
+
const o = new EngineObject3D(vec3(), part.mesh, part.textureInfo, part.color);
|
|
273
|
+
o.transparent = part.transparent;
|
|
274
|
+
o.pixelated = part.pixelated;
|
|
275
|
+
o.emissive = part.unlit ? 1 : 0;
|
|
276
|
+
o.normalMap = part.normalMap, o.normalScale = part.normalScale;
|
|
277
|
+
o.emissiveMap = part.emissiveMap, o.emissiveMapColor = part.emissiveMapColor.copy(); // its own, as color is
|
|
278
|
+
const rest = model.nodeTree?.restPose?.[part.node];
|
|
279
|
+
if (rest)
|
|
280
|
+
{
|
|
281
|
+
// baked at scale 1 from a node resting at 0, it starts as it rests, where a pose would put it
|
|
282
|
+
const modelMatrix = model.modelMatrix, m = modelMatrix.copy().multiply(rest).multiply(modelMatrix.copy().invert());
|
|
283
|
+
o.localMatrix = m; // whole, as a pose is
|
|
284
|
+
o.pos3D = m.getTranslation();
|
|
285
|
+
o.rotation3D = m.getRotation();
|
|
286
|
+
o.scale3D = m.getScale();
|
|
287
|
+
}
|
|
288
|
+
this.addChild(o);
|
|
289
|
+
this.parts.push(o);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Play an animation from its start
|
|
294
|
+
* @param {string|number|GLTFAnimation} [animation] - Its name, its number in model.animations, or itself
|
|
295
|
+
* @param {boolean} [loop] - Start again at the end, or stop there
|
|
296
|
+
* @param {number} [speed] - 1 is as made, negative plays it backward from its end */
|
|
297
|
+
play(animation=0, loop=true, speed=1)
|
|
298
|
+
{
|
|
299
|
+
const found = this.model.getAnimation(animation);
|
|
300
|
+
ASSERT(found, 'the model has no animation ' + animation, this.model.animations.map(a=> a.name));
|
|
301
|
+
if (!found) return;
|
|
302
|
+
this.animation = found;
|
|
303
|
+
this.animationLoop = loop;
|
|
304
|
+
this.animationSpeed = speed;
|
|
305
|
+
this.animationPlaying = true;
|
|
306
|
+
this.setAnimationTime(speed < 0 ? found.duration : 0);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Stop the animation where it is, the parts hold that pose */
|
|
310
|
+
stop() { this.animationPlaying = false; }
|
|
311
|
+
|
|
312
|
+
/** Put the parts where the animation has them at a time, playing or not
|
|
313
|
+
* @param {number} time - Seconds into the animation */
|
|
314
|
+
setAnimationTime(time)
|
|
315
|
+
{
|
|
316
|
+
this.animationTime = time;
|
|
317
|
+
if (!this.animation) return;
|
|
318
|
+
// its own list of the part objects, so a child removed or added does not hand a part another's pose
|
|
319
|
+
const pose = this.model.getPose(this.animation, time), parts = this.parts;
|
|
320
|
+
for (let i = 0; i < pose.length && i < parts.length; ++i)
|
|
321
|
+
{
|
|
322
|
+
const o = parts[i], m = pose[i];
|
|
323
|
+
if (o.destroyed || o.parent !== this) continue;
|
|
324
|
+
// drawn with the whole pose, which a parent's uneven scale can shear, the parts kept for what reads them
|
|
325
|
+
o.localMatrix = m;
|
|
326
|
+
o.pos3D = m.getTranslation();
|
|
327
|
+
o.rotation3D = m.getRotation();
|
|
328
|
+
o.scale3D = m.getScale();
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Move through the animation, called automatically each frame */
|
|
333
|
+
update()
|
|
334
|
+
{
|
|
335
|
+
super.update();
|
|
336
|
+
const animation = this.animation;
|
|
337
|
+
if (!this.animationPlaying || !animation) return;
|
|
338
|
+
const duration = animation.duration;
|
|
339
|
+
let t = this.animationTime + timeDelta * this.animationSpeed;
|
|
340
|
+
if (this.animationLoop)
|
|
341
|
+
t = duration ? mod(t, duration) : 0;
|
|
342
|
+
else if (t >= duration || t <= 0)
|
|
343
|
+
{
|
|
344
|
+
// the end, or the start when playing backward: hold the last pose there
|
|
345
|
+
t = clamp(t, 0, duration);
|
|
346
|
+
this.animationPlaying = false;
|
|
347
|
+
}
|
|
348
|
+
this.setAnimationTime(t);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
353
|
+
|
|
354
|
+
/** Load a glTF or GLB model, the .bin and images of a .gltf from beside it
|
|
355
|
+
* - A texture only OPAQUE materials use loads with its alpha set to 1, so the 3D pass cuts no holes in it
|
|
356
|
+
* @param {string} url
|
|
357
|
+
* @return {Promise<GLTFModel>}
|
|
358
|
+
* @memberof GLTF */
|
|
359
|
+
async function loadGLTF(url)
|
|
360
|
+
{
|
|
361
|
+
const response = await fetch(url);
|
|
362
|
+
if (!response.ok)
|
|
363
|
+
throw new Error('loadGLTF failed: ' + url);
|
|
364
|
+
return parseGLTF(await response.arrayBuffer(), url.slice(0, url.lastIndexOf('/') + 1));
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Parse a model from GLB bytes or glTF JSON, fetching the buffers and images it refers to
|
|
368
|
+
* @param {ArrayBuffer|Object|string} data - GLB bytes, or the glTF JSON as bytes, text or an object
|
|
369
|
+
* @param {string} [baseUrl] - Where the .bin and image files are, with its trailing slash; loadGLTF passes the file's folder
|
|
370
|
+
* @return {Promise<GLTFModel>}
|
|
371
|
+
* @memberof GLTF */
|
|
372
|
+
async function parseGLTF(data, baseUrl='')
|
|
373
|
+
{
|
|
374
|
+
let json = data, glbBuffer;
|
|
375
|
+
if (data instanceof ArrayBuffer)
|
|
376
|
+
{
|
|
377
|
+
const view = new DataView(data);
|
|
378
|
+
if (data.byteLength >= 12 && view.getUint32(0, true) === 0x46546C67) // 'glTF', the binary container
|
|
379
|
+
{
|
|
380
|
+
if (view.getUint32(4, true) !== 2)
|
|
381
|
+
throw new Error('only GLB version 2 is read');
|
|
382
|
+
for (let offset = 12; offset + 8 <= data.byteLength;)
|
|
383
|
+
{
|
|
384
|
+
const length = view.getUint32(offset, true), type = view.getUint32(offset + 4, true);
|
|
385
|
+
const chunk = data.slice(offset + 8, offset + 8 + length);
|
|
386
|
+
if (type === 0x4E4F534A) json = new TextDecoder().decode(chunk); // JSON
|
|
387
|
+
else if (type === 0x004E4942) glbBuffer = chunk; // BIN
|
|
388
|
+
offset += 8 + length;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
else
|
|
392
|
+
json = new TextDecoder().decode(data);
|
|
393
|
+
}
|
|
394
|
+
if (typeof json == 'string')
|
|
395
|
+
json = JSON.parse(json);
|
|
396
|
+
if (!json.asset || !String(json.asset.version).startsWith('2'))
|
|
397
|
+
throw new Error('only glTF 2.0 is read'); // a file problem, not a code one, so it throws in every build
|
|
398
|
+
// compressed geometry has no plain accessors to read; the other extensions add to what is here, and can be left out
|
|
399
|
+
for (const name of json.extensionsRequired || [])
|
|
400
|
+
if (name === 'KHR_draco_mesh_compression' || name === 'EXT_meshopt_compression')
|
|
401
|
+
throw new Error(`glTF with ${name} is not read, export it uncompressed`);
|
|
402
|
+
|
|
403
|
+
// the buffers: the GLB's own, a data uri, or a file beside the model
|
|
404
|
+
const buffers = await Promise.all((json.buffers || []).map((buffer, i)=>
|
|
405
|
+
{
|
|
406
|
+
if (!buffer.uri)
|
|
407
|
+
{
|
|
408
|
+
ASSERT(glbBuffer && !i, 'a buffer without a uri is the GLB chunk, and only the first can be');
|
|
409
|
+
return glbBuffer;
|
|
410
|
+
}
|
|
411
|
+
return gltfFetch(buffer.uri, baseUrl).then(r=> r.arrayBuffer());
|
|
412
|
+
}));
|
|
413
|
+
|
|
414
|
+
// the textures, decoded together first; none without WebGL, and a failed image only logs; the base color, normal
|
|
415
|
+
// and emissive textures are drawn with, the roughness and occlusion maps are not loaded at all
|
|
416
|
+
const usedTextures = new Set((json.materials || []).flatMap(m=> [m.pbrMetallicRoughness?.baseColorTexture?.index,
|
|
417
|
+
m.normalTexture?.index, m.emissiveTexture?.index]));
|
|
418
|
+
const normalTextures = new Set((json.materials || []).map(m=> m.normalTexture?.index));
|
|
419
|
+
const opaqueTextures = gltfOpaqueTextures(json);
|
|
420
|
+
const textures = await Promise.all((json.textures || []).map(async (texture, index)=>
|
|
421
|
+
{
|
|
422
|
+
if (!glContext || typeof createImageBitmap == 'undefined' || !usedTextures.has(index)) return;
|
|
423
|
+
try
|
|
424
|
+
{
|
|
425
|
+
// a WebP or AVIF image is named by its extension, the browser decodes those like any other
|
|
426
|
+
const source = texture.source ?? texture.extensions?.EXT_texture_webp?.source ?? texture.extensions?.EXT_texture_avif?.source;
|
|
427
|
+
const image = json.images[source], sampler = json.samplers?.[texture.sampler] || {};
|
|
428
|
+
let blob;
|
|
429
|
+
if (image.uri)
|
|
430
|
+
blob = await gltfFetch(image.uri, baseUrl).then(r=> r.blob());
|
|
431
|
+
else
|
|
432
|
+
{
|
|
433
|
+
const view = json.bufferViews[image.bufferView];
|
|
434
|
+
blob = new Blob([new Uint8Array(buffers[view.buffer], view.byteOffset || 0, view.byteLength)], {type: image.mimeType});
|
|
435
|
+
}
|
|
436
|
+
// an OPAQUE material ignores its texture's alpha, which the 3D pass would cut holes by, so a texture only
|
|
437
|
+
// those use has it set to 1; decoded as stored for that, and a jpeg has no alpha to set
|
|
438
|
+
const opaque = opaqueTextures.has(index) && blob.type !== 'image/jpeg';
|
|
439
|
+
// a normal map is directions, not colors, so it is decoded as stored; one a material also uses as a
|
|
440
|
+
// color, an odd file, is decoded that way too
|
|
441
|
+
const bitmap = normalTextures.has(index) ?
|
|
442
|
+
await createImageBitmap(blob, {colorSpaceConversion: 'none', premultiplyAlpha: 'none'}) :
|
|
443
|
+
opaque ? await createImageBitmap(blob, {premultiplyAlpha: 'none'}).then(gltfOpaqueImage) : await createImageBitmap(blob);
|
|
444
|
+
return new TextureInfo(bitmap, true, [sampler.wrapS ?? 10497, sampler.wrapT ?? 10497]); // REPEAT by default
|
|
445
|
+
}
|
|
446
|
+
catch (e) { LOG('glTF image not loaded', e); }
|
|
447
|
+
}));
|
|
448
|
+
|
|
449
|
+
// the parts: the scene's nodes walked with their transforms, every primitive of a node's mesh placed by it;
|
|
450
|
+
// each node's parent and resting place are kept, so an animation can move a part from where it rests
|
|
451
|
+
const parts = [], parents = [], restInverse = [], restPose = [];
|
|
452
|
+
const visit = (index, parentMatrix, parentIndex, parentRest)=>
|
|
453
|
+
{
|
|
454
|
+
const node = json.nodes[index], local = gltfNodeMatrix(node);
|
|
455
|
+
let matrix = parentMatrix ? parentMatrix.copy().multiply(local) : local;
|
|
456
|
+
let rest = parentRest && parentRest.copy().multiply(local); // where it rests, when that is not where it is baked
|
|
457
|
+
if (!node.matrix && node.scale?.includes(0))
|
|
458
|
+
{
|
|
459
|
+
// resting at scale 0, like a pop in exported at its first frame, would bake its parts onto a point and
|
|
460
|
+
// leave no inverse to pose them from; it and its children are baked at scale 1 on that axis instead,
|
|
461
|
+
// and a pose takes them back to 0, or up from there as an animation says
|
|
462
|
+
rest ||= matrix;
|
|
463
|
+
const unscaled = gltfNodeMatrix({...node, scale: node.scale.map(s=> s || 1)});
|
|
464
|
+
matrix = parentMatrix ? parentMatrix.copy().multiply(unscaled) : unscaled;
|
|
465
|
+
}
|
|
466
|
+
parents[index] = parentIndex;
|
|
467
|
+
restInverse[index] = matrix.copy().invert();
|
|
468
|
+
if (rest)
|
|
469
|
+
restPose[index] = rest.copy().multiply(restInverse[index]); // from where it is baked to where it rests
|
|
470
|
+
if (node.mesh !== undefined)
|
|
471
|
+
{
|
|
472
|
+
const mesh = json.meshes[node.mesh];
|
|
473
|
+
for (const primitive of mesh.primitives)
|
|
474
|
+
{
|
|
475
|
+
const part = gltfPart(json, buffers, textures, primitive, matrix, node.name || mesh.name || 'part ' + parts.length);
|
|
476
|
+
if (!part) continue;
|
|
477
|
+
part.node = index;
|
|
478
|
+
parts.push(part);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
for (const child of node.children || [])
|
|
482
|
+
visit(child, matrix, index, rest);
|
|
483
|
+
};
|
|
484
|
+
let animations;
|
|
485
|
+
try
|
|
486
|
+
{
|
|
487
|
+
const scene = json.scenes?.[json.scene ?? 0];
|
|
488
|
+
if (scene)
|
|
489
|
+
(scene.nodes || []).forEach(i=> visit(i)); // a scene may be empty
|
|
490
|
+
else if (json.nodes)
|
|
491
|
+
{
|
|
492
|
+
// no scene: every node that is not another's child is a root
|
|
493
|
+
const children = new Set(json.nodes.flatMap(n=> n.children || []));
|
|
494
|
+
json.nodes.forEach((n, i)=> children.has(i) || visit(i));
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// the animations: each channel that moves a node's translation, rotation or scale, with its keys; morph
|
|
498
|
+
// weights are not read, and a node given as a matrix cannot be animated, the format says
|
|
499
|
+
animations = (json.animations || []).map((animation, i)=> new GLTFAnimation(animation.name || 'animation ' + i,
|
|
500
|
+
animation.channels.filter(c=> c.target.node !== undefined &&
|
|
501
|
+
['translation', 'rotation', 'scale'].includes(c.target.path) && !json.nodes[c.target.node].matrix).map(c=>
|
|
502
|
+
{
|
|
503
|
+
const sampler = animation.samplers[c.sampler];
|
|
504
|
+
return {node: c.target.node, path: c.target.path, interpolation: sampler.interpolation || 'LINEAR',
|
|
505
|
+
times: gltfAccessor(json, buffers, sampler.input).data,
|
|
506
|
+
values: gltfAccessor(json, buffers, sampler.output).data,
|
|
507
|
+
components: c.target.path === 'rotation' ? 4 : 3};
|
|
508
|
+
})));
|
|
509
|
+
}
|
|
510
|
+
catch (error)
|
|
511
|
+
{
|
|
512
|
+
// a file that fails partway leaves no model to dispose, so the textures it made go now
|
|
513
|
+
for (const texture of textures)
|
|
514
|
+
texture?.destroyWebGLTexture();
|
|
515
|
+
throw error;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// a texture none of the parts draws with, another scene's, goes now too, since dispose finds them by the parts
|
|
519
|
+
const used = new Set(parts.flatMap(p=> [p.textureInfo, p.normalMap, p.emissiveMap]));
|
|
520
|
+
for (const texture of textures)
|
|
521
|
+
texture && !used.has(texture) && texture.destroyWebGLTexture();
|
|
522
|
+
return new GLTFModel(parts, animations, {nodes: json.nodes, parents, restInverse, restPose});
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// a node's translation, rotation and scale as arrays to animate, copies so the file's stay as they rest
|
|
526
|
+
function gltfNodeTRS(node)
|
|
527
|
+
{
|
|
528
|
+
return {translation: [...(node.translation || [0, 0, 0])], rotation: [...(node.rotation || [0, 0, 0, 1])],
|
|
529
|
+
scale: [...(node.scale || [1, 1, 1])]};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// the base color textures that only OPAQUE materials use, the default mode, which the format says ignores alpha;
|
|
533
|
+
// one a MASK or BLEND material also uses keeps its alpha for that
|
|
534
|
+
function gltfOpaqueTextures(json)
|
|
535
|
+
{
|
|
536
|
+
const opaque = new Set, cut = new Set;
|
|
537
|
+
for (const material of json.materials || [])
|
|
538
|
+
{
|
|
539
|
+
const index = material.pbrMetallicRoughness?.baseColorTexture?.index;
|
|
540
|
+
if (index !== undefined)
|
|
541
|
+
(!material.alphaMode || material.alphaMode === 'OPAQUE' ? opaque : cut).add(index);
|
|
542
|
+
}
|
|
543
|
+
for (const index of cut)
|
|
544
|
+
opaque.delete(index);
|
|
545
|
+
return opaque;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// an image decoded without premultiplied alpha, given alpha 1 all over and its colors as they are; read back through
|
|
549
|
+
// a framebuffer, since a 2D canvas would multiply the colors by the alpha and lose those under a clear texel
|
|
550
|
+
function gltfOpaqueImage(image)
|
|
551
|
+
{
|
|
552
|
+
const gl = glContext, {width, height} = image;
|
|
553
|
+
if (gl.isContextLost()) return image; // nothing to read back through, it keeps its alpha
|
|
554
|
+
const texture = gl.createTexture(), framebuffer = gl.createFramebuffer(), bound = gl.getParameter(gl.FRAMEBUFFER_BINDING);
|
|
555
|
+
gl.bindTexture(gl.TEXTURE_2D, texture);
|
|
556
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
|
|
557
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
|
|
558
|
+
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
|
|
559
|
+
const data = new Uint8ClampedArray(width * height * 4);
|
|
560
|
+
gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, data); // row 0 is the image's first row, as uploaded
|
|
561
|
+
gl.bindFramebuffer(gl.FRAMEBUFFER, bound);
|
|
562
|
+
gl.bindTexture(gl.TEXTURE_2D, glActiveTexture);
|
|
563
|
+
gl.deleteFramebuffer(framebuffer);
|
|
564
|
+
gl.deleteTexture(texture);
|
|
565
|
+
for (let i = 3; i < data.length; i += 4)
|
|
566
|
+
data[i] = 255;
|
|
567
|
+
image.close();
|
|
568
|
+
return createImageBitmap(new ImageData(data, width, height));
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// write a channel's value at a time into out: its keys held before the first and after the last, stepped, straight
|
|
572
|
+
// between, or on the curve their tangents make; a rotation goes the short way round and stays a unit quaternion
|
|
573
|
+
function gltfSample(channel, time, out)
|
|
574
|
+
{
|
|
575
|
+
const {times, values, components: n, interpolation} = channel, last = times.length - 1;
|
|
576
|
+
const cubic = interpolation === 'CUBICSPLINE', stride = cubic ? 3 * n : n, at = cubic ? n : 0; // a cubic key is in, value, out
|
|
577
|
+
// the last key at or before the time, found by halving, since a baked animation has thousands of keys
|
|
578
|
+
let k = 0, high = last;
|
|
579
|
+
while (k < high)
|
|
580
|
+
{
|
|
581
|
+
const mid = k + high + 1 >> 1;
|
|
582
|
+
times[mid] <= time ? k = mid : high = mid - 1;
|
|
583
|
+
}
|
|
584
|
+
if (k >= last || time <= times[0] || interpolation === 'STEP')
|
|
585
|
+
{
|
|
586
|
+
for (let j = 0; j < n; ++j)
|
|
587
|
+
out[j] = values[k * stride + at + j];
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
const dt = times[k + 1] - times[k], t = (time - times[k]) / dt, a = k * stride, b = a + stride;
|
|
591
|
+
if (cubic)
|
|
592
|
+
{
|
|
593
|
+
// hermite between the two values, with the out tangent of the first and the in tangent of the second
|
|
594
|
+
const t2 = t * t, t3 = t2 * t;
|
|
595
|
+
const h00 = 2*t3 - 3*t2 + 1, h10 = t3 - 2*t2 + t, h01 = -2*t3 + 3*t2, h11 = t3 - t2;
|
|
596
|
+
for (let j = 0; j < n; ++j)
|
|
597
|
+
out[j] = h00 * values[a + n + j] + h10 * dt * values[a + 2*n + j] + h01 * values[b + n + j] + h11 * dt * values[b + j];
|
|
598
|
+
}
|
|
599
|
+
else if (n === 4)
|
|
600
|
+
{
|
|
601
|
+
// a rotation turns along the arc between the two, the shorter way round
|
|
602
|
+
let dot = 0;
|
|
603
|
+
for (let j = 0; j < 4; ++j)
|
|
604
|
+
dot += values[a + j] * values[b + j];
|
|
605
|
+
const flip = dot < 0 ? -1 : 1;
|
|
606
|
+
dot *= flip;
|
|
607
|
+
let wa = 1 - t, wb = t * flip;
|
|
608
|
+
if (dot < .9995) // close together the straight line is as good and does not divide by nothing
|
|
609
|
+
{
|
|
610
|
+
const angle = Math.acos(dot), s = sin(angle);
|
|
611
|
+
wa = sin((1 - t) * angle) / s, wb = sin(t * angle) / s * flip;
|
|
612
|
+
}
|
|
613
|
+
for (let j = 0; j < 4; ++j)
|
|
614
|
+
out[j] = wa * values[a + j] + wb * values[b + j];
|
|
615
|
+
}
|
|
616
|
+
else
|
|
617
|
+
for (let j = 0; j < n; ++j)
|
|
618
|
+
out[j] = values[a + j] + (values[b + j] - values[a + j]) * t;
|
|
619
|
+
if (n === 4)
|
|
620
|
+
{
|
|
621
|
+
const l = hypot(out[0], out[1], out[2], out[3]) || 1;
|
|
622
|
+
for (let j = 0; j < 4; ++j)
|
|
623
|
+
out[j] /= l;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// fetch a uri beside the model, or decode a data uri without going out
|
|
628
|
+
function gltfFetch(uri, baseUrl)
|
|
629
|
+
{
|
|
630
|
+
if (uri.startsWith('data:'))
|
|
631
|
+
{
|
|
632
|
+
const comma = uri.indexOf(','), bytes = atob(uri.slice(comma + 1)), data = new Uint8Array(bytes.length);
|
|
633
|
+
for (let i = 0; i < bytes.length; ++i)
|
|
634
|
+
data[i] = bytes.charCodeAt(i);
|
|
635
|
+
return Promise.resolve(new Response(data.buffer, {headers: {'Content-Type': uri.slice(5, uri.indexOf(';'))}}));
|
|
636
|
+
}
|
|
637
|
+
// resolved as a link in the model is, from the model's folder: a leading slash from the site root, a leading
|
|
638
|
+
// // from the page's scheme, ../ up a folder; with no page to start from, a relative folder is only prefixed
|
|
639
|
+
const page = typeof location !== 'undefined' && location.href;
|
|
640
|
+
const base = page ? new URL(baseUrl, page) : /^[a-z][a-z0-9+.-]*:/i.test(baseUrl) ? baseUrl : undefined;
|
|
641
|
+
const url = base ? new URL(uri, base).href : baseUrl + uri;
|
|
642
|
+
return fetch(url).then(r=>
|
|
643
|
+
{
|
|
644
|
+
if (!r.ok) throw new Error('glTF file not found: ' + url);
|
|
645
|
+
return r;
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// a node's local transform: its matrix, or translation, rotation quaternion and scale composed
|
|
650
|
+
function gltfNodeMatrix(node)
|
|
651
|
+
{
|
|
652
|
+
if (node.matrix)
|
|
653
|
+
return new Matrix4(node.matrix); // column major, like ours
|
|
654
|
+
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];
|
|
655
|
+
return new Matrix4([
|
|
656
|
+
(1 - 2*(y*y + z*z)) * sx, 2*(x*y + z*w) * sx, 2*(x*z - y*w) * sx, 0,
|
|
657
|
+
2*(x*y - z*w) * sy, (1 - 2*(x*x + z*z)) * sy, 2*(y*z + x*w) * sy, 0,
|
|
658
|
+
2*(x*z + y*w) * sz, 2*(y*z - x*w) * sz, (1 - 2*(x*x + y*y)) * sz, 0,
|
|
659
|
+
t[0], t[1], t[2], 1]);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// an accessor's values as floats, one row per element, normalized integer types brought to 0 to 1; a sparse one
|
|
663
|
+
// starts from its bufferView, or from zeros without one, and then has the listed elements replaced
|
|
664
|
+
function gltfAccessor(json, buffers, index)
|
|
665
|
+
{
|
|
666
|
+
const a = json.accessors[index], view = json.bufferViews?.[a.bufferView];
|
|
667
|
+
const components = {SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4, MAT4: 16}[a.type];
|
|
668
|
+
const types = {5120: Int8Array, 5121: Uint8Array, 5122: Int16Array, 5123: Uint16Array, 5125: Uint32Array, 5126: Float32Array};
|
|
669
|
+
const Type = types[a.componentType];
|
|
670
|
+
if (!components || !Type) // a file problem, so it throws in every build
|
|
671
|
+
throw new Error(`glTF accessor of ${a.type} ${a.componentType} is not read`);
|
|
672
|
+
const size = Type.BYTES_PER_ELEMENT;
|
|
673
|
+
const scales = /** @type {Array<[Object, number]>} */ ([[Int8Array, 127], [Uint8Array, 255], [Int16Array, 32767], [Uint16Array, 65535]]);
|
|
674
|
+
const scale = a.normalized ? new Map(scales).get(Type) || 1 : 1;
|
|
675
|
+
const out = new Float32Array(a.count * components);
|
|
676
|
+
if (view)
|
|
677
|
+
{
|
|
678
|
+
const buffer = buffers[view.buffer], offset = (view.byteOffset || 0) + (a.byteOffset || 0);
|
|
679
|
+
const stride = view.byteStride || components * size;
|
|
680
|
+
if (stride === components * size)
|
|
681
|
+
out.set(new Type(buffer, offset, a.count * components)); // packed, one view over all of it
|
|
682
|
+
else
|
|
683
|
+
for (let i = 0; i < a.count; ++i) // interleaved with other attributes, an element at each stride
|
|
684
|
+
out.set(new Type(buffer, offset + i * stride, components), i * components);
|
|
685
|
+
}
|
|
686
|
+
const sparse = a.sparse;
|
|
687
|
+
if (sparse)
|
|
688
|
+
{
|
|
689
|
+
// which elements change, and their new values packed in the accessor's own type
|
|
690
|
+
const {indices, values} = sparse, IndexType = types[indices.componentType];
|
|
691
|
+
const indexView = json.bufferViews?.[indices.bufferView], valueView = json.bufferViews?.[values.bufferView];
|
|
692
|
+
if (!IndexType || !indexView || !valueView)
|
|
693
|
+
throw new Error('glTF sparse accessor is missing its indices or values');
|
|
694
|
+
const at = new IndexType(buffers[indexView.buffer], (indexView.byteOffset || 0) + (indices.byteOffset || 0), sparse.count);
|
|
695
|
+
const data = new Type(buffers[valueView.buffer], (valueView.byteOffset || 0) + (values.byteOffset || 0), sparse.count * components);
|
|
696
|
+
for (let i = 0; i < sparse.count; ++i)
|
|
697
|
+
out.set(data.subarray(i * components, (i + 1) * components), at[i] * components);
|
|
698
|
+
}
|
|
699
|
+
if (scale !== 1)
|
|
700
|
+
for (let i = 0; i < out.length; ++i)
|
|
701
|
+
out[i] /= scale;
|
|
702
|
+
return {data: out, components, count: a.count};
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// one primitive as a part: its attributes into a mesh, the indices as triangles, then placed and given its material
|
|
706
|
+
function gltfPart(json, buffers, textures, primitive, matrix, name)
|
|
707
|
+
{
|
|
708
|
+
const mode = primitive.mode ?? 4; // triangles
|
|
709
|
+
if (mode < 4)
|
|
710
|
+
{
|
|
711
|
+
LOG('glTF points and lines are not drawn:', name);
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
const attributes = primitive.attributes;
|
|
715
|
+
ASSERT(attributes.POSITION !== undefined, 'a glTF primitive needs positions');
|
|
716
|
+
const read = (index, make)=>
|
|
717
|
+
{
|
|
718
|
+
const {data, components, count} = gltfAccessor(json, buffers, index), list = [];
|
|
719
|
+
for (let i = 0; i < count; ++i)
|
|
720
|
+
list.push(make(data, i * components, components));
|
|
721
|
+
return list;
|
|
722
|
+
};
|
|
723
|
+
const points = read(attributes.POSITION, (d, k)=> vec3(d[k], d[k+1], d[k+2]));
|
|
724
|
+
const normals = attributes.NORMAL !== undefined ? read(attributes.NORMAL, (d, k)=> vec3(d[k], d[k+1], d[k+2])) : undefined;
|
|
725
|
+
// the uv set the base color texture names, moved by its KHR_texture_transform once here, offset + rotation * scale
|
|
726
|
+
const material = json.materials?.[primitive.material] || {}, pbr = material.pbrMetallicRoughness || {};
|
|
727
|
+
const textureRef = pbr.baseColorTexture, uvTransform = textureRef?.extensions?.KHR_texture_transform;
|
|
728
|
+
const uvAccessor = attributes['TEXCOORD_' + (uvTransform?.texCoord ?? textureRef?.texCoord ?? 0)];
|
|
729
|
+
const [ox, oy] = uvTransform?.offset || [0, 0], [sx, sy] = uvTransform?.scale || [1, 1], r = uvTransform?.rotation || 0;
|
|
730
|
+
const c = cos(r), s = sin(r);
|
|
731
|
+
const uvs = uvAccessor !== undefined ? read(uvAccessor, (d, k)=>
|
|
732
|
+
vec2(c*sx*d[k] + s*sy*d[k+1] + ox, c*sy*d[k+1] - s*sx*d[k] + oy)) : undefined;
|
|
733
|
+
const colors = attributes.COLOR_0 !== undefined ? read(attributes.COLOR_0, (d, k, n)=>
|
|
734
|
+
rgb(gltfSRGB(d[k]), gltfSRGB(d[k+1]), gltfSRGB(d[k+2]), n > 3 ? d[k+3] : 1)) : undefined;
|
|
735
|
+
let indices = primitive.indices !== undefined ? Array.from(gltfAccessor(json, buffers, primitive.indices).data) : points.map((_, i)=> i);
|
|
736
|
+
if (mode === 5) // a strip: triangle i is the three entries up to i, the odd ones read the other way
|
|
737
|
+
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]]);
|
|
738
|
+
else if (mode === 6) // a fan around the first entry
|
|
739
|
+
indices = indices.flatMap((_, i, s)=> i < 2 ? [] : [s[0], s[i-1], s[i]]);
|
|
740
|
+
const mesh = new Mesh().addTriangles(points, indices, normals, uvs, colors);
|
|
741
|
+
normals || mesh.computeNormals(false); // flat when the file gives none, as the format says
|
|
742
|
+
mesh.transform(matrix);
|
|
743
|
+
const factor = pbr.baseColorFactor || [1, 1, 1, 1];
|
|
744
|
+
mesh.doubleSided = !!material.doubleSided;
|
|
745
|
+
// glass is usually made with transmission, an opaque white material the light passes through, which would
|
|
746
|
+
// draw solid white; it comes in blended instead, a faint tint of its color that lets the rest show through
|
|
747
|
+
// only a blending material reads its alpha, an opaque or masked one is solid whatever the factor says
|
|
748
|
+
const transmission = material.extensions?.KHR_materials_transmission?.transmissionFactor || 0;
|
|
749
|
+
const blend = material.alphaMode === 'BLEND', alpha = (blend ? factor[3] : 1) * (1 - .8 * transmission);
|
|
750
|
+
const texture = textureRef && json.textures?.[textureRef.index];
|
|
751
|
+
const part = new GLTFPart(name, mesh, rgb(gltfSRGB(factor[0]), gltfSRGB(factor[1]), gltfSRGB(factor[2]), alpha),
|
|
752
|
+
textureRef ? textures[textureRef.index] : undefined, blend || transmission > 0);
|
|
753
|
+
part.pixelated = json.samplers?.[texture?.sampler]?.magFilter === 9728; // NEAREST
|
|
754
|
+
part.unlit = !!material.extensions?.KHR_materials_unlit;
|
|
755
|
+
|
|
756
|
+
// the normal and emissive maps, read at the base color texture's uvs; the emissive texture is multiplied by the
|
|
757
|
+
// factor, which is black by default as the format says, so a texture alone does not glow; a factor with no
|
|
758
|
+
// texture glows all over
|
|
759
|
+
const normalRef = material.normalTexture, emissiveRef = material.emissiveTexture;
|
|
760
|
+
part.normalMap = normalRef && textures[normalRef.index];
|
|
761
|
+
part.normalScale = normalRef?.scale ?? 1;
|
|
762
|
+
const [er, eg, eb] = material.emissiveFactor || [0, 0, 0];
|
|
763
|
+
if (er || eg || eb)
|
|
764
|
+
{
|
|
765
|
+
part.emissiveMap = emissiveRef ? textures[emissiveRef.index] : gltfWhiteTexture();
|
|
766
|
+
part.emissiveMapColor = rgb(gltfSRGB(er), gltfSRGB(eg), gltfSRGB(eb));
|
|
767
|
+
}
|
|
768
|
+
return part;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// a 1 by 1 white texture, the emissive map of a material that glows all over with no texture; made once and never
|
|
772
|
+
// freed, like the engine's own white texture, undefined without WebGL
|
|
773
|
+
let gltfWhiteTextureInfo;
|
|
774
|
+
function gltfWhiteTexture()
|
|
775
|
+
{
|
|
776
|
+
if (gltfWhiteTextureInfo || !glContext || typeof OffscreenCanvas == 'undefined') return gltfWhiteTextureInfo;
|
|
777
|
+
const context = createCanvasContext(1);
|
|
778
|
+
context.fillStyle = '#fff';
|
|
779
|
+
context.fillRect(0, 0, 1, 1);
|
|
780
|
+
return gltfWhiteTextureInfo = new TextureInfo(context.canvas);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
// a glTF color factor or vertex color is linear, where the renderer works in sRGB like the textures, so it is
|
|
784
|
+
// brought across at load or a mid gray material would come out nearly black; 0 and 1 stay exact
|
|
785
|
+
function gltfSRGB(c)
|
|
786
|
+
{ return c <= .0031308 ? c * 12.92 : c >= 1 ? c : 1.055 * c ** (1 / 2.4) - .055; }
|