trid3d 0.5.0 → 0.7.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 +43 -20
- package/package.json +1 -1
- package/trid/index.d.ts +210 -7
- package/trid/index.js +10 -3
- package/trid/math/projection.js +8 -2
- package/trid/math/transform.js +4 -5
- package/trid/modeling/query.js +706 -0
- package/trid/render/canvasRenderer.js +268 -5
- package/trid/render/draw.js +12 -2
- package/trid/render/lights.js +14 -1
- package/trid/render/post.js +155 -1
- package/trid/render/shadows.js +184 -0
- package/trid/render/shapes.js +42 -23
- package/trid/render/webglRenderer.js +348 -17
- package/trid/scene.js +1 -1
- package/trid/tools/index.js +1 -0
- package/trid/tools/selectionOutline.js +244 -0
- package/trid/tools/transformGizmo.js +9 -1
|
@@ -7,7 +7,8 @@ import { newell, cornerNormals, creaseOf } from "./normals.js";
|
|
|
7
7
|
import { textureReady, textureSize, textureIsLive, textureVersion } from "../loaders/texture.js";
|
|
8
8
|
import { uvsFor, uvTransform, applyUVTransform } from "./uvMapping.js";
|
|
9
9
|
import { DEFAULT_LIGHT, DEFAULT_AMBIENT, sceneLights, lightAt as lightOf, lightLevels } from "./lights.js";
|
|
10
|
-
import { runCanvasPost } from "./post.js";
|
|
10
|
+
import { runCanvasPost, activePost, maskedObjects } from "./post.js";
|
|
11
|
+
import { shadowFlag, findGround, flattenOnGround } from "./shadows.js";
|
|
11
12
|
|
|
12
13
|
export { newell };
|
|
13
14
|
|
|
@@ -125,7 +126,15 @@ export class CanvasRenderer {
|
|
|
125
126
|
// For effects
|
|
126
127
|
const camPos = { x: camera.x ?? 0, y: camera.y ?? 0, z: camera.z ?? 0 };
|
|
127
128
|
const time = (performance.now() - this._start) / 1000;
|
|
128
|
-
const background = this._rgba(ctx, scene.background);
|
|
129
|
+
const background = this._rgba(ctx, scene.background ?? "transparent");
|
|
130
|
+
// Post effects drawn as lines along the meshes (outline()): silhouettes and creases
|
|
131
|
+
const contours = activePost(scene.post).filter((e) => e.contour).map((e) => ({ e, mask: maskedObjects(e) }));
|
|
132
|
+
const outlines = [];
|
|
133
|
+
// Ground shadows (see trid/render/shadows.js): the floor's faces, then the shadows, then the rest
|
|
134
|
+
const sun = lights.shadow && lights.shadow.dir.y > 0.01 ? lights.shadow : null;
|
|
135
|
+
const ground = sun ? sun.ground ?? findGround(drawn.filter((o) => materialsOf(o) && shadowFlag(o, "receiveShadow"))) : null;
|
|
136
|
+
const shadowing = ground !== null && (camera.y ?? 0) > ground;
|
|
137
|
+
const shadowPolys = [];
|
|
129
138
|
|
|
130
139
|
for (const obj of drawn) {
|
|
131
140
|
const mats = materialsOf(obj);
|
|
@@ -137,6 +146,18 @@ export class CanvasRenderer {
|
|
|
137
146
|
const color = obj.color ?? scene.color;
|
|
138
147
|
const lineWidth = obj.lineWidth ?? scene.lineWidth;
|
|
139
148
|
|
|
149
|
+
// Its faces flattened on the floor along the light
|
|
150
|
+
if (shadowing && mats && shadowFlag(obj, "castShadow")) {
|
|
151
|
+
for (const face of obj.mesh.faces ?? []) {
|
|
152
|
+
if (face.length < 3) continue;
|
|
153
|
+
const flat = flattenOnGround(face.map((i) => world[i]), sun.dir, ground);
|
|
154
|
+
if (flat.length < 3) continue;
|
|
155
|
+
const clipped = clipPolygon(flat.map((p) => worldToCamera(p, camera)), near);
|
|
156
|
+
if (clipped.length >= 3) shadowPolys.push(clipped.map(toScreen));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const onGround = shadowing && mats && shadowFlag(obj, "receiveShadow");
|
|
160
|
+
|
|
140
161
|
if (!mats) {
|
|
141
162
|
for (const [i, j] of edgeSegments(wireEdges(obj.mesh))) {
|
|
142
163
|
const seg = clipNear(cam[i], cam[j], near);
|
|
@@ -213,11 +234,28 @@ export class CanvasRenderer {
|
|
|
213
234
|
return (looks[k] ??= prepare(mats[k]));
|
|
214
235
|
};
|
|
215
236
|
|
|
237
|
+
// Outline lines (outline()): placed in the drawing order once the faces are sorted
|
|
238
|
+
const lines = contours.length ? this._contours(obj, cam, flip, near, toScreen, contours) : null;
|
|
239
|
+
if (lines) outlines.push(lines);
|
|
240
|
+
let pendingF = -1, pendingStart = items.length, pendingGround = false;
|
|
241
|
+
// Marks the items of the last face with the face they come from, and whether it's floor
|
|
242
|
+
const attach = () => {
|
|
243
|
+
for (let k = pendingStart; k < items.length; k++) {
|
|
244
|
+
if (lines && pendingF >= 0) items[k].face = { lines, F: pendingF };
|
|
245
|
+
if (pendingGround) items[k].ground = true;
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
|
|
216
249
|
const faces = split ? split.faces : sourceFaces;
|
|
217
250
|
for (let f = 0; f < faces.length; f++) {
|
|
218
251
|
const face = faces[f];
|
|
219
252
|
if (face.length < 3) continue;
|
|
220
253
|
const F = split ? split.origins[f] : f; // the mesh face this one comes from
|
|
254
|
+
attach();
|
|
255
|
+
pendingF = F;
|
|
256
|
+
pendingStart = items.length;
|
|
257
|
+
// Floor: lying on the ground level, facing up
|
|
258
|
+
pendingGround = onGround && face.every((i) => Math.abs(world[i].y - ground) < 1e-4) && newell(face.map((i) => world[i])).y * flip > 0;
|
|
221
259
|
const L = lookOf(F);
|
|
222
260
|
const { fill, shade, outline, corners } = L;
|
|
223
261
|
const pts = face.map((i) => cam[i]);
|
|
@@ -333,11 +371,31 @@ export class CanvasRenderer {
|
|
|
333
371
|
edges,
|
|
334
372
|
});
|
|
335
373
|
}
|
|
374
|
+
attach();
|
|
336
375
|
}
|
|
337
376
|
|
|
338
377
|
items.sort((a, b) => b.depth - a.depth);
|
|
339
378
|
|
|
340
|
-
|
|
379
|
+
// Each outline line is drawn once every face around its two ends is: those
|
|
380
|
+
// faces don't cover it then, but nearer ones of other objects still do
|
|
381
|
+
const linesAfter = new Map(); // item index → lines to draw after it
|
|
382
|
+
if (outlines.length) {
|
|
383
|
+
items.forEach((it, k) => {
|
|
384
|
+
if (it.face) it.face.lines.last[it.face.F] = k;
|
|
385
|
+
});
|
|
386
|
+
for (const L of outlines) {
|
|
387
|
+
for (const ln of L.list) {
|
|
388
|
+
let k = -1;
|
|
389
|
+
for (const F of ln.faces) k = Math.max(k, L.last[F] ?? -1);
|
|
390
|
+
if (k < 0) continue;
|
|
391
|
+
if (!linesAfter.has(k)) linesAfter.set(k, []);
|
|
392
|
+
linesAfter.get(k).push(ln);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const drawItem = (n) => {
|
|
398
|
+
const it = items[n];
|
|
341
399
|
ctx.beginPath();
|
|
342
400
|
if (it.line) {
|
|
343
401
|
ctx.moveTo(it.line[0].x, it.line[0].y);
|
|
@@ -345,7 +403,7 @@ export class CanvasRenderer {
|
|
|
345
403
|
ctx.lineWidth = it.lineWidth;
|
|
346
404
|
ctx.strokeStyle = it.color;
|
|
347
405
|
ctx.stroke();
|
|
348
|
-
|
|
406
|
+
return;
|
|
349
407
|
}
|
|
350
408
|
|
|
351
409
|
if (it.texture) {
|
|
@@ -385,6 +443,31 @@ export class CanvasRenderer {
|
|
|
385
443
|
ctx.strokeStyle = it.fill.css;
|
|
386
444
|
ctx.stroke();
|
|
387
445
|
}
|
|
446
|
+
|
|
447
|
+
const after = linesAfter.get(n);
|
|
448
|
+
if (after) {
|
|
449
|
+
ctx.save();
|
|
450
|
+
ctx.lineCap = "round";
|
|
451
|
+
for (const ln of after) {
|
|
452
|
+
ctx.beginPath();
|
|
453
|
+
ctx.moveTo(ln.p.x, ln.p.y);
|
|
454
|
+
ctx.lineTo(ln.q.x, ln.q.y);
|
|
455
|
+
ctx.lineWidth = ln.width;
|
|
456
|
+
ctx.strokeStyle = ln.color;
|
|
457
|
+
ctx.stroke();
|
|
458
|
+
}
|
|
459
|
+
ctx.restore();
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
const floor = shadowing && shadowPolys.length ? items.filter((it) => it.ground) : [];
|
|
464
|
+
if (floor.length) {
|
|
465
|
+
// Everything else stands on the floor, so it's in front of it: floor, shadows, then the rest
|
|
466
|
+
for (let n = 0; n < items.length; n++) if (items[n].ground) drawItem(n);
|
|
467
|
+
this._groundShadow(ctx, w, h, shadowPolys, floor, lights, sun, ground);
|
|
468
|
+
for (let n = 0; n < items.length; n++) if (!items[n].ground) drawItem(n);
|
|
469
|
+
} else {
|
|
470
|
+
for (let n = 0; n < items.length; n++) drawItem(n);
|
|
388
471
|
}
|
|
389
472
|
|
|
390
473
|
// Meshes marked as changed have been redrawn from their new data
|
|
@@ -394,6 +477,118 @@ export class CanvasRenderer {
|
|
|
394
477
|
runCanvasPost(scene, scene.post, this._start);
|
|
395
478
|
}
|
|
396
479
|
|
|
480
|
+
/**
|
|
481
|
+
* Darkens the floor where the shadows fall: the shadow shapes, blurred,
|
|
482
|
+
* kept only on the floor's faces, as dark as the light they take away.
|
|
483
|
+
*/
|
|
484
|
+
_groundShadow(ctx, w, h, polys, floor, lights, sun, ground) {
|
|
485
|
+
const [shade, mask] = [0, 1].map((i) => {
|
|
486
|
+
const c = (this._scratch ??= [])[i] ??= document.createElement("canvas");
|
|
487
|
+
if (c.width !== w || c.height !== h) {
|
|
488
|
+
c.width = w;
|
|
489
|
+
c.height = h;
|
|
490
|
+
}
|
|
491
|
+
const g = c.getContext("2d");
|
|
492
|
+
g.clearRect(0, 0, w, h);
|
|
493
|
+
return { c, g };
|
|
494
|
+
});
|
|
495
|
+
// The shadows, all turned the same way so overlaps don't cancel out
|
|
496
|
+
if (typeof shade.g.filter === "string" && sun.softness > 0) shade.g.filter = `blur(${sun.softness}px)`;
|
|
497
|
+
shade.g.beginPath();
|
|
498
|
+
for (const poly of polys) {
|
|
499
|
+
const p = signedArea(poly) < 0 ? poly.slice().reverse() : poly;
|
|
500
|
+
shade.g.moveTo(p[0].x, p[0].y);
|
|
501
|
+
for (let k = 1; k < p.length; k++) shade.g.lineTo(p[k].x, p[k].y);
|
|
502
|
+
shade.g.closePath();
|
|
503
|
+
}
|
|
504
|
+
shade.g.fillStyle = "#000000";
|
|
505
|
+
shade.g.fill();
|
|
506
|
+
shade.g.filter = "none";
|
|
507
|
+
// Only on the floor
|
|
508
|
+
mask.g.beginPath();
|
|
509
|
+
for (const it of floor) {
|
|
510
|
+
mask.g.moveTo(it.poly[0].x, it.poly[0].y);
|
|
511
|
+
for (let k = 1; k < it.poly.length; k++) mask.g.lineTo(it.poly[k].x, it.poly[k].y);
|
|
512
|
+
mask.g.closePath();
|
|
513
|
+
}
|
|
514
|
+
mask.g.fillStyle = mask.g.strokeStyle = "#ffffff";
|
|
515
|
+
mask.g.fill();
|
|
516
|
+
mask.g.lineWidth = 1;
|
|
517
|
+
mask.g.stroke();
|
|
518
|
+
shade.g.globalCompositeOperation = "destination-in";
|
|
519
|
+
shade.g.drawImage(mask.c, 0, 0);
|
|
520
|
+
shade.g.globalCompositeOperation = "source-over";
|
|
521
|
+
|
|
522
|
+
// As dark as the share of the floor's light this light gives
|
|
523
|
+
const up = { x: 0, y: 1, z: 0 }, at = { x: 0, y: ground, z: 0 };
|
|
524
|
+
const all = Math.max(...lightOf(lights, up, at));
|
|
525
|
+
const without = Math.max(...lightOf({ ...lights, lights: lights.lights.filter((_, i) => i !== sun.index) }, up, at));
|
|
526
|
+
const alpha = all > 0 ? Math.min(1, Math.max(0, 1 - without / all)) : 0;
|
|
527
|
+
if (alpha <= 0) return;
|
|
528
|
+
ctx.save();
|
|
529
|
+
ctx.globalAlpha = alpha;
|
|
530
|
+
ctx.drawImage(shade.c, 0, 0);
|
|
531
|
+
ctx.restore();
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Outline lines of one object, for post effects with `contour` (outline()):
|
|
536
|
+
* its silhouette (edges between a face seen from the front and one seen
|
|
537
|
+
* from behind, or with no other face) and its creases (two front faces
|
|
538
|
+
* meeting at more than the effect's `uCreaseAngle`).
|
|
539
|
+
*
|
|
540
|
+
* @returns {{ list: {p:Object, q:Object, faces:number[], color:string, width:number}[], last: number[] }|null}
|
|
541
|
+
* `faces`: the mesh faces around the line's two ends. `last` is filled in after sorting
|
|
542
|
+
*/
|
|
543
|
+
_contours(obj, cam, flip, near, toScreen, contours) {
|
|
544
|
+
const used = contours.filter((c) => !c.mask || c.mask.has(obj));
|
|
545
|
+
if (!used.length) return null;
|
|
546
|
+
const faces = obj.mesh.faces ?? [];
|
|
547
|
+
const { edges, spot, facesAt } = meshTopology(obj.mesh);
|
|
548
|
+
// Each face's normal in camera space, and whether it faces the camera
|
|
549
|
+
const info = faces.map((face) => {
|
|
550
|
+
if (face.length < 3) return null;
|
|
551
|
+
const pts = face.map((i) => cam[i]);
|
|
552
|
+
const n = normalize(newell(pts));
|
|
553
|
+
const c = centroid(pts);
|
|
554
|
+
const back = (n.x * c.x + n.y * c.y + n.z * c.z >= 0) !== (flip < 0);
|
|
555
|
+
return { n, front: !back };
|
|
556
|
+
});
|
|
557
|
+
const mats = materialsOf(obj);
|
|
558
|
+
const faceMats = obj.mesh.faceMaterials;
|
|
559
|
+
const shown = (F) => {
|
|
560
|
+
if (!info[F]) return false;
|
|
561
|
+
if (info[F].front) return true;
|
|
562
|
+
return !!(mats[faceMats?.[F] ?? 0] ?? mats[0])?.doubleSided;
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
const list = [];
|
|
566
|
+
for (const c of used) {
|
|
567
|
+
const u = c.e.uniforms ?? {};
|
|
568
|
+
const angle = u.uCreaseAngle ?? 180;
|
|
569
|
+
const cos = angle >= 180 ? -2 : Math.cos((angle * Math.PI) / 180);
|
|
570
|
+
const rgba = typeof u.uColor === "string" ? null : u.uColor;
|
|
571
|
+
const color = rgba ? `rgba(${Math.round(rgba[0] * 255)}, ${Math.round(rgba[1] * 255)}, ${Math.round(rgba[2] * 255)}, ${rgba[3] ?? 1})` : u.uColor ?? "#1a1a1a";
|
|
572
|
+
const width = u.uWidth ?? 2;
|
|
573
|
+
for (const { a, b, f1, f2 } of edges) {
|
|
574
|
+
let line;
|
|
575
|
+
if (f2 < 0) line = shown(f1); // open border
|
|
576
|
+
else {
|
|
577
|
+
const s1 = shown(f1), s2 = shown(f2);
|
|
578
|
+
if (s1 && s2) {
|
|
579
|
+
const p = info[f1].n, q = info[f2].n;
|
|
580
|
+
line = info[f1].front !== info[f2].front || p.x * q.x + p.y * q.y + p.z * q.z < cos;
|
|
581
|
+
} else line = s1 || s2; // the other one faces away
|
|
582
|
+
}
|
|
583
|
+
if (!line) continue;
|
|
584
|
+
const seg = clipNear(cam[a], cam[b], near);
|
|
585
|
+
if (!seg) continue;
|
|
586
|
+
list.push({ p: toScreen(seg[0]), q: toScreen(seg[1]), faces: [...new Set([...facesAt[spot[a]], ...facesAt[spot[b]]])], color, width });
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
return list.length ? { list, last: [] } : null;
|
|
590
|
+
}
|
|
591
|
+
|
|
397
592
|
/**
|
|
398
593
|
* Splits a mesh's big faces into triangles no longer than
|
|
399
594
|
* maxFaceSize × the mesh's size. Cached until the mesh's faces change.
|
|
@@ -707,6 +902,74 @@ function withMidpoints(vertices, mids) {
|
|
|
707
902
|
return out;
|
|
708
903
|
}
|
|
709
904
|
|
|
905
|
+
function signedArea(pts) {
|
|
906
|
+
let a = 0;
|
|
907
|
+
for (let i = 0; i < pts.length; i++) {
|
|
908
|
+
const p = pts[i], q = pts[(i + 1) % pts.length];
|
|
909
|
+
a += p.x * q.y - q.x * p.y;
|
|
910
|
+
}
|
|
911
|
+
return a;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
const edgeCache = new WeakMap();
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* Each edge of a mesh's faces once, with the (up to two) faces on its sides.
|
|
918
|
+
* Vertices at the same spot count as one, so seams (where a shape repeats
|
|
919
|
+
* vertices for its texture coordinates) aren't taken for open borders.
|
|
920
|
+
* Cached until the mesh's arrays change or `mesh.dirty` is set.
|
|
921
|
+
*
|
|
922
|
+
* @returns {{a:number, b:number, f1:number, f2:number}[]} `f2` is -1 on an open border
|
|
923
|
+
*/
|
|
924
|
+
export function meshEdges(mesh) {
|
|
925
|
+
return meshTopology(mesh).edges;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* meshEdges, plus `spot` (vertex → id of its position) and `facesAt`
|
|
930
|
+
* (position id → the faces touching it).
|
|
931
|
+
*/
|
|
932
|
+
function meshTopology(mesh) {
|
|
933
|
+
const v = mesh.vertices, faces = mesh.faces ?? [];
|
|
934
|
+
const hit = edgeCache.get(mesh);
|
|
935
|
+
if (hit && !mesh.dirty && hit.v === v && hit.faces === faces && hit.counts === `${v.length}/${faces.length}`) return hit.topology;
|
|
936
|
+
|
|
937
|
+
// One id per spot
|
|
938
|
+
let size = 0;
|
|
939
|
+
for (const p of v) size = Math.max(size, Math.abs(p.x), Math.abs(p.y), Math.abs(p.z));
|
|
940
|
+
const step = (size || 1) * 1e-6;
|
|
941
|
+
const ids = new Map();
|
|
942
|
+
const spot = v.map((p) => {
|
|
943
|
+
const key = `${Math.round(p.x / step)},${Math.round(p.y / step)},${Math.round(p.z / step)}`;
|
|
944
|
+
let id = ids.get(key);
|
|
945
|
+
if (id === undefined) ids.set(key, (id = ids.size));
|
|
946
|
+
return id;
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
const byKey = new Map();
|
|
950
|
+
const edges = [];
|
|
951
|
+
const facesAt = Array.from({ length: ids.size }, () => []);
|
|
952
|
+
faces.forEach((f, F) => {
|
|
953
|
+
if (f.length < 3) return;
|
|
954
|
+
for (const i of f) if (facesAt[spot[i]].at(-1) !== F) facesAt[spot[i]].push(F);
|
|
955
|
+
for (let k = 0; k < f.length; k++) {
|
|
956
|
+
const a = f[k], b = f[(k + 1) % f.length];
|
|
957
|
+
const sa = spot[a], sb = spot[b];
|
|
958
|
+
if (sa === sb) continue;
|
|
959
|
+
const key = sa < sb ? sa * 4294967296 + sb : sb * 4294967296 + sa;
|
|
960
|
+
const e = byKey.get(key);
|
|
961
|
+
if (!e) {
|
|
962
|
+
const n = { a, b, f1: F, f2: -1 };
|
|
963
|
+
byKey.set(key, n);
|
|
964
|
+
edges.push(n);
|
|
965
|
+
} else if (e.f2 < 0 && e.f1 !== F) e.f2 = F;
|
|
966
|
+
}
|
|
967
|
+
});
|
|
968
|
+
const topology = { edges, spot, facesAt };
|
|
969
|
+
edgeCache.set(mesh, { v, faces, counts: `${v.length}/${faces.length}`, topology });
|
|
970
|
+
return topology;
|
|
971
|
+
}
|
|
972
|
+
|
|
710
973
|
function centroid(pts) {
|
|
711
974
|
let x = 0, y = 0, z = 0;
|
|
712
975
|
for (const p of pts) { x += p.x; y += p.y; z += p.z; }
|
|
@@ -719,7 +982,7 @@ function normalize(v) {
|
|
|
719
982
|
}
|
|
720
983
|
|
|
721
984
|
/** Cuts a camera-space polygon at z = near, keeping the part in front. */
|
|
722
|
-
function clipPolygon(pts, near) {
|
|
985
|
+
export function clipPolygon(pts, near) {
|
|
723
986
|
if (pts.every((p) => p.z >= near)) return pts;
|
|
724
987
|
const out = [];
|
|
725
988
|
for (let i = 0; i < pts.length; i++) {
|
package/trid/render/draw.js
CHANGED
|
@@ -2,19 +2,29 @@ import { localToWorld, worldToCamera, cameraToScreen, clipNear, DEFAULT_NEAR } f
|
|
|
2
2
|
import { transformPoint } from "../math/matrix.js";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Clears the entire canvas with a
|
|
5
|
+
* Clears the entire canvas, then paints it with a background color.
|
|
6
6
|
* This should usually be called once per frame before drawing.
|
|
7
7
|
*
|
|
8
|
+
* A see-through color (`"transparent"`, or `rgba(...)` with an alpha below 1),
|
|
9
|
+
* or no color (`null`), lets the page behind the canvas show through.
|
|
10
|
+
*
|
|
8
11
|
* @param {CanvasRenderingContext2D} ctx - Canvas 2D context
|
|
9
12
|
* @param {number} w - Canvas width
|
|
10
13
|
* @param {number} h - Canvas height
|
|
11
|
-
* @param {string} color - Background color (CSS color)
|
|
14
|
+
* @param {string|null} [color] - Background color (CSS color); none: left see-through
|
|
12
15
|
*/
|
|
13
16
|
export function clear(ctx, w, h, color) {
|
|
17
|
+
ctx.clearRect(0, 0, w, h);
|
|
18
|
+
if (!isVisible(color)) return;
|
|
14
19
|
ctx.fillStyle = color;
|
|
15
20
|
ctx.fillRect(0, 0, w, h);
|
|
16
21
|
}
|
|
17
22
|
|
|
23
|
+
/** Whether a background color paints anything (not null, empty or "transparent"). */
|
|
24
|
+
function isVisible(color) {
|
|
25
|
+
return color != null && color !== "" && String(color).trim().toLowerCase() !== "transparent";
|
|
26
|
+
}
|
|
27
|
+
|
|
18
28
|
/**
|
|
19
29
|
* Draws a square point centered at a given 2D position.
|
|
20
30
|
* Useful for debugging or drawing vertices.
|
package/trid/render/lights.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
* - `range`: a point light fades out smoothly and reaches nothing past it
|
|
13
13
|
* (default: no fading)
|
|
14
14
|
* - `enabled: false` turns a light off without removing it
|
|
15
|
+
* - `shadows: true` (directional lights): it casts shadows, see trid/render/shadows.js
|
|
15
16
|
*
|
|
16
17
|
* Lights add up: a face gets the ambient lights, plus each other light
|
|
17
18
|
* times how much the face turns toward it. Both renderers use at most
|
|
@@ -46,7 +47,10 @@ export function defaultLights(direction = DEFAULT_LIGHT, ambient = DEFAULT_AMBIE
|
|
|
46
47
|
];
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
import { shadowOptions } from "./shadows.js";
|
|
51
|
+
|
|
49
52
|
let warnedTooMany = false;
|
|
53
|
+
let warnedPointShadows = false;
|
|
50
54
|
|
|
51
55
|
/**
|
|
52
56
|
* A scene's lights, ready for shading: colors parsed and multiplied by
|
|
@@ -62,8 +66,10 @@ let warnedTooMany = false;
|
|
|
62
66
|
* ambientLevel: number,
|
|
63
67
|
* lights: { point:boolean, dir:{x,y,z}|null, pos:{x,y,z}|null, color:number[], range:number }[],
|
|
64
68
|
* mainDir: {x,y,z},
|
|
69
|
+
* shadow: { index:number, dir:{x,y,z}, size:number, softness:number, bias:number, ground:number|null } | null,
|
|
65
70
|
* }} `ambient` is the summed ambient color [r, g, b]; `ambientLevel` its brightest
|
|
66
|
-
* channel; `mainDir` the unit direction of the first directional light (for effects)
|
|
71
|
+
* channel; `mainDir` the unit direction of the first directional light (for effects);
|
|
72
|
+
* `shadow` the light casting shadows (`index` in `lights`) and its settings
|
|
67
73
|
*/
|
|
68
74
|
export function sceneLights(scene, rgba) {
|
|
69
75
|
const list = Array.isArray(scene.lights)
|
|
@@ -73,6 +79,7 @@ export function sceneLights(scene, rgba) {
|
|
|
73
79
|
const ambient = [0, 0, 0];
|
|
74
80
|
const lights = [];
|
|
75
81
|
let mainDir = null;
|
|
82
|
+
let shadow = null;
|
|
76
83
|
for (const l of list) {
|
|
77
84
|
if (!l || l.enabled === false) continue;
|
|
78
85
|
const k = l.intensity ?? 1;
|
|
@@ -90,11 +97,16 @@ export function sceneLights(scene, rgba) {
|
|
|
90
97
|
continue;
|
|
91
98
|
}
|
|
92
99
|
if (l.type === "point") {
|
|
100
|
+
if (l.shadows && !warnedPointShadows) {
|
|
101
|
+
warnedPointShadows = true;
|
|
102
|
+
console.warn("Trid: point lights don't cast shadows yet, only directional ones");
|
|
103
|
+
}
|
|
93
104
|
const p = l.position ?? { x: 0, y: 0, z: 0 };
|
|
94
105
|
lights.push({ point: true, dir: null, pos: { x: p.x, y: p.y, z: p.z }, color, range: l.range > 0 ? l.range : 0 });
|
|
95
106
|
} else {
|
|
96
107
|
const dir = unit(l.direction ?? DEFAULT_LIGHT);
|
|
97
108
|
mainDir ??= dir;
|
|
109
|
+
if (l.shadows && !shadow) shadow = { index: lights.length, dir, ...shadowOptions(l.shadows) };
|
|
98
110
|
lights.push({ point: false, dir, pos: null, color, range: 0 });
|
|
99
111
|
}
|
|
100
112
|
}
|
|
@@ -103,6 +115,7 @@ export function sceneLights(scene, rgba) {
|
|
|
103
115
|
ambientLevel: Math.max(...ambient),
|
|
104
116
|
lights,
|
|
105
117
|
mainDir: mainDir ?? unit(DEFAULT_LIGHT),
|
|
118
|
+
shadow,
|
|
106
119
|
};
|
|
107
120
|
}
|
|
108
121
|
|
package/trid/render/post.js
CHANGED
|
@@ -18,11 +18,23 @@
|
|
|
18
18
|
* - `uniforms`: settings used by the code, like `{ uStrength: 0.5 }`. Same
|
|
19
19
|
* types as material effects; read every frame
|
|
20
20
|
* - `enabled: false` turns the effect off without removing it
|
|
21
|
+
* - `geometry: true`: the effect also reads the scene's depth and normals
|
|
22
|
+
* (WebGL renderer). Its GLSL can then call `depthAt(uv)`, `normalAt(uv)`
|
|
23
|
+
* and `maskAt(uv)`, see below. The renderer draws them in one extra pass,
|
|
24
|
+
* only while such an effect is on
|
|
25
|
+
* - `objects`: with `geometry`, the objects `maskAt` is 1 on (their children
|
|
26
|
+
* too). Default: every object
|
|
21
27
|
*
|
|
22
28
|
* In GLSL, every pass can call:
|
|
23
29
|
* - `frame(uv)`: the previous pass's picture (the first pass: the picture
|
|
24
30
|
* this effect starts from)
|
|
25
31
|
* - `source(uv)`: the picture this effect starts from
|
|
32
|
+
* With `geometry: true`, also:
|
|
33
|
+
* - `depthAt(uv)`: distance from the camera, along its view (`f.far` or more
|
|
34
|
+
* where nothing is drawn). Only opaque faces count, not lines
|
|
35
|
+
* - `normalAt(uv)`: the surface's normal, in camera space (z points away from
|
|
36
|
+
* the camera, so faces seen from the front have z < 0)
|
|
37
|
+
* - `maskAt(uv)`: 1 on the effect's `objects`, else 0 (nothing drawn: 0)
|
|
26
38
|
* Colors are premultiplied by alpha (the same as plain colors where the
|
|
27
39
|
* picture is opaque, which it is when the scene has an opaque background).
|
|
28
40
|
*
|
|
@@ -32,6 +44,7 @@
|
|
|
32
44
|
*/
|
|
33
45
|
|
|
34
46
|
import { parseColor } from "./color.js";
|
|
47
|
+
import { DEFAULT_NEAR } from "../math/transform.js";
|
|
35
48
|
|
|
36
49
|
/**
|
|
37
50
|
* What a post effect knows about the picture. GLSL fields and the canvas
|
|
@@ -44,6 +57,8 @@ import { parseColor } from "./color.js";
|
|
|
44
57
|
* @property {{x:number,y:number}} texel - GLSL only: one pixel of the input picture, in uv units
|
|
45
58
|
* @property {number} time - Seconds since the renderer started
|
|
46
59
|
* @property {number[]} background - The scene's background color [r, g, b, a]
|
|
60
|
+
* @property {number} near - The camera's near distance
|
|
61
|
+
* @property {number} far - The camera's far distance (WebGL renderer: what's farther isn't drawn)
|
|
47
62
|
*/
|
|
48
63
|
|
|
49
64
|
/** The GLSL declaration of `Frame`, and the `frame` / `source` helpers. */
|
|
@@ -54,6 +69,8 @@ struct Frame {
|
|
|
54
69
|
vec2 texel;
|
|
55
70
|
float time;
|
|
56
71
|
vec4 background;
|
|
72
|
+
float near;
|
|
73
|
+
float far;
|
|
57
74
|
};
|
|
58
75
|
uniform sampler2D uFrame;
|
|
59
76
|
uniform sampler2D uSource;
|
|
@@ -61,6 +78,52 @@ vec4 frame(vec2 uv) { return texture2D(uFrame, uv); }
|
|
|
61
78
|
vec4 source(vec2 uv) { return texture2D(uSource, uv); }
|
|
62
79
|
`;
|
|
63
80
|
|
|
81
|
+
/**
|
|
82
|
+
* GLSL helpers for effects with `geometry: true`: `depthAt`, `normalAt` and
|
|
83
|
+
* `maskAt`. They read a picture where each pixel packs the surface's normal
|
|
84
|
+
* (red, green) and its distance on a log scale between near and far (blue,
|
|
85
|
+
* alpha), with the mask in alpha's lowest bit.
|
|
86
|
+
*/
|
|
87
|
+
export const GEOMETRY_GLSL = `
|
|
88
|
+
uniform sampler2D uGeometry;
|
|
89
|
+
uniform float uGeoNear;
|
|
90
|
+
uniform float uGeoRange; // log(far / near)
|
|
91
|
+
vec4 geoTexel(vec2 uv) { return floor(texture2D(uGeometry, uv) * 255.0 + 0.5); }
|
|
92
|
+
float depthAt(vec2 uv) {
|
|
93
|
+
vec4 g = geoTexel(uv);
|
|
94
|
+
return uGeoNear * exp((g.b * 128.0 + floor(g.a / 2.0)) / 32766.0 * uGeoRange);
|
|
95
|
+
}
|
|
96
|
+
vec3 normalAt(vec2 uv) {
|
|
97
|
+
vec2 p = geoTexel(uv).rg / 127.5 - 1.0;
|
|
98
|
+
vec3 n = vec3(p, 1.0 - abs(p.x) - abs(p.y));
|
|
99
|
+
if (n.z < 0.0) n.xy = (1.0 - abs(n.yx)) * vec2(n.x >= 0.0 ? 1.0 : -1.0, n.y >= 0.0 ? 1.0 : -1.0);
|
|
100
|
+
return normalize(n);
|
|
101
|
+
}
|
|
102
|
+
float maskAt(vec2 uv) { return mod(geoTexel(uv).a, 2.0); }
|
|
103
|
+
`;
|
|
104
|
+
|
|
105
|
+
/** Whether a post effect reads the scene's depth and normals (`geometry: true`, with GLSL). */
|
|
106
|
+
export function wantsGeometry(effect) {
|
|
107
|
+
return !!effect?.geometry && postPasses(effect).length > 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Every object an effect's `objects` list covers, children included, as a
|
|
112
|
+
* Set. Null when the effect has no list (it covers everything).
|
|
113
|
+
*/
|
|
114
|
+
export function maskedObjects(effect) {
|
|
115
|
+
const list = effect?.objects;
|
|
116
|
+
if (!Array.isArray(list)) return null;
|
|
117
|
+
const out = new Set();
|
|
118
|
+
const visit = (o) => {
|
|
119
|
+
if (!o || typeof o !== "object" || out.has(o)) return;
|
|
120
|
+
out.add(o);
|
|
121
|
+
if (Array.isArray(o.children)) o.children.forEach(visit);
|
|
122
|
+
};
|
|
123
|
+
list.forEach(visit);
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
64
127
|
/** A post effect's passes, as `{ glsl, scale }`. Empty when it has no GLSL (canvas only). */
|
|
65
128
|
export function postPasses(effect) {
|
|
66
129
|
if (Array.isArray(effect.passes)) return effect.passes.filter((p) => p?.glsl).map((p) => ({ glsl: p.glsl, scale: p.scale > 0 ? p.scale : 1 }));
|
|
@@ -90,8 +153,10 @@ export function runCanvasPost(scene, list, start) {
|
|
|
90
153
|
time: (performance.now() - start) / 1000,
|
|
91
154
|
canvas,
|
|
92
155
|
rgba: (v) => (typeof v === "string" ? parseColor(ctx, v).map((x, i) => (i < 3 ? x / 255 : x)) : v),
|
|
156
|
+
near: scene.camera?.near ?? DEFAULT_NEAR,
|
|
157
|
+
far: scene.camera?.far ?? 1000,
|
|
93
158
|
};
|
|
94
|
-
frame.background = frame.rgba(scene.background ?? "
|
|
159
|
+
frame.background = frame.rgba(scene.background ?? "transparent");
|
|
95
160
|
for (const e of effects) {
|
|
96
161
|
ctx.save();
|
|
97
162
|
try {
|
|
@@ -290,6 +355,95 @@ vec4 post(vec4 color, Frame f) {
|
|
|
290
355
|
};
|
|
291
356
|
}
|
|
292
357
|
|
|
358
|
+
/** Crease angle the outline uses when `creases` isn't given, in degrees. */
|
|
359
|
+
export const DEFAULT_OUTLINE_CREASE = 40;
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Cartoon-style lines: around each object (where the distance jumps:
|
|
363
|
+
* silhouettes, and where one object passes in front of another) and,
|
|
364
|
+
* with `creases`, inside it where the surface turns sharply (a cube's edges).
|
|
365
|
+
* Lines are drawn on the nearer side of a jump, so they hug the objects.
|
|
366
|
+
*
|
|
367
|
+
* Smooth-shaded surfaces (`smooth: true`) only get lines where their shading
|
|
368
|
+
* keeps a hard edge; flat-shaded ones get one wherever faces meet at more
|
|
369
|
+
* than `creases` degrees.
|
|
370
|
+
*
|
|
371
|
+
* The canvas renderer draws the meshes' outer edges and creases as lines
|
|
372
|
+
* instead (sorted with the faces), so the same scene looks close on both.
|
|
373
|
+
*
|
|
374
|
+
* @param {Object} [opts]
|
|
375
|
+
* @param {string} [opts.color="#1a1a1a"] - Line color (its alpha makes lines see-through)
|
|
376
|
+
* @param {number} [opts.width=2] - Line width, in pixels (WebGL: up to 16)
|
|
377
|
+
* @param {number|false} [opts.creases=40] - Draw a line where the surface turns by more than this
|
|
378
|
+
* many degrees. `false`: outer lines only
|
|
379
|
+
* @param {number} [opts.depth=1] - How readily a jump in distance makes a line (WebGL): higher finds
|
|
380
|
+
* smaller steps (an object resting on another), 0 = none (creases only)
|
|
381
|
+
* @param {Object[]} [opts.objects] - Only outline these objects (and their children). Default: all
|
|
382
|
+
*/
|
|
383
|
+
export function outline({ color = "#1a1a1a", width = 2, creases = DEFAULT_OUTLINE_CREASE, depth = 1, objects } = {}) {
|
|
384
|
+
return {
|
|
385
|
+
name: "outline",
|
|
386
|
+
geometry: true,
|
|
387
|
+
contour: true,
|
|
388
|
+
objects,
|
|
389
|
+
uniforms: { uColor: color, uWidth: width, uCreaseAngle: creases === false ? 180 : creases, uDepth: depth },
|
|
390
|
+
passes: [
|
|
391
|
+
{
|
|
392
|
+
// Where the lines are, 1 pixel thin (red channel)
|
|
393
|
+
glsl: `
|
|
394
|
+
vec4 post(vec4 color, Frame f) {
|
|
395
|
+
if (maskAt(f.uv) < 0.5) return vec4(0.0); // not an outlined object (or nothing there)
|
|
396
|
+
float zc = depthAt(f.uv);
|
|
397
|
+
vec2 d = f.texel;
|
|
398
|
+
|
|
399
|
+
// Jumps in distance. 1 / distance changes evenly across a flat face, so its
|
|
400
|
+
// second difference is 0 there and big at a jump; > 0 only on the nearer side.
|
|
401
|
+
float ic = 1.0 / zc;
|
|
402
|
+
float jx = 2.0 * ic - 1.0 / depthAt(f.uv - vec2(d.x, 0.0)) - 1.0 / depthAt(f.uv + vec2(d.x, 0.0));
|
|
403
|
+
float jy = 2.0 * ic - 1.0 / depthAt(f.uv - vec2(0.0, d.y)) - 1.0 / depthAt(f.uv + vec2(0.0, d.y));
|
|
404
|
+
float line = smoothstep(0.03, 0.08, max(jx, jy) / ic * uDepth);
|
|
405
|
+
|
|
406
|
+
// Creases: the normal turns sharply toward the next pixel, on the same surface
|
|
407
|
+
if (uCreaseAngle < 180.0) {
|
|
408
|
+
vec3 nc = normalAt(f.uv);
|
|
409
|
+
float bend = 0.0;
|
|
410
|
+
for (int k = 0; k < 2; k++) {
|
|
411
|
+
vec2 uv = f.uv + (k == 0 ? vec2(d.x, 0.0) : vec2(0.0, d.y));
|
|
412
|
+
if (abs(depthAt(uv) - zc) > zc * 0.05) continue; // another object: the jump test handles it
|
|
413
|
+
bend = max(bend, 1.0 - dot(nc, normalAt(uv)));
|
|
414
|
+
}
|
|
415
|
+
float limit = 1.0 - cos(radians(uCreaseAngle));
|
|
416
|
+
line = max(line, smoothstep(limit * 0.8, limit * 1.2 + 1e-3, bend));
|
|
417
|
+
}
|
|
418
|
+
return vec4(line, 0.0, 0.0, 1.0);
|
|
419
|
+
}`,
|
|
420
|
+
},
|
|
421
|
+
// Widened to `width` pixels: across, then down (soft at the ends)
|
|
422
|
+
{ glsl: widen("vec2(1.0, 0.0)", "return vec4(w, 0.0, 0.0, 1.0);") },
|
|
423
|
+
{
|
|
424
|
+
glsl: widen("vec2(0.0, 1.0)", `
|
|
425
|
+
float a = w * uColor.a;
|
|
426
|
+
return source(f.uv) * (1.0 - a) + vec4(uColor.rgb * a, a);`),
|
|
427
|
+
},
|
|
428
|
+
],
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** One direction of the outline's widening: the strongest line nearby, by distance. */
|
|
433
|
+
function widen(dir, finish) {
|
|
434
|
+
return `
|
|
435
|
+
vec4 post(vec4 color, Frame f) {
|
|
436
|
+
float reach = max(uWidth, 1.0) * 0.5 + 0.5; // full strength up to here, fading over the next pixel
|
|
437
|
+
float w = 0.0;
|
|
438
|
+
for (int i = -8; i <= 8; i++) {
|
|
439
|
+
float o = float(i);
|
|
440
|
+
if (abs(o) > reach) continue;
|
|
441
|
+
w = max(w, frame(f.uv + ${dir} * o * f.texel).r * clamp(reach - abs(o), 0.0, 1.0));
|
|
442
|
+
}
|
|
443
|
+
${finish}
|
|
444
|
+
}`;
|
|
445
|
+
}
|
|
446
|
+
|
|
293
447
|
// --- Helpers for the canvas versions ---
|
|
294
448
|
|
|
295
449
|
function supportsFilter(ctx) {
|