trid3d 0.1.1 → 0.3.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.
@@ -1,27 +1,28 @@
1
1
  import { DEFAULT_FOV, DEFAULT_NEAR } from "../math/transform.js";
2
- import { clear, edgeSegments } from "./draw.js";
2
+ import { normalMatrix, determinantMat4 } from "../math/matrix.js";
3
+ import { worldObjects } from "../sceneGraph.js";
4
+ import { clear, edgeSegments, wireEdges } from "./draw.js";
3
5
  import { parseColor } from "./color.js";
4
- import { DEFAULT_LIGHT, DEFAULT_AMBIENT } from "./canvasRenderer.js";
6
+ import { DEFAULT_LIGHT, DEFAULT_AMBIENT, materialsOf } from "./canvasRenderer.js";
7
+ import { uvsFor, uvTransform, mappingKey } from "./uvMapping.js";
5
8
  import { newell, cornerNormals, creaseOf } from "./normals.js";
6
9
  import { SURFACE_GLSL, buildEffects, effectsKey } from "./effects.js";
7
- import { textureReady, textureSize, textureIsLive } from "../loaders/texture.js";
10
+ import { textureReady, textureSize, textureIsLive, textureVersion } from "../loaders/texture.js";
8
11
 
9
12
  /** Farthest distance drawn by the WebGL renderer, unless the camera sets `far`. */
10
13
  export const DEFAULT_FAR = 1000;
11
14
 
12
- // Trid's transform pipeline, written in GLSL. It matches localToWorld,
13
- // worldToCamera and project() exactly, so tools and picking line up.
15
+ // Trid's transform pipeline, written in GLSL. It matches the objects' world
16
+ // matrices, worldToCamera and project() exactly, so tools and picking line up.
14
17
  const COMMON = `
15
- uniform vec3 uPos, uRot, uScale;
18
+ uniform mat4 uModel; // the object's world matrix (its parents' transforms included)
16
19
  uniform vec3 uCamPos;
17
20
  uniform vec2 uCamRot; // yaw, pitch
18
21
  uniform float uF, uAspect, uNear, uFar;
19
22
 
20
23
  vec3 rotX(vec3 p, float a) { float c = cos(a), s = sin(a); return vec3(p.x, p.y * c - p.z * s, p.y * s + p.z * c); }
21
24
  vec3 rotY(vec3 p, float a) { float c = cos(a), s = sin(a); return vec3(p.x * c - p.z * s, p.y, p.x * s + p.z * c); }
22
- vec3 rotZ(vec3 p, float a) { float c = cos(a), s = sin(a); return vec3(p.x * c - p.y * s, p.x * s + p.y * c, p.z); }
23
- vec3 rotXYZ(vec3 p) { return rotZ(rotY(rotX(p, uRot.x), uRot.y), uRot.z); }
24
- vec3 toWorld(vec3 v) { return rotXYZ(v * uScale) + uPos; }
25
+ vec3 toWorld(vec3 v) { return (uModel * vec4(v, 1.0)).xyz; }
25
26
  vec3 toCamera(vec3 p) { return rotX(rotY(p - uCamPos, uCamRot.x), uCamRot.y); }
26
27
  vec4 toClip(vec3 p) {
27
28
  float a = (uFar + uNear) / (uFar - uNear);
@@ -38,11 +39,13 @@ varying vec3 vNormal;
38
39
  varying vec3 vWorld;
39
40
  varying vec3 vLocal;
40
41
  varying vec2 vUV;
42
+ uniform mat3 uUVTransform; // textureRepeat, textureOffset, textureRotation
43
+ uniform mat3 uNormalMatrix; // keeps normals right on stretched and mirrored objects
41
44
  ${COMMON}
42
45
  void main() {
43
- vNormal = rotXYZ(aNormal / uScale); // inverse scale keeps normals right on stretched objects
46
+ vNormal = uNormalMatrix * aNormal;
44
47
  vLocal = aPos;
45
- vUV = aUV;
48
+ vUV = (uUVTransform * vec3(aUV, 1.0)).xy;
46
49
  vWorld = toWorld(aPos);
47
50
  gl_Position = toClip(toCamera(vWorld));
48
51
  }
@@ -106,7 +109,7 @@ ${fx.calls}
106
109
  return { source, uniforms: fx.uniforms };
107
110
  }
108
111
 
109
- const FACE_UNIFORMS = ["uLight", "uAmbient", "uShade", "uTime", "uBackground", "uCamWorld", "uTexture", "uUseTexture"];
112
+ const FACE_UNIFORMS = ["uUVTransform", "uNormalMatrix", "uLight", "uAmbient", "uShade", "uTime", "uBackground", "uCamWorld", "uTexture", "uUseTexture"];
110
113
 
111
114
  // Each line segment is a thin rectangle facing the screen, `uWidth` pixels wide
112
115
  const LINE_VS = `
