trid3d 0.3.1 → 0.4.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.
@@ -6,6 +6,8 @@ import { parseColor } from "./color.js";
6
6
  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
+ import { DEFAULT_LIGHT, DEFAULT_AMBIENT, sceneLights, lightAt as lightOf, lightLevels } from "./lights.js";
10
+ import { runCanvasPost } from "./post.js";
9
11
 
10
12
  export { newell };
11
13
 
@@ -23,11 +25,7 @@ export function materialsOf(obj) {
23
25
  return m.length && m[0] ? m : null;
24
26
  }
25
27
 
26
- /** Direction light comes from when the scene doesn't set one: above, left, in front. */
27
- export const DEFAULT_LIGHT = { x: -0.5, y: 1, z: -0.8 };
28
-
29
- /** How bright a face turned fully away from the light still is (0..1). */
30
- export const DEFAULT_AMBIENT = 0.25;
28
+ export { DEFAULT_LIGHT, DEFAULT_AMBIENT };
31
29
 
32
30
  /**
33
31
  * Material: how an object's faces look. Objects without one are drawn
@@ -115,12 +113,13 @@ export class CanvasRenderer {
115
113
  drawInstance(ctx, obj, camera, w, h, obj.color ?? scene.color, obj.lineWidth ?? scene.lineWidth, obj.world);
116
114
  }
117
115
  for (const obj of drawn) if (obj.mesh.dirty) obj.mesh.dirty = false;
116
+ runCanvasPost(scene, scene.post, this._start);
118
117
  return;
119
118
  }
120
119
 
121
120
  const near = camera.near ?? DEFAULT_NEAR;
122
- const light = normalize(scene.light ?? DEFAULT_LIGHT);
123
- const ambient = scene.ambient ?? DEFAULT_AMBIENT;
121
+ // Every light, colors parsed (see trid/render/lights.js)
122
+ const lights = sceneLights(scene, (css) => this._rgba(ctx, css));
124
123
  const toScreen = (p) => cameraToScreen(p, camera, w, h);
125
124
  const items = [];
126
125
  // For effects
@@ -168,16 +167,9 @@ export class CanvasRenderer {
168
167
  const na = normalAt(corners, F, a), nb = normalAt(corners, F, b);
169
168
  return normalize({ x: na.x + nb.x, y: na.y + nb.y, z: na.z + nb.z });
170
169
  };
171
- const lightAt = (n, back) => {
172
- const r = transformNormal(nm, n);
173
- const d = (back ? -1 : 1) * (r.x * light.x + r.y * light.y + r.z * light.z);
174
- return ambient + (1 - ambient) * Math.max(0, d);
175
- };
176
- const flatLight = (face, back) => {
177
- const nw = flatNormal(face);
178
- const d = (back ? -1 : 1) * (nw.x * light.x + nw.y * light.y + nw.z * light.z);
179
- return ambient + (1 - ambient) * Math.max(0, d);
180
- };
170
+ // Light reaching a point, as [r, g, b]: at a corner (smooth) or a face's center (flat)
171
+ const lightAt = (n, back, p) => lightOf(lights, flipIf(transformNormal(nm, n), back), p);
172
+ const flatLight = (face, back) => lightOf(lights, flipIf(flatNormal(face), back), centroid(face.map((i) => world[i])));
181
173
  const toWorldNormal = (n) => transformNormal(nm, n);
182
174
 
183
175
  // Everything a material needs, worked out once per object and frame
@@ -247,9 +239,12 @@ export class CanvasRenderer {
247
239
  const ts = face.map((i) => L.uvAt(F, i));
248
240
  if (ts.every(Boolean)) {
249
241
  let lit;
250
- if (!shade) lit = face.map(() => 1);
251
- else if (corners) lit = face.map((i) => lightAt(normalAt(corners, F, i), back));
252
- else lit = face.map(() => flatLight(face, back));
242
+ if (!shade) lit = face.map(() => WHITE);
243
+ else if (corners) lit = face.map((i) => lightAt(normalAt(corners, F, i), back, world[i]));
244
+ else {
245
+ const one = flatLight(face, back);
246
+ lit = face.map(() => one);
247
+ }
253
248
  const screen = pts.map(toScreen);
254
249
  const n = face.length;
255
250
  for (let k = 1; k < n - 1; k++) {
@@ -282,17 +277,18 @@ export class CanvasRenderer {
282
277
 
283
278
  const position = centroid(face.map((i) => world[i]));
284
279
  const toCam = { x: camPos.x - position.x, y: camPos.y - position.y, z: camPos.z - position.z };
285
- const diffuse = Math.max(0, nw.x * light.x + nw.y * light.y + nw.z * light.z);
286
- const lit = shade ? ambient + (1 - ambient) * diffuse : 1;
280
+ const reach = lightOf(lights, nw, position);
281
+ const { light: level, diffuse } = lightLevels(reach, lights.ambientLevel);
282
+ const lightColor = shade ? reach : WHITE.slice();
287
283
  const rgba = this._rgba(ctx, fill);
288
284
  const s = {
289
285
  base: rgba, normal: nw, position, local: centroid(face.map((i) => verts[i])),
290
- view: normalize(toCam), lightDir: light, diffuse, light: lit, ambient,
286
+ view: normalize(toCam), lightDir: lights.mainDir, diffuse, light: shade ? level : 1, ambient: lights.ambientLevel, lightColor,
291
287
  uv: centerUV(face.map((i) => L.uvAt(F, i))),
292
288
  depth: Math.hypot(toCam.x, toCam.y, toCam.z), time, background,
293
289
  rgba: (v) => (typeof v === "string" ? this._rgba(ctx, v) : Array.isArray(v) ? v : [v.x, v.y, v.z, 1]),
294
290
  };
295
- let col = [rgba[0] * lit, rgba[1] * lit, rgba[2] * lit, rgba[3]];
291
+ let col = [rgba[0] * lightColor[0], rgba[1] * lightColor[1], rgba[2] * lightColor[2], rgba[3]];
296
292
  for (const e of L.effects) if (e.canvas) col = e.canvas(col, s, e.uniforms ?? {});
297
293
  const k = col.map((x) => Math.min(1, Math.max(0, x)));
298
294
  items.push({
@@ -305,7 +301,7 @@ export class CanvasRenderer {
305
301
  }
306
302
 
307
303
  if (corners) {
308
- const lit = face.map((i) => lightAt(normalAt(corners, F, i), back));
304
+ const lit = face.map((i) => lightAt(normalAt(corners, F, i), back, world[i]));
309
305
  if (clipped === pts) {
310
306
  // One gradient per triangle: brightness changes linearly across a triangle
311
307
  const screen = pts.map(toScreen);
@@ -323,13 +319,12 @@ export class CanvasRenderer {
323
319
  }
324
320
  continue;
325
321
  }
326
- // Cut by the near plane: use the average brightness
327
- const avg = lit.reduce((a, b) => a + b, 0) / lit.length;
328
- items.push({ depth: c.z, poly: clipped.map(toScreen), fill: this._shade(ctx, fill, avg), outline, lineWidth, edges });
322
+ // Cut by the near plane: use the average light
323
+ items.push({ depth: c.z, poly: clipped.map(toScreen), fill: this._shade(ctx, fill, average(lit)), outline, lineWidth, edges });
329
324
  continue;
330
325
  }
331
326
 
332
- const brightness = shade ? flatLight(face, back) : 1;
327
+ const brightness = shade ? flatLight(face, back) : WHITE;
333
328
  items.push({
334
329
  depth: c.z,
335
330
  poly: clipped.map(toScreen),
@@ -394,6 +389,9 @@ export class CanvasRenderer {
394
389
 
395
390
  // Meshes marked as changed have been redrawn from their new data
396
391
  for (const obj of drawn) if (obj.mesh.dirty) obj.mesh.dirty = false;
392
+
393
+ // Post effects that have a canvas version (see trid/render/post.js)
394
+ runCanvasPost(scene, scene.post, this._start);
397
395
  }
398
396
 
399
397
  /**
@@ -487,15 +485,17 @@ export class CanvasRenderer {
487
485
  }
488
486
 
489
487
  /**
490
- * A fill for a triangle whose corners have different brightness:
491
- * a linear gradient along the direction brightness changes fastest.
492
- * Exact, because brightness changes linearly across a triangle.
488
+ * A fill for a triangle whose corners get different light ([r, g, b] each):
489
+ * a linear gradient along the direction the light changes fastest.
490
+ * Exact for white light (brightness changes linearly across a triangle);
491
+ * close for colored lights whose colors change in different directions.
493
492
  */
