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,634 @@
|
|
|
1
|
+
import { localToWorld, worldToCamera, cameraToScreen, clipNear, rotateXYZ, DEFAULT_NEAR } from "../math/transform.js";
|
|
2
|
+
import { clear, drawInstance, edgeSegments } from "./draw.js";
|
|
3
|
+
import { parseColor } from "./color.js";
|
|
4
|
+
import { newell, cornerNormals, creaseOf } from "./normals.js";
|
|
5
|
+
import { textureReady, textureSize, textureIsLive } from "../loaders/texture.js";
|
|
6
|
+
|
|
7
|
+
export { newell };
|
|
8
|
+
|
|
9
|
+
/** Direction light comes from when the scene doesn't set one: above, left, in front. */
|
|
10
|
+
export const DEFAULT_LIGHT = { x: -0.5, y: 1, z: -0.8 };
|
|
11
|
+
|
|
12
|
+
/** How bright a face turned fully away from the light still is (0..1). */
|
|
13
|
+
export const DEFAULT_AMBIENT = 0.25;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Material: how an object's faces look. Objects without one are drawn
|
|
17
|
+
* as wireframes using their `color` and `lineWidth`.
|
|
18
|
+
*
|
|
19
|
+
* @typedef {Object} Material
|
|
20
|
+
* @property {string} [fill] - Face color, any CSS color (default: the scene's color)
|
|
21
|
+
* @property {boolean} [shade=true] - Darken faces that turn away from the light
|
|
22
|
+
* @property {boolean} [wireframe=false] - Also draw the face outlines, with the object's `color` and `lineWidth`
|
|
23
|
+
* @property {boolean} [doubleSided=false] - Also draw faces seen from behind
|
|
24
|
+
* @property {boolean|number} [smooth=false] - Blend the light across faces so curved
|
|
25
|
+
* shapes look round. `true` keeps edges sharper than 60° hard; a number sets that angle.
|
|
26
|
+
* @property {CanvasImageSource} [texture] - Image drawn on the faces (needs texture coordinates:
|
|
27
|
+
* built-in shapes and OBJ files with `vt` have them). `fill` tints it (default white).
|
|
28
|
+
* @property {boolean} [pixelated=false] - Keep texture pixels sharp instead of smoothing them
|
|
29
|
+
* @property {Object[]} [effects] - Effects applied in order, like `[toon(), rim()]`.
|
|
30
|
+
* See trid/render/effects.js. The canvas renderer runs them once per face.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Draws a scene with the canvas 2D context.
|
|
35
|
+
*
|
|
36
|
+
* - Objects without a `material` are wireframes. If no object has one,
|
|
37
|
+
* they're drawn exactly like `drawInstance` does, as fast as before.
|
|
38
|
+
* - Objects with a `material` draw their mesh's `faces`. Faces and
|
|
39
|
+
* wireframe lines from every object are sorted far to near and drawn
|
|
40
|
+
* in that order (painter's algorithm), so near things cover far ones.
|
|
41
|
+
* - Faces seen from behind are skipped unless `doubleSided` is set.
|
|
42
|
+
* A face's front is the side its vertices go clockwise from.
|
|
43
|
+
*
|
|
44
|
+
* The painter's algorithm is an approximation: faces that cross each
|
|
45
|
+
* other, or a long face next to small ones, can be drawn in the wrong order.
|
|
46
|
+
* `maxFaceSize` reduces this by splitting big faces into small triangles.
|
|
47
|
+
* For exact results, use a renderer with a depth buffer (WebGL).
|
|
48
|
+
*
|
|
49
|
+
* A renderer is any object with a `render(scene)` method, so another
|
|
50
|
+
* one (for example WebGL) can replace this one through `new Scene(canvas, { renderer })`.
|
|
51
|
+
*/
|
|
52
|
+
export class CanvasRenderer {
|
|
53
|
+
/**
|
|
54
|
+
* @param {Object} [opts]
|
|
55
|
+
* @param {number|null} [opts.maxFaceSize=null] - Split faces bigger than this
|
|
56
|
+
* fraction of their mesh's size (for example 0.05) into smaller triangles,
|
|
57
|
+
* so they sort better. Off by default: more faces means slower drawing.
|
|
58
|
+
*/
|
|
59
|
+
constructor({ maxFaceSize = null } = {}) {
|
|
60
|
+
this.maxFaceSize = maxFaceSize;
|
|
61
|
+
this._shades = new Map();
|
|
62
|
+
this._splits = new WeakMap();
|
|
63
|
+
this._colors = new Map();
|
|
64
|
+
this._start = performance.now();
|
|
65
|
+
this._patterns = new Map(); // texture source → canvas pattern
|
|
66
|
+
this._warned = new WeakSet();
|
|
67
|
+
this._frame = 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Clears the canvas and draws every object of the scene.
|
|
72
|
+
*
|
|
73
|
+
* @param {import("../scene.js").Scene} scene
|
|
74
|
+
*/
|
|
75
|
+
render(scene) {
|
|
76
|
+
const { ctx, canvas, camera } = scene;
|
|
77
|
+
const w = canvas.width, h = canvas.height;
|
|
78
|
+
clear(ctx, w, h, scene.background);
|
|
79
|
+
this._frame++;
|
|
80
|
+
|
|
81
|
+
// Wireframes only: nothing to sort
|
|
82
|
+
if (!scene.objects.some((o) => o.material)) {
|
|
83
|
+
for (const obj of scene.objects) {
|
|
84
|
+
drawInstance(ctx, obj, camera, w, h, obj.color ?? scene.color, obj.lineWidth ?? scene.lineWidth);
|
|
85
|
+
}
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const near = camera.near ?? DEFAULT_NEAR;
|
|
90
|
+
const light = normalize(scene.light ?? DEFAULT_LIGHT);
|
|
91
|
+
const ambient = scene.ambient ?? DEFAULT_AMBIENT;
|
|
92
|
+
const toScreen = (p) => cameraToScreen(p, camera, w, h);
|
|
93
|
+
const items = [];
|
|
94
|
+
// For effects
|
|
95
|
+
const camPos = { x: camera.x ?? 0, y: camera.y ?? 0, z: camera.z ?? 0 };
|
|
96
|
+
const time = (performance.now() - this._start) / 1000;
|
|
97
|
+
const background = this._rgba(ctx, scene.background);
|
|
98
|
+
|
|
99
|
+
for (const obj of scene.objects) {
|
|
100
|
+
// Split big faces (cached per mesh); extra vertices are midpoints of existing ones
|
|
101
|
+
const split = obj.material && this.maxFaceSize ? this._split(obj.mesh) : null;
|
|
102
|
+
const verts = split ? withMidpoints(obj.mesh.vertices, split.mids) : obj.mesh.vertices;
|
|
103
|
+
const world = verts.map((v) => localToWorld(v, obj));
|
|
104
|
+
const cam = world.map((p) => worldToCamera(p, camera));
|
|
105
|
+
const color = obj.color ?? scene.color;
|
|
106
|
+
const lineWidth = obj.lineWidth ?? scene.lineWidth;
|
|
107
|
+
const mat = obj.material;
|
|
108
|
+
|
|
109
|
+
if (!mat) {
|
|
110
|
+
for (const [i, j] of edgeSegments(obj.mesh.edges)) {
|
|
111
|
+
const seg = clipNear(cam[i], cam[j], near);
|
|
112
|
+
if (!seg) continue;
|
|
113
|
+
items.push({
|
|
114
|
+
depth: (seg[0].z + seg[1].z) / 2,
|
|
115
|
+
line: [toScreen(seg[0]), toScreen(seg[1])],
|
|
116
|
+
color, lineWidth,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const fill = mat.fill ?? (mat.texture ? "#ffffff" : scene.color);
|
|
123
|
+
const shade = mat.shade !== false;
|
|
124
|
+
const outline = mat.wireframe ? color : null;
|
|
125
|
+
|
|
126
|
+
// Smooth shading: a normal per face corner (split faces get midpoints' normals by averaging)
|
|
127
|
+
const crease = shade ? creaseOf(mat.smooth) : null;
|
|
128
|
+
const corners = crease ? cornerNormals(obj.mesh, crease) : null;
|
|
129
|
+
const sourceFaces = obj.mesh.faces ?? [];
|
|
130
|
+
const base = obj.mesh.vertices.length;
|
|
131
|
+
const normalAt = (F, i) => {
|
|
132
|
+
if (i < base) return corners[F][sourceFaces[F].indexOf(i)];
|
|
133
|
+
const [a, b] = split.mids[i - base];
|
|
134
|
+
const na = normalAt(F, a), nb = normalAt(F, b);
|
|
135
|
+
return normalize({ x: na.x + nb.x, y: na.y + nb.y, z: na.z + nb.z });
|
|
136
|
+
};
|
|
137
|
+
const lightAt = (n, back) => {
|
|
138
|
+
// Same steps as the vertices: undo the scale, then rotate (keeps normals right on stretched objects)
|
|
139
|
+
const r = normalize(rotateXYZ({ x: n.x / obj.scale.x, y: n.y / obj.scale.y, z: n.z / obj.scale.z }, obj.rot));
|
|
140
|
+
const d = (back ? -1 : 1) * (r.x * light.x + r.y * light.y + r.z * light.z);
|
|
141
|
+
return ambient + (1 - ambient) * Math.max(0, d);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// Textures: each triangle is filled with the image, mapped by its corners' texture coordinates
|
|
145
|
+
const meshUVs = obj.mesh.uvs, faceUVs = obj.mesh.faceUVs;
|
|
146
|
+
let tex = null;
|
|
147
|
+
if (mat.texture && textureReady(mat.texture)) {
|
|
148
|
+
if (faceUVs && meshUVs) tex = mat.texture;
|
|
149
|
+
else if (!this._warned.has(obj.mesh)) {
|
|
150
|
+
this._warned.add(obj.mesh);
|
|
151
|
+
console.warn("Trid: this mesh has no texture coordinates (uvs / faceUVs), so its texture isn't drawn");
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const uvAt = (F, i) => {
|
|
155
|
+
const t = faceUVs?.[F];
|
|
156
|
+
if (!t || !meshUVs) return null;
|
|
157
|
+
if (i < base) return meshUVs[t[sourceFaces[F].indexOf(i)]];
|
|
158
|
+
const [a, b] = split.mids[i - base];
|
|
159
|
+
const ta = uvAt(F, a), tb = uvAt(F, b);
|
|
160
|
+
return ta && tb ? { u: (ta.u + tb.u) / 2, v: (ta.v + tb.v) / 2 } : null;
|
|
161
|
+
};
|
|
162
|
+
const flatLight = (face, back) => {
|
|
163
|
+
const nw = normalize(newell(face.map((i) => world[i])));
|
|
164
|
+
const d = (back ? -1 : 1) * (nw.x * light.x + nw.y * light.y + nw.z * light.z);
|
|
165
|
+
return ambient + (1 - ambient) * Math.max(0, d);
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
// Effects run once per face, at its center (see trid/render/effects.js)
|
|
169
|
+
const effects = mat.effects?.length ? mat.effects : null;
|
|
170
|
+
const toWorldNormal = (n) =>
|
|
171
|
+
normalize(rotateXYZ({ x: n.x / obj.scale.x, y: n.y / obj.scale.y, z: n.z / obj.scale.z }, obj.rot));
|
|
172
|
+
|
|
173
|
+
const faces = split ? split.faces : sourceFaces;
|
|
174
|
+
for (let f = 0; f < faces.length; f++) {
|
|
175
|
+
const face = faces[f];
|
|
176
|
+
if (face.length < 3) continue;
|
|
177
|
+
const pts = face.map((i) => cam[i]);
|
|
178
|
+
|
|
179
|
+
// Seen from behind? (camera sits at the origin of camera space)
|
|
180
|
+
const n = newell(pts);
|
|
181
|
+
const c = centroid(pts);
|
|
182
|
+
const back = n.x * c.x + n.y * c.y + n.z * c.z >= 0;
|
|
183
|
+
if (back && !mat.doubleSided) continue;
|
|
184
|
+
|
|
185
|
+
const clipped = clipPolygon(pts, near);
|
|
186
|
+
if (clipped.length < 3) continue;
|
|
187
|
+
|
|
188
|
+
// Which edges are real outlines (split faces have extra inner edges); null = all
|
|
189
|
+
const edges = split && clipped === pts ? split.masks[f] : null;
|
|
190
|
+
|
|
191
|
+
if (tex && clipped === pts) {
|
|
192
|
+
const F = split ? split.origins[f] : f;
|
|
193
|
+
const ts = face.map((i) => uvAt(F, i));
|
|
194
|
+
if (ts.every(Boolean)) {
|
|
195
|
+
let lit;
|
|
196
|
+
if (!shade) lit = face.map(() => 1);
|
|
197
|
+
else if (corners) lit = face.map((i) => lightAt(normalAt(F, i), back));
|
|
198
|
+
else lit = face.map(() => flatLight(face, back));
|
|
199
|
+
const screen = pts.map(toScreen);
|
|
200
|
+
const n = face.length;
|
|
201
|
+
for (let k = 1; k < n - 1; k++) {
|
|
202
|
+
const fan = (k === 1 ? 1 : 0) | 2 | (k === n - 2 ? 4 : 0);
|
|
203
|
+
items.push({
|
|
204
|
+
depth: c.z,
|
|
205
|
+
poly: [screen[0], screen[k], screen[k + 1]],
|
|
206
|
+
texture: { src: tex, uvs: [ts[0], ts[k], ts[k + 1]], pixelated: !!mat.pixelated, tint: fill },
|
|
207
|
+
lit: [lit[0], lit[k], lit[k + 1]],
|
|
208
|
+
outline, lineWidth,
|
|
209
|
+
edges: n === 3 ? edges : fan,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (effects) {
|
|
217
|
+
// Normal: the face's own, or the average of its corners when smooth
|
|
218
|
+
let nw;
|
|
219
|
+
if (corners) {
|
|
220
|
+
const F = split ? split.origins[f] : f;
|
|
221
|
+
const sum = face.map((i) => toWorldNormal(normalAt(F, i)))
|
|
222
|
+
.reduce((a, b) => ({ x: a.x + b.x, y: a.y + b.y, z: a.z + b.z }), { x: 0, y: 0, z: 0 });
|
|
223
|
+
nw = normalize(sum);
|
|
224
|
+
} else {
|
|
225
|
+
nw = normalize(newell(face.map((i) => world[i])));
|
|
226
|
+
}
|
|
227
|
+
if (back) nw = { x: -nw.x, y: -nw.y, z: -nw.z };
|
|
228
|
+
|
|
229
|
+
const position = centroid(face.map((i) => world[i]));
|
|
230
|
+
const toCam = { x: camPos.x - position.x, y: camPos.y - position.y, z: camPos.z - position.z };
|
|
231
|
+
const diffuse = Math.max(0, nw.x * light.x + nw.y * light.y + nw.z * light.z);
|
|
232
|
+
const lit = shade ? ambient + (1 - ambient) * diffuse : 1;
|
|
233
|
+
const base = this._rgba(ctx, fill);
|
|
234
|
+
const s = {
|
|
235
|
+
base, normal: nw, position, local: centroid(face.map((i) => verts[i])),
|
|
236
|
+
view: normalize(toCam), lightDir: light, diffuse, light: lit, ambient,
|
|
237
|
+
uv: centerUV(face.map((i) => uvAt(split ? split.origins[f] : f, i))),
|
|
238
|
+
depth: Math.hypot(toCam.x, toCam.y, toCam.z), time, background,
|
|
239
|
+
rgba: (v) => (typeof v === "string" ? this._rgba(ctx, v) : Array.isArray(v) ? v : [v.x, v.y, v.z, 1]),
|
|
240
|
+
};
|
|
241
|
+
let col = [base[0] * lit, base[1] * lit, base[2] * lit, base[3]];
|
|
242
|
+
for (const e of effects) if (e.canvas) col = e.canvas(col, s, e.uniforms ?? {});
|
|
243
|
+
const k = col.map((x) => Math.min(1, Math.max(0, x)));
|
|
244
|
+
items.push({
|
|
245
|
+
depth: c.z,
|
|
246
|
+
poly: clipped.map(toScreen),
|
|
247
|
+
fill: { css: `rgba(${Math.round(k[0] * 255)}, ${Math.round(k[1] * 255)}, ${Math.round(k[2] * 255)}, ${k[3]})`, opaque: k[3] >= 1 },
|
|
248
|
+
outline, lineWidth, edges,
|
|
249
|
+
});
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (corners) {
|
|
254
|
+
const F = split ? split.origins[f] : f;
|
|
255
|
+
const lit = face.map((i) => lightAt(normalAt(F, i), back));
|
|
256
|
+
if (clipped === pts) {
|
|
257
|
+
// One gradient per triangle: brightness changes linearly across a triangle
|
|
258
|
+
const screen = pts.map(toScreen);
|
|
259
|
+
const n = face.length;
|
|
260
|
+
for (let k = 1; k < n - 1; k++) {
|
|
261
|
+
const tri = [screen[0], screen[k], screen[k + 1]];
|
|
262
|
+
const fan = (k === 1 ? 1 : 0) | 2 | (k === n - 2 ? 4 : 0);
|
|
263
|
+
items.push({
|
|
264
|
+
depth: c.z,
|
|
265
|
+
poly: tri,
|
|
266
|
+
fill: this._gradient(ctx, fill, tri, [lit[0], lit[k], lit[k + 1]]),
|
|
267
|
+
outline, lineWidth,
|
|
268
|
+
edges: n === 3 ? edges : fan,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
// Cut by the near plane: use the average brightness
|
|
274
|
+
const avg = lit.reduce((a, b) => a + b, 0) / lit.length;
|
|
275
|
+
items.push({ depth: c.z, poly: clipped.map(toScreen), fill: this._shade(ctx, fill, avg), outline, lineWidth, edges });
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
let brightness = 1;
|
|
280
|
+
if (shade) {
|
|
281
|
+
const nw = normalize(newell(face.map((i) => world[i])));
|
|
282
|
+
const s = back ? -1 : 1;
|
|
283
|
+
const d = s * (nw.x * light.x + nw.y * light.y + nw.z * light.z);
|
|
284
|
+
brightness = ambient + (1 - ambient) * Math.max(0, d);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
items.push({
|
|
288
|
+
depth: c.z,
|
|
289
|
+
poly: clipped.map(toScreen),
|
|
290
|
+
fill: this._shade(ctx, fill, brightness),
|
|
291
|
+
outline, lineWidth,
|
|
292
|
+
edges,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
items.sort((a, b) => b.depth - a.depth);
|
|
298
|
+
|
|
299
|
+
for (const it of items) {
|
|
300
|
+
ctx.beginPath();
|
|
301
|
+
if (it.line) {
|
|
302
|
+
ctx.moveTo(it.line[0].x, it.line[0].y);
|
|
303
|
+
ctx.lineTo(it.line[1].x, it.line[1].y);
|
|
304
|
+
ctx.lineWidth = it.lineWidth;
|
|
305
|
+
ctx.strokeStyle = it.color;
|
|
306
|
+
ctx.stroke();
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (it.texture) {
|
|
311
|
+
this._drawTextured(ctx, it);
|
|
312
|
+
if (it.outline) {
|
|
313
|
+
ctx.beginPath();
|
|
314
|
+
ctx.moveTo(it.poly[0].x, it.poly[0].y);
|
|
315
|
+
for (let k = 1; k < it.poly.length; k++) ctx.lineTo(it.poly[k].x, it.poly[k].y);
|
|
316
|
+
ctx.closePath();
|
|
317
|
+
}
|
|
318
|
+
} else {
|
|
319
|
+
ctx.moveTo(it.poly[0].x, it.poly[0].y);
|
|
320
|
+
for (let k = 1; k < it.poly.length; k++) ctx.lineTo(it.poly[k].x, it.poly[k].y);
|
|
321
|
+
ctx.closePath();
|
|
322
|
+
ctx.fillStyle = it.fill.css;
|
|
323
|
+
ctx.fill();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (it.outline) {
|
|
327
|
+
ctx.lineWidth = it.lineWidth;
|
|
328
|
+
ctx.strokeStyle = it.outline;
|
|
329
|
+
if (it.edges !== null) {
|
|
330
|
+
// Only the edges that belong to the original face
|
|
331
|
+
const p = it.poly;
|
|
332
|
+
ctx.beginPath();
|
|
333
|
+
for (let k = 0; k < p.length; k++) {
|
|
334
|
+
if (!(it.edges & (1 << k))) continue;
|
|
335
|
+
const q = p[(k + 1) % p.length];
|
|
336
|
+
ctx.moveTo(p[k].x, p[k].y);
|
|
337
|
+
ctx.lineTo(q.x, q.y);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
ctx.stroke();
|
|
341
|
+
} else if (it.fill?.opaque) {
|
|
342
|
+
// A thin stroke in the face's own color hides the hairline gaps between faces
|
|
343
|
+
ctx.lineWidth = 1;
|
|
344
|
+
ctx.strokeStyle = it.fill.css;
|
|
345
|
+
ctx.stroke();
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Meshes marked as changed have been redrawn from their new data
|
|
350
|
+
for (const obj of scene.objects) if (obj.mesh.dirty) obj.mesh.dirty = false;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Splits a mesh's big faces into triangles no longer than
|
|
355
|
+
* maxFaceSize × the mesh's size. Cached until the mesh's faces change.
|
|
356
|
+
*
|
|
357
|
+
* @returns {{ faces:number[][], masks:(number|null)[], mids:number[][] }}
|
|
358
|
+
* `mids[k]` is the pair of vertices whose midpoint is vertex `vertices.length + k`.
|
|
359
|
+
* `masks[f]` has bit k set when edge k of face f is part of an original face
|
|
360
|
+
* outline (null when the face wasn't split).
|
|
361
|
+
*/
|
|
362
|
+
_split(mesh) {
|
|
363
|
+
const faces0 = mesh.faces ?? [];
|
|
364
|
+
const cached = this._splits.get(mesh);
|
|
365
|
+
if (cached && !mesh.dirty && cached.source === faces0 && cached.count === faces0.length && cached.size === this.maxFaceSize) {
|
|
366
|
+
return cached;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const pos = mesh.vertices.map((v) => ({ x: v.x, y: v.y, z: v.z }));
|
|
370
|
+
let min = { x: Infinity, y: Infinity, z: Infinity }, max = { x: -Infinity, y: -Infinity, z: -Infinity };
|
|
371
|
+
for (const v of pos) {
|
|
372
|
+
min = { x: Math.min(min.x, v.x), y: Math.min(min.y, v.y), z: Math.min(min.z, v.z) };
|
|
373
|
+
max = { x: Math.max(max.x, v.x), y: Math.max(max.y, v.y), z: Math.max(max.z, v.z) };
|
|
374
|
+
}
|
|
375
|
+
const limit = this.maxFaceSize * Math.hypot(max.x - min.x, max.y - min.y, max.z - min.z);
|
|
376
|
+
const limit2 = limit * limit;
|
|
377
|
+
const len2 = (a, b) => (pos[a].x - pos[b].x) ** 2 + (pos[a].y - pos[b].y) ** 2 + (pos[a].z - pos[b].z) ** 2;
|
|
378
|
+
|
|
379
|
+
const mids = [];
|
|
380
|
+
const midOf = new Map(); // shared midpoints, so neighbours split the same way
|
|
381
|
+
const midpoint = (a, b) => {
|
|
382
|
+
const key = a < b ? a + "," + b : b + "," + a;
|
|
383
|
+
let m = midOf.get(key);
|
|
384
|
+
if (m === undefined) {
|
|
385
|
+
m = pos.length;
|
|
386
|
+
pos.push({ x: (pos[a].x + pos[b].x) / 2, y: (pos[a].y + pos[b].y) / 2, z: (pos[a].z + pos[b].z) / 2 });
|
|
387
|
+
mids.push([a, b]);
|
|
388
|
+
midOf.set(key, m);
|
|
389
|
+
}
|
|
390
|
+
return m;
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
const faces = [], masks = [], origins = [];
|
|
394
|
+
const MAX_DEPTH = 10;
|
|
395
|
+
for (let f = 0; f < faces0.length; f++) {
|
|
396
|
+
const face = faces0[f];
|
|
397
|
+
if (face.length < 3) continue;
|
|
398
|
+
const n = face.length;
|
|
399
|
+
let big = false;
|
|
400
|
+
for (let i = 0; i < n && !big; i++) big = len2(face[i], face[(i + 1) % n]) > limit2;
|
|
401
|
+
if (!big || !(limit > 0)) {
|
|
402
|
+
faces.push(face);
|
|
403
|
+
masks.push(null);
|
|
404
|
+
origins.push(f);
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Fan into triangles; mask bits mark edges on the face's outline (ab = 1, bc = 2, ca = 4)
|
|
409
|
+
const stack = [];
|
|
410
|
+
for (let i = 1; i < n - 1; i++) {
|
|
411
|
+
const mask = (i === 1 ? 1 : 0) | 2 | (i === n - 2 ? 4 : 0);
|
|
412
|
+
stack.push([face[0], face[i], face[i + 1], mask, 0]);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Cut each triangle at the middle of its longest edge until it's small enough
|
|
416
|
+
while (stack.length) {
|
|
417
|
+
const [a, b, c, mask, depth] = stack.pop();
|
|
418
|
+
const ab = len2(a, b), bc = len2(b, c), ca = len2(c, a);
|
|
419
|
+
const longest = Math.max(ab, bc, ca);
|
|
420
|
+
if (longest <= limit2 || depth >= MAX_DEPTH) {
|
|
421
|
+
faces.push([a, b, c]);
|
|
422
|
+
masks.push(mask);
|
|
423
|
+
origins.push(f);
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
// Rotate so the longest edge is a-b, keeping the mask in step
|
|
427
|
+
let t = [a, b, c], m = mask;
|
|
428
|
+
if (bc === longest) { t = [b, c, a]; m = ((mask >> 1) | (mask << 2)) & 7; }
|
|
429
|
+
else if (ca === longest) { t = [c, a, b]; m = ((mask >> 2) | (mask << 1)) & 7; }
|
|
430
|
+
const [p, q, r] = t;
|
|
431
|
+
const mid = midpoint(p, q);
|
|
432
|
+
const onAB = m & 1, onBC = (m >> 1) & 1, onCA = (m >> 2) & 1;
|
|
433
|
+
stack.push([p, mid, r, onAB | (onCA << 2), depth + 1]); // p-mid, mid-r (inner), r-p
|
|
434
|
+
stack.push([mid, q, r, onAB | (onBC << 1), depth + 1]); // mid-q, q-r, r-mid (inner)
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const out = { faces, masks, mids, origins, source: faces0, count: faces0.length, size: this.maxFaceSize };
|
|
439
|
+
this._splits.set(mesh, out);
|
|
440
|
+
return out;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* A fill for a triangle whose corners have different brightness:
|
|
445
|
+
* a linear gradient along the direction brightness changes fastest.
|
|
446
|
+
* Exact, because brightness changes linearly across a triangle.
|
|
447
|
+
*/
|
|
448
|
+
_gradient(ctx, color, tri, lit) {
|
|
449
|
+
const g = gradientLine(tri, lit);
|
|
450
|
+
if (!g) return this._shade(ctx, color, (Math.min(...lit) + Math.max(...lit)) / 2);
|
|
451
|
+
const grad = ctx.createLinearGradient(g.x0, g.y0, g.x1, g.y1);
|
|
452
|
+
const a = this._shade(ctx, color, g.lo), b = this._shade(ctx, color, g.hi);
|
|
453
|
+
grad.addColorStop(0, a.css);
|
|
454
|
+
grad.addColorStop(1, b.css);
|
|
455
|
+
return { css: grad, opaque: a.opaque };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Draws a textured triangle: the image as a pattern mapped onto the
|
|
460
|
+
* triangle's corners, tinted by the fill color, then darkened by the light.
|
|
461
|
+
*/
|
|
462
|
+
_drawTextured(ctx, it) {
|
|
463
|
+
const { src, uvs, pixelated, tint } = it.texture;
|
|
464
|
+
const { w: W, h: H } = textureSize(src);
|
|
465
|
+
const [p0, p1, p2] = it.poly;
|
|
466
|
+
|
|
467
|
+
// Image pixels of each corner (v goes up, image rows go down)
|
|
468
|
+
const U = uvs.map((t) => t.u * W), V = uvs.map((t) => (1 - t.v) * H);
|
|
469
|
+
const du1 = U[1] - U[0], dv1 = V[1] - V[0], du2 = U[2] - U[0], dv2 = V[2] - V[0];
|
|
470
|
+
const det = du1 * dv2 - du2 * dv1;
|
|
471
|
+
|
|
472
|
+
const path = (poly) => {
|
|
473
|
+
ctx.beginPath();
|
|
474
|
+
ctx.moveTo(poly[0].x, poly[0].y);
|
|
475
|
+
for (let k = 1; k < poly.length; k++) ctx.lineTo(poly[k].x, poly[k].y);
|
|
476
|
+
ctx.closePath();
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
// Slightly enlarged so neighbouring triangles leave no hairline gap. The image,
|
|
480
|
+
// tint and light all use this shape: the next triangle paints over the overlap
|
|
481
|
+
// with its own image and light, so nothing gets darkened twice.
|
|
482
|
+
const shape = grow(it.poly, 0.5);
|
|
483
|
+
path(shape);
|
|
484
|
+
if (Math.abs(det) > 1e-12) {
|
|
485
|
+
// The transform that sends image pixel (U, V) to the screen
|
|
486
|
+
const dx1 = p1.x - p0.x, dy1 = p1.y - p0.y, dx2 = p2.x - p0.x, dy2 = p2.y - p0.y;
|
|
487
|
+
const a = (dx1 * dv2 - dx2 * dv1) / det, b = (dy1 * dv2 - dy2 * dv1) / det;
|
|
488
|
+
const c = (dx2 * du1 - dx1 * du2) / det, d = (dy2 * du1 - dy1 * du2) / det;
|
|
489
|
+
const pattern = this._pattern(ctx, src);
|
|
490
|
+
pattern.setTransform(new DOMMatrix([a, b, c, d, p0.x - a * U[0] - c * V[0], p0.y - b * U[0] - d * V[0]]));
|
|
491
|
+
ctx.imageSmoothingEnabled = !pixelated;
|
|
492
|
+
ctx.fillStyle = pattern;
|
|
493
|
+
} else {
|
|
494
|
+
ctx.fillStyle = tint;
|
|
495
|
+
}
|
|
496
|
+
ctx.fill();
|
|
497
|
+
ctx.imageSmoothingEnabled = true;
|
|
498
|
+
|
|
499
|
+
// Tint: multiply by the fill color
|
|
500
|
+
const t = this._rgba(ctx, tint);
|
|
501
|
+
if (t[0] < 1 || t[1] < 1 || t[2] < 1) {
|
|
502
|
+
ctx.globalCompositeOperation = "multiply";
|
|
503
|
+
ctx.fillStyle = tint;
|
|
504
|
+
ctx.fill();
|
|
505
|
+
ctx.globalCompositeOperation = "source-over";
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Light: black on top, as see-through as the face is bright
|
|
509
|
+
const lo = Math.min(...it.lit), hi = Math.max(...it.lit);
|
|
510
|
+
if (lo >= 0.999) return;
|
|
511
|
+
const g = gradientLine(it.poly, it.lit);
|
|
512
|
+
if (g) {
|
|
513
|
+
const grad = ctx.createLinearGradient(g.x0, g.y0, g.x1, g.y1);
|
|
514
|
+
grad.addColorStop(0, `rgba(0, 0, 0, ${1 - g.lo})`);
|
|
515
|
+
grad.addColorStop(1, `rgba(0, 0, 0, ${1 - g.hi})`);
|
|
516
|
+
ctx.fillStyle = grad;
|
|
517
|
+
} else {
|
|
518
|
+
ctx.fillStyle = `rgba(0, 0, 0, ${1 - (lo + hi) / 2})`;
|
|
519
|
+
}
|
|
520
|
+
ctx.fill();
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** A repeating pattern for a texture (made again each frame for canvases and videos). */
|
|
524
|
+
_pattern(ctx, src) {
|
|
525
|
+
let p = this._patterns.get(src);
|
|
526
|
+
if (!p || (textureIsLive(src) && p.frame !== this._frame)) {
|
|
527
|
+
p = { pattern: ctx.createPattern(src, "repeat"), frame: this._frame };
|
|
528
|
+
this._patterns.set(src, p);
|
|
529
|
+
}
|
|
530
|
+
return p.pattern;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** [r, g, b, a] from 0 to 1 for a CSS color, cached. */
|
|
534
|
+
_rgba(ctx, css) {
|
|
535
|
+
let c = this._colors.get(css);
|
|
536
|
+
if (!c) {
|
|
537
|
+
const [r, g, b, a] = parseColor(ctx, css);
|
|
538
|
+
c = [r / 255, g / 255, b / 255, a];
|
|
539
|
+
this._colors.set(css, c);
|
|
540
|
+
}
|
|
541
|
+
return c;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** CSS color for `color` at a brightness, cached (brightness is rounded to 1/255). */
|
|
545
|
+
_shade(ctx, color, brightness) {
|
|
546
|
+
const level = Math.round(brightness * 255);
|
|
547
|
+
const key = color + "|" + level;
|
|
548
|
+
let out = this._shades.get(key);
|
|
549
|
+
if (!out) {
|
|
550
|
+
const [r, g, b, a] = parseColor(ctx, color);
|
|
551
|
+
const k = level / 255;
|
|
552
|
+
out = {
|
|
553
|
+
css: `rgba(${Math.round(r * k)}, ${Math.round(g * k)}, ${Math.round(b * k)}, ${a})`,
|
|
554
|
+
opaque: a >= 1,
|
|
555
|
+
};
|
|
556
|
+
this._shades.set(key, out);
|
|
557
|
+
}
|
|
558
|
+
return out;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Where a linear gradient must start and end so its value matches `lit`
|
|
564
|
+
* at the triangle's three corners. Null when the values are all the same.
|
|
565
|
+
*/
|
|
566
|
+
function gradientLine(tri, lit) {
|
|
567
|
+
const [p0, p1, p2] = tri;
|
|
568
|
+
const d1x = p1.x - p0.x, d1y = p1.y - p0.y, d2x = p2.x - p0.x, d2y = p2.y - p0.y;
|
|
569
|
+
const db1 = lit[1] - lit[0], db2 = lit[2] - lit[0];
|
|
570
|
+
const det = d1x * d2y - d1y * d2x;
|
|
571
|
+
const lo = Math.min(...lit), hi = Math.max(...lit);
|
|
572
|
+
if (Math.abs(det) < 1e-9 || hi - lo < 1 / 255) return null;
|
|
573
|
+
|
|
574
|
+
// Value = lit[0] + g · (point - p0)
|
|
575
|
+
const gx = (db1 * d2y - db2 * d1y) / det;
|
|
576
|
+
const gy = (db2 * d1x - db1 * d2x) / det;
|
|
577
|
+
const g2 = gx * gx + gy * gy;
|
|
578
|
+
const s = (lo - lit[0]) / g2, e = (hi - lit[0]) / g2;
|
|
579
|
+
return { x0: p0.x + gx * s, y0: p0.y + gy * s, x1: p0.x + gx * e, y1: p0.y + gy * e, lo, hi };
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** A polygon pushed outward from its center by `px` pixels. */
|
|
583
|
+
function grow(poly, px) {
|
|
584
|
+
const cx = poly.reduce((a, p) => a + p.x, 0) / poly.length;
|
|
585
|
+
const cy = poly.reduce((a, p) => a + p.y, 0) / poly.length;
|
|
586
|
+
return poly.map((p) => {
|
|
587
|
+
const dx = p.x - cx, dy = p.y - cy;
|
|
588
|
+
const len = Math.hypot(dx, dy) || 1;
|
|
589
|
+
return { x: p.x + (dx / len) * px, y: p.y + (dy / len) * px };
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** Average of texture coordinates, or 0, 0 when some are missing. */
|
|
594
|
+
function centerUV(ts) {
|
|
595
|
+
if (!ts.every(Boolean)) return { u: 0, v: 0 };
|
|
596
|
+
return { u: ts.reduce((a, t) => a + t.u, 0) / ts.length, v: ts.reduce((a, t) => a + t.v, 0) / ts.length };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** The mesh's vertices followed by the midpoints listed in `mids`. */
|
|
600
|
+
function withMidpoints(vertices, mids) {
|
|
601
|
+
const out = vertices.slice();
|
|
602
|
+
for (const [a, b] of mids) {
|
|
603
|
+
const p = out[a], q = out[b];
|
|
604
|
+
out.push({ x: (p.x + q.x) / 2, y: (p.y + q.y) / 2, z: (p.z + q.z) / 2 });
|
|
605
|
+
}
|
|
606
|
+
return out;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function centroid(pts) {
|
|
610
|
+
let x = 0, y = 0, z = 0;
|
|
611
|
+
for (const p of pts) { x += p.x; y += p.y; z += p.z; }
|
|
612
|
+
return { x: x / pts.length, y: y / pts.length, z: z / pts.length };
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function normalize(v) {
|
|
616
|
+
const len = Math.hypot(v.x, v.y, v.z) || 1;
|
|
617
|
+
return { x: v.x / len, y: v.y / len, z: v.z / len };
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** Cuts a camera-space polygon at z = near, keeping the part in front. */
|
|
621
|
+
function clipPolygon(pts, near) {
|
|
622
|
+
if (pts.every((p) => p.z >= near)) return pts;
|
|
623
|
+
const out = [];
|
|
624
|
+
for (let i = 0; i < pts.length; i++) {
|
|
625
|
+
const a = pts[i], b = pts[(i + 1) % pts.length];
|
|
626
|
+
const aIn = a.z >= near, bIn = b.z >= near;
|
|
627
|
+
if (aIn) out.push(a);
|
|
628
|
+
if (aIn !== bIn) {
|
|
629
|
+
const t = (near - a.z) / (b.z - a.z);
|
|
630
|
+
out.push({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t, z: near });
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
return out;
|
|
634
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns any CSS color into [r, g, b, a], using the canvas to understand it.
|
|
3
|
+
* Unknown colors come out black.
|
|
4
|
+
*
|
|
5
|
+
* @param {CanvasRenderingContext2D} ctx - Any 2D context (only used to read the color)
|
|
6
|
+
* @param {string} color - Any CSS color
|
|
7
|
+
* @returns {number[]} [r, g, b, a] with r, g, b in 0..255 and a in 0..1
|
|
8
|
+
*/
|
|
9
|
+
export function parseColor(ctx, color) {
|
|
10
|
+
ctx.save();
|
|
11
|
+
ctx.fillStyle = "#000000";
|
|
12
|
+
ctx.fillStyle = color;
|
|
13
|
+
const s = String(ctx.fillStyle);
|
|
14
|
+
ctx.restore();
|
|
15
|
+
|
|
16
|
+
if (s[0] === "#") {
|
|
17
|
+
const hex = s.slice(1);
|
|
18
|
+
return [0, 2, 4].map((i) => parseInt(hex.slice(i, i + 2), 16)).concat(1);
|
|
19
|
+
}
|
|
20
|
+
const nums = (s.match(/[\d.]+/g) ?? []).map(Number);
|
|
21
|
+
return [nums[0] ?? 0, nums[1] ?? 0, nums[2] ?? 0, nums[3] ?? 1];
|
|
22
|
+
}
|