trid3d 0.6.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.
@@ -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,6 +153,8 @@ 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
159
  frame.background = frame.rgba(scene.background ?? "transparent");
95
160
  for (const e of effects) {
@@ -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) {
@@ -0,0 +1,184 @@
1
+ /**
2
+ * Shadows from a directional light.
3
+ *
4
+ * scene.lights = [
5
+ * { type: "ambient", intensity: 0.3 },
6
+ * { type: "directional", direction: { x: -0.5, y: 1, z: -0.8 }, intensity: 0.7, shadows: true },
7
+ * ];
8
+ *
9
+ * - The first directional light with `shadows` casts them (point lights
10
+ * don't, yet). `shadows` is `true` or options, see `shadowOptions`
11
+ * - Objects cast and receive shadows unless they (or a group they're in)
12
+ * have `castShadow: false` / `receiveShadow: false`. Only faces cast
13
+ * shadows, not lines or see-through faces
14
+ *
15
+ * WebGL renderer: a shadow map. The scene is drawn from the light into a
16
+ * picture of distances, fitted around the casting objects every frame;
17
+ * each pixel then checks whether something is nearer the light than it.
18
+ *
19
+ * Canvas renderer: ground shadows. The casting objects are flattened onto
20
+ * the floor (the level where most of the area of flat, upward faces is, or
21
+ * `ground`) and drawn as one darker shape on the floor's faces. Objects
22
+ * don't shadow each other there.
23
+ */
24
+
25
+ import { transformPoint } from "../math/matrix.js";
26
+ import { newell } from "./normals.js";
27
+
28
+ /** Settings used for `shadows: true`. */
29
+ export const DEFAULT_SHADOWS = Object.freeze({ size: 2048, softness: 1.5, bias: 1, ground: null });
30
+
31
+ /**
32
+ * A light's shadow settings, defaults filled in.
33
+ *
34
+ * @param {boolean|Object} value - The light's `shadows`
35
+ * @returns {{ size:number, softness:number, bias:number, ground:number|null }}
36
+ * - `size`: width of the shadow map in pixels (WebGL). Bigger: sharper, slower
37
+ * - `softness`: blur of the shadows' edges, in shadow map pixels (0: sharp)
38
+ * - `bias`: pushes the test away from surfaces, against speckles ("shadow acne")
39
+ * on surfaces facing the light. Raise it if they appear; too high and shadows
40
+ * come loose from their objects
41
+ * - `ground`: the floor's height for the canvas renderer (default: found)
42
+ */
43
+ export function shadowOptions(value) {
44
+ const o = value && typeof value === "object" ? value : {};
45
+ return {
46
+ size: Math.max(64, Math.min(8192, Math.round(o.size ?? DEFAULT_SHADOWS.size))),
47
+ softness: Math.max(0, o.softness ?? DEFAULT_SHADOWS.softness),
48
+ bias: Math.max(0, o.bias ?? DEFAULT_SHADOWS.bias),
49
+ ground: typeof o.ground === "number" ? o.ground : null,
50
+ };
51
+ }
52
+
53
+ /** False when the object or one of its groups sets `key` to false. */
54
+ export function shadowFlag(obj, key) {
55
+ for (let o = obj; o; o = o.parent) if (o[key] === false) return false;
56
+ return true;
57
+ }
58
+
59
+ const spheres = new WeakMap();
60
+
61
+ /** A sphere around a mesh's vertices, in its own space (cached per vertex array). */
62
+ function meshSphere(mesh) {
63
+ const v = mesh.vertices;
64
+ const hit = spheres.get(v);
65
+ if (hit && hit.n === v.length && !mesh.dirty) return hit;
66
+ let min = { x: Infinity, y: Infinity, z: Infinity }, max = { x: -Infinity, y: -Infinity, z: -Infinity };
67
+ for (const p of v) {
68
+ min = { x: Math.min(min.x, p.x), y: Math.min(min.y, p.y), z: Math.min(min.z, p.z) };
69
+ max = { x: Math.max(max.x, p.x), y: Math.max(max.y, p.y), z: Math.max(max.z, p.z) };
70
+ }
71
+ const c = { x: (min.x + max.x) / 2, y: (min.y + max.y) / 2, z: (min.z + max.z) / 2 };
72
+ let r = 0;
73
+ for (const p of v) r = Math.max(r, Math.hypot(p.x - c.x, p.y - c.y, p.z - c.z));
74
+ const s = { c, r, n: v.length };
75
+ spheres.set(v, s);
76
+ return s;
77
+ }
78
+
79
+ /**
80
+ * Where the shadow map looks: a box facing along the light, around every
81
+ * casting object (each object's mesh sphere, placed and scaled).
82
+ *
83
+ * @param {Object[]} casters - Objects with `mesh` and `world`
84
+ * @param {{x:number,y:number,z:number}} dir - Unit direction the light comes from
85
+ * @returns {{ matrix: Float32Array, radius: number } | null} `matrix` takes world points to
86
+ * the shadow map's -1..1 cube (column-major, for WebGL)
87
+ */
88
+ export function shadowFrame(casters, dir) {
89
+ let min = { x: Infinity, y: Infinity, z: Infinity }, max = { x: -Infinity, y: -Infinity, z: -Infinity };
90
+ let any = false;
91
+ for (const obj of casters) {
92
+ if (!obj.mesh?.vertices?.length) continue;
93
+ const s = meshSphere(obj.mesh);
94
+ const w = obj.world;
95
+ const c = transformPoint(w, s.c);
96
+ const k = Math.max(Math.hypot(w[0], w[1], w[2]), Math.hypot(w[4], w[5], w[6]), Math.hypot(w[8], w[9], w[10]));
97
+ const r = s.r * k;
98
+ min = { x: Math.min(min.x, c.x - r), y: Math.min(min.y, c.y - r), z: Math.min(min.z, c.z - r) };
99
+ max = { x: Math.max(max.x, c.x + r), y: Math.max(max.y, c.y + r), z: Math.max(max.z, c.z + r) };
100
+ any = true;
101
+ }
102
+ if (!any) return null;
103
+ const c = { x: (min.x + max.x) / 2, y: (min.y + max.y) / 2, z: (min.z + max.z) / 2 };
104
+ const R = Math.max(1e-6, Math.hypot(max.x - min.x, max.y - min.y, max.z - min.z) / 2);
105
+
106
+ // The light looks along -dir; right and up across it
107
+ const f = { x: -dir.x, y: -dir.y, z: -dir.z };
108
+ const up = Math.abs(f.y) > 0.99 ? { x: 0, y: 0, z: 1 } : { x: 0, y: 1, z: 0 };
109
+ const r = unit(cross(up, f));
110
+ const u = cross(f, r);
111
+ const dot = (a, b) => a.x * b.x + a.y * b.y + a.z * b.z;
112
+ const m = new Float32Array([
113
+ r.x / R, u.x / R, f.x / R, 0,
114
+ r.y / R, u.y / R, f.y / R, 0,
115
+ r.z / R, u.z / R, f.z / R, 0,
116
+ -dot(r, c) / R, -dot(u, c) / R, -dot(f, c) / R, 1,
117
+ ]);
118
+ return { matrix: m, radius: R };
119
+ }
120
+
121
+ /**
122
+ * The floor for ground shadows: the height where receiving objects have the
123
+ * most area of flat faces turned up. Null when there's none.
124
+ *
125
+ * @param {Object[]} receivers - Objects with `mesh` and `world`
126
+ */
127
+ export function findGround(receivers) {
128
+ const area = new Map();
129
+ for (const obj of receivers) {
130
+ const v = obj.mesh.vertices;
131
+ for (const face of obj.mesh.faces ?? []) {
132
+ if (face.length < 3) continue;
133
+ const p = face.map((i) => transformPoint(obj.world, v[i]));
134
+ const n = newell(p);
135
+ const len = Math.hypot(n.x, n.y, n.z);
136
+ if (!(len > 0) || n.y / len < 0.99) continue;
137
+ const y = Math.round(p[0].y * 1e4) / 1e4;
138
+ area.set(y, (area.get(y) ?? 0) + len / 2);
139
+ }
140
+ }
141
+ let best = null, most = 0;
142
+ for (const [y, a] of area) if (a > most) [best, most] = [y, a];
143
+ return best;
144
+ }
145
+
146
+ /**
147
+ * A world polygon's part above `ground`, flattened onto it along the light.
148
+ * Empty when it's all at or below the ground.
149
+ *
150
+ * @param {{x:number,y:number,z:number}[]} pts - World points
151
+ * @param {{x:number,y:number,z:number}} dir - Unit direction the light comes from (dir.y > 0)
152
+ * @param {number} ground
153
+ */
154
+ export function flattenOnGround(pts, dir, ground) {
155
+ const eps = 1e-5;
156
+ if (pts.every((p) => p.y <= ground + eps)) return [];
157
+ // Cut off what's below the ground
158
+ let poly = pts;
159
+ if (pts.some((p) => p.y < ground)) {
160
+ poly = [];
161
+ for (let i = 0; i < pts.length; i++) {
162
+ const a = pts[i], b = pts[(i + 1) % pts.length];
163
+ const aIn = a.y >= ground, bIn = b.y >= ground;
164
+ if (aIn) poly.push(a);
165
+ if (aIn !== bIn) {
166
+ const t = (ground - a.y) / (b.y - a.y);
167
+ poly.push({ x: a.x + (b.x - a.x) * t, y: ground, z: a.z + (b.z - a.z) * t });
168
+ }
169
+ }
170
+ }
171
+ return poly.map((p) => {
172
+ const t = (p.y - ground) / dir.y;
173
+ return { x: p.x - dir.x * t, y: ground, z: p.z - dir.z * t };
174
+ });
175
+ }
176
+
177
+ function cross(a, b) {
178
+ return { x: a.y * b.z - a.z * b.y, y: a.z * b.x - a.x * b.z, z: a.x * b.y - a.y * b.x };
179
+ }
180
+
181
+ function unit(v) {
182
+ const len = Math.hypot(v.x, v.y, v.z) || 1;
183
+ return { x: v.x / len, y: v.y / len, z: v.z / len };
184
+ }