494
493
  _gradient(ctx, color, tri, lit) {
495
- const g = gradientLine(tri, lit);
496
- if (!g) return this._shade(ctx, color, (Math.min(...lit) + Math.max(...lit)) / 2);
494
+ const g = gradientLine(tri, lit.map((c) => c[0] + c[1] + c[2]));
495
+ if (!g) return this._shade(ctx, color, average(lit));
497
496
  const grad = ctx.createLinearGradient(g.x0, g.y0, g.x1, g.y1);
498
- const a = this._shade(ctx, color, g.lo), b = this._shade(ctx, color, g.hi);
497
+ const a = this._shade(ctx, color, lightOnTriangle(tri, lit, g.x0, g.y0));
498
+ const b = this._shade(ctx, color, lightOnTriangle(tri, lit, g.x1, g.y1));
499
499
  grad.addColorStop(0, a.css);
500
500
  grad.addColorStop(1, b.css);
501
501
  return { css: grad, opaque: a.opaque };
@@ -551,19 +551,40 @@ export class CanvasRenderer {
551
551
  ctx.globalCompositeOperation = "source-over";
552
552
  }
553
553
 
554
- // Light: black on top, as see-through as the face is bright
555
- const lo = Math.min(...it.lit), hi = Math.max(...it.lit);
556
- if (lo >= 0.999) return;
557
- const g = gradientLine(it.poly, it.lit);
554
+ // Light (the canvas can darken a texture, not make it brighter than it is)
555
+ const lit = it.lit.map((c) => c.map((x) => Math.min(1, x)));
556
+ if (lit.every((c) => c[0] >= 0.999 && c[1] >= 0.999 && c[2] >= 0.999)) return;
557
+ const white = lit.every((c) => Math.abs(c[0] - c[1]) < 1e-3 && Math.abs(c[1] - c[2]) < 1e-3);
558
+ if (white) {
559
+ // White light: black on top, as see-through as the face is bright
560
+ const level = lit.map((c) => c[0]);
561
+ const lo = Math.min(...level), hi = Math.max(...level);
562
+ const g = gradientLine(it.poly, level);
563
+ if (g) {
564
+ const grad = ctx.createLinearGradient(g.x0, g.y0, g.x1, g.y1);
565
+ grad.addColorStop(0, `rgba(0, 0, 0, ${1 - g.lo})`);
566
+ grad.addColorStop(1, `rgba(0, 0, 0, ${1 - g.hi})`);
567
+ ctx.fillStyle = grad;
568
+ } else {
569
+ ctx.fillStyle = `rgba(0, 0, 0, ${1 - (lo + hi) / 2})`;
570
+ }
571
+ ctx.fill();
572
+ return;
573
+ }
574
+ // Colored light: multiply by its color
575
+ const css = (c) => `rgb(${Math.round(c[0] * 255)}, ${Math.round(c[1] * 255)}, ${Math.round(c[2] * 255)})`;
576
+ const g = gradientLine(it.poly, lit.map((c) => c[0] + c[1] + c[2]));
558
577
  if (g) {
559
578
  const grad = ctx.createLinearGradient(g.x0, g.y0, g.x1, g.y1);
560
- grad.addColorStop(0, `rgba(0, 0, 0, ${1 - g.lo})`);
561
- grad.addColorStop(1, `rgba(0, 0, 0, ${1 - g.hi})`);
579
+ grad.addColorStop(0, css(lightOnTriangle(it.poly, lit, g.x0, g.y0)));
580
+ grad.addColorStop(1, css(lightOnTriangle(it.poly, lit, g.x1, g.y1)));
562
581
  ctx.fillStyle = grad;
563
582
  } else {
564
- ctx.fillStyle = `rgba(0, 0, 0, ${1 - (lo + hi) / 2})`;
583
+ ctx.fillStyle = css(average(lit));
565
584
  }
585
+ ctx.globalCompositeOperation = "multiply";
566
586
  ctx.fill();
587
+ ctx.globalCompositeOperation = "source-over";
567
588
  }
