trid3d 0.2.0 → 0.3.1

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.
@@ -0,0 +1,211 @@
1
+ import { fbm2, noise2 } from "../math/noise.js";
2
+ import { updateTexture } from "../loaders/texture.js";
3
+
4
+ /**
5
+ * Procedural textures: patterns painted in code, from a style, two colors
6
+ * and a seed. Every style tiles with no seam (the noise repeats and every
7
+ * count is a whole number), so they wrap around spheres, lathes and
8
+ * repeated textures cleanly. Like a canvas painted once, they're uploaded
9
+ * once.
10
+ *
11
+ * scene.add({ mesh: makeShape("cube"), material: { texture: makeTexture("wood") } });
12
+ */
13
+
14
+ /** The built-in styles of `makeTexture`. */
15
+ export const TEXTURE_STYLES = ["noise", "marble", "wood", "bricks", "checker", "bark", "spots", "stripes", "solid"];
16
+
17
+ /** Default colors for each style: [first, second]. */
18
+ const DEFAULT_COLORS = {
19
+ noise: ["#3a3f4b", "#c9ced6"],
20
+ marble: ["#e9e6df", "#5d5a55"],
21
+ wood: ["#8a5a33", "#5a3820"],
22
+ bricks: ["#9c4a35", "#cfc8bb"],
23
+ checker: ["#f5f0e6", "#E9806E"],
24
+ bark: ["#4a3426", "#2a1d15"],
25
+ spots: ["#e8c88f", "#5a3d22"],
26
+ stripes: ["#f2efe8", "#4a6fa5"],
27
+ solid: ["#c0c0c0", "#c0c0c0"],
28
+ };
29
+
30
+ const clamp01 = (t) => Math.min(1, Math.max(0, t));
31
+ const smooth = (a, b, t) => {
32
+ const x = clamp01((t - a) / (b - a));
33
+ return x * x * (3 - 2 * x);
34
+ };
35
+ const even = (n) => Math.max(2, Math.round(n / 2) * 2);
36
+ const frac = (x) => x - Math.floor(x);
37
+
38
+ /**
39
+ * For a style, a function of (u, v) in [0, 1) giving `[t, light]`: how much
40
+ * of the second color to use (0..1), and a brightness to multiply by.
41
+ */
42
+ function styleFn(style, s, seed) {
43
+ // Noise with `period` p repeats every p units: sampled at u * (a multiple of p), it tiles
44
+ const f = (x, y, octaves, period, k = 0) => fbm2(x, y, { octaves, period, seed: seed + k });
45
+ switch (style) {
46
+ case "solid":
47
+ return () => [0, 1];
48
+ case "noise":
49
+ return (u, v) => [smooth(0.25, 0.75, f(u * s, v * s, 5, s)), 1];
50
+ case "marble":
51
+ return (u, v) => {
52
+ const turb = f(u * s, v * s, 6, s);
53
+ const t = 0.5 + 0.5 * Math.sin(((u + v) * s + turb * 2.5) * Math.PI * 2);
54
+ return [Math.pow(t, 6), 1];
55
+ };
56
+ case "wood":
57
+ return (u, v) => {
58
+ const warp = f(u * s, v * s, 4, s) * 3;
59
+ const ring = frac(u * s * 2 + warp);
60
+ // Fine grain along the boards (64 and 4 are both multiples of the period 4)
61
+ return [smooth(0.55, 0.95, ring), 0.9 + 0.1 * f(u * 64, v * 4, 2, 4, 7)];
62
+ };
63
+ case "bricks": {
64
+ const rows = even(s * 2), cols = s;
65
+ return (u, v) => {
66
+ const row = Math.floor(v * rows);
67
+ const x = u * cols + (row % 2) * 0.5;
68
+ const fx = frac(x), fy = v * rows - row;
69
+ if (Math.min(fx, 1 - fx) < 0.04 || Math.min(fy, 1 - fy) < 0.08) return [1, 1]; // mortar
70
+ // Each brick its own shade (bricks cut by the edge are the same brick on the other side)
71
+ const brick = ((Math.floor(x) % cols) + cols) % cols;
72
+ const tint = 0.85 + 0.3 * noise2(brick, row, { seed });
73
+ return [0.15 * f(u * 32, v * 32, 3, 32), tint];
74
+ };
75
+ }
76
+ case "checker": {
77
+ const c = even(s);
78
+ return (u, v) => [(Math.floor(u * c) + Math.floor(v * c)) % 2, 1];
79
+ }
80
+ case "bark":
81
+ return (u, v) => {
82
+ const ridges = Math.abs(f(u * s * 3, v * s, 5, s) * 2 - 1);
83
+ return [smooth(0.05, 0.4, ridges), 0.8 + 0.4 * ridges];
84
+ };
85
+ case "spots":
86
+ return (u, v) => [smooth(0.55, 0.62, f(u * s, v * s, 4, s)), 1];
87
+ case "stripes":
88
+ return (u, v) => [smooth(0.45, 0.55, 0.5 + 0.5 * Math.sin((v * s + f(u * 4, v * 4, 3, 4) * 0.4) * Math.PI * 2)), 1];
89
+ default:
90
+ throw new Error(`makeTexture: unknown style "${style}" (one of ${TEXTURE_STYLES.join(", ")}, or a function)`);
91
+ }
92
+ }
93
+
94
+ /** [r, g, b, a] (0..255, a 0..1) from "#rgb", "#rrggbb", "#rrggbbaa", "rgb(...)" / "rgba(...)" or an array. */
95
+ export function colorBytes(color) {
96
+ if (Array.isArray(color)) return [color[0] ?? 0, color[1] ?? 0, color[2] ?? 0, color[3] ?? 1];
97
+ const s = String(color).trim();
98
+ if (s[0] === "#") {
99
+ let h = s.slice(1);
100
+ if (h.length === 3 || h.length === 4) h = h.replace(/./g, "$&$&");
101
+ const v = (i) => parseInt(h.slice(i, i + 2), 16);
102
+ return [v(0), v(2), v(4), h.length === 8 ? v(6) / 255 : 1];
103
+ }
104
+ const nums = s.match(/-?[\d.]+%?/g);
105
+ if (/^rgba?\(/i.test(s) && nums && nums.length >= 3) {
106
+ const ch = (x) => (x.endsWith("%") ? (parseFloat(x) * 255) / 100 : +x);
107
+ const a = nums[3] === undefined ? 1 : nums[3].endsWith("%") ? parseFloat(nums[3]) / 100 : +nums[3];
108
+ return [ch(nums[0]), ch(nums[1]), ch(nums[2]), a];
109
+ }
110
+ // Named CSS colors need the browser to read them
111
+ if (typeof document !== "undefined") {
112
+ const ctx = document.createElement("canvas").getContext("2d");
113
+ if (ctx) {
114
+ ctx.fillStyle = "#000";
115
+ ctx.fillStyle = s;
116
+ if (ctx.fillStyle !== s) return colorBytes(ctx.fillStyle);
117
+ }
118
+ }
119
+ throw new Error(`Can't read the color "${color}"`);
120
+ }
121
+
122
+ /**
123
+ * The pixels of a procedural texture, without a canvas (for workers, or to
124
+ * process them further). Same options as `makeTexture`.
125
+ *
126
+ * @param {string|((u:number, v:number) => number)} style - A style name, or `(u, v) => t`
127
+ * @param {Object} [opts] - See `makeTexture`
128
+ * @returns {{ width:number, height:number, data:Uint8ClampedArray }} RGBA rows, top row first
129
+ */
130
+ export function texturePixels(style = "noise", opts = {}) {
131
+ const {
132
+ size = 256,
133
+ colors = typeof style === "string" ? DEFAULT_COLORS[style] ?? DEFAULT_COLORS.noise : ["#000000", "#ffffff"],
134
+ scale = 4,
135
+ seed = 0,
136
+ grain = 0,
137
+ } = opts;
138
+ const width = Math.max(1, Math.floor(size)), height = width;
139
+ const s = Math.max(1, Math.round(scale));
140
+ const a = colorBytes(colors[0]), b = colorBytes(colors[1] ?? colors[0]);
141
+ const fn = typeof style === "function" ? (u, v) => [clamp01(style(u, v)), 1] : styleFn(style, s, seed);
142
+ const data = new Uint8ClampedArray(width * height * 4);
143
+
144
+ for (let y = 0; y < height; y++) {
145
+ // Image rows go down, texture v goes up
146
+ const v = 1 - (y + 0.5) / height;
147
+ for (let x = 0; x < width; x++) {
148
+ const u = (x + 0.5) / width;
149
+ const [t, light] = fn(u, v);
150
+ const g = grain > 0 ? 1 + (fbm2(u * 128, v * 128, { octaves: 2, period: 128, seed: seed + 99 }) - 0.5) * grain : 1;
151
+ const k = (y * width + x) * 4;
152
+ for (let c = 0; c < 3; c++) data[k + c] = (a[c] + (b[c] - a[c]) * t) * light * g;
153
+ data[k + 3] = (a[3] + (b[3] - a[3]) * t) * 255;
154
+ }
155
+ }
156
+ return { width, height, data };
157
+ }
158
+
159
+ /**
160
+ * Paints a procedural texture on a canvas: wood, marble, bricks... Every
161
+ * style tiles with no seam. Pass the result as a material's `texture`.
162
+ *
163
+ * | Style | Looks like | `scale` is |
164
+ * | --- | --- | --- |
165
+ * | `"noise"` | Soft clouds | Blobs across |
166
+ * | `"marble"` | Veined stone | Veins across |
167
+ * | `"wood"` | Boards with rings and grain (rings run along v) | Rings across, ×2 |
168
+ * | `"bricks"` | A brick wall, each brick its own shade | Bricks across (rows: 2× as many) |
169
+ * | `"checker"` | A checkerboard | Squares across (made even) |
170
+ * | `"bark"` | Rough ridges running along v | Ridges across, ×3 |
171
+ * | `"spots"` | Blotches, like a giraffe | Spots across |
172
+ * | `"stripes"` | Wavy stripes across v | Stripes |
173
+ * | `"solid"` | Just the first color (with `grain`: a rough surface) | — |
174
+ *
175
+ * `style` can also be your own function `(u, v) => t`: 0 gives the first
176
+ * color, 1 the second. It should repeat for u and v in [0, 1) to tile.
177
+ *
178
+ * Repainting into the same canvas (`opts.canvas`) calls `updateTexture` for you.
179
+ *
180
+ * @example
181
+ * const floor = makeTexture("wood", { colors: ["#a87447", "#6b4426"], scale: 3, grain: 0.15 });
182
+ * scene.add({ mesh: makeShape("plane"), material: { texture: floor, textureRepeat: 4 } });
183
+ *
184
+ * @param {string|((u:number, v:number) => number)} [style="noise"] - One of `TEXTURE_STYLES`, or a function
185
+ * @param {Object} [opts]
186
+ * @param {number} [opts.size=256] - Width and height in pixels (a power of two is best for WebGL 1)
187
+ * @param {(string|number[])[]} [opts.colors] - [first, second]: hex, rgb() / rgba(), CSS names or [r, g, b, a?]
188
+ * (default: a pair that suits the style)
189
+ * @param {number} [opts.scale=4] - How many times the pattern repeats across (a whole number)
190
+ * @param {number} [opts.seed=0] - Another whole number gives another texture of the same style
191
+ * @param {number} [opts.grain=0] - Fine speckles on top, 0 to 1
192
+ * @param {HTMLCanvasElement|OffscreenCanvas} [opts.canvas] - Paint into this canvas instead of a new one
193
+ * @returns {HTMLCanvasElement|OffscreenCanvas} The canvas
194
+ */
195
+ export function makeTexture(style = "noise", opts = {}) {
196
+ const { width, height, data } = texturePixels(style, opts);
197
+ let canvas = opts.canvas;
198
+ if (!canvas) {
199
+ if (typeof document !== "undefined") canvas = document.createElement("canvas");
200
+ else if (typeof OffscreenCanvas !== "undefined") canvas = new OffscreenCanvas(width, height);
201
+ else throw new Error("makeTexture needs a browser (or a worker with OffscreenCanvas); use texturePixels instead");
202
+ }
203
+ canvas.width = width;
204
+ canvas.height = height;
205
+ const ctx = canvas.getContext("2d");
206
+ const img = ctx.createImageData(width, height);
207
+ img.data.set(data);
208
+ ctx.putImageData(img, 0, 0);
209
+ if (opts.canvas) updateTexture(canvas);
210
+ return canvas;
211
+ }
@@ -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}`;