@@ -142,7 +145,7 @@ void main() {
142
145
  }
143
146
  `;
144
147
 
145
- const UNIFORMS = ["uPos", "uRot", "uScale", "uCamPos", "uCamRot", "uF", "uAspect", "uNear", "uFar", "uColor"];
148
+ const UNIFORMS = ["uModel", "uCamPos", "uCamRot", "uF", "uAspect", "uNear", "uFar", "uColor"];
146
149
 
147
150
  /**
148
151
  * Draws a scene with WebGL, on the graphics card.
@@ -158,6 +161,12 @@ const UNIFORMS = ["uPos", "uRot", "uScale", "uCamPos", "uCamRot", "uF", "uAspect
158
161
  *
159
162
  * Meshes are sent to the graphics card once and reused. After changing
160
163
  * a mesh's vertices, edges or faces in place, set `mesh.dirty = true`.
164
+ * Textures are sent once too (videos every frame): after redrawing a canvas,
165
+ * call `updateTexture(canvas)`.
166
+ *
167
+ * Meshes and textures that haven't been drawn for `releaseAfter` frames
168
+ * are freed from the graphics card, so replacing them (`obj.mesh = newMesh`)
169
+ * doesn't fill its memory. They're sent again if they come back.
161
170
  *
162
171
  * @example
163
172
  * const scene = new Scene(canvas, { renderer: new WebGLRenderer() });
@@ -176,9 +185,13 @@ export class WebGLRenderer {
176
185
  /**
177
186
  * @param {Object} [opts]
178
187
  * @param {boolean} [opts.antialias=true] - Smooth edges
188
+ * @param {number} [opts.releaseAfter=300] - Free meshes and textures not drawn for this many frames
189
+ * (about 5 s at 60 fps). `Infinity` keeps everything until `dispose()`
179
190
  * @throws {Error} If WebGL isn't available (check `WebGLRenderer.isSupported()` first)
180
191
  */
181
- constructor({ antialias = true } = {}) {
192
+ constructor({ antialias = true, releaseAfter = 300 } = {}) {
193
+ /** Frames a mesh or texture can go undrawn before it's freed (Infinity: never). */
194
+ this.releaseAfter = releaseAfter;
182
195
  /** The hidden canvas WebGL draws on. */
183
196
  this.canvas = document.createElement("canvas");
184
197
  this._options = { antialias, alpha: true, premultipliedAlpha: true, depth: true };
@@ -208,6 +221,9 @@ export class WebGLRenderer {
208
221
  this._buffers = new Set();
209
222
  this._textures = new WeakMap(); // image / canvas / video → GL texture
210
223
  this._glTextures = new Set();
224
+ // Last frame each mesh / texture source was drawn, to free the ones no longer used
225
+ this._meshUse = new Map();
226
+ this._textureUse = new Map();
211
227
  this._warned = new WeakSet();
212
228
  this._frame = 0;
213
229
 
@@ -264,20 +280,34 @@ export class WebGLRenderer {
264
280
 
265
281
  // Opaque things first; see-through ones after, without writing depth
266
282
  const opaque = [], clear2 = [];
267
- for (const obj of scene.objects) {
283
+ // Every object with a mesh, groups' children included, with its world matrix
284
+ const drawn = worldObjects(scene).filter((o) => o.mesh);
285
+ for (const obj of drawn) {
268
286
  const geo = this._geometry(obj.mesh);
269
287
  const lineColor = this._color(ctx, obj.color ?? scene.color);
270
288
  const lineWidth = obj.lineWidth ?? scene.lineWidth;
271
- const mat = obj.material;
272
-
273
- if (mat) {
274
- const fill = this._color(ctx, mat.fill ?? (mat.texture ? "#ffffff" : scene.color));
275
- (fill[3] < 1 ? clear2 : opaque).push({ kind: "face", obj, geo, color: fill, mat });
276
- if (mat.wireframe) {
277
- (lineColor[3] < 1 ? clear2 : opaque).push({ kind: "line", obj, buf: geo.outlines, color: lineColor, width: lineWidth });
289
+ const mats = materialsOf(obj);
290
+
291
+ if (mats) {
292
+ // Normals turn with the object; a mirrored one has its faces' winding reversed
293
+ const place = { normals: normalMatrix(obj.world), mirrored: determinantMat4(obj.world) < 0 };
294
+ // One draw per group of faces sharing a material (mesh.faceMaterials)
295
+ const groups = geo.groups.map((g) => {
296
+ const k = g.index >= 0 && g.index < mats.length && mats[g.index] ? g.index : 0;
297
+ return { ...g, mat: mats[k] };
298
+ });
299
+ for (const g of groups) {
300
+ const fill = this._color(ctx, g.mat.fill ?? (g.mat.texture ? "#ffffff" : scene.color));
301
+ (fill[3] < 1 ? clear2 : opaque).push({ kind: "face", obj, geo, color: fill, mat: g.mat, first: g.first, count: g.count, place });
278
302
  }
303
+ const lines = (buf) => (lineColor[3] < 1 ? clear2 : opaque).push({ kind: "line", obj, buf, color: lineColor, width: lineWidth });
304
+ if (groups.every((g) => g.mat.wireframe)) lines(this._lineBuffer(geo, "outlines", () => outlinePairs(geo.faceList ?? [])));
305
+ else for (const g of groups) if (g.mat.wireframe) lines(this._outlineBuffer(geo, obj.mesh, g));
279
306
  } else {
280
- (lineColor[3] < 1 ? clear2 : opaque).push({ kind: "line", obj, buf: geo.edges, color: lineColor, width: lineWidth });
307
+ (lineColor[3] < 1 ? clear2 : opaque).push({
308
+ kind: "line", obj, color: lineColor, width: lineWidth,
309
+ buf: this._lineBuffer(geo, "edges", () => edgeSegments(wireEdges(obj.mesh))),
310
+ });
281
311
  }
282
312
  }
283
313
 
@@ -288,7 +318,49 @@ export class WebGLRenderer {
288
318
  ctx.drawImage(this.canvas, 0, 0);
289
319
 
290
320
  // Meshes marked as changed have been sent again
291
- for (const obj of scene.objects) if (obj.mesh.dirty) obj.mesh.dirty = false;
321
+ for (const obj of drawn) if (obj.mesh.dirty) obj.mesh.dirty = false;
322
+
323
+ if (this._frame % 60 === 0) this._sweep();
324
+ }
325
+
326
+ /**
327
+ * What the renderer keeps on the graphics card right now.
328
+ *
329
+ * @returns {{ meshes:number, buffers:number, textures:number }}
330
+ */
331
+ get memory() {
332
+ return { meshes: this._meshUse.size, buffers: this._buffers.size, textures: this._glTextures.size };
333
+ }
334
+
335
+ /**
336
+ * Frees a mesh's buffers or a texture's image on the graphics card now,
337
+ * instead of waiting for `releaseAfter`. If it's drawn again, it's sent again.
338
+ *
339
+ * @param {Object} item - A mesh, or a texture source (image, canvas, video)
340
+ */
341
+ release(item) {
342
+ if (!item || this._disposed) return;
343
+ const geo = this._meshes.get(item);
344
+ if (geo) {
345
+ for (const b of buffersOf(geo)) this._free(b);
346
+ this._meshes.delete(item);
347
+ }
348
+ this._meshUse.delete(item);
349
+ const t = this._textures.get(item);
350
+ if (t) {
351
+ this.gl.deleteTexture(t.tex);
352
+ this._glTextures.delete(t.tex);
353
+ this._textures.delete(item);
354
+ }
355
+ this._textureUse.delete(item);
356
+ }
357
+
358
+ /** Frees what hasn't been drawn for `releaseAfter` frames. */
359
+ _sweep() {
360
+ if (!(this.releaseAfter < Infinity)) return;
361
+ const oldest = this._frame - this.releaseAfter;
362
+ for (const [item, frame] of this._meshUse) if (frame < oldest) this.release(item);
363
+ for (const [item, frame] of this._textureUse) if (frame < oldest) this.release(item);
292
364
  }
293
365
 
294
366
  /** Frees the graphics card memory. The renderer can't be used afterwards. */
@@ -300,6 +372,8 @@ export class WebGLRenderer {
300
372
  for (const t of this._glTextures) gl.deleteTexture(t);
301
373
  this._glTextures.clear();
302
374
  this._buffers.clear();
375
+ this._meshUse.clear();
376
+ this._textureUse.clear();
303
377
  for (const p of new Set(this._faces.values())) gl.deleteProgram(p.program);
304
378
  gl.deleteProgram(this._line.program);
305
379
  gl.getExtension("WEBGL_lose_context")?.loseContext();
@@ -310,15 +384,15 @@ export class WebGLRenderer {
310
384
  const isFace = d.kind === "face";
311
385
  const effects = isFace ? d.mat.effects ?? [] : null;
312
386
  const prog = isFace ? this._faceProgram(effects) : this._line;
313
- const buf = isFace ? this._faceBuffer(d.geo, d.obj.mesh, d.mat.shade === false ? null : creaseOf(d.mat.smooth)) : d.buf;
314
- if (!buf.count) return;
387
+ const buf = isFace
388
+ ? this._faceBuffer(d.geo, d.obj.mesh, d.mat.shade === false ? null : creaseOf(d.mat.smooth), d.mat.mapping)
389
+ : d.buf;
390
+ const first = d.first ?? 0, count = d.count ?? buf.count;
391
+ if (!count) return;
315
392
 
316
393
  gl.useProgram(prog.program);
317
394
  const u = prog.uniforms;
318
- const { pos, rot, scale } = d.obj;
319
- gl.uniform3f(u.uPos, pos.x, pos.y, pos.z);
320
- gl.uniform3f(u.uRot, rot.x, rot.y, rot.z);
321
- gl.uniform3f(u.uScale, scale.x, scale.y, scale.z);
395
+ gl.uniformMatrix4fv(u.uModel, false, d.obj.world);
322
396
  gl.uniform3fv(u.uCamPos, view.camPos);
323
397
  gl.uniform2fv(u.uCamRot, view.camRot);
324
398
  gl.uniform1f(u.uF, view.f);
@@ -331,6 +405,10 @@ export class WebGLRenderer {
331
405
  const a = prog.attributes;
332
406
 
333
407
  if (isFace) {
408
+ const m = uvTransform(d.mat) ?? [1, 0, 0, 1, 0, 0];
409
+ gl.uniformMatrix3fv(u.uUVTransform, false, [m[0], m[1], 0, m[2], m[3], 0, m[4], m[5], 1]);
410
+ gl.uniformMatrix3fv(u.uNormalMatrix, false, d.place.normals);
411
+ gl.frontFace(d.place.mirrored ? gl.CCW : gl.CW);
334
412
  gl.uniform3fv(u.uLight, light);
335
413
  gl.uniform1f(u.uAmbient, ambient);
336
414
  gl.uniform1f(u.uShade, d.mat.shade === false ? 0 : 1);
@@ -346,7 +424,9 @@ export class WebGLRenderer {
346
424
  gl.vertexAttribPointer(a.aPos, 3, gl.FLOAT, false, 32, 0);
347
425
  if (a.aNormal >= 0) gl.vertexAttribPointer(a.aNormal, 3, gl.FLOAT, false, 32, 12);
348
426
  if (a.aUV >= 0) gl.vertexAttribPointer(a.aUV, 2, gl.FLOAT, false, 32, 24);
349
- const tex = d.mat.texture ? this._texture(d.mat.texture, !!d.mat.pixelated, d.obj.mesh) : null;
427
+ const tex = d.mat.texture
428
+ ? this._texture(d.mat.texture, d.mat, d.obj.mesh, !!uvsFor(d.obj.mesh, d.mat.mapping).faceUVs)
429
+ : null;
350
430
  gl.activeTexture(gl.TEXTURE0);
351
431
  gl.bindTexture(gl.TEXTURE_2D, tex);
352
432
  gl.uniform1i(u.uTexture, 0);
@@ -363,7 +443,7 @@ export class WebGLRenderer {
363
443
  }
364
444
  const locs = Object.values(a).filter((loc) => loc >= 0);
365
445
  for (const loc of locs) gl.enableVertexAttribArray(loc);
366
- gl.drawArrays(gl.TRIANGLES, 0, buf.count);
446
+ gl.drawArrays(gl.TRIANGLES, first, count);
367
447
  for (const loc of locs) gl.disableVertexAttribArray(loc);
368
448
  }
369
449
 
@@ -398,33 +478,53 @@ export class WebGLRenderer {
398
478
  * (canvases and videos: every frame). Null while it isn't ready,
399
479
  * or if the mesh has no texture coordinates.
400
480
  */
401
- _texture(src, pixelated, mesh) {
481
+ _texture(src, mat, mesh, hasUVs) {
402
482
  const gl = this.gl;
483
+ const pixelated = !!mat.pixelated;
403
484
  if (!textureReady(src)) return null;
404
- if (!mesh.faceUVs) {
485
+ if (!hasUVs) {
405
486
  if (!this._warned.has(mesh)) {
406
487
  this._warned.add(mesh);
407
- console.warn("Trid: this mesh has no texture coordinates (uvs / faceUVs), so its texture isn't drawn");
488
+ console.warn("Trid: this mesh has no texture coordinates (uvs / faceUVs), so its texture isn't drawn. Set material.mapping to make some.");
408
489
  }
409
490
  return null;
410
491
  }
411
492
 
412
493
  let t = this._textures.get(src);
413
- const live = textureIsLive(src);
414
- if (!t || live || t.failed) {
494
+ const live = textureIsLive(src, mat);
495
+ const version = textureVersion(src);
496
+ this._textureUse.set(src, this._frame);
497
+ // Sent once, then again after updateTexture(src), or every frame when live
498
+ if (!t || live || t.version !== version || t.failed) {
415
499
  if (t?.failed) return null;
416
- const { w, h } = textureSize(src);
500
+ let { w, h } = textureSize(src);
417
501
  if (!t) {
418
- t = { tex: gl.createTexture(), frame: -1 };
502
+ t = { tex: gl.createTexture(), frame: -1, version: -1 };
419
503
  this._textures.set(src, t);
420
504
  this._glTextures.add(t.tex);
421
505
  }
422
506
  if (t.frame !== this._frame) {
507
+ const changed = t.version !== version;
423
508
  t.frame = this._frame;
509
+ t.version = version;
424
510
  gl.bindTexture(gl.TEXTURE_2D, t.tex);
511
+ // Bigger than the graphics card allows: upload a smaller copy
512
+ let pixels = src;
513
+ const max = gl.getParameter(gl.MAX_TEXTURE_SIZE);
514
+ if (w > max || h > max) {
515
+ const k = max / Math.max(w, h);
516
+ if (!t.small || live || changed) t.small = shrink(src, Math.floor(w * k), Math.floor(h * k), t.small);
517
+ if (!t.warned) {
518
+ t.warned = true;
519
+ console.warn(`Trid: a ${w} × ${h} texture is bigger than this graphics card allows (${max}), so it's drawn at ${t.small.width} × ${t.small.height}`);
520
+ }
521
+ pixels = t.small;
522
+ w = t.small.width;
523
+ h = t.small.height;
524
+ }
425
525
  gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true); // images go top-down, v goes up