568
589
 
569
590
  /**
@@ -591,16 +612,20 @@ export class CanvasRenderer {
591
612
  return c;
592
613
  }
593
614
 
594
- /** CSS color for `color` at a brightness, cached (brightness is rounded to 1/255). */
595
- _shade(ctx, color, brightness) {
596
- const level = Math.round(brightness * 255);
597
- const key = color + "|" + level;
615
+ /**
616
+ * CSS color for `color` under some light: a brightness, or [r, g, b]
617
+ * (colored light). Cached; the light is rounded to 1/255.
618
+ */
619
+ _shade(ctx, color, light) {
620
+ const [lr, lg, lb] = typeof light === "number" ? [light, light, light] : light;
621
+ const q = (x) => Math.round(Math.max(0, x) * 255);
622
+ const key = color + "|" + q(lr) + "," + q(lg) + "," + q(lb);
598
623
  let out = this._shades.get(key);
599
624
  if (!out) {
600
625
  const [r, g, b, a] = parseColor(ctx, color);
601
- const k = level / 255;
626
+ const ch = (c, k) => Math.min(255, Math.round((c * q(k)) / 255));
602
627
  out = {
603
- css: `rgba(${Math.round(r * k)}, ${Math.round(g * k)}, ${Math.round(b * k)}, ${a})`,
628
+ css: `rgba(${ch(r, lr)}, ${ch(g, lg)}, ${ch(b, lb)}, ${a})`,
604
629
  opaque: a >= 1,
605
630
  };
606
631
  this._shades.set(key, out);
@@ -629,6 +654,32 @@ function gradientLine(tri, lit) {
629
654
  return { x0: p0.x + gx * s, y0: p0.y + gy * s, x1: p0.x + gx * e, y1: p0.y + gy * e, lo, hi };
630
655
  }
631
656
 
657
+ /** Light [r, g, b] at screen point (x, y), blended from a triangle's corners (it can lie outside). */
658
+ function lightOnTriangle(tri, lit, x, y) {
659
+ const [a, b, c] = tri;
660
+ const d = (b.y - c.y) * (a.x - c.x) + (c.x - b.x) * (a.y - c.y);
661
+ if (Math.abs(d) < 1e-9) return average(lit);
662
+ const u = ((b.y - c.y) * (x - c.x) + (c.x - b.x) * (y - c.y)) / d;
663
+ const v = ((c.y - a.y) * (x - c.x) + (a.x - c.x) * (y - c.y)) / d;
664
+ const w = 1 - u - v;
665
+ return [0, 1, 2].map((i) => Math.max(0, u * lit[0][i] + v * lit[1][i] + w * lit[2][i]));
666
+ }
667
+
668
+ /** Average of [r, g, b] lights. */
669
+ function average(lit) {
670
+ const out = [0, 0, 0];
671
+ for (const c of lit) for (let i = 0; i < 3; i++) out[i] += c[i] / lit.length;
672
+ return out;
673
+ }
674
+
675
+ /** White light: colors drawn as they are. */
676
+ const WHITE = [1, 1, 1];
677
+
678
+ /** The vector, reversed when `back` (a face seen from behind is lit from its other side). */
679
+ function flipIf(n, back) {
680
+ return back ? { x: -n.x, y: -n.y, z: -n.z } : n;
681
+ }
682
+
632
683
  /** A polygon pushed outward from its center by `px` pixels. */
633
684
  function grow(poly, px) {
634
685
  const cx = poly.reduce((a, p) => a + p.x, 0) / poly.length;
@@ -29,10 +29,14 @@
29
29
  * @property {{x,y,z}} position - World position
30
30
  * @property {{x,y,z}} local - Position in the mesh's own space
31
31
  * @property {{x,y,z}} view - Unit direction from the point to the camera
32
- * @property {{x,y,z}} lightDir - Unit direction light comes from
33
- * @property {number} diffuse - How much the point faces the light (0..1)
34
- * @property {number} light - Brightness Trid used: ambient + (1 - ambient) × diffuse
35
- * @property {number} ambient - The scene's ambient level
32
+ * @property {{x,y,z}} lightDir - Unit direction the scene's first directional light comes from
33
+ * @property {number} diffuse - How much of the light comes from the lights rather than the
34
+ * ambient, 0..1. With the default single light: how much the point faces it
35
+ * @property {number} light - Brightness Trid used (the brightest channel of `lightColor`):
36
+ * ambient + (1 - ambient) × diffuse with the default lights
37
+ * @property {number[]} lightColor - The light reaching the point, [r, g, b] (all the
38
+ * scene's lights added up; can go above 1). Trid's color is `base × lightColor`
39
+ * @property {number} ambient - The scene's ambient level (brightest channel)
36
40
  * @property {number} depth - Distance to the camera
37
41
  * @property {number} time - Seconds since the renderer started
38
42
  * @property {number[]} background - The scene's background color [r, g, b, a]
@@ -50,6 +54,7 @@ struct Surface {
50
54
  vec3 lightDir;
51
55
  float diffuse;
52
56
  float light;
57
+ vec3 lightColor;
53
58
  float ambient;
54
59
  float depth;
55
60
  float time;
@@ -59,6 +64,7 @@ struct Surface {
59
64
 
60
65
  /**
61
66
  * Cartoon look: the light comes in a few hard steps instead of a smooth fade.
67
+ * Colored lights keep their color; only the brightness is stepped.
62
68
  *
63
69
  * @param {Object} [opts]
64
70
  * @param {number} [opts.steps=3] - Number of brightness levels
@@ -71,12 +77,14 @@ export function toon({ steps = 3 } = {}) {
71
77
  vec4 effect(vec4 color, Surface s) {
72
78
  float level = min(floor(s.diffuse * uSteps), uSteps - 1.0) / max(uSteps - 1.0, 1.0);
73
79
  float b = s.ambient + (1.0 - s.ambient) * level;
74
- return vec4(s.base.rgb * b, color.a);
80
+ vec3 hue = s.lightColor / max(s.light, 1e-4); // the light's color at full brightness
81
+ return vec4(s.base.rgb * hue * b, color.a);
75
82
  }`,
76
83
  canvas: (color, s, u) => {
77
84
  const level = Math.min(Math.floor(s.diffuse * u.uSteps), u.uSteps - 1) / Math.max(u.uSteps - 1, 1);
78
85
  const b = s.ambient + (1 - s.ambient) * level;
79
- return [s.base[0] * b, s.base[1] * b, s.base[2] * b, color[3]];
86
+ const k = b / Math.max(s.light, 1e-4);
87
+ return [0, 1, 2].map((i) => s.base[i] * s.lightColor[i] * k).concat(color[3]);
80
88
  },
81
89
  };
82
90
  }
@@ -170,14 +178,13 @@ export function gradient({
170
178
  vec4 effect(vec4 color, Surface s) {
171
179
  float t = clamp((s.${p}[${k}] - uMin) / (uMax - uMin), 0.0, 1.0);
172
180
  vec3 c = mix(uFrom.rgb, uTo.rgb, t);
173
- return vec4(c * mix(1.0, s.light, uLit), color.a);
181
+ return vec4(c * mix(vec3(1.0), s.lightColor, uLit), color.a);
174
182
  }`,
175
183
  canvas: (color, s, u) => {
176
184
  const v = s[p][axis] ?? s[p].y;
177
185
  const t = Math.min(1, Math.max(0, (v - u.uMin) / (u.uMax - u.uMin)));
178
186
  const a = s.rgba(u.uFrom), b = s.rgba(u.uTo);
179
- const l = u.uLit ? s.light : 1;
180
- return [0, 1, 2].map((i) => (a[i] + (b[i] - a[i]) * t) * l).concat(color[3]);
187
+ return [0, 1, 2].map((i) => (a[i] + (b[i] - a[i]) * t) * (u.uLit ? s.lightColor[i] : 1)).concat(color[3]);
181
188
  },
182
189
  };
183
190
  }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Floor grids: lines on a plane, drawn as line-only meshes (no faces, so
3
+ * they're drawn with the object's `color` and `lineWidth`).
4
+ *
5
+ * scene.add(gridHelper({ size: 4, divisions: 40 })); // a floor with axes, fading out
6
+ * scene.add({ mesh: gridMesh({ size: 2 }), color: "#444" }); // just the lines
7
+ */
8
+
9
+ /** Which world axes each plane uses: [first, second], and the one it faces. */
10
+ const PLANES = {
11
+ xz: ["x", "z", "y"],
12
+ xy: ["x", "y", "z"],
13
+ yz: ["y", "z", "x"],
14
+ };
15
+
16
+ /** Axis colors, the same as `TransformGizmo` and `ViewGizmo`. */
17
+ const AXIS_COLORS = { x: "#e8605a", y: "#7cc46b", z: "#5f9be8" };
18
+
19
+ /** A point on a plane at (a, b) along its two axes. */
20
+ function onPlane(plane, a, b) {
21
+ const [p, q] = PLANES[plane];
22
+ const v = { x: 0, y: 0, z: 0 };
23
+ v[p] = a;
24
+ v[q] = b;
25
+ return v;
26
+ }
27
+
28
+ function planeOf(plane) {
29
+ if (!PLANES[plane]) throw new Error(`Trid: unknown grid plane "${plane}" (use "xz", "xy" or "yz")`);
30
+ return plane;
31
+ }
32
+
33
+ /**
34
+ * A square grid of lines, centered on the origin: `divisions + 1` lines
35
+ * each way. A line-only mesh (`vertices` + `edges`).
36
+ *
37
+ * @param {Object} [opts]
38
+ * @param {number} [opts.size=4] - Width of the whole grid
39
+ * @param {number} [opts.divisions=40] - Cells along each side
40
+ * @param {"xz"|"xy"|"yz"} [opts.plane="xz"] - The plane it lies in ("xz" = a floor)
41
+ * @returns {{ vertices:{x,y,z}[], edges:number[][] }}
42
+ */
43
+ export function gridMesh({ size = 4, divisions = 40, plane = "xz" } = {}) {
44
+ planeOf(plane);
45
+ const half = size / 2;
46
+ const n = Math.max(1, Math.round(divisions));
47
+ const vertices = [];
48
+ const edges = [];
49
+ for (let i = 0; i <= n; i++) {
50
+ const t = -half + (size * i) / n;
51
+ vertices.push(onPlane(plane, t, -half), onPlane(plane, t, half), onPlane(plane, -half, t), onPlane(plane, half, t));
52
+ const k = vertices.length;
53
+ edges.push([k - 4, k - 3], [k - 2, k - 1]);
54
+ }
55
+ return { vertices, edges };
56
+ }
57
+
58
+ /**
59
+ * A floor grid like 3D editors have, as one object to `scene.add`: thin
60
+ * minor lines, brighter major lines every `major` cells, the two axes
61
+ * through the origin in their colors (X red, Y green, Z blue), fading out
62
+ * toward the edge. It can't be picked (`pickable: false`).
63
+ *
64
+ * It's a group whose children are line-only meshes (one per color and
65
+ * fade step), so move, turn or hide it like any object.
66
+ *
67
+ * @param {Object} [opts]
68
+ * @param {number} [opts.size=4] - Width of the whole grid
69
+ * @param {number} [opts.divisions=40] - Cells along each side
70
+ * @param {number} [opts.major=10] - A major line every this many cells (0: none)
71
+ * @param {"xz"|"xy"|"yz"} [opts.plane="xz"] - The plane it lies in ("xz" = a floor)
72
+ * @param {string} [opts.color="rgba(255, 255, 255, 0.12)"] - Minor lines
73
+ * @param {string} [opts.majorColor="rgba(255, 255, 255, 0.25)"] - Major lines
74
+ * @param {boolean|{[axis:string]:string}} [opts.axes=true] - Draw the axes; or their colors, like `{ x: "#f00" }`
75
+ * @param {boolean|number} [opts.fade=true] - Fade toward the edge; a number is where the fade starts
76
+ * (0 = center, 1 = edge; `true` = 0.5). Needs colors written as hex, `rgb()` or `rgba()`
77
+ * @param {number} [opts.lineWidth=1] - Line width in pixels
78
+ * @returns {Object} A scene object (a group)
79
+ */
80
+ export function gridHelper({
81
+ size = 4,
82
+ divisions = 40,
83
+ major = 10,
84
+ plane = "xz",
85
+ color = "rgba(255, 255, 255, 0.12)",
86
+ majorColor = "rgba(255, 255, 255, 0.25)",
87
+ axes = true,
88
+ fade = true,
89
+ lineWidth = 1,
90
+ } = {}) {
91
+ planeOf(plane);
92
+ const [p, q] = PLANES[plane];
93
+ const half = size / 2;
94
+ const n = Math.max(1, Math.round(divisions));
95
+ const cell = size / n;
96
+ const axisColors = axes ? { ...AXIS_COLORS, ...(typeof axes === "object" ? axes : {}) } : null;
97
+ const fadeFrom = fade === false ? null : typeof fade === "number" ? Math.min(0.99, Math.max(0, fade)) : 0.5;
98
+
99
+ // Lines, sorted by color; with a fade each line is cut at every cell so its
100
+ // pieces can get fainter with distance (one object per color and step)
101
+ const STEPS = fadeFrom === null ? 1 : 8;
102
+ const groups = new Map(); // "color|step" → { color, step, vertices, edges }
103
+ const add = (css, a0, b0, a1, b1) => {
104
+ const pieces = fadeFrom === null ? 1 : n;
105
+ for (let k = 0; k < pieces; k++) {
106
+ const s = k / pieces, e = (k + 1) / pieces;
107
+ const pa = a0 + (a1 - a0) * s, pb = b0 + (b1 - b0) * s;
108
+ const qa = a0 + (a1 - a0) * e, qb = b0 + (b1 - b0) * e;
109
+ let step = 0;
110
+ if (fadeFrom !== null) {
111
+ // Distance of the piece's middle from the center: 0 → 1 at the edge (circle)
112
+ const r = Math.hypot((pa + qa) / 2, (pb + qb) / 2) / half;
113
+ if (r >= 1) continue;
114
+ step = r <= fadeFrom ? 0 : Math.min(STEPS - 1, 1 + Math.floor(((r - fadeFrom) / (1 - fadeFrom)) * (STEPS - 1)));
115
+ }
116
+ const key = css + "|" + step;
117
+ let g = groups.get(key);
118
+ if (!g) groups.set(key, (g = { css, step, vertices: [], edges: [] }));
119
+ g.vertices.push(onPlane(plane, pa, pb), onPlane(plane, qa, qb));
120
+ g.edges.push([g.vertices.length - 2, g.vertices.length - 1]);
121
+ }
122
+ };
123
+
124
+ const center = n / 2; // the line through the origin, when divisions is even
125
+ for (let i = 0; i <= n; i++) {
126
+ const t = -half + i * cell;
127
+ if (axisColors && i === center) continue; // the axes go there
128
+ // Major lines count from the origin (or from the edge when no line goes through it)
129
+ const from = Number.isInteger(center) ? i - center : i;
130
+ const css = major > 0 && from % major === 0 ? majorColor : color;
131
+ add(css, t, -half, t, half); // along the second axis
132
+ add(css, -half, t, half, t); // along the first axis
133
+ }
134
+ if (axisColors) {
135
+ add(axisColors[p], -half, 0, half, 0);
136
+ add(axisColors[q], 0, -half, 0, half);
137
+ }
138
+
139
+ const children = [];
140
+ for (const g of groups.values()) {
141
+ children.push({
142
+ name: "grid lines",
143
+ mesh: { vertices: g.vertices, edges: g.edges },
144
+ color: g.step === 0 ? g.css : fadeColor(g.css, 1 - g.step / STEPS),
145
+ lineWidth,
146
+ });
147
+ }
148
+ return { name: "grid", pickable: false, children };
149
+ }
150
+
151
+ /**
152
+ * A CSS color with its alpha multiplied by `k`, for hex, `rgb()` and
153
+ * `rgba()` colors; other colors come back unchanged.
154
+ */
155
+ function fadeColor(css, k) {
156
+ const c = parseSimpleColor(css);
157
+ if (!c) return css;
158
+ return `rgba(${c[0]}, ${c[1]}, ${c[2]}, ${+(c[3] * k).toFixed(4)})`;
159
+ }
160
+
161
+ function parseSimpleColor(css) {
162
+ const s = String(css).trim();
163
+ let m = /^#([0-9a-f]{3,8})$/i.exec(s);
164
+ if (m) {
165
+ let h = m[1];
166
+ if (h.length === 3 || h.length === 4) h = [...h].map((x) => x + x).join("");
167
+ if (h.length !== 6 && h.length !== 8) return null;
168
+ const v = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
169
+ return v.concat(h.length === 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1);
170
+ }
171
+ m = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:[\s,/]+([\d.]+%?))?\s*\)$/i.exec(s);
172
+ if (m) {
173
+ let a = m[4] === undefined ? 1 : m[4].endsWith("%") ? parseFloat(m[4]) / 100 : parseFloat(m[4]);
174
+ return [+m[1], +m[2], +m[3], a];
175
+ }
176
+ return null;
177
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Lights: what makes faces bright or dark.
3
+ *
4
+ * A scene has a list of lights, `scene.lights`. Each one is a plain object:
5
+ *
6
+ * { type: "ambient", color: "#ffffff", intensity: 0.25 } // everywhere, from every side
7
+ * { type: "directional", direction: { x: -0.5, y: 1, z: -0.8 }, intensity: 0.75 } // like the sun
8
+ * { type: "point", position: { x: 0, y: 1, z: 0 }, color: "#ffb070", intensity: 1, range: 3 } // like a bulb
9
+ *
10
+ * - `direction` is where the light comes **from** (it doesn't need to be unit length)
11
+ * - `color` is any CSS color (default white), `intensity` multiplies it (default 1)
12
+ * - `range`: a point light fades out smoothly and reaches nothing past it
13
+ * (default: no fading)
14
+ * - `enabled: false` turns a light off without removing it
15
+ *
16
+ * Lights add up: a face gets the ambient lights, plus each other light
17
+ * times how much the face turns toward it. Both renderers use at most
18
+ * `MAX_LIGHTS` directional and point lights (ambient ones don't count).
19
+ *
20
+ * The default scene has an ambient light at 0.25 and a directional light
21
+ * at 0.75, so a face turned right at the light is exactly its own color.
22
+ */
23
+
24
+ /** Direction light comes from when the scene doesn't set one: above, left, in front. */
25
+ export const DEFAULT_LIGHT = { x: -0.5, y: 1, z: -0.8 };
26
+
27
+ /** How bright a face turned fully away from the light still is (0..1). */
28
+ export const DEFAULT_AMBIENT = 0.25;
29
+
30
+ /** Most directional + point lights the renderers use; extra ones are ignored. */
31
+ export const MAX_LIGHTS = 8;
32
+
33
+ /**
34
+ * The default lights: an ambient light at `ambient`, and a white
35
+ * directional light from `direction` at `1 - ambient`.
36
+ *
37
+ * @param {{x:number,y:number,z:number}} [direction=DEFAULT_LIGHT]
38
+ * @param {number} [ambient=DEFAULT_AMBIENT]
39
+ * @returns {Object[]} A new list of lights
40
+ */
41
+ export function defaultLights(direction = DEFAULT_LIGHT, ambient = DEFAULT_AMBIENT) {
42
+ return [
43
+ { type: "ambient", color: "#ffffff", intensity: ambient },
44
+ // The given direction object is kept (so changing it moves the light), the default is copied
45
+ { type: "directional", direction: direction === DEFAULT_LIGHT ? { ...DEFAULT_LIGHT } : direction, color: "#ffffff", intensity: 1 - ambient },
46
+ ];
47
+ }
48
+
49
+ let warnedTooMany = false;
50
+
51
+ /**
52
+ * A scene's lights, ready for shading: colors parsed and multiplied by
53
+ * their intensity, directions made unit length.
54
+ *
55
+ * Reads `scene.lights`. A scene-like object without it (only `light` and
56
+ * `ambient`, like before Trid 0.4) gets the default pair built from those.
57
+ *
58
+ * @param {Object} scene
59
+ * @param {(css:string) => number[]} rgba - Parses a CSS color to [r, g, b, a] in 0..1
60
+ * @returns {{
61
+ * ambient: number[],
62
+ * ambientLevel: number,
63
+ * lights: { point:boolean, dir:{x,y,z}|null, pos:{x,y,z}|null, color:number[], range:number }[],
64
+ * mainDir: {x,y,z},
65
+ * }} `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)
67
+ */
68
+ export function sceneLights(scene, rgba) {
69
+ const list = Array.isArray(scene.lights)
70
+ ? scene.lights
71
+ : defaultLights(scene.light ?? DEFAULT_LIGHT, scene.ambient ?? DEFAULT_AMBIENT);
72
+
73
+ const ambient = [0, 0, 0];
74
+ const lights = [];
75
+ let mainDir = null;
76
+ for (const l of list) {
77
+ if (!l || l.enabled === false) continue;
78
+ const k = l.intensity ?? 1;
79
+ const c = rgba(l.color ?? "#ffffff");
80
+ const color = [c[0] * k, c[1] * k, c[2] * k];
81
+ if (l.type === "ambient") {
82
+ for (let i = 0; i < 3; i++) ambient[i] += color[i];
83
+ continue;
84
+ }
85
+ if (lights.length >= MAX_LIGHTS) {
86
+ if (!warnedTooMany) {
87
+ warnedTooMany = true;
88
+ console.warn(`Trid: only the first ${MAX_LIGHTS} directional and point lights are used`);
89
+ }
90
+ continue;
91
+ }
92
+ if (l.type === "point") {
93
+ const p = l.position ?? { x: 0, y: 0, z: 0 };
94
+ 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
+ } else {
96
+ const dir = unit(l.direction ?? DEFAULT_LIGHT);
97
+ mainDir ??= dir;
98
+ lights.push({ point: false, dir, pos: null, color, range: 0 });
99
+ }
100
+ }
101
+ return {
102
+ ambient,
103
+ ambientLevel: Math.max(...ambient),
104
+ lights,
105
+ mainDir: mainDir ?? unit(DEFAULT_LIGHT),
106
+ };
107
+ }
108
+
109
+ /**
110
+ * The light reaching a point, as [r, g, b] (can go above 1).
111
+ * The same math as the WebGL renderer's shader.
112
+ *
113
+ * @param {ReturnType<typeof sceneLights>} L - From `sceneLights`
114
+ * @param {{x,y,z}} n - Unit normal, world space
115
+ * @param {{x,y,z}} p - World position (for point lights)
116
+ * @returns {number[]}
117
+ */
118
+ export function lightAt(L, n, p) {
119
+ const c = L.ambient.slice();
120
+ for (const l of L.lights) {
121
+ let d = l.dir, fade = 1;
122
+ if (l.point) {
123
+ const dx = l.pos.x - p.x, dy = l.pos.y - p.y, dz = l.pos.z - p.z;
124
+ const dist = Math.hypot(dx, dy, dz) || 1e-6;
125
+ d = { x: dx / dist, y: dy / dist, z: dz / dist };
126
+ fade = rangeFade(dist, l.range);
127
+ }
128
+ const k = Math.max(0, n.x * d.x + n.y * d.y + n.z * d.z) * fade;
129
+ if (k <= 0) continue;
130
+ c[0] += l.color[0] * k;
131
+ c[1] += l.color[1] * k;
132
+ c[2] += l.color[2] * k;
133
+ }
134
+ return c;
135
+ }
136
+
137
+ /**
138
+ * The single numbers effects get, from the light at a point:
139
+ * - `light`: its brightness (brightest channel)
140
+ * - `diffuse`: how much of it comes from the lights rather than the
141
+ * ambient, 0..1 (with one light: how much the point faces it)
142
+ *
143
+ * @param {number[]} color - From `lightAt`
144
+ * @param {number} ambientLevel - From `sceneLights`
145
+ */
146
+ export function lightLevels(color, ambientLevel) {
147
+ const light = Math.max(color[0], color[1], color[2]);
148
+ const diffuse = Math.min(1, Math.max(0, (light - ambientLevel) / Math.max(1 - ambientLevel, 1e-4)));
149
+ return { light, diffuse };
150
+ }
151
+
152
+ /** How much of a point light reaches `dist` away: 1 up close, 0 at `range` (0 = no fading). */
153
+ export function rangeFade(dist, range) {
154
+ if (!(range > 0)) return 1;
155
+ const k = Math.min(1, Math.max(0, 1 - (dist * dist) / (range * range)));
156
+ return k * k;
157
+ }
158
+
159
+ function unit(v) {
160
+ const len = Math.hypot(v.x, v.y, v.z) || 1;
161
+ return { x: v.x / len, y: v.y / len, z: v.z / len };
162
+ }