trid3d 0.2.0 → 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.
@@ -364,67 +364,134 @@ export function triangulate(pts) {
364
364
  * centered on the origin: blades, axes, keys, stars, letters, floor plans.
365
365
  * The outline may go either way round and may be concave (no holes).
366
366
  *
367
+ * Options:
368
+ * - `bevel`: cuts the edges between the sides and the front / back by this
369
+ * much, so they catch the light (and a blade gets a thin edge). Up to half
370
+ * the depth.
371
+ * - `bevelSegments`: 1 is a flat chamfer, more makes a rounded edge.
372
+ * - `taper`: size of the back compared to the front (1 = same, 0.5 = half
373
+ * as big), scaled around the middle of the outline's bounding box.
374
+ *
367
375
  * Texture coordinates: the front and back map the outline's bounding box,
368
- * the sides wrap u along the outline and v across the depth. Edges are the
369
- * front and back outlines plus a line at each corner.
376
+ * the sides wrap u along the outline and v from front to back. Edges are
377
+ * the outline at each step plus a line along each corner.
370
378
  *
371
379
  * @example
372
380
  * const star = Array.from({ length: 10 }, (_, i) => {
373
381
  * const a = (i / 10) * Math.PI * 2, r = i % 2 ? 0.2 : 0.5;
374
382
  * return { x: Math.cos(a) * r, y: Math.sin(a) * r };
375
383
  * });
376
- * scene.add({ mesh: extrude(star, 0.15), material: { fill: "#fc5" } });
384
+ * scene.add({ mesh: extrude(star, 0.15, { bevel: 0.03, bevelSegments: 3 }), material: { fill: "#fc5", smooth: true } });
377
385
  *
378
386
  * @param {{x:number, y:number}[]} outline - The flat shape (3 points or more)
379
387
  * @param {number} [depth=0.2] - Thickness along Z
388
+ * @param {Object} [opts]
389
+ * @param {number} [opts.bevel=0] - How much the edges are cut (clamped to half the depth)
390
+ * @param {number} [opts.bevelSegments=1] - Steps around a bevel (1 = flat chamfer)
391
+ * @param {number} [opts.taper=1] - Size of the back compared to the front
380
392
  * @returns {import("../index.d.ts").Mesh}
381
393
  */
382
- export function extrude(outline, depth = 0.2) {
394
+ export function extrude(outline, depth = 0.2, { bevel = 0, bevelSegments = 1, taper = 1 } = {}) {
383
395
  const pts = signedArea(outline) < 0 ? [...outline].reverse() : outline;
384
396
  const n = pts.length;
397
+ const half = depth / 2;
398
+ const b = Math.max(0, Math.min(bevel, half));
399
+ const steps = Math.max(1, Math.floor(bevelSegments));
400
+
401
+ const xs = pts.map((p) => p.x), ys = pts.map((p) => p.y);
402
+ const x0 = Math.min(...xs), y0 = Math.min(...ys);
403
+ const w = Math.max(...xs) - x0 || 1, h = Math.max(...ys) - y0 || 1;
404
+ const cx = x0 + w / 2, cy = y0 + h / 2;
405
+
406
+ // The profile from front (-z) to back (+z): how far each ring is pulled in, and its z.
407
+ // A bevel is a quarter circle (or a chamfer) at each end.
408
+ const profile = [];
409
+ if (b > 0) {
410
+ for (let i = 0; i <= steps; i++) {
411
+ const a = (i / steps) * (Math.PI / 2);
412
+ profile.push({ inset: steps === 1 ? b * (1 - i) : b * (1 - Math.sin(a)), z: -half + (steps === 1 ? b * i : b * (1 - Math.cos(a))) });
413
+ }
414
+ // Skip the straight middle when the bevels meet
415
+ const backStart = b < half - 1e-9 ? 0 : 1;
416
+ for (let i = backStart; i <= steps; i++) {
417
+ const a = (i / steps) * (Math.PI / 2);
418
+ profile.push({ inset: steps === 1 ? b * i : b * (1 - Math.cos(a)), z: half - (steps === 1 ? b * (1 - i) : b * (1 - Math.sin(a))) });
419
+ }
420
+ } else {
421
+ profile.push({ inset: 0, z: -half }, { inset: 0, z: half });
422
+ }
423
+
424
+ // Inward direction at each corner (the outline is counter-clockwise, so inside is on the left)
425
+ const inward = pts.map((p, i) => {
426
+ const prev = pts[(i + n - 1) % n], next = pts[(i + 1) % n];
427
+ const n1 = leftNormal(prev, p), n2 = leftNormal(p, next);
428
+ let mx = n1.x + n2.x, my = n1.y + n2.y;
429
+ const ml = Math.hypot(mx, my) || 1;
430
+ mx /= ml; my /= ml;
431
+ // Miter: longer at sharp corners, but capped so they don't spike
432
+ const k = Math.min(3, 1 / Math.max(0.2, mx * n1.x + my * n1.y));
433
+ return { x: mx * k, y: my * k };
434
+ });
435
+
385
436
  const vertices = [];
386
437
  const faces = [];
387
438
  const uvs = [];
388
439
  const faceUVs = [];
389
440
  const uv = (u, v) => uvs.push({ u, v }) - 1;
390
-
391
- // 0..n-1 at the front (-z), n..2n-1 at the back (+z)
392
- for (const p of pts) vertices.push({ x: p.x, y: p.y, z: -depth / 2 });
393
- for (const p of pts) vertices.push({ x: p.x, y: p.y, z: depth / 2 });
394
-
395
- const xs = pts.map((p) => p.x), ys = pts.map((p) => p.y);
396
- const x0 = Math.min(...xs), y0 = Math.min(...ys);
397
- const w = Math.max(...xs) - x0 || 1, h = Math.max(...ys) - y0 || 1;
398
- const capUV = (i) => uv((pts[i].x - x0) / w, (pts[i].y - y0) / h);
441
+ // Ring r is vertices r*n .. r*n+n-1
442
+ for (const { inset, z } of profile) {
443
+ const t = depth > 0 ? (z + half) / depth : 0;
444
+ const k = 1 + (taper - 1) * t;
445
+ for (let i = 0; i < n; i++) {
446
+ const x = pts[i].x + inward[i].x * inset, y = pts[i].y + inward[i].y * inset;
447
+ vertices.push({ x: cx + (x - cx) * k, y: cy + (y - cy) * k, z });
448
+ }
449
+ }
450
+ const last = (profile.length - 1) * n;
399
451
 
400
452
  // Caps: counter-clockwise outlines look clockwise from the back (+z), so the
401
453
  // back cap keeps that order and the front cap (seen from -z) is reversed
402
- for (const [a, b, c] of triangulate(pts)) {
403
- faces.push([n + a, n + b, n + c]);
404
- faceUVs.push([capUV(a), capUV(b), capUV(c)]);
405
- faces.push([c, b, a]);
406
- faceUVs.push([capUV(c), capUV(b), capUV(a)]);
454
+ const capUV = (i) => uv((pts[i].x - x0) / w, (pts[i].y - y0) / h);
455
+ for (const [a, c1, c2] of triangulate(profile[0].inset > 0 ? vertices.slice(0, n) : pts)) {
456
+ faces.push([last + a, last + c1, last + c2]);
457
+ faceUVs.push([capUV(a), capUV(c1), capUV(c2)]);
458
+ faces.push([c2, c1, a]);
459
+ faceUVs.push([capUV(c2), capUV(c1), capUV(a)]);
407
460
  }
408
461
 
409
- // Sides, u by length along the outline
462
+ // Sides between each pair of rings, u by length along the outline, v by length along the profile
410
463
  const len = [0];
411
464
  for (let i = 0; i < n; i++) len.push(len[i] + Math.hypot(pts[(i + 1) % n].x - pts[i].x, pts[(i + 1) % n].y - pts[i].y));
412
465
  const total = len[n] || 1;
413
- for (let i = 0; i < n; i++) {
414
- const j = (i + 1) % n;
415
- const ua = len[i] / total, ub = len[i + 1] / total;
416
- faces.push([n + j, n + i, i, j]);
417
- faceUVs.push([uv(ub, 1), uv(ua, 1), uv(ua, 0), uv(ub, 0)]);
466
+ const along = [0];
467
+ for (let r = 1; r < profile.length; r++) {
468
+ along.push(along[r - 1] + Math.hypot(profile[r].inset - profile[r - 1].inset, profile[r].z - profile[r - 1].z));
469
+ }
470
+ const vTotal = along[along.length - 1] || 1;
471
+ for (let r = 0; r < profile.length - 1; r++) {
472
+ const f0 = r * n, f1 = (r + 1) * n;
473
+ const va = along[r] / vTotal, vb = along[r + 1] / vTotal;
474
+ for (let i = 0; i < n; i++) {
475
+ const j = (i + 1) % n;
476
+ const ua = len[i] / total, ub = len[i + 1] / total;
477
+ faces.push([f1 + j, f1 + i, f0 + i, f0 + j]);
478
+ faceUVs.push([uv(ub, vb), uv(ua, vb), uv(ua, va), uv(ub, va)]);
479
+ }
418
480
  }
419
481
 
420
482
  const edges = [
421
- pts.map((_, i) => i),
422
- pts.map((_, i) => n + i),
423
- ...pts.map((_, i) => [i, n + i]),
483
+ ...profile.map((_, r) => pts.map((_, i) => r * n + i)),
484
+ ...pts.flatMap((_, i) => profile.slice(1).map((_, r) => [r * n + i, (r + 1) * n + i])),
424
485
  ];
425
486
  return { vertices, edges, faces, uvs, faceUVs };
426
487
  }
427
488
 
489
+ /** Unit normal on the left of the edge a → b (inside, for a counter-clockwise outline). */
490
+ function leftNormal(a, b) {
491
+ const dx = b.x - a.x, dy = b.y - a.y, l = Math.hypot(dx, dy) || 1;
492
+ return { x: -dy / l, y: dx / l };
493
+ }
494
+
428
495
  /**
429
496
  * Scales a mesh along the X, Y, and Z axes.
430
497
  *
@@ -452,6 +519,7 @@ export function scaleMesh(mesh, sx = 1, sy = 1, sz = 1) {
452
519
  v.y *= sy;
453
520
  v.z *= sz;
454
521
  }
522
+ mesh.dirty = true; // so the WebGL renderer uploads the new positions
455
523
  return mesh;
456
524
  }
457
525
 
@@ -1,28 +1,28 @@
1
1
  import { DEFAULT_FOV, DEFAULT_NEAR } from "../math/transform.js";
2
+ import { normalMatrix, determinantMat4 } from "../math/matrix.js";
3
+ import { worldObjects } from "../sceneGraph.js";
2
4
  import { clear, edgeSegments, wireEdges } from "./draw.js";
3
5
  import { parseColor } from "./color.js";
4
6
  import { DEFAULT_LIGHT, DEFAULT_AMBIENT, materialsOf } from "./canvasRenderer.js";
5
7
  import { uvsFor, uvTransform, mappingKey } from "./uvMapping.js";
6
8
  import { newell, cornerNormals, creaseOf } from "./normals.js";
7
9
  import { SURFACE_GLSL, buildEffects, effectsKey } from "./effects.js";
8
- import { textureReady, textureSize, textureIsLive } from "../loaders/texture.js";
10
+ import { textureReady, textureSize, textureIsLive, textureVersion } from "../loaders/texture.js";
9
11
 
10
12
  /** Farthest distance drawn by the WebGL renderer, unless the camera sets `far`. */
11
13
  export const DEFAULT_FAR = 1000;
12
14
 
13
- // Trid's transform pipeline, written in GLSL. It matches localToWorld,
14
- // 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.
15
17
  const COMMON = `
16
- uniform vec3 uPos, uRot, uScale;
18
+ uniform mat4 uModel; // the object's world matrix (its parents' transforms included)
17
19
  uniform vec3 uCamPos;
18
20
  uniform vec2 uCamRot; // yaw, pitch
19
21
  uniform float uF, uAspect, uNear, uFar;
20
22
 
21
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); }
22
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); }
23
- 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); }
24
- vec3 rotXYZ(vec3 p) { return rotZ(rotY(rotX(p, uRot.x), uRot.y), uRot.z); }
25
- vec3 toWorld(vec3 v) { return rotXYZ(v * uScale) + uPos; }
25
+ vec3 toWorld(vec3 v) { return (uModel * vec4(v, 1.0)).xyz; }
26
26
  vec3 toCamera(vec3 p) { return rotX(rotY(p - uCamPos, uCamRot.x), uCamRot.y); }
27
27
  vec4 toClip(vec3 p) {
28
28
  float a = (uFar + uNear) / (uFar - uNear);
@@ -40,9 +40,10 @@ varying vec3 vWorld;
40
40
  varying vec3 vLocal;
41
41
  varying vec2 vUV;
42
42
  uniform mat3 uUVTransform; // textureRepeat, textureOffset, textureRotation
43
+ uniform mat3 uNormalMatrix; // keeps normals right on stretched and mirrored objects
43
44
  ${COMMON}
44
45
  void main() {
45
- vNormal = rotXYZ(aNormal / uScale); // inverse scale keeps normals right on stretched objects
46
+ vNormal = uNormalMatrix * aNormal;
46
47
  vLocal = aPos;
47
48
  vUV = (uUVTransform * vec3(aUV, 1.0)).xy;
48
49
  vWorld = toWorld(aPos);
@@ -108,7 +109,7 @@ ${fx.calls}
108
109
  return { source, uniforms: fx.uniforms };
109
110
  }
110
111
 
111
- const FACE_UNIFORMS = ["uUVTransform", "uLight", "uAmbient", "uShade", "uTime", "uBackground", "uCamWorld", "uTexture", "uUseTexture"];
112
+ const FACE_UNIFORMS = ["uUVTransform", "uNormalMatrix", "uLight", "uAmbient", "uShade", "uTime", "uBackground", "uCamWorld", "uTexture", "uUseTexture"];
112
113
 
113
114
  // Each line segment is a thin rectangle facing the screen, `uWidth` pixels wide
114
115
  const LINE_VS = `
@@ -144,7 +145,7 @@ void main() {
144
145
  }
145
146
  `;
146
147
 
147
- const UNIFORMS = ["uPos", "uRot", "uScale", "uCamPos", "uCamRot", "uF", "uAspect", "uNear", "uFar", "uColor"];
148
+ const UNIFORMS = ["uModel", "uCamPos", "uCamRot", "uF", "uAspect", "uNear", "uFar", "uColor"];
148
149
 
149
150
  /**
150
151
  * Draws a scene with WebGL, on the graphics card.
@@ -160,6 +161,12 @@ const UNIFORMS = ["uPos", "uRot", "uScale", "uCamPos", "uCamRot", "uF", "uAspect
160
161
  *
161
162
  * Meshes are sent to the graphics card once and reused. After changing
162
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.
163
170
  *
164
171
  * @example
165
172
  * const scene = new Scene(canvas, { renderer: new WebGLRenderer() });
@@ -178,9 +185,13 @@ export class WebGLRenderer {
178
185
  /**
179
186
  * @param {Object} [opts]
180
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()`
181
190
  * @throws {Error} If WebGL isn't available (check `WebGLRenderer.isSupported()` first)
182
191
  */
183
- 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;
184
195
  /** The hidden canvas WebGL draws on. */
185
196
  this.canvas = document.createElement("canvas");
186
197
  this._options = { antialias, alpha: true, premultipliedAlpha: true, depth: true };
@@ -210,6 +221,9 @@ export class WebGLRenderer {
210
221
  this._buffers = new Set();
211
222
  this._textures = new WeakMap(); // image / canvas / video → GL texture
212
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();
213
227
  this._warned = new WeakSet();
214
228
  this._frame = 0;
215
229
 
@@ -266,13 +280,17 @@ export class WebGLRenderer {
266
280
 
267
281
  // Opaque things first; see-through ones after, without writing depth
268
282
  const opaque = [], clear2 = [];
269
- 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) {
270
286
  const geo = this._geometry(obj.mesh);
271
287
  const lineColor = this._color(ctx, obj.color ?? scene.color);
272
288
  const lineWidth = obj.lineWidth ?? scene.lineWidth;
273
289
  const mats = materialsOf(obj);
274
290
 
275
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 };
276
294
  // One draw per group of faces sharing a material (mesh.faceMaterials)
277
295
  const groups = geo.groups.map((g) => {
278
296
  const k = g.index >= 0 && g.index < mats.length && mats[g.index] ? g.index : 0;
@@ -280,7 +298,7 @@ export class WebGLRenderer {
280
298
  });
281
299
  for (const g of groups) {
282
300
  const fill = this._color(ctx, g.mat.fill ?? (g.mat.texture ? "#ffffff" : scene.color));
283
- (fill[3] < 1 ? clear2 : opaque).push({ kind: "face", obj, geo, color: fill, mat: g.mat, first: g.first, count: g.count });
301
+ (fill[3] < 1 ? clear2 : opaque).push({ kind: "face", obj, geo, color: fill, mat: g.mat, first: g.first, count: g.count, place });
284
302
  }
285
303
  const lines = (buf) => (lineColor[3] < 1 ? clear2 : opaque).push({ kind: "line", obj, buf, color: lineColor, width: lineWidth });
286
304
  if (groups.every((g) => g.mat.wireframe)) lines(this._lineBuffer(geo, "outlines", () => outlinePairs(geo.faceList ?? [])));
@@ -300,7 +318,49 @@ export class WebGLRenderer {
300
318
  ctx.drawImage(this.canvas, 0, 0);
301
319
 
302
320
  // Meshes marked as changed have been sent again
303
- 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);
304
364
  }
305
365
 
306
366
  /** Frees the graphics card memory. The renderer can't be used afterwards. */
@@ -312,6 +372,8 @@ export class WebGLRenderer {
312
372
  for (const t of this._glTextures) gl.deleteTexture(t);
313
373
  this._glTextures.clear();
314
374
  this._buffers.clear();
375
+ this._meshUse.clear();
376
+ this._textureUse.clear();
315
377
  for (const p of new Set(this._faces.values())) gl.deleteProgram(p.program);
316
378
  gl.deleteProgram(this._line.program);
317
379
  gl.getExtension("WEBGL_lose_context")?.loseContext();
@@ -330,10 +392,7 @@ export class WebGLRenderer {
330
392
 
331
393
  gl.useProgram(prog.program);
332
394
  const u = prog.uniforms;
333
- const { pos, rot, scale } = d.obj;
334
- gl.uniform3f(u.uPos, pos.x, pos.y, pos.z);
335
- gl.uniform3f(u.uRot, rot.x, rot.y, rot.z);
336
- gl.uniform3f(u.uScale, scale.x, scale.y, scale.z);
395
+ gl.uniformMatrix4fv(u.uModel, false, d.obj.world);
337
396
  gl.uniform3fv(u.uCamPos, view.camPos);
338
397
  gl.uniform2fv(u.uCamRot, view.camRot);
339
398
  gl.uniform1f(u.uF, view.f);
@@ -348,6 +407,8 @@ export class WebGLRenderer {
348
407
  if (isFace) {
349
408
  const m = uvTransform(d.mat) ?? [1, 0, 0, 1, 0, 0];
350
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);
351
412
  gl.uniform3fv(u.uLight, light);
352
413
  gl.uniform1f(u.uAmbient, ambient);
353
414
  gl.uniform1f(u.uShade, d.mat.shade === false ? 0 : 1);
@@ -364,7 +425,7 @@ export class WebGLRenderer {
364
425
  if (a.aNormal >= 0) gl.vertexAttribPointer(a.aNormal, 3, gl.FLOAT, false, 32, 12);
365
426
  if (a.aUV >= 0) gl.vertexAttribPointer(a.aUV, 2, gl.FLOAT, false, 32, 24);
366
427
  const tex = d.mat.texture
367
- ? this._texture(d.mat.texture, !!d.mat.pixelated, d.obj.mesh, !!uvsFor(d.obj.mesh, d.mat.mapping).faceUVs)
428
+ ? this._texture(d.mat.texture, d.mat, d.obj.mesh, !!uvsFor(d.obj.mesh, d.mat.mapping).faceUVs)
368
429
  : null;
369
430
  gl.activeTexture(gl.TEXTURE0);
370
431
  gl.bindTexture(gl.TEXTURE_2D, tex);
@@ -417,8 +478,9 @@ export class WebGLRenderer {
417
478
  * (canvases and videos: every frame). Null while it isn't ready,
418
479
  * or if the mesh has no texture coordinates.
419
480
  */
420
- _texture(src, pixelated, mesh, hasUVs) {
481
+ _texture(src, mat, mesh, hasUVs) {
421
482
  const gl = this.gl;
483
+ const pixelated = !!mat.pixelated;
422
484
  if (!textureReady(src)) return null;
423
485
  if (!hasUVs) {
424
486
  if (!this._warned.has(mesh)) {
@@ -429,24 +491,29 @@ export class WebGLRenderer {
429
491
  }
430
492
 
431
493
  let t = this._textures.get(src);
432
- const live = textureIsLive(src);
433
- 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) {
434
499
  if (t?.failed) return null;
435
500
  let { w, h } = textureSize(src);
436
501
  if (!t) {
437
- t = { tex: gl.createTexture(), frame: -1 };
502
+ t = { tex: gl.createTexture(), frame: -1, version: -1 };
438
503
  this._textures.set(src, t);
439
504
  this._glTextures.add(t.tex);
440
505
  }
441
506
  if (t.frame !== this._frame) {
507
+ const changed = t.version !== version;
442
508
  t.frame = this._frame;
509
+ t.version = version;
443
510
  gl.bindTexture(gl.TEXTURE_2D, t.tex);
444
511
  // Bigger than the graphics card allows: upload a smaller copy
445
512
  let pixels = src;
446
513
  const max = gl.getParameter(gl.MAX_TEXTURE_SIZE);
447
514
  if (w > max || h > max) {
448
515
  const k = max / Math.max(w, h);
449
- if (!t.small || live) t.small = shrink(src, Math.floor(w * k), Math.floor(h * k), t.small);
516
+ if (!t.small || live || changed) t.small = shrink(src, Math.floor(w * k), Math.floor(h * k), t.small);
450
517
  if (!t.warned) {
451
518
  t.warned = true;
452
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}`);
@@ -496,14 +563,13 @@ export class WebGLRenderer {
496
563
 
497
564
  /** Graphics card buffers for a mesh, built once and rebuilt when it changes. */
498
565
  _geometry(mesh) {
566
+ this._meshUse.set(mesh, this._frame);
499
567
  let geo = this._meshes.get(mesh);
500
568
  const stale = !geo || mesh.dirty || geo.vertices !== mesh.vertices || geo.faceList !== mesh.faces
501
569
  || geo.edgeList !== mesh.edges || geo.faceMats !== mesh.faceMaterials || geo.counts !== countsOf(mesh);
502
570
  if (!stale) return geo;
503
571
 
504
- if (geo) {
505
- for (const b of [...geo.lines.values(), ...geo.faceBuffers.values(), ...geo.groupOutlines.values()]) this._free(b);
506
- }
572
+ if (geo) for (const b of buffersOf(geo)) this._free(b);
507
573
  const faces = mesh.faces ?? [];
508
574
 
509
575
  // Faces grouped by material index, in the order the face buffers store them
@@ -631,6 +697,11 @@ export class WebGLRenderer {
631
697
  }
632
698
  }
633
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
+
634
705
  /** A short string that changes when a mesh's arrays grow or shrink. */
635
706
  function countsOf(mesh) {
636
707
  return `${mesh.vertices.length}/${mesh.edges?.length ?? 0}/${mesh.faces?.length ?? 0}`;
package/trid/scene.js CHANGED
@@ -1,6 +1,7 @@
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";
4
5
  import { screenshot, record } from "./export/capture.js";
5
6
 
6
7
  /**
@@ -71,27 +72,37 @@ export class Scene {
71
72
  }
72
73
 
73
74
  /**
74
- * 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).
75
77
  *
76
- * @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...
77
87
  * @returns The same object, so it can be kept and animated
78
88
  */
79
89
  add(obj) {
80
- obj.pos ??= { x: 0, y: 0, z: 0 };
81
- obj.rot ??= { x: 0, y: 0, z: 0 };
82
- obj.scale ??= { x: 1, y: 1, z: 1 };
90
+ linkTree(obj, null);
83
91
  this.objects.push(obj);
84
92
  return obj;
85
93
  }
86
94
 
87
95
  /**
88
- * 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.
89
98
  *
90
- * @param {Object} obj - Object previously passed to `add`
99
+ * @param {Object} obj - Object previously added (or a child of one)
91
100
  */
92
101
  remove(obj) {
93
- const i = this.objects.indexOf(obj);
94
- 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;
95
106
  }
96
107
 
97
108
  /**
@@ -0,0 +1,131 @@
1
+ import { mat4FromTransform, mulMat4, transformPoint } from "./math/matrix.js";
2
+
3
+ /**
4
+ * Groups: any object can have `children`, placed relative to it. An object
5
+ * without a `mesh` is just a group. Moving, turning or scaling an object
6
+ * moves its whole subtree.
7
+ *
8
+ * const sword = scene.add({ pos, children: [
9
+ * { mesh: blade },
10
+ * { name: "hilt", children: [{ mesh: guard }, { mesh: grip, pos: { x: 0, y: -0.12, z: 0 } }] },
11
+ * ] });
12
+ * sword.rot.y += dt; // everything turns
13
+ *
14
+ * `children` is a plain array, read every frame: push and splice freely.
15
+ * Each object gets two read-only-by-convention fields, kept up to date every
16
+ * frame (and set right away by `scene.add`):
17
+ * - `parent`: the object it's a child of, or null at the top
18
+ * - `world`: its world matrix (parent's world matrix × its own transform)
19
+ * Both are non-enumerable, so they don't show up in JSON or `{ ...obj }`.
20
+ *
21
+ * `visible: false` hides an object and everything under it.
22
+ */
23
+
24
+ /** Sets a field without making it enumerable (keeps objects clean for JSON and spreads). */
25
+ function hidden(obj, key, value) {
26
+ if (Object.prototype.hasOwnProperty.call(obj, key)) obj[key] = value;
27
+ else Object.defineProperty(obj, key, { value, writable: true, configurable: true, enumerable: false });
28
+ }
29
+
30
+ /** Fills missing transform fields, like `scene.add` does. */
31
+ function fill(obj) {
32
+ obj.pos ??= { x: 0, y: 0, z: 0 };
33
+ obj.rot ??= { x: 0, y: 0, z: 0 };
34
+ obj.scale ??= { x: 1, y: 1, z: 1 };
35
+ }
36
+
37
+ /**
38
+ * An object's world matrix, worked out now from its own transform and its
39
+ * parents' (following `obj.parent`). Use it between frames, after changing
40
+ * a transform; renderers keep `obj.world` up to date on their own.
41
+ *
42
+ * @param {Object} obj - A scene object
43
+ * @returns {number[]} 4×4 matrix (see `trid/math/matrix.js`)
44
+ */
45
+ export function worldMatrix(obj) {
46
+ const own = mat4FromTransform(obj);
47
+ return obj.parent ? mulMat4(worldMatrix(obj.parent), own) : own;
48
+ }
49
+
50
+ /**
51
+ * Where an object's origin is in the world, now (with its parents).
52
+ *
53
+ * @param {Object} obj - A scene object
54
+ * @returns {{x:number, y:number, z:number}}
55
+ */
56
+ export function worldPosition(obj) {
57
+ return transformPoint(worldMatrix(obj), { x: 0, y: 0, z: 0 });
58
+ }
59
+
60
+ /**
61
+ * Calls `fn(obj, world)` for every visible object under some roots, parents
62
+ * before children, with each one's world matrix. Changes nothing: objects
63
+ * don't need to be in a scene.
64
+ *
65
+ * @param {Object[]} roots - Top objects
66
+ * @param {(root:Object) => number[]|null} baseOf - Matrix a root's own transform goes under (null: none)
67
+ * @param {(obj:Object, world:number[], parent:Object|null) => void} fn
68
+ */
69
+ export function forEachPlaced(roots, baseOf, fn) {
70
+ const visit = (obj, parent, parentWorld) => {
71
+ if (!obj || typeof obj !== "object") return;
72
+ const world = parentWorld ? mulMat4(parentWorld, mat4FromTransform(obj)) : mat4FromTransform(obj);
73
+ if (obj.visible === false) return;
74
+ fn(obj, world, parent);
75
+ if (Array.isArray(obj.children)) for (const child of obj.children) visit(child, obj, world);
76
+ };
77
+ for (const root of roots) visit(root, root?.parent ?? null, baseOf(root));
78
+ }
79
+
80
+ /**
81
+ * Every visible object of a scene (or of a list of objects and their
82
+ * subtrees), parents before their children, with `parent` and `world` set
83
+ * and missing transforms filled in. Groups without a mesh are included;
84
+ * renderers skip them.
85
+ *
86
+ * Objects given in a list keep their place in the scene: an object that is
87
+ * a child of a group still gets its parents' transforms.
88
+ *
89
+ * @param {Object|Object[]} input - A Scene, a scene object or a list of them
90
+ * @returns {Object[]} The objects, flattened
91
+ */
92
+ export function worldObjects(input) {
93
+ const roots = Array.isArray(input) ? input : Array.isArray(input?.objects) ? input.objects : input ? [input] : [];
94
+ // The scene's own list is the top: those have no parent
95
+ if (roots === input?.objects) for (const obj of roots) if (obj && obj.parent) hidden(obj, "parent", null);
96
+ const out = [];
97
+ forEachPlaced(roots, (root) => (root.parent ? worldMatrix(root.parent) : null), (obj, world, parent) => {
98
+ fill(obj);
99
+ if (obj.parent !== parent) hidden(obj, "parent", parent);
100
+ hidden(obj, "world", world);
101
+ out.push(obj);
102
+ });
103
+ // Hidden objects still get their links (so a hidden group's children know their parent)
104
+ for (const root of roots) if (root?.visible === false) linkTree(root, root.parent ?? null);
105
+ return out;
106
+ }
107
+
108
+ /**
109
+ * Sets `parent` links and fills transforms down a subtree, right away.
110
+ * `scene.add` calls it, so `obj.parent` works before the first frame.
111
+ *
112
+ * @param {Object} obj
113
+ * @param {Object|null} [parent=null]
114
+ */
115
+ export function linkTree(obj, parent = null) {
116
+ fill(obj);
117
+ if (obj.parent !== parent) hidden(obj, "parent", parent);
118
+ if (Array.isArray(obj.children)) for (const child of obj.children) linkTree(child, obj);
119
+ }
120
+
121
+ /**
122
+ * The chain from an object up to the top of its tree: `[obj, parent, ..., root]`.
123
+ *
124
+ * @param {Object} obj
125
+ * @returns {Object[]}
126
+ */
127
+ export function ancestors(obj) {
128
+ const path = [];
129
+ for (let o = obj; o; o = o.parent) path.push(o);
130
+ return path;
131
+ }