426
526
  try {
427
- gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, src);
527
+ gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
428
528
  } catch (err) {
429
529
  // Usually an image from another site without permission (CORS)
430
530
  console.warn("Trid: can't use this image as a texture: " + err.message);
@@ -463,61 +563,88 @@ export class WebGLRenderer {
463
563
 
464
564
  /** Graphics card buffers for a mesh, built once and rebuilt when it changes. */
465
565
  _geometry(mesh) {
566
+ this._meshUse.set(mesh, this._frame);
466
567
  let geo = this._meshes.get(mesh);
467
568
  const stale = !geo || mesh.dirty || geo.vertices !== mesh.vertices || geo.faceList !== mesh.faces
468
- || geo.edgeList !== mesh.edges || geo.counts !== countsOf(mesh);
569
+ || geo.edgeList !== mesh.edges || geo.faceMats !== mesh.faceMaterials || geo.counts !== countsOf(mesh);
469
570
  if (!stale) return geo;
470
571
 
471
- if (geo) {
472
- for (const b of [geo.outlines, geo.edges, ...geo.faceBuffers.values()]) this._free(b);
473
- }
474
- const v = mesh.vertices;
572
+ if (geo) for (const b of buffersOf(geo)) this._free(b);
475
573
  const faces = mesh.faces ?? [];
476
574
 
477
- // Outlines of the faces, each edge once
478
- const outline = new Set();
479
- const outlinePairs = [];
480
- for (const f of faces) {
481
- for (let k = 0; k < f.length; k++) {
482
- const a = f[k], b = f[(k + 1) % f.length];
483
- const key = a < b ? a + "," + b : b + "," + a;
484
- if (!outline.has(key)) {
485
- outline.add(key);
486
- outlinePairs.push([a, b]);
487
- }
488
- }
575
+ // Faces grouped by material index, in the order the face buffers store them
576
+ const byIndex = new Map();
577
+ faces.forEach((f, fi) => {
578
+ if (f.length < 3) return;
579
+ const k = mesh.faceMaterials?.[fi] ?? 0;
580
+ if (!byIndex.has(k)) byIndex.set(k, []);
581
+ byIndex.get(k).push(fi);
582
+ });
583
+ const groups = [];
584
+ let first = 0;
585
+ for (const k of [...byIndex.keys()].sort((a, b) => a - b)) {
586
+ const list = byIndex.get(k);
587
+ const count = list.reduce((n, fi) => n + (faces[fi].length - 2) * 3, 0);
588
+ groups.push({ index: k, faces: list, first, count });
589
+ first += count;
489
590
  }
490
591
 
491
592
  geo = {
492
593
  vertices: mesh.vertices,
493
594
  faceList: mesh.faces,
494
595
  edgeList: mesh.edges,
596
+ faceMats: mesh.faceMaterials,
495
597
  counts: countsOf(mesh),
496
- faceBuffers: new Map(), // one per shading mode, built when first needed
497
- outlines: this._upload(lineData(v, outlinePairs), 8),
498
- edges: this._upload(lineData(v, edgeSegments(mesh.edges ?? [])), 8),
598
+ groups,
599
+ groupOutlines: new Map(), // outlines of one group, when only some materials have `wireframe`
600
+ faceBuffers: new Map(), // one per shading mode and mapping, built when first needed
601
+ lines: new Map(), // "outlines" and "edges", built when first drawn (they're big on big models)
499
602
  };
500
603
  this._meshes.set(mesh, geo);
501
604
  return geo;
502
605
  }
503
606
 
607
+ /** A line buffer of the mesh, built the first time it's drawn. */
608
+ _lineBuffer(geo, name, pairs) {
609
+ let buf = geo.lines.get(name);
610
+ if (!buf) {
611
+ buf = this._upload(lineData(geo.vertices, pairs()), 8);
612
+ geo.lines.set(name, buf);
613
+ }
614
+ return buf;
615
+ }
616
+
617
+ /** Outlines of one group of faces (see `_geometry`), built when first needed. */
618
+ _outlineBuffer(geo, mesh, group) {
619
+ let buf = geo.groupOutlines.get(group.index);
620
+ if (!buf) {
621
+ const faces = mesh.faces ?? [];
622
+ buf = this._upload(lineData(mesh.vertices, outlinePairs(group.faces.map((fi) => faces[fi]))), 8);
623
+ geo.groupOutlines.set(group.index, buf);
624
+ }
625
+ return buf;
626
+ }
627
+
504
628
  /**
505
- * Triangles of a mesh's faces, each corner with its own position and
506
- * normal. Flat shading uses the face's normal at every corner; smooth
507
- * shading uses the corner normals.
629
+ * Triangles of a mesh's faces, each corner with its own position,
630
+ * normal and texture coordinate, grouped by material (`geo.groups`).
631
+ * Flat shading uses the face's normal at every corner; smooth shading
632
+ * uses the corner normals. `mapping` picks the texture coordinates.
508
633
  */
509
- _faceBuffer(geo, mesh, crease) {
510
- const key = crease ?? "flat";
634
+ _faceBuffer(geo, mesh, crease, mapping) {
635
+ const key = (crease ?? "flat") + "|" + mappingKey(mapping);
511
636
  let buf = geo.faceBuffers.get(key);
512
637
  if (buf) return buf;
513
638
 
514
639
  const v = mesh.vertices;
515
640
  const faces = mesh.faces ?? [];
516
641
  const corners = crease ? cornerNormals(mesh, crease) : null;
517
- const uvs = mesh.uvs, faceUVs = mesh.faceUVs;
518
- const tri = [];
519
- faces.forEach((f, fi) => {
520
- if (f.length < 3) return;
642
+ const { uvs, faceUVs } = uvsFor(mesh, mapping);
643
+ const total = geo.groups.reduce((n, g) => n + g.count, 0);
644
+ const tri = new Float32Array(total * 8);
645
+ let o = 0;
646
+ for (const fi of geo.groups.flatMap((g) => g.faces)) {
647
+ const f = faces[fi];
521
648
  let ns;
522
649
  if (corners) {
523
650
  ns = corners[fi];
@@ -532,10 +659,12 @@ export class WebGLRenderer {
532
659
  for (const c of [0, k, k + 1]) {
533
660
  const p = v[f[c]], n = ns[c];
534
661
  const t = ts ? uvs[ts[c]] : null;
535
- tri.push(p.x, p.y, p.z, n.x, n.y, n.z, t ? t.u : 0, t ? t.v : 0);
662
+ tri[o++] = p.x; tri[o++] = p.y; tri[o++] = p.z;
663
+ tri[o++] = n.x; tri[o++] = n.y; tri[o++] = n.z;
664
+ tri[o++] = t ? t.u : 0; tri[o++] = t ? t.v : 0;
536
665
  }
537
666
  }
538
- });
667
+ }
539
668
 
540
669
  buf = this._upload(tri, 8);
541
670
  geo.faceBuffers.set(key, buf);
@@ -546,7 +675,7 @@ export class WebGLRenderer {
546
675
  const gl = this.gl;
547
676
  const buffer = gl.createBuffer();
548
677
  gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
549
- gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.STATIC_DRAW);
678
+ gl.bufferData(gl.ARRAY_BUFFER, data instanceof Float32Array ? data : new Float32Array(data), gl.STATIC_DRAW);
550
679
  this._buffers.add(buffer);
551
680
  return { buffer, count: data.length / floatsPerVertex };
552
681
  }
@@ -568,18 +697,54 @@ export class WebGLRenderer {
568
697
  }
569
698
  }
570
699
 
700
+ /** Every graphics card buffer of a mesh's geometry. */
701
+ function buffersOf(geo) {
702
+ return [...geo.lines.values(), ...geo.faceBuffers.values(), ...geo.groupOutlines.values()];
703
+ }
704
+
571
705
  /** A short string that changes when a mesh's arrays grow or shrink. */
572
706
  function countsOf(mesh) {
573
707
  return `${mesh.vertices.length}/${mesh.edges?.length ?? 0}/${mesh.faces?.length ?? 0}`;
574
708
  }
575
709
 
710
+ /** A smaller copy of an image, drawn on a canvas (reused when given one). */
711
+ function shrink(src, w, h, canvas = document.createElement("canvas")) {
712
+ canvas.width = w;
713
+ canvas.height = h;
714
+ canvas.getContext("2d").drawImage(src, 0, 0, w, h);
715
+ return canvas;
716
+ }
717
+
718
+ /** Each edge of the faces once, as [a, b] pairs. */
719
+ function outlinePairs(faces) {
720
+ const seen = new Set();
721
+ const pairs = [];
722
+ for (const f of faces) {
723
+ for (let k = 0; k < f.length; k++) {
724
+ const a = f[k], b = f[(k + 1) % f.length];
725
+ const key = a < b ? a * 4294967296 + b : b * 4294967296 + a; // one number per edge
726
+ if (!seen.has(key)) {
727
+ seen.add(key);
728
+ pairs.push([a, b]);
729
+ }
730
+ }
731
+ }
732
+ return pairs;
733
+ }
734
+
576
735
  /** Six corners (two triangles) per segment: A, B, then which corner. */
577
- function lineData(v, pairs) {
578
- const out = [];
736
+ function lineData(v, segments) {
737
+ const pairs = Array.isArray(segments) ? segments : [...segments]; // edgeSegments is a generator
738
+ const out = new Float32Array(pairs.length * 48);
579
739
  const corners = [[0, -1], [1, -1], [1, 1], [0, -1], [1, 1], [0, 1]];
740
+ let o = 0;
580
741
  for (const [i, j] of pairs) {
581
742
  const a = v[i], b = v[j];
582
- for (const [cx, cy] of corners) out.push(a.x, a.y, a.z, b.x, b.y, b.z, cx, cy);
743
+ for (const [cx, cy] of corners) {
744
+ out[o++] = a.x; out[o++] = a.y; out[o++] = a.z;
745
+ out[o++] = b.x; out[o++] = b.y; out[o++] = b.z;
746
+ out[o++] = cx; out[o++] = cy;
747
+ }
583
748
  }
584
749
  return out;
585
750
  }
package/trid/scene.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { CanvasRenderer, DEFAULT_LIGHT, DEFAULT_AMBIENT } from "./render/canvasRenderer.js";
2
2
  import { loop } from "./render/loop.js";
3
3
  import { ToolManager } from "./tools/manager.js";
4
+ import { linkTree } from "./sceneGraph.js";
5
+ import { screenshot, record } from "./export/capture.js";
4
6
 
5
7
  /**
6
8
  * Holds a canvas, a camera and a list of objects, and draws them.
@@ -70,27 +72,37 @@ export class Scene {
70
72
  }
71
73
 
72
74
  /**
73
- * Adds an object to the scene, filling in missing transform fields.
75
+ * Adds an object to the scene, filling in missing transform fields
76
+ * (down its `children` too).
74
77
  *
75
- * @param {{ mesh:{ vertices:{x:number,y:number,z:number}[], edges:number[][] } }} obj - Mesh instance
78
+ * An object can be a group: give it `children`, objects placed relative
79
+ * to it, and leave out `mesh` if it's only there to hold them. Moving it
80
+ * moves them all. See `trid/sceneGraph.js`.
81
+ *
82
+ * @example
83
+ * const car = scene.add({ children: [{ mesh: body }, { mesh: wheel, pos: { x: 0.4, y: -0.2, z: 0 } }] });
84
+ * car.pos.x += 0.1; // the body and the wheel move together
85
+ *
86
+ * @param {Object} obj - An object: `mesh` and/or `children`, plus pos, rot, scale, material...
76
87
  * @returns The same object, so it can be kept and animated
77
88
  */
78
89
  add(obj) {
79
- obj.pos ??= { x: 0, y: 0, z: 0 };
80
- obj.rot ??= { x: 0, y: 0, z: 0 };
81
- obj.scale ??= { x: 1, y: 1, z: 1 };
90
+ linkTree(obj, null);
82
91
  this.objects.push(obj);
83
92
  return obj;
84
93
  }
85
94
 
86
95
  /**
87
- * Removes an object from the scene. Does nothing if it isn't in it.
96
+ * Removes an object: from the scene's top level, or from its group's
97
+ * `children`. Its own children go with it. Does nothing if it isn't there.
88
98
  *
89
- * @param {Object} obj - Object previously passed to `add`
99
+ * @param {Object} obj - Object previously added (or a child of one)
90
100
  */
91
101
  remove(obj) {
92
- const i = this.objects.indexOf(obj);
93
- if (i !== -1) this.objects.splice(i, 1);
102
+ const list = obj.parent ? obj.parent.children : this.objects;
103
+ const i = list?.indexOf(obj) ?? -1;
104
+ if (i !== -1) list.splice(i, 1);
105
+ if (obj.parent) obj.parent = null;
94
106
  }
95
107
 
96
108
  /**
@@ -150,6 +162,28 @@ export class Scene {
150
162
  this._stop = null;
151
163
  }
152
164
 
165
+ /**
166
+ * Draws a new frame and returns it as an image file (PNG by default).
167
+ * The page's canvas doesn't change. Save it with `download`.
168
+ *
169
+ * @param {import("./export/capture.js").ScreenshotOptions} [opts]
170
+ * @returns {Promise<Blob>}
171
+ */
172
+ screenshot(opts) {
173
+ return screenshot(this, opts);
174
+ }
175
+
176
+ /**
177
+ * Starts recording the canvas as a video. Call `stop()` on the result,
178
+ * or pass `duration`, then save `await recording.done` with `download`.
179
+ *
180
+ * @param {import("./export/capture.js").RecordOptions} [opts]
181
+ * @returns {import("./export/capture.js").Recording}
182
+ */
183
+ record(opts) {
184
+ return record(this, opts);
185
+ }
186
+
153
187
  /**
154
188
  * Stops the loop, removes every tool (and its event listeners) and
155
189
  * frees the renderer's resources. Use it when the scene is no longer needed.