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.
- package/README.md +8 -7
- package/package.json +1 -1
- package/trid/export/mtl.js +119 -0
- package/trid/export/obj.js +107 -21
- package/trid/index.d.ts +417 -8
- package/trid/index.js +19 -3
- package/trid/loaders/mtl.js +63 -0
- package/trid/loaders/obj.js +58 -6
- package/trid/loaders/texture.js +47 -6
- package/trid/math/matrix.js +200 -0
- package/trid/math/noise.js +162 -0
- package/trid/math/rotation.js +152 -0
- package/trid/render/canvasRenderer.js +40 -23
- package/trid/render/draw.js +6 -2
- package/trid/render/meshUtils.js +257 -0
- package/trid/render/proceduralTextures.js +211 -0
- package/trid/render/shapes.js +95 -27
- package/trid/render/webglRenderer.js +98 -27
- package/trid/scene.js +20 -9
- package/trid/sceneGraph.js +131 -0
- package/trid/tools/dragObject.js +33 -9
- package/trid/tools/helpers.js +109 -10
- package/trid/tools/hoverHighlight.js +4 -2
- package/trid/tools/index.js +3 -1
- package/trid/tools/orbitCamera.js +9 -0
- package/trid/tools/transformGizmo.js +316 -0
- package/trid/tools/viewGizmo.js +178 -0
package/trid/render/shapes.js
CHANGED
|
@@ -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
|
|
369
|
-
*
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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 = ["
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
99
|
+
* @param {Object} obj - Object previously added (or a child of one)
|
|
91
100
|
*/
|
|
92
101
|
remove(obj) {
|
|
93
|
-
const
|
|
94
|
-
|
|
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
|
+
}
|