trid3d 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +102 -2
- package/package.json +41 -4
- package/trid/index.js +53 -0
- package/trid/loaders/obj.js +206 -0
- package/trid/loaders/texture.js +56 -0
- package/trid/math/projection.js +49 -0
- package/trid/math/transform.js +302 -0
- package/trid/render/canvasRenderer.js +634 -0
- package/trid/render/color.js +22 -0
- package/trid/render/draw.js +146 -0
- package/trid/render/effects.js +265 -0
- package/trid/render/loop.js +42 -0
- package/trid/render/normals.js +90 -0
- package/trid/render/shapes.js +230 -0
- package/trid/render/webglRenderer.js +613 -0
- package/trid/scene.js +167 -0
- package/trid/tools/dragObject.js +89 -0
- package/trid/tools/flyCamera.js +111 -0
- package/trid/tools/fpsMeter.js +51 -0
- package/trid/tools/helpers.js +85 -0
- package/trid/tools/hoverHighlight.js +62 -0
- package/trid/tools/index.js +8 -0
- package/trid/tools/manager.js +241 -0
- package/trid/tools/objUploader.js +153 -0
- package/trid/tools/orbitCamera.js +132 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { localToWorld, worldToCamera, cameraToScreen, clipNear, DEFAULT_NEAR } from "../math/transform.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Clears the entire canvas with a solid background color.
|
|
5
|
+
* This should usually be called once per frame before drawing.
|
|
6
|
+
*
|
|
7
|
+
* @param {CanvasRenderingContext2D} ctx - Canvas 2D context
|
|
8
|
+
* @param {number} w - Canvas width
|
|
9
|
+
* @param {number} h - Canvas height
|
|
10
|
+
* @param {string} color - Background color (CSS color)
|
|
11
|
+
*/
|
|
12
|
+
export function clear(ctx, w, h, color) {
|
|
13
|
+
ctx.fillStyle = color;
|
|
14
|
+
ctx.fillRect(0, 0, w, h);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Draws a square point centered at a given 2D position.
|
|
19
|
+
* Useful for debugging or drawing vertices.
|
|
20
|
+
*
|
|
21
|
+
* @param {CanvasRenderingContext2D} ctx - Canvas 2D context
|
|
22
|
+
* @param {{x:number, y:number}} p - Screen-space point
|
|
23
|
+
* @param {string} color - Point color
|
|
24
|
+
* @param {number} size - Width/height of the square (default: 20)
|
|
25
|
+
*/
|
|
26
|
+
export function point(ctx, p, color, size = 20) {
|
|
27
|
+
ctx.fillStyle = color;
|
|
28
|
+
ctx.fillRect(
|
|
29
|
+
p.x - size / 2,
|
|
30
|
+
p.y - size / 2,
|
|
31
|
+
size,
|
|
32
|
+
size
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Draws a straight line between two 2D screen-space points.
|
|
38
|
+
*
|
|
39
|
+
* @param {CanvasRenderingContext2D} ctx - Canvas 2D context
|
|
40
|
+
* @param {{x:number, y:number}} p1 - Start point
|
|
41
|
+
* @param {{x:number, y:number}} p2 - End point
|
|
42
|
+
* @param {string} color - Line color
|
|
43
|
+
* @param {number} width - Line thickness (default: 4)
|
|
44
|
+
*/
|
|
45
|
+
export function line(ctx, p1, p2, color, width = 4) {
|
|
46
|
+
ctx.lineWidth = width;
|
|
47
|
+
ctx.strokeStyle = color;
|
|
48
|
+
|
|
49
|
+
ctx.beginPath();
|
|
50
|
+
ctx.moveTo(p1.x, p1.y);
|
|
51
|
+
ctx.lineTo(p2.x, p2.y);
|
|
52
|
+
ctx.stroke();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Turns edge definitions into pairs of vertex indices.
|
|
57
|
+
*
|
|
58
|
+
* - An edge with 2 indices is a single segment: [a, b]
|
|
59
|
+
* - An edge with 3+ indices is a closed loop: the last index
|
|
60
|
+
* connects back to the first
|
|
61
|
+
*
|
|
62
|
+
* @param {number[][]} edges - Edge definitions using vertex indices
|
|
63
|
+
* @returns {Generator<[number, number]>} Index pairs to connect
|
|
64
|
+
*/
|
|
65
|
+
export function* edgeSegments(edges) {
|
|
66
|
+
for (const e of edges) {
|
|
67
|
+
const closed = e.length > 2;
|
|
68
|
+
const count = closed ? e.length : e.length - 1;
|
|
69
|
+
for (let i = 0; i < count; i++) {
|
|
70
|
+
yield [e[i], e[(i + 1) % e.length]];
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Draws a wireframe object by connecting projected vertices
|
|
77
|
+
* using index-based edge definitions.
|
|
78
|
+
*
|
|
79
|
+
* Each edge is defined as an array of vertex indices.
|
|
80
|
+
* A 2-index edge is a single segment; with 3 or more indices,
|
|
81
|
+
* consecutive indices are connected and the last index
|
|
82
|
+
* connects back to the first (closed loop).
|
|
83
|
+
*
|
|
84
|
+
* This function assumes:
|
|
85
|
+
* - `points2` indices match the original vertex order
|
|
86
|
+
* - All points are already in screen space
|
|
87
|
+
*
|
|
88
|
+
* @param {CanvasRenderingContext2D} ctx - Canvas 2D context
|
|
89
|
+
* @param {{x:number, y:number}[]} points2 - 2D screen-space points
|
|
90
|
+
* @param {number[][]} edges - Edge definitions using vertex indices
|
|
91
|
+
* All segments go into one path and are stroked once.
|
|
92
|
+
*
|
|
93
|
+
* @param {string} color - Stroke color
|
|
94
|
+
* @param {number} width - Line thickness (default: 4)
|
|
95
|
+
*/
|
|
96
|
+
export function drawWireframe(ctx, points2, edges, color, width = 4) {
|
|
97
|
+
ctx.lineWidth = width;
|
|
98
|
+
ctx.strokeStyle = color;
|
|
99
|
+
|
|
100
|
+
ctx.beginPath();
|
|
101
|
+
for (const [i, j] of edgeSegments(edges)) {
|
|
102
|
+
ctx.moveTo(points2[i].x, points2[i].y);
|
|
103
|
+
ctx.lineTo(points2[j].x, points2[j].y);
|
|
104
|
+
}
|
|
105
|
+
ctx.stroke();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Draws a mesh instance by applying its transform and rendering
|
|
110
|
+
* its wireframe representation.
|
|
111
|
+
*
|
|
112
|
+
* Segments are clipped against the camera's near plane, so parts
|
|
113
|
+
* of the mesh behind the camera are not drawn. All segments go
|
|
114
|
+
* into one path and are stroked once.
|
|
115
|
+
*
|
|
116
|
+
* @param {CanvasRenderingContext2D} ctx - Canvas 2D context
|
|
117
|
+
* @param {{
|
|
118
|
+
* mesh:{ vertices:{x:number,y:number,z:number}[], edges:number[][] },
|
|
119
|
+
* pos:{x:number,y:number,z:number},
|
|
120
|
+
* rot:{x:number,y:number,z:number},
|
|
121
|
+
* scale:{x:number,y:number,z:number}
|
|
122
|
+
* }} inst - Mesh instance
|
|
123
|
+
* @param {import("../math/transform.js").Camera} camera - Camera settings
|
|
124
|
+
* @param {number} w - Canvas width
|
|
125
|
+
* @param {number} h - Canvas height
|
|
126
|
+
* @param {string} color - Wireframe color
|
|
127
|
+
* @param {number} lineWidth - Wireframe width
|
|
128
|
+
*/
|
|
129
|
+
export function drawInstance(ctx, inst, camera, w, h, color, lineWidth = 3) {
|
|
130
|
+
const near = camera.near ?? DEFAULT_NEAR;
|
|
131
|
+
const ptsCam = inst.mesh.vertices.map(v => worldToCamera(localToWorld(v, inst), camera));
|
|
132
|
+
|
|
133
|
+
ctx.lineWidth = lineWidth;
|
|
134
|
+
ctx.strokeStyle = color;
|
|
135
|
+
|
|
136
|
+
ctx.beginPath();
|
|
137
|
+
for (const [i, j] of edgeSegments(inst.mesh.edges)) {
|
|
138
|
+
const seg = clipNear(ptsCam[i], ptsCam[j], near);
|
|
139
|
+
if (!seg) continue;
|
|
140
|
+
const a = cameraToScreen(seg[0], camera, w, h);
|
|
141
|
+
const b = cameraToScreen(seg[1], camera, w, h);
|
|
142
|
+
ctx.moveTo(a.x, a.y);
|
|
143
|
+
ctx.lineTo(b.x, b.y);
|
|
144
|
+
}
|
|
145
|
+
ctx.stroke();
|
|
146
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Effects: small shaders that change how a material looks.
|
|
3
|
+
*
|
|
4
|
+
* A material can have a list of them, applied in order. Each one gets
|
|
5
|
+
* the color so far and the `Surface` being drawn, and returns a new color:
|
|
6
|
+
*
|
|
7
|
+
* material: { fill: "#8ab4f8", effects: [toon({ steps: 3 }), rim()] }
|
|
8
|
+
*
|
|
9
|
+
* An effect is a plain object:
|
|
10
|
+
* - `glsl`: GLSL code defining `vec4 effect(vec4 color, Surface s)` (WebGL renderer)
|
|
11
|
+
* - `canvas`: optional `(color, s, uniforms) => color` run once per face
|
|
12
|
+
* by the canvas renderer; colors are [r, g, b, a] from 0 to 1
|
|
13
|
+
* - `uniforms`: optional settings used by the code, like `{ uPower: 2 }`.
|
|
14
|
+
* Change them at any time; they're read every frame.
|
|
15
|
+
* Numbers are `float`, [x, y] / [x, y, z] / [x, y, z, w] are `vec2..4`,
|
|
16
|
+
* CSS color strings are `vec4` colors, `true` / `false` are `float` 1 / 0.
|
|
17
|
+
*
|
|
18
|
+
* The built-in effects below are written exactly that way.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* What an effect knows about the point being drawn. GLSL fields and
|
|
23
|
+
* the canvas renderer's `s` object have the same names.
|
|
24
|
+
*
|
|
25
|
+
* @typedef {Object} Surface
|
|
26
|
+
* @property {number[]} base - The material's fill color [r, g, b, a], times the texture's color
|
|
27
|
+
* @property {{u,v}} uv - Texture coordinates (0, 0 when the mesh has none)
|
|
28
|
+
* @property {{x,y,z}} normal - Unit normal, in world space, facing the camera's side
|
|
29
|
+
* @property {{x,y,z}} position - World position
|
|
30
|
+
* @property {{x,y,z}} local - Position in the mesh's own space
|
|
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
|
|
36
|
+
* @property {number} depth - Distance to the camera
|
|
37
|
+
* @property {number} time - Seconds since the renderer started
|
|
38
|
+
* @property {number[]} background - The scene's background color [r, g, b, a]
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/** The GLSL declaration of `Surface`, shared by every effect. */
|
|
42
|
+
export const SURFACE_GLSL = `
|
|
43
|
+
struct Surface {
|
|
44
|
+
vec4 base;
|
|
45
|
+
vec2 uv;
|
|
46
|
+
vec3 normal;
|
|
47
|
+
vec3 position;
|
|
48
|
+
vec3 local;
|
|
49
|
+
vec3 view;
|
|
50
|
+
vec3 lightDir;
|
|
51
|
+
float diffuse;
|
|
52
|
+
float light;
|
|
53
|
+
float ambient;
|
|
54
|
+
float depth;
|
|
55
|
+
float time;
|
|
56
|
+
vec4 background;
|
|
57
|
+
};
|
|
58
|
+
`;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Cartoon look: the light comes in a few hard steps instead of a smooth fade.
|
|
62
|
+
*
|
|
63
|
+
* @param {Object} [opts]
|
|
64
|
+
* @param {number} [opts.steps=3] - Number of brightness levels
|
|
65
|
+
*/
|
|
66
|
+
export function toon({ steps = 3 } = {}) {
|
|
67
|
+
return {
|
|
68
|
+
name: "toon",
|
|
69
|
+
uniforms: { uSteps: steps },
|
|
70
|
+
glsl: `
|
|
71
|
+
vec4 effect(vec4 color, Surface s) {
|
|
72
|
+
float level = min(floor(s.diffuse * uSteps), uSteps - 1.0) / max(uSteps - 1.0, 1.0);
|
|
73
|
+
float b = s.ambient + (1.0 - s.ambient) * level;
|
|
74
|
+
return vec4(s.base.rgb * b, color.a);
|
|
75
|
+
}`,
|
|
76
|
+
canvas: (color, s, u) => {
|
|
77
|
+
const level = Math.min(Math.floor(s.diffuse * u.uSteps), u.uSteps - 1) / Math.max(u.uSteps - 1, 1);
|
|
78
|
+
const b = s.ambient + (1 - s.ambient) * level;
|
|
79
|
+
return [s.base[0] * b, s.base[1] * b, s.base[2] * b, color[3]];
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A glow along the edges, where the surface turns away from the camera.
|
|
86
|
+
*
|
|
87
|
+
* @param {Object} [opts]
|
|
88
|
+
* @param {string} [opts.color="#ffffff"] - Glow color
|
|
89
|
+
* @param {number} [opts.power=2] - Higher = thinner glow
|
|
90
|
+
* @param {number} [opts.strength=1] - How bright the glow is
|
|
91
|
+
*/
|
|
92
|
+
export function rim({ color = "#ffffff", power = 2, strength = 1 } = {}) {
|
|
93
|
+
return {
|
|
94
|
+
name: "rim",
|
|
95
|
+
uniforms: { uRimColor: color, uPower: power, uStrength: strength },
|
|
96
|
+
glsl: `
|
|
97
|
+
vec4 effect(vec4 color, Surface s) {
|
|
98
|
+
float edge = pow(1.0 - max(0.0, dot(s.normal, s.view)), uPower) * uStrength;
|
|
99
|
+
return vec4(color.rgb + uRimColor.rgb * edge, color.a);
|
|
100
|
+
}`,
|
|
101
|
+
canvas: (c, s, u) => {
|
|
102
|
+
const n = s.normal, v = s.view;
|
|
103
|
+
const edge = Math.pow(1 - Math.max(0, n.x * v.x + n.y * v.y + n.z * v.z), u.uPower) * u.uStrength;
|
|
104
|
+
const r = s.rgba(u.uRimColor);
|
|
105
|
+
return [c[0] + r[0] * edge, c[1] + r[1] * edge, c[2] + r[2] * edge, c[3]];
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A glow along the border of each face, using the texture coordinates
|
|
112
|
+
* (0 and 1 are a face's edges). Made for boxy shapes, where `rim` barely
|
|
113
|
+
* shows. Needs texture coordinates: built-in shapes have them.
|
|
114
|
+
*
|
|
115
|
+
* WebGL only: the canvas renderer runs effects once per face, at its center,
|
|
116
|
+
* which is never near a border.
|
|
117
|
+
*
|
|
118
|
+
* @param {Object} [opts]
|
|
119
|
+
* @param {string} [opts.color="#ffe08a"] - Glow color
|
|
120
|
+
* @param {number} [opts.width=0.08] - Thickness, as a fraction of the face (0.08 = 8%)
|
|
121
|
+
* @param {number} [opts.strength=1] - How bright the glow is
|
|
122
|
+
*/
|
|
123
|
+
export function edgeGlow({ color = "#ffe08a", width = 0.08, strength = 1 } = {}) {
|
|
124
|
+
return {
|
|
125
|
+
name: "edgeGlow",
|
|
126
|
+
uniforms: { uGlowColor: color, uWidth: width, uStrength: strength },
|
|
127
|
+
glsl: `
|
|
128
|
+
vec4 effect(vec4 color, Surface s) {
|
|
129
|
+
vec2 d = min(fract(s.uv), 1.0 - fract(s.uv)); // distance to the nearest face border
|
|
130
|
+
float edge = 1.0 - smoothstep(0.0, uWidth, min(d.x, d.y));
|
|
131
|
+
return vec4(color.rgb + uGlowColor.rgb * edge * uStrength, color.a);
|
|
132
|
+
}`,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Ignores the light: the object shows its fill color everywhere, like it glows. */
|
|
137
|
+
export function unlit() {
|
|
138
|
+
return {
|
|
139
|
+
name: "unlit",
|
|
140
|
+
uniforms: {},
|
|
141
|
+
glsl: `
|
|
142
|
+
vec4 effect(vec4 color, Surface s) {
|
|
143
|
+
return s.base;
|
|
144
|
+
}`,
|
|
145
|
+
canvas: (color, s) => s.base.slice(),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Color that changes along an axis, like a sky or terrain by height.
|
|
151
|
+
*
|
|
152
|
+
* @param {Object} [opts]
|
|
153
|
+
* @param {string} [opts.from="#E9806E"] - Color at `min`
|
|
154
|
+
* @param {string} [opts.to="#8ab4f8"] - Color at `max`
|
|
155
|
+
* @param {"x"|"y"|"z"} [opts.axis="y"] - Direction of the change
|
|
156
|
+
* @param {number} [opts.min=-0.5] - Where `from` is fully reached
|
|
157
|
+
* @param {number} [opts.max=0.5] - Where `to` is fully reached
|
|
158
|
+
* @param {"local"|"world"} [opts.space="local"] - Measure in the mesh's own space (moves with it) or in the world
|
|
159
|
+
* @param {boolean} [opts.lit=true] - Keep the shading from the light
|
|
160
|
+
*/
|
|
161
|
+
export function gradient({
|
|
162
|
+
from = "#E9806E", to = "#8ab4f8", axis = "y", min = -0.5, max = 0.5, space = "local", lit = true,
|
|
163
|
+
} = {}) {
|
|
164
|
+
const k = { x: 0, y: 1, z: 2 }[axis] ?? 1;
|
|
165
|
+
const p = space === "world" ? "position" : "local";
|
|
166
|
+
return {
|
|
167
|
+
name: "gradient",
|
|
168
|
+
uniforms: { uFrom: from, uTo: to, uMin: min, uMax: max, uLit: lit },
|
|
169
|
+
glsl: `
|
|
170
|
+
vec4 effect(vec4 color, Surface s) {
|
|
171
|
+
float t = clamp((s.${p}[${k}] - uMin) / (uMax - uMin), 0.0, 1.0);
|
|
172
|
+
vec3 c = mix(uFrom.rgb, uTo.rgb, t);
|
|
173
|
+
return vec4(c * mix(1.0, s.light, uLit), color.a);
|
|
174
|
+
}`,
|
|
175
|
+
canvas: (color, s, u) => {
|
|
176
|
+
const v = s[p][axis] ?? s[p].y;
|
|
177
|
+
const t = Math.min(1, Math.max(0, (v - u.uMin) / (u.uMax - u.uMin)));
|
|
178
|
+
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]);
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Fades objects into a color with distance.
|
|
187
|
+
*
|
|
188
|
+
* @param {Object} [opts]
|
|
189
|
+
* @param {number} [opts.near=1] - Distance where the fog starts
|
|
190
|
+
* @param {number} [opts.far=5] - Distance where it's fully fog
|
|
191
|
+
* @param {string} [opts.color] - Fog color (default: the scene's background)
|
|
192
|
+
*/
|
|
193
|
+
export function fog({ near = 1, far = 5, color } = {}) {
|
|
194
|
+
return {
|
|
195
|
+
name: "fog",
|
|
196
|
+
uniforms: { uNearFog: near, uFarFog: far, uFogColor: color ?? "#000000", uOwnColor: color != null },
|
|
197
|
+
glsl: `
|
|
198
|
+
vec4 effect(vec4 color, Surface s) {
|
|
199
|
+
float t = smoothstep(uNearFog, uFarFog, s.depth);
|
|
200
|
+
vec3 c = mix(s.background.rgb, uFogColor.rgb, uOwnColor);
|
|
201
|
+
return vec4(mix(color.rgb, c, t), color.a);
|
|
202
|
+
}`,
|
|
203
|
+
canvas: (color, s, u) => {
|
|
204
|
+
let t = Math.min(1, Math.max(0, (s.depth - u.uNearFog) / (u.uFarFog - u.uNearFog)));
|
|
205
|
+
t = t * t * (3 - 2 * t);
|
|
206
|
+
const c = u.uOwnColor ? s.rgba(u.uFogColor) : s.background;
|
|
207
|
+
return [0, 1, 2].map((i) => color[i] + (c[i] - color[i]) * t).concat(color[3]);
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// --- Used by the renderers ---
|
|
213
|
+
|
|
214
|
+
/** GLSL type of a uniform value, or null if it can't be one. */
|
|
215
|
+
export function uniformType(value) {
|
|
216
|
+
if (typeof value === "number" || typeof value === "boolean") return "float";
|
|
217
|
+
if (typeof value === "string") return "vec4";
|
|
218
|
+
if (Array.isArray(value) && value.length >= 2 && value.length <= 4) return "vec" + value.length;
|
|
219
|
+
if (value && typeof value === "object" && "x" in value) return "vec3";
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Joins a material's effects into GLSL. Each effect's `effect` function,
|
|
225
|
+
* helper functions and uniforms get a numbered suffix, so the same effect
|
|
226
|
+
* can be used twice and two effects can use the same names.
|
|
227
|
+
*
|
|
228
|
+
* @param {Object[]} effects
|
|
229
|
+
* @returns {{ code:string, calls:string, uniforms:{ effect:number, name:string, glslName:string, type:string }[] }}
|
|
230
|
+
*/
|
|
231
|
+
export function buildEffects(effects) {
|
|
232
|
+
let code = "";
|
|
233
|
+
let calls = "";
|
|
234
|
+
const uniforms = [];
|
|
235
|
+
|
|
236
|
+
effects.forEach((e, i) => {
|
|
237
|
+
let src = e.glsl ?? "vec4 effect(vec4 color, Surface s) { return color; }";
|
|
238
|
+
const rename = new Map();
|
|
239
|
+
|
|
240
|
+
for (const [name, value] of Object.entries(e.uniforms ?? {})) {
|
|
241
|
+
const type = uniformType(value);
|
|
242
|
+
if (!type) continue;
|
|
243
|
+
rename.set(name, `${name}_${i}`);
|
|
244
|
+
uniforms.push({ effect: i, name, glslName: `${name}_${i}`, type });
|
|
245
|
+
}
|
|
246
|
+
// Every function the effect defines, including `effect` itself
|
|
247
|
+
const fn = /(?:^|[\s;}])(?:void|float|int|bool|vec[234]|mat[234]|Surface)\s+([A-Za-z_]\w*)\s*\(/g;
|
|
248
|
+
for (const m of src.matchAll(fn)) rename.set(m[1], `${m[1]}_${i}`);
|
|
249
|
+
|
|
250
|
+
for (const [from, to] of rename) src = src.replace(new RegExp(`\\b${from}\\b`, "g"), to);
|
|
251
|
+
|
|
252
|
+
const decls = uniforms.filter((u) => u.effect === i).map((u) => `uniform ${u.type} ${u.glslName};`).join("\n");
|
|
253
|
+
code += `\n// Effect ${i}${e.name ? ": " + e.name : ""}\n${decls}\n${src}\n`;
|
|
254
|
+
calls += ` color = effect_${i}(color, s);\n`;
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
return { code, calls, uniforms };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** A key that's the same for effect lists that compile to the same shader. */
|
|
261
|
+
export function effectsKey(effects) {
|
|
262
|
+
return effects
|
|
263
|
+
.map((e) => (e.glsl ?? "") + "|" + Object.entries(e.uniforms ?? {}).map(([k, v]) => k + ":" + uniformType(v)).join(","))
|
|
264
|
+
.join("\n");
|
|
265
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runs a callback every animation frame using requestAnimationFrame.
|
|
3
|
+
*
|
|
4
|
+
* The callback receives:
|
|
5
|
+
* - `dt`: seconds since the previous frame (0 on the first frame),
|
|
6
|
+
* capped at `maxDt` so a hidden tab doesn't cause a big jump
|
|
7
|
+
* - `time`: seconds since the loop started
|
|
8
|
+
*
|
|
9
|
+
* Typical usage:
|
|
10
|
+
* const stop = loop((dt) => { ... });
|
|
11
|
+
* stop(); // later, to end the loop
|
|
12
|
+
*
|
|
13
|
+
* @param {(dt:number, time:number) => void} update - Called once per frame
|
|
14
|
+
* @param {Object} [opts]
|
|
15
|
+
* @param {number} [opts.maxDt=0.1] - Largest dt passed to `update`, in seconds
|
|
16
|
+
* @returns {() => void} Function that stops the loop
|
|
17
|
+
*/
|
|
18
|
+
export function loop(update, { maxDt = 0.1 } = {}) {
|
|
19
|
+
let running = true;
|
|
20
|
+
let id = 0;
|
|
21
|
+
let start = null;
|
|
22
|
+
let last = null;
|
|
23
|
+
|
|
24
|
+
function frame(now) {
|
|
25
|
+
if (!running) return;
|
|
26
|
+
|
|
27
|
+
if (start === null) start = last = now;
|
|
28
|
+
const dt = Math.min(Math.max(now - last, 0) / 1000, maxDt);
|
|
29
|
+
last = now;
|
|
30
|
+
|
|
31
|
+
update(dt, (now - start) / 1000);
|
|
32
|
+
|
|
33
|
+
if (running) id = requestAnimationFrame(frame);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
id = requestAnimationFrame(frame);
|
|
37
|
+
|
|
38
|
+
return function stop() {
|
|
39
|
+
running = false;
|
|
40
|
+
cancelAnimationFrame(id);
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/** Faces meeting at a sharper angle than this (degrees) keep a hard edge with `smooth: true`. */
|
|
2
|
+
export const DEFAULT_CREASE = 60;
|
|
3
|
+
|
|
4
|
+
const cache = new WeakMap();
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Normals at each corner of each face, for smooth shading.
|
|
8
|
+
*
|
|
9
|
+
* A corner's normal is the average of the faces around its vertex
|
|
10
|
+
* (bigger faces count more), skipping faces that meet this one at a
|
|
11
|
+
* sharper angle than `crease`. So a sphere looks round while a cube
|
|
12
|
+
* keeps its sharp edges.
|
|
13
|
+
*
|
|
14
|
+
* When the mesh has its own normals (`mesh.normals` and
|
|
15
|
+
* `mesh.faceNormals`, as loaded from an OBJ file), those are used
|
|
16
|
+
* for the faces that have them.
|
|
17
|
+
*
|
|
18
|
+
* The result is cached per mesh until its arrays are replaced,
|
|
19
|
+
* resized, or `mesh.dirty` is set.
|
|
20
|
+
*
|
|
21
|
+
* @param {{ vertices:{x:number,y:number,z:number}[], faces?:number[][],
|
|
22
|
+
* normals?:{x:number,y:number,z:number}[], faceNormals?:(number[]|null)[] }} mesh
|
|
23
|
+
* @param {number} [crease=60] - Angle in degrees
|
|
24
|
+
* @returns {{x:number,y:number,z:number}[][]} For each face, one unit normal per corner
|
|
25
|
+
*/
|
|
26
|
+
export function cornerNormals(mesh, crease = DEFAULT_CREASE) {
|
|
27
|
+
const faces = mesh.faces ?? [];
|
|
28
|
+
const key = `${crease}/${mesh.vertices.length}/${faces.length}`;
|
|
29
|
+
const hit = cache.get(mesh);
|
|
30
|
+
if (hit && !mesh.dirty && hit.key === key && hit.vertices === mesh.vertices && hit.faces === faces) {
|
|
31
|
+
return hit.result;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const v = mesh.vertices;
|
|
35
|
+
const raw = faces.map((f) => (f.length >= 3 ? newell(f.map((i) => v[i])) : { x: 0, y: 0, z: 0 }));
|
|
36
|
+
const unit = raw.map(normalize);
|
|
37
|
+
|
|
38
|
+
// Faces touching each vertex
|
|
39
|
+
const around = v.map(() => []);
|
|
40
|
+
faces.forEach((f, k) => {
|
|
41
|
+
for (const i of f) around[i].push(k);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const minDot = Math.cos((crease * Math.PI) / 180) - 1e-6;
|
|
45
|
+
const result = faces.map((f, k) => {
|
|
46
|
+
const own = mesh.faceNormals?.[k];
|
|
47
|
+
if (own && mesh.normals) return own.map((n) => normalize(mesh.normals[n]));
|
|
48
|
+
|
|
49
|
+
const u = unit[k];
|
|
50
|
+
return f.map((i) => {
|
|
51
|
+
let x = 0, y = 0, z = 0;
|
|
52
|
+
for (const g of around[i]) {
|
|
53
|
+
const w = unit[g];
|
|
54
|
+
if (u.x * w.x + u.y * w.y + u.z * w.z < minDot) continue;
|
|
55
|
+
x += raw[g].x; y += raw[g].y; z += raw[g].z;
|
|
56
|
+
}
|
|
57
|
+
return normalize({ x, y, z });
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
cache.set(mesh, { key, vertices: mesh.vertices, faces, result });
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The `smooth` material setting as a crease angle, or null for flat shading. */
|
|
66
|
+
export function creaseOf(smooth) {
|
|
67
|
+
if (smooth === true) return DEFAULT_CREASE;
|
|
68
|
+
if (typeof smooth === "number" && smooth > 0) return smooth;
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Normal of a polygon (Newell's method, works for any number of points).
|
|
74
|
+
* Points clockwise as seen from a side give a normal pointing to that side.
|
|
75
|
+
*/
|
|
76
|
+
export function newell(pts) {
|
|
77
|
+
let x = 0, y = 0, z = 0;
|
|
78
|
+
for (let i = 0; i < pts.length; i++) {
|
|
79
|
+
const a = pts[i], b = pts[(i + 1) % pts.length];
|
|
80
|
+
x += (a.y - b.y) * (a.z + b.z);
|
|
81
|
+
y += (a.z - b.z) * (a.x + b.x);
|
|
82
|
+
z += (a.x - b.x) * (a.y + b.y);
|
|
83
|
+
}
|
|
84
|
+
return { x, y, z };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function normalize(p) {
|
|
88
|
+
const len = Math.hypot(p.x, p.y, p.z) || 1;
|
|
89
|
+
return { x: p.x / len, y: p.y / len, z: p.z / len };
|
|
90
|
+
}
|