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.
- package/README.md +15 -6
- package/package.json +1 -1
- package/trid/index.d.ts +245 -10
- package/trid/index.js +8 -1
- package/trid/render/canvasRenderer.js +99 -48
- package/trid/render/effects.js +16 -9
- package/trid/render/grid.js +177 -0
- package/trid/render/lights.js +162 -0
- package/trid/render/post.js +325 -0
- package/trid/render/webglRenderer.js +341 -19
- package/trid/scene.js +48 -6
- package/trid/tools/helpers.js +107 -9
- package/trid/tools/index.js +1 -1
- package/trid/tools/transformGizmo.js +40 -6
|
@@ -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
|
-
|
|
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
|
-
|
|
123
|
-
const
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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(() =>
|
|
251
|
-
else if (corners) lit = face.map((i) => lightAt(normalAt(corners, F, i), back));
|
|
252
|
-
else
|
|
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
|
|
286
|
-
const
|
|
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:
|
|
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] *
|
|
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
|
|
327
|
-
|
|
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) :
|
|
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
|
|
491
|
-
* a linear gradient along the direction
|
|
492
|
-
* Exact
|
|
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, (
|
|
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,
|
|
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
|
|
555
|
-
const
|
|
556
|
-
if (
|
|
557
|
-
const
|
|
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,
|
|
561
|
-
grad.addColorStop(1,
|
|
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 =
|
|
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
|
-
/**
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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
|
|
626
|
+
const ch = (c, k) => Math.min(255, Math.round((c * q(k)) / 255));
|
|
602
627
|
out = {
|
|
603
|
-
css: `rgba(${
|
|
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;
|
package/trid/render/effects.js
CHANGED
|
@@ -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
|
|
34
|
-
*
|
|
35
|
-
* @property {number}
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
+
}
|