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.
@@ -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
- for (const it of items) {
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
- continue;
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++) {
@@ -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 solid background color.
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.
@@ -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
 
@@ -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 ?? "#000000");
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) {