3draft 26.11.2005 → 26.11.4010
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/APP.md +121 -0
- package/README.md +69 -200
- package/dist/csg.d.ts +35 -0
- package/dist/csg.js +330 -0
- package/dist/gpu.d.ts +14 -2
- package/dist/gpu.js +139 -17
- package/dist/index.d.ts +7 -1
- package/dist/index.js +4 -1
- package/dist/material.d.ts +32 -0
- package/dist/material.js +18 -0
- package/dist/svg.d.ts +9 -0
- package/dist/svg.js +117 -4
- package/dist/types.d.ts +2 -0
- package/dist/wire.d.ts +57 -0
- package/dist/wire.js +244 -0
- package/package.json +2 -1
package/dist/csg.js
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
// Boolean operations on closed solids: union, subtract (a - b) and intersect.
|
|
2
|
+
// A BSP tree per solid (after Evan Wallace's csg.js): polygons are cut by the planes of the other solid
|
|
3
|
+
// and the pieces inside or outside are kept, so every result face stays planar and convex, which is what
|
|
4
|
+
// the rest of the engine assumes. The result is welded and its T-junctions are closed, so it is a closed
|
|
5
|
+
// solid again and can go into the next boolean. Faces keep the color and material of the face they came from;
|
|
6
|
+
// a face cut into a solid by the other one takes the color of the cutting face.
|
|
7
|
+
import { sub, dot, cross, len } from './vec.js';
|
|
8
|
+
import { faceNormal, bounds } from './mesh.js';
|
|
9
|
+
import { validate } from './check.js';
|
|
10
|
+
let CSG_EPS = 1e-6; // set per operation, relative to the size of the two solids
|
|
11
|
+
const unit = (v) => {
|
|
12
|
+
const l = len(v) || 1;
|
|
13
|
+
return [v[0] / l, v[1] / l, v[2] / l];
|
|
14
|
+
};
|
|
15
|
+
const polysOf = (mesh) => mesh.faces.flatMap((f) => {
|
|
16
|
+
const pts = f.v.map((k) => mesh.positions[k]);
|
|
17
|
+
const n = unit(faceNormal(pts));
|
|
18
|
+
return len(faceNormal(pts)) < 1e-12 ? [] : [{ pts, n, w: dot(n, pts[0]), color: f.color, mat: f.mat }];
|
|
19
|
+
});
|
|
20
|
+
const flip = (p) => ({ ...p, pts: [...p.pts].reverse(), n: [-p.n[0], -p.n[1], -p.n[2]], w: -p.w });
|
|
21
|
+
/** Cuts `p` by the plane into the four buckets. */
|
|
22
|
+
const splitPoly = (plane, p, cf, cb, front, back) => {
|
|
23
|
+
const COPLANAR = 0;
|
|
24
|
+
const FRONT = 1;
|
|
25
|
+
const BACK = 2;
|
|
26
|
+
const SPANNING = 3;
|
|
27
|
+
let type = 0;
|
|
28
|
+
const types = p.pts.map((q) => {
|
|
29
|
+
const t = dot(plane.n, q) - plane.w;
|
|
30
|
+
const k = t < -CSG_EPS ? BACK : t > CSG_EPS ? FRONT : COPLANAR;
|
|
31
|
+
type |= k;
|
|
32
|
+
return k;
|
|
33
|
+
});
|
|
34
|
+
if (type === COPLANAR)
|
|
35
|
+
(dot(plane.n, p.n) > 0 ? cf : cb).push(p);
|
|
36
|
+
else if (type === FRONT)
|
|
37
|
+
front.push(p);
|
|
38
|
+
else if (type === BACK)
|
|
39
|
+
back.push(p);
|
|
40
|
+
else if (type === SPANNING) {
|
|
41
|
+
const f = [];
|
|
42
|
+
const b = [];
|
|
43
|
+
for (let i = 0; i < p.pts.length; i++) {
|
|
44
|
+
const j = (i + 1) % p.pts.length;
|
|
45
|
+
const ti = types[i];
|
|
46
|
+
const tj = types[j];
|
|
47
|
+
const vi = p.pts[i];
|
|
48
|
+
const vj = p.pts[j];
|
|
49
|
+
if (ti !== BACK)
|
|
50
|
+
f.push(vi);
|
|
51
|
+
if (ti !== FRONT)
|
|
52
|
+
b.push(vi);
|
|
53
|
+
if ((ti | tj) === SPANNING) {
|
|
54
|
+
const t = (plane.w - dot(plane.n, vi)) / dot(plane.n, sub(vj, vi));
|
|
55
|
+
const v = [vi[0] + (vj[0] - vi[0]) * t, vi[1] + (vj[1] - vi[1]) * t, vi[2] + (vj[2] - vi[2]) * t];
|
|
56
|
+
f.push(v);
|
|
57
|
+
b.push(v);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (f.length >= 3)
|
|
61
|
+
front.push({ ...p, pts: f });
|
|
62
|
+
if (b.length >= 3)
|
|
63
|
+
back.push({ ...p, pts: b });
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
const build = (node, polys) => {
|
|
67
|
+
if (!polys.length)
|
|
68
|
+
return;
|
|
69
|
+
if (!node.plane)
|
|
70
|
+
node.plane = { n: polys[0].n, w: polys[0].w };
|
|
71
|
+
const front = [];
|
|
72
|
+
const back = [];
|
|
73
|
+
for (const p of polys)
|
|
74
|
+
splitPoly(node.plane, p, node.polys, node.polys, front, back);
|
|
75
|
+
if (front.length)
|
|
76
|
+
build((node.front ??= { plane: null, front: null, back: null, polys: [] }), front);
|
|
77
|
+
if (back.length)
|
|
78
|
+
build((node.back ??= { plane: null, front: null, back: null, polys: [] }), back);
|
|
79
|
+
};
|
|
80
|
+
const tree = (polys) => {
|
|
81
|
+
const node = { plane: null, front: null, back: null, polys: [] };
|
|
82
|
+
build(node, polys);
|
|
83
|
+
return node;
|
|
84
|
+
};
|
|
85
|
+
const invert = (node) => {
|
|
86
|
+
node.polys = node.polys.map(flip);
|
|
87
|
+
if (node.plane)
|
|
88
|
+
node.plane = { n: [-node.plane.n[0], -node.plane.n[1], -node.plane.n[2]], w: -node.plane.w };
|
|
89
|
+
invert0(node);
|
|
90
|
+
};
|
|
91
|
+
const invert0 = (node) => {
|
|
92
|
+
if (node.front)
|
|
93
|
+
invert(node.front);
|
|
94
|
+
if (node.back)
|
|
95
|
+
invert(node.back);
|
|
96
|
+
[node.front, node.back] = [node.back, node.front];
|
|
97
|
+
};
|
|
98
|
+
/** The parts of `polys` that are outside the solid of `node`. */
|
|
99
|
+
const clipPolys = (node, polys) => {
|
|
100
|
+
if (!node.plane)
|
|
101
|
+
return [...polys];
|
|
102
|
+
let front = [];
|
|
103
|
+
let back = [];
|
|
104
|
+
for (const p of polys)
|
|
105
|
+
splitPoly(node.plane, p, front, back, front, back);
|
|
106
|
+
front = node.front ? clipPolys(node.front, front) : front;
|
|
107
|
+
back = node.back ? clipPolys(node.back, back) : [];
|
|
108
|
+
return [...front, ...back];
|
|
109
|
+
};
|
|
110
|
+
const clipTo = (node, other) => {
|
|
111
|
+
node.polys = clipPolys(other, node.polys);
|
|
112
|
+
if (node.front)
|
|
113
|
+
clipTo(node.front, other);
|
|
114
|
+
if (node.back)
|
|
115
|
+
clipTo(node.back, other);
|
|
116
|
+
};
|
|
117
|
+
const all = (node) => [...node.polys, ...(node.front ? all(node.front) : []), ...(node.back ? all(node.back) : [])];
|
|
118
|
+
/** Polygons to a welded mesh: shared vertices, no sliver faces, T-junctions closed. */
|
|
119
|
+
const toMesh = (polys, scale) => {
|
|
120
|
+
const cell = scale * 1e-5;
|
|
121
|
+
const ids = new Map();
|
|
122
|
+
const positions = [];
|
|
123
|
+
const weld = (p) => {
|
|
124
|
+
// look in the neighbouring cells too, so a point on a cell border still finds its twin
|
|
125
|
+
const base = p.map((c) => Math.round(c / cell));
|
|
126
|
+
for (let dx = -1; dx <= 1; dx++)
|
|
127
|
+
for (let dy = -1; dy <= 1; dy++)
|
|
128
|
+
for (let dz = -1; dz <= 1; dz++) {
|
|
129
|
+
const hit = ids.get(`${base[0] + dx},${base[1] + dy},${base[2] + dz}`);
|
|
130
|
+
if (hit !== undefined && len(sub(positions[hit], p)) <= cell * 1.5)
|
|
131
|
+
return hit;
|
|
132
|
+
}
|
|
133
|
+
ids.set(base.join(','), positions.length);
|
|
134
|
+
positions.push(p);
|
|
135
|
+
return positions.length - 1;
|
|
136
|
+
};
|
|
137
|
+
let faces = polys.flatMap((p) => {
|
|
138
|
+
const v = [];
|
|
139
|
+
for (const q of p.pts) {
|
|
140
|
+
const k = weld(q);
|
|
141
|
+
if (v[v.length - 1] !== k)
|
|
142
|
+
v.push(k);
|
|
143
|
+
}
|
|
144
|
+
if (v.length > 1 && v[0] === v[v.length - 1])
|
|
145
|
+
v.pop();
|
|
146
|
+
if (v.length < 3)
|
|
147
|
+
return [];
|
|
148
|
+
// slivers stay for now: their vertices still give the neighbours' edges something to join to (see below)
|
|
149
|
+
return [{ v, color: p.color, mat: p.mat }];
|
|
150
|
+
});
|
|
151
|
+
// T-junctions: a vertex lying on the edge of a neighbouring face gets inserted into that edge
|
|
152
|
+
const used = new Set(faces.flatMap((f) => f.v));
|
|
153
|
+
const verts = [...used];
|
|
154
|
+
if (verts.length * faces.length < 6e7) {
|
|
155
|
+
const tol = scale * 1e-5 * 3;
|
|
156
|
+
faces = faces.map((f) => {
|
|
157
|
+
const out = [];
|
|
158
|
+
for (let i = 0; i < f.v.length; i++) {
|
|
159
|
+
const a = f.v[i];
|
|
160
|
+
const b = f.v[(i + 1) % f.v.length];
|
|
161
|
+
out.push(a);
|
|
162
|
+
const pa = positions[a];
|
|
163
|
+
const pb = positions[b];
|
|
164
|
+
const d = sub(pb, pa);
|
|
165
|
+
const dl = len(d);
|
|
166
|
+
if (dl < tol * 4)
|
|
167
|
+
continue;
|
|
168
|
+
const on = [];
|
|
169
|
+
for (const k of verts) {
|
|
170
|
+
if (k === a || k === b || f.v.includes(k))
|
|
171
|
+
continue; // never twice in one face
|
|
172
|
+
const pk = positions[k];
|
|
173
|
+
const t = dot(sub(pk, pa), d) / (dl * dl);
|
|
174
|
+
if (t <= 0 || t >= 1)
|
|
175
|
+
continue;
|
|
176
|
+
if (len(cross(sub(pk, pa), d)) / dl <= tol)
|
|
177
|
+
on.push({ t, k });
|
|
178
|
+
}
|
|
179
|
+
on.sort((x, y) => x.t - y.t);
|
|
180
|
+
for (const { k } of on)
|
|
181
|
+
out.push(k);
|
|
182
|
+
}
|
|
183
|
+
return { ...f, v: out };
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
// Now the slivers can go: a face with no area (its corners are in a line, the neighbours already have the extra vertices)
|
|
187
|
+
// closes up around it. Dropping one before the T-junctions are done would leave a hole (an open edge) behind.
|
|
188
|
+
const flat = 1e-9 * scale * scale;
|
|
189
|
+
faces = faces.filter((f) => len(faceNormal(f.v.map((k) => positions[k]))) >= flat);
|
|
190
|
+
return { positions, faces };
|
|
191
|
+
};
|
|
192
|
+
const closedSolid = (name, m) => {
|
|
193
|
+
const v = validate(m);
|
|
194
|
+
if (!v.closed)
|
|
195
|
+
throw new Error(`boolean needs closed solids: ${name} has ${v.openEdges} open edge(s)`);
|
|
196
|
+
if (v.inside_out)
|
|
197
|
+
throw new Error(`boolean needs outward faces: ${name} is inside out`);
|
|
198
|
+
};
|
|
199
|
+
const subMesh = (mesh, idx) => {
|
|
200
|
+
const map = new Map();
|
|
201
|
+
const positions = [];
|
|
202
|
+
const faces = idx.map((i) => {
|
|
203
|
+
const f = mesh.faces[i];
|
|
204
|
+
return { ...f, v: f.v.map((k) => (map.has(k) ? map.get(k) : (map.set(k, positions.length), positions.push(mesh.positions[k]), positions.length - 1))) };
|
|
205
|
+
});
|
|
206
|
+
return { ...mesh, positions, faces };
|
|
207
|
+
};
|
|
208
|
+
/** Splits a mesh into connected parts and says which of them are closed solids (the health check). */
|
|
209
|
+
export const health = (mesh) => {
|
|
210
|
+
const n = mesh.faces.length;
|
|
211
|
+
const root = Array.from({ length: n }, (_, i) => i);
|
|
212
|
+
const find = (i) => (root[i] === i ? i : (root[i] = find(root[i])));
|
|
213
|
+
const seen = new Map();
|
|
214
|
+
mesh.faces.forEach((f, i) => f.v.forEach((v, k) => {
|
|
215
|
+
const w = f.v[(k + 1) % f.v.length];
|
|
216
|
+
const key = v < w ? `${v},${w}` : `${w},${v}`;
|
|
217
|
+
const j = seen.get(key);
|
|
218
|
+
if (j === undefined)
|
|
219
|
+
seen.set(key, i);
|
|
220
|
+
else
|
|
221
|
+
root[find(i)] = find(j);
|
|
222
|
+
}));
|
|
223
|
+
const groups = new Map();
|
|
224
|
+
for (let i = 0; i < n; i++)
|
|
225
|
+
groups.set(find(i), [...(groups.get(find(i)) ?? []), i]);
|
|
226
|
+
const parts = [...groups.values()].map((faces) => {
|
|
227
|
+
const v = validate(subMesh(mesh, faces));
|
|
228
|
+
return { faces, closed: v.closed, insideOut: v.inside_out, openEdges: v.openEdges };
|
|
229
|
+
});
|
|
230
|
+
const usable = parts.filter((p) => p.closed && !p.insideOut);
|
|
231
|
+
const usableFaces = usable.reduce((t, p) => t + p.faces.length, 0);
|
|
232
|
+
return { parts, usableFaces, skippedFaces: n - usableFaces, hasSolid: usable.length > 0, allSolid: usable.length === parts.length && parts.length > 0 };
|
|
233
|
+
};
|
|
234
|
+
const append = (base, extra) => {
|
|
235
|
+
const o = base.positions.length;
|
|
236
|
+
return { ...base, positions: [...base.positions, ...extra.positions], faces: [...base.faces, ...extra.faces.map((f) => ({ ...f, v: f.v.map((k) => k + o) }))] };
|
|
237
|
+
};
|
|
238
|
+
/**
|
|
239
|
+
* Boolean of two closed solids (outward CCW faces): 'union', 'subtract' (a minus b) or 'intersect'.
|
|
240
|
+
* Throws with a reason when an input is open or inside out. With `strict: false` the parts that are not closed solids are
|
|
241
|
+
* left out of the calculation instead (see `health`): a union keeps them as they are, a subtract keeps those of `a`,
|
|
242
|
+
* an intersect drops them; it throws only when `a` has no closed part at all.
|
|
243
|
+
*/
|
|
244
|
+
export const boolean = (a, b, op, { strict = true } = {}) => {
|
|
245
|
+
if (strict) {
|
|
246
|
+
closedSolid('a', a);
|
|
247
|
+
closedSolid('b', b);
|
|
248
|
+
return run(a, b, op);
|
|
249
|
+
}
|
|
250
|
+
const ha = health(a);
|
|
251
|
+
const hb = health(b);
|
|
252
|
+
if (!ha.hasSolid)
|
|
253
|
+
throw new Error('boolean: the model has no closed part (nothing to calculate with)');
|
|
254
|
+
if (!hb.hasSolid)
|
|
255
|
+
throw new Error('boolean: the second model has no closed part');
|
|
256
|
+
const pick = (m, h, closed) => subMesh(m, h.parts.filter((p) => (p.closed && !p.insideOut) === closed).flatMap((p) => p.faces).sort((x, y) => x - y));
|
|
257
|
+
let out = run(pick(a, ha, true), pick(b, hb, true), op);
|
|
258
|
+
if (ha.skippedFaces)
|
|
259
|
+
out = append(out, pick(a, ha, false));
|
|
260
|
+
if (op === 'union' && hb.skippedFaces)
|
|
261
|
+
out = append(out, pick(b, hb, false));
|
|
262
|
+
return out;
|
|
263
|
+
};
|
|
264
|
+
// A result is sound when it is a closed, outward solid with consistent winding (and the inputs were).
|
|
265
|
+
const sound = (m) => {
|
|
266
|
+
if (!m.faces.length)
|
|
267
|
+
return true; // nothing is a fine result (disjoint intersect)
|
|
268
|
+
const v = validate(m);
|
|
269
|
+
return v.closed && !v.inside_out && !v.notes.some((n) => /wound inconsistently/.test(n));
|
|
270
|
+
};
|
|
271
|
+
const shiftBy = (m, d) => ({ ...m, positions: m.positions.map((p) => [p[0] + d[0], p[1] + d[1], p[2] + d[2]]) });
|
|
272
|
+
/**
|
|
273
|
+
* BSP booleans hit numerical trouble when faces are (nearly) coincident or a cut passes within a hair of a vertex; the
|
|
274
|
+
* result then comes out with a crack or a flipped face. A solid must stay a solid, so a result that is not sound is
|
|
275
|
+
* thrown away and the operation is repeated with the second operand moved by a tiny amount (a few tries, growing).
|
|
276
|
+
* When no try gives a sound solid it throws, so the caller keeps the original.
|
|
277
|
+
*/
|
|
278
|
+
const run = (a, b, op) => {
|
|
279
|
+
const first = run0(a, b, op);
|
|
280
|
+
if (sound(first) || !sound(a) || !sound(b))
|
|
281
|
+
return first;
|
|
282
|
+
const size = Math.max(bounds(a).radius, bounds(b).radius, 1e-9);
|
|
283
|
+
for (let k = 1; k <= 12; k++) {
|
|
284
|
+
const mag = size * 1e-4 * k;
|
|
285
|
+
const d = [Math.sin(k * 12.9898) * mag, Math.sin(k * 78.233) * mag, Math.sin(k * 37.719) * mag];
|
|
286
|
+
const r = run0(a, shiftBy(b, d), op);
|
|
287
|
+
if (sound(r))
|
|
288
|
+
return r;
|
|
289
|
+
}
|
|
290
|
+
throw new Error('boolean: the result would not be a closed solid (numerical trouble); the operands were left alone');
|
|
291
|
+
};
|
|
292
|
+
const run0 = (a, b, op) => {
|
|
293
|
+
const sa = bounds(a);
|
|
294
|
+
const sb = bounds(b);
|
|
295
|
+
const scale = Math.max(sa.radius, sb.radius, 1e-9) * 2 + len(sub(sa.center, sb.center));
|
|
296
|
+
CSG_EPS = scale * 1e-9 * 1000;
|
|
297
|
+
const A = tree(polysOf(a));
|
|
298
|
+
const B = tree(polysOf(b));
|
|
299
|
+
if (op === 'union') {
|
|
300
|
+
clipTo(A, B);
|
|
301
|
+
clipTo(B, A);
|
|
302
|
+
invert(B);
|
|
303
|
+
clipTo(B, A);
|
|
304
|
+
invert(B);
|
|
305
|
+
build(A, all(B));
|
|
306
|
+
}
|
|
307
|
+
else if (op === 'subtract') {
|
|
308
|
+
invert(A);
|
|
309
|
+
clipTo(A, B);
|
|
310
|
+
clipTo(B, A);
|
|
311
|
+
invert(B);
|
|
312
|
+
clipTo(B, A);
|
|
313
|
+
invert(B);
|
|
314
|
+
build(A, all(B));
|
|
315
|
+
invert(A);
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
invert(A);
|
|
319
|
+
clipTo(B, A);
|
|
320
|
+
invert(B);
|
|
321
|
+
clipTo(A, B);
|
|
322
|
+
clipTo(B, A);
|
|
323
|
+
build(A, all(B));
|
|
324
|
+
invert(A);
|
|
325
|
+
}
|
|
326
|
+
return toMesh(all(A), scale);
|
|
327
|
+
};
|
|
328
|
+
export const union = (a, b) => boolean(a, b, 'union');
|
|
329
|
+
export const subtract = (a, b) => boolean(a, b, 'subtract');
|
|
330
|
+
export const intersect = (a, b) => boolean(a, b, 'intersect');
|
package/dist/gpu.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Palette } from './material.js';
|
|
1
2
|
import type { Axes, Camera, Fit, Mesh, Vec3, ViewName, ViewSpec } from './types.js';
|
|
2
3
|
type Handle = any;
|
|
3
4
|
export interface GpuLight {
|
|
@@ -21,12 +22,23 @@ export interface GpuRenderOptions {
|
|
|
21
22
|
background?: Vec3;
|
|
22
23
|
light?: GpuLight;
|
|
23
24
|
material?: GpuMaterial;
|
|
25
|
+
/** Named materials the faces point at (`face.mat`); pass a new object when an entry changes. */
|
|
26
|
+
palette?: Palette;
|
|
24
27
|
dpr?: number;
|
|
25
28
|
/** Many copies of one prototype mesh (modelled around the origin), drawn after `mesh`. */
|
|
26
29
|
instances?: {
|
|
27
30
|
mesh: Mesh;
|
|
28
31
|
data: Float32Array;
|
|
29
32
|
};
|
|
33
|
+
/**
|
|
34
|
+
* Filmic look: lighting in linear colour, HDR light and emission, Fresnel on the edges, ACES tone mapping.
|
|
35
|
+
* `true` for the defaults or { exposure = 1, emit = 2 } (emit: brightness of one unit of material `emit`, which goes up to 5).
|
|
36
|
+
* Off by default (the plain flat look).
|
|
37
|
+
*/
|
|
38
|
+
tonemap?: boolean | {
|
|
39
|
+
exposure?: number;
|
|
40
|
+
emit?: number;
|
|
41
|
+
};
|
|
30
42
|
}
|
|
31
43
|
/** One copy of a prototype: its position, orthonormal frame and color. */
|
|
32
44
|
export interface Instance {
|
|
@@ -52,8 +64,8 @@ export interface Gpu {
|
|
|
52
64
|
* like camera.project(): the same pixels, plus a depth between near and far.
|
|
53
65
|
*/
|
|
54
66
|
export declare const viewProjection: (cam: Camera, near?: number, far?: number) => Float32Array;
|
|
55
|
-
/** Triangle list for the GPU: flat face normals
|
|
56
|
-
export declare const buildVertices: (mesh: Mesh) => Float32Array;
|
|
67
|
+
/** Triangle list for the GPU: flat face normals, face colors and materials. Float32Array, 13 floats per vertex. */
|
|
68
|
+
export declare const buildVertices: (mesh: Mesh, palette?: Palette) => Float32Array;
|
|
57
69
|
/**
|
|
58
70
|
* Per-instance data for drawInstances: one entry { at, axes: [ex, ey, ez], color } per copy of a
|
|
59
71
|
* prototype mesh (modelled around the origin). Float32Array, 15 floats per instance.
|
package/dist/gpu.js
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
import { dot, norm } from './vec.js';
|
|
6
6
|
import { faceNormal } from './mesh.js';
|
|
7
7
|
import { makeCamera } from './camera.js';
|
|
8
|
-
|
|
8
|
+
import { surfaceOf } from './material.js';
|
|
9
|
+
const FLOATS = 13; // position(3) normal(3) color(3) material(4: metal, rough, alpha, emit)
|
|
9
10
|
/**
|
|
10
11
|
* Column-major 4x4 matrix (for WebGPU, z in 0..1) that maps world points exactly
|
|
11
12
|
* like camera.project(): the same pixels, plus a depth between near and far.
|
|
@@ -28,8 +29,8 @@ const rgb = (color) => {
|
|
|
28
29
|
return [0.78, 0.78, 0.78];
|
|
29
30
|
return [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16) / 255);
|
|
30
31
|
};
|
|
31
|
-
/** Triangle list for the GPU: flat face normals
|
|
32
|
-
export const buildVertices = (mesh) => {
|
|
32
|
+
/** Triangle list for the GPU: flat face normals, face colors and materials. Float32Array, 13 floats per vertex. */
|
|
33
|
+
export const buildVertices = (mesh, palette) => {
|
|
33
34
|
let tris = 0;
|
|
34
35
|
for (const f of mesh.faces)
|
|
35
36
|
tris += Math.max(0, f.v.length - 2);
|
|
@@ -38,10 +39,12 @@ export const buildVertices = (mesh) => {
|
|
|
38
39
|
for (const face of mesh.faces) {
|
|
39
40
|
const pts = face.v.map((k) => mesh.positions[k]);
|
|
40
41
|
const n = norm(faceNormal(pts));
|
|
41
|
-
const
|
|
42
|
+
const s = surfaceOf(face, palette);
|
|
43
|
+
const c = rgb(s.color);
|
|
44
|
+
const mat = [s.metal, s.rough, s.alpha, s.emit];
|
|
42
45
|
for (let j = 1; j < pts.length - 1; j++) {
|
|
43
46
|
for (const p of [pts[0], pts[j], pts[j + 1]]) {
|
|
44
|
-
out.set([...p, ...n, ...c], o);
|
|
47
|
+
out.set([...p, ...n, ...c, ...mat], o);
|
|
45
48
|
o += FLOATS;
|
|
46
49
|
}
|
|
47
50
|
}
|
|
@@ -68,15 +71,17 @@ struct U {
|
|
|
68
71
|
ground : vec4<f32>,
|
|
69
72
|
eye : vec4<f32>,
|
|
70
73
|
mat : vec4<f32>, // ambient, diffuse, specular, shininess
|
|
74
|
+
fx : vec4<f32>, // filmic (0 or 1), exposure, emission gain
|
|
71
75
|
};
|
|
72
76
|
@group(0) @binding(0) var<uniform> u : U;
|
|
73
77
|
|
|
74
|
-
struct VIn { @location(0) pos : vec3<f32>, @location(1) nrm : vec3<f32>, @location(2) col : vec3<f32> };
|
|
78
|
+
struct VIn { @location(0) pos : vec3<f32>, @location(1) nrm : vec3<f32>, @location(2) col : vec3<f32>, @location(8) mat : vec4<f32> };
|
|
75
79
|
struct VOut {
|
|
76
80
|
@builtin(position) p : vec4<f32>,
|
|
77
81
|
@location(0) n : vec3<f32>,
|
|
78
82
|
@location(1) c : vec3<f32>,
|
|
79
83
|
@location(2) w : vec3<f32>,
|
|
84
|
+
@location(3) m : vec4<f32>, // metal, rough, alpha, emit
|
|
80
85
|
};
|
|
81
86
|
|
|
82
87
|
@vertex fn vs(i : VIn) -> VOut {
|
|
@@ -85,6 +90,7 @@ struct VOut {
|
|
|
85
90
|
o.n = i.nrm;
|
|
86
91
|
o.c = i.col;
|
|
87
92
|
o.w = i.pos;
|
|
93
|
+
o.m = i.mat;
|
|
88
94
|
return o;
|
|
89
95
|
}
|
|
90
96
|
|
|
@@ -103,20 +109,67 @@ struct IIn {
|
|
|
103
109
|
o.n = normalize(i.ex) * i.nrm.x + normalize(i.ey) * i.nrm.y + normalize(i.ez) * i.nrm.z;
|
|
104
110
|
o.c = i.col;
|
|
105
111
|
o.w = w;
|
|
112
|
+
o.m = vec4<f32>(0.0, 0.7, 1.0, 0.0);
|
|
106
113
|
return o;
|
|
107
114
|
}
|
|
108
115
|
|
|
116
|
+
fn toLinear(c : vec3<f32>) -> vec3<f32> {
|
|
117
|
+
return select(pow((c + 0.055) / 1.055, vec3<f32>(2.4)), c / 12.92, c <= vec3<f32>(0.04045));
|
|
118
|
+
}
|
|
119
|
+
fn toSrgb(c : vec3<f32>) -> vec3<f32> {
|
|
120
|
+
return select(1.055 * pow(c, vec3<f32>(1.0 / 2.4)) - 0.055, c * 12.92, c <= vec3<f32>(0.0031308));
|
|
121
|
+
}
|
|
122
|
+
// ACES filmic curve (Narkowicz fit): highlights roll off softly instead of clipping
|
|
123
|
+
fn aces(x : vec3<f32>) -> vec3<f32> {
|
|
124
|
+
return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), vec3<f32>(0.0), vec3<f32>(1.0));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// The filmic path: the same light model, but in linear colour with HDR light and emission, then tone mapped.
|
|
128
|
+
fn filmic(i : VOut, n : vec3<f32>, l : vec3<f32>, v : vec3<f32>) -> vec4<f32> {
|
|
129
|
+
let metal = i.m.x;
|
|
130
|
+
let rough = i.m.y;
|
|
131
|
+
let albedo = toLinear(i.c);
|
|
132
|
+
let hemi = mix(toLinear(u.ground.rgb), toLinear(u.sky.rgb), n.y * 0.5 + 0.5) * u.mat.x;
|
|
133
|
+
let diff = max(dot(n, l), 0.0) * u.mat.y * 1.6;
|
|
134
|
+
let h = normalize(l + v);
|
|
135
|
+
let shin = mix(u.mat.w * 7.0, u.mat.w * 0.15, rough);
|
|
136
|
+
let spec = pow(max(dot(n, h), 0.0), shin) * mix(u.mat.z, 1.0, metal) * mix(1.0, 0.35, rough) * step(0.0, dot(n, l)) * 2.5;
|
|
137
|
+
let r = reflect(-v, n);
|
|
138
|
+
// Schlick: edges mirror more, metal keeps its tint
|
|
139
|
+
let f0 = mix(vec3<f32>(0.04), albedo, metal);
|
|
140
|
+
let fres = f0 + (vec3<f32>(1.0) - f0) * pow(1.0 - max(dot(n, v), 0.0), 5.0) * (1.0 - rough);
|
|
141
|
+
let env = mix(toLinear(u.ground.rgb) * 1.2, toLinear(u.sky.rgb) * 1.5, smoothstep(-0.25 - rough * 0.5, 0.25 + rough * 0.5, r.y));
|
|
142
|
+
var lit = albedo * (hemi + vec3<f32>(diff)) * (1.0 - metal)
|
|
143
|
+
+ albedo * env * metal * (1.0 - 0.6 * rough)
|
|
144
|
+
+ env * fres * (1.0 - rough) * 0.5
|
|
145
|
+
+ vec3<f32>(spec) * mix(vec3<f32>(1.0), albedo, metal);
|
|
146
|
+
// emission adds light, so a lamp can go above 1.0 and still keep its colour after tone mapping
|
|
147
|
+
lit = lit + albedo * i.m.w * u.fx.z;
|
|
148
|
+
return vec4<f32>(toSrgb(aces(lit * u.fx.y)), i.m.z);
|
|
149
|
+
}
|
|
150
|
+
|
|
109
151
|
@fragment fn fs(i : VOut, @builtin(front_facing) front : bool) -> @location(0) vec4<f32> {
|
|
110
152
|
var n = normalize(i.n);
|
|
111
153
|
if (!front) { n = -n; }
|
|
112
154
|
let l = normalize(u.light.xyz);
|
|
113
155
|
let v = normalize(u.eye.xyz - i.w);
|
|
156
|
+
if (u.fx.x > 0.5) { return filmic(i, n, l, v); }
|
|
157
|
+
let metal = i.m.x;
|
|
158
|
+
let rough = i.m.y;
|
|
114
159
|
let hemi = mix(u.ground.rgb, u.sky.rgb, n.y * 0.5 + 0.5) * u.mat.x;
|
|
115
160
|
let diff = max(dot(n, l), 0.0) * u.mat.y;
|
|
116
161
|
let h = normalize(l + v);
|
|
117
|
-
|
|
118
|
-
let
|
|
119
|
-
|
|
162
|
+
// roughness widens the highlight; metal makes it strong and tinted by the color
|
|
163
|
+
let shin = mix(u.mat.w * 7.0, u.mat.w * 0.15, rough);
|
|
164
|
+
let spec = pow(max(dot(n, h), 0.0), shin) * mix(u.mat.z, 1.0, metal) * mix(1.0, 0.35, rough) * step(0.0, dot(n, l));
|
|
165
|
+
// metal mirrors the sky above and the ground below, blurred by roughness
|
|
166
|
+
let r = reflect(-v, n);
|
|
167
|
+
let env = mix(u.ground.rgb * 1.2, u.sky.rgb * 1.5, smoothstep(-0.25 - rough * 0.5, 0.25 + rough * 0.5, r.y));
|
|
168
|
+
var lit = i.c * (hemi + vec3<f32>(diff)) * (1.0 - metal)
|
|
169
|
+
+ i.c * env * metal * (1.0 - 0.6 * rough)
|
|
170
|
+
+ vec3<f32>(spec) * mix(vec3<f32>(1.0), i.c, metal);
|
|
171
|
+
lit = mix(lit, i.c * 1.15, min(i.m.w, 1.0)); // the plain look: emit 0..1 fades to flat colour, more does not add
|
|
172
|
+
return vec4<f32>(clamp(lit, vec3<f32>(0.0), vec3<f32>(1.0)), i.m.z);
|
|
120
173
|
}
|
|
121
174
|
`;
|
|
122
175
|
/**
|
|
@@ -147,6 +200,7 @@ export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
|
|
|
147
200
|
{ shaderLocation: 0, offset: 0, format: 'float32x3' },
|
|
148
201
|
{ shaderLocation: 1, offset: 12, format: 'float32x3' },
|
|
149
202
|
{ shaderLocation: 2, offset: 24, format: 'float32x3' },
|
|
203
|
+
{ shaderLocation: 8, offset: 36, format: 'float32x4' },
|
|
150
204
|
],
|
|
151
205
|
};
|
|
152
206
|
const instanceLayout = {
|
|
@@ -154,22 +208,39 @@ export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
|
|
|
154
208
|
stepMode: 'instance',
|
|
155
209
|
attributes: [3, 4, 5, 6, 7].map((loc, k) => ({ shaderLocation: loc, offset: k * 12, format: 'float32x3' })),
|
|
156
210
|
};
|
|
157
|
-
const make = (entry, buffers) => device.createRenderPipeline({
|
|
211
|
+
const make = (entry, buffers, glass = false) => device.createRenderPipeline({
|
|
158
212
|
layout: 'auto',
|
|
159
213
|
vertex: { module, entryPoint: entry, buffers },
|
|
160
|
-
fragment: {
|
|
214
|
+
fragment: {
|
|
215
|
+
module,
|
|
216
|
+
entryPoint: 'fs',
|
|
217
|
+
targets: [
|
|
218
|
+
glass
|
|
219
|
+
? {
|
|
220
|
+
format,
|
|
221
|
+
blend: {
|
|
222
|
+
color: { srcFactor: 'src-alpha', dstFactor: 'one-minus-src-alpha', operation: 'add' },
|
|
223
|
+
alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' },
|
|
224
|
+
},
|
|
225
|
+
}
|
|
226
|
+
: { format },
|
|
227
|
+
],
|
|
228
|
+
},
|
|
161
229
|
primitive: { topology: 'triangle-list', cullMode: 'none' },
|
|
162
|
-
|
|
230
|
+
// see-through faces (alpha < 1) are blended over what is behind them: they test depth but do not write it
|
|
231
|
+
depthStencil: { format: 'depth24plus', depthWriteEnabled: !glass, depthCompare: 'less' },
|
|
163
232
|
multisample: { count: sampleCount },
|
|
164
233
|
});
|
|
165
234
|
const pipeline = make('vs', [meshLayout]);
|
|
235
|
+
const glassPipeline = make('vs', [meshLayout], true);
|
|
166
236
|
// the instanced pipeline reads position and normal only from the prototype (locations 0, 1)
|
|
167
237
|
const instPipeline = make('vsi', [{ ...meshLayout, attributes: meshLayout.attributes.slice(0, 2) }, instanceLayout]);
|
|
168
|
-
const uniforms = device.createBuffer({ size:
|
|
238
|
+
const uniforms = device.createBuffer({ size: 160, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
|
|
169
239
|
const bind = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: uniforms } }] });
|
|
170
240
|
const instBind = device.createBindGroup({ layout: instPipeline.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: uniforms } }] });
|
|
171
241
|
// Geometry that did not change since the last frame is not rebuilt or uploaded again.
|
|
172
242
|
let uploaded = null; // the mesh now in vertexBuffer
|
|
243
|
+
let uploadedPalette; // and the palette it was built with
|
|
173
244
|
let vertexCount = 0;
|
|
174
245
|
let protoMesh = null;
|
|
175
246
|
let protoBuffer = null;
|
|
@@ -180,6 +251,12 @@ export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
|
|
|
180
251
|
let instCount = 0;
|
|
181
252
|
let vertexBuffer = null;
|
|
182
253
|
let capacity = 0;
|
|
254
|
+
let glassBuffer = null; // the see-through triangles, sorted far to near for the eye
|
|
255
|
+
let glassCapacity = 0;
|
|
256
|
+
let glassTris = []; // centroid and offset (in floats) in glassSource
|
|
257
|
+
let glassSource = new Float32Array(0);
|
|
258
|
+
let glassCount = 0;
|
|
259
|
+
let glassEye = '';
|
|
183
260
|
let px = 0;
|
|
184
261
|
let depth = null;
|
|
185
262
|
let color = null;
|
|
@@ -194,13 +271,30 @@ export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
|
|
|
194
271
|
depth = device.createTexture({ size: [n, n], format: 'depth24plus', sampleCount, usage: GPUTextureUsage.RENDER_ATTACHMENT });
|
|
195
272
|
color = sampleCount > 1 ? device.createTexture({ size: [n, n], format, sampleCount, usage: GPUTextureUsage.RENDER_ATTACHMENT }) : null;
|
|
196
273
|
};
|
|
197
|
-
const draw = (mesh, view, { size = 400, fit, background = [0.98, 0.98, 0.97], light = {}, material = {}, dpr = globalThis.devicePixelRatio || 1, instances } = {}, readback = false) => {
|
|
274
|
+
const draw = (mesh, view, { size = 400, fit, background = [0.98, 0.98, 0.97], light = {}, material = {}, palette, dpr = globalThis.devicePixelRatio || 1, instances, tonemap = false } = {}, readback = false) => {
|
|
198
275
|
const L = { ...defaultLight, ...light };
|
|
276
|
+
const filmic = tonemap ? { exposure: 1, emit: 2, ...(tonemap === true ? {} : tonemap) } : null;
|
|
199
277
|
const M = { ...defaultMaterial, ...material };
|
|
200
278
|
const cam = makeCamera(view, mesh, size, fit);
|
|
201
279
|
targets(Math.max(1, Math.round(size * dpr)));
|
|
202
|
-
if (mesh !== uploaded) {
|
|
203
|
-
const
|
|
280
|
+
if (mesh !== uploaded || palette !== uploadedPalette) {
|
|
281
|
+
const all = buildVertices(mesh, palette);
|
|
282
|
+
// split: solid triangles are drawn first; triangles with alpha < 1 are kept apart and blended afterwards
|
|
283
|
+
const tri = 3 * FLOATS;
|
|
284
|
+
const solid = [];
|
|
285
|
+
glassTris = [];
|
|
286
|
+
for (let o = 0; o < all.length; o += tri) {
|
|
287
|
+
if (all[o + 11] < 1) {
|
|
288
|
+
const c = [0, 1, 2].map((k) => (all[o + k] + all[o + FLOATS + k] + all[o + 2 * FLOATS + k]) / 3);
|
|
289
|
+
glassTris.push({ c, at: o });
|
|
290
|
+
}
|
|
291
|
+
else
|
|
292
|
+
for (let k = 0; k < tri; k++)
|
|
293
|
+
solid.push(all[o + k]);
|
|
294
|
+
}
|
|
295
|
+
glassSource = all;
|
|
296
|
+
glassEye = '';
|
|
297
|
+
const verts = Float32Array.from(solid);
|
|
204
298
|
if (verts.byteLength > capacity) {
|
|
205
299
|
vertexBuffer?.destroy();
|
|
206
300
|
capacity = Math.max(verts.byteLength, 4096);
|
|
@@ -210,6 +304,7 @@ export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
|
|
|
210
304
|
device.queue.writeBuffer(vertexBuffer, 0, verts);
|
|
211
305
|
vertexCount = verts.length / FLOATS;
|
|
212
306
|
uploaded = mesh;
|
|
307
|
+
uploadedPalette = palette;
|
|
213
308
|
}
|
|
214
309
|
if (instances?.mesh && instances.mesh !== protoMesh) {
|
|
215
310
|
const v = buildVertices(instances.mesh);
|
|
@@ -233,13 +328,32 @@ export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
|
|
|
233
328
|
}
|
|
234
329
|
if (!instances)
|
|
235
330
|
instCount = 0;
|
|
236
|
-
const
|
|
331
|
+
const eyeKey = cam.eye.join(',');
|
|
332
|
+
if (glassTris.length && eyeKey !== glassEye) {
|
|
333
|
+
glassEye = eyeKey;
|
|
334
|
+
const [ex, ey, ez] = cam.eye;
|
|
335
|
+
const far = (t) => (t.c[0] - ex) ** 2 + (t.c[1] - ey) ** 2 + (t.c[2] - ez) ** 2;
|
|
336
|
+
const sorted = [...glassTris].sort((a, b) => far(b) - far(a)); // far first
|
|
337
|
+
const out = new Float32Array(sorted.length * 3 * FLOATS);
|
|
338
|
+
sorted.forEach((t, k) => out.set(glassSource.subarray(t.at, t.at + 3 * FLOATS), k * 3 * FLOATS));
|
|
339
|
+
if (out.byteLength > glassCapacity) {
|
|
340
|
+
glassBuffer?.destroy();
|
|
341
|
+
glassCapacity = Math.max(out.byteLength, 4096);
|
|
342
|
+
glassBuffer = device.createBuffer({ size: glassCapacity, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
|
|
343
|
+
}
|
|
344
|
+
device.queue.writeBuffer(glassBuffer, 0, out);
|
|
345
|
+
glassCount = sorted.length * 3;
|
|
346
|
+
}
|
|
347
|
+
if (!glassTris.length)
|
|
348
|
+
glassCount = 0;
|
|
349
|
+
const u = new Float32Array(40);
|
|
237
350
|
u.set(viewProjection(cam), 0);
|
|
238
351
|
u.set([...(norm(L.direction)), 0], 16);
|
|
239
352
|
u.set([...L.sky, 0], 20);
|
|
240
353
|
u.set([...L.ground, 0], 24);
|
|
241
354
|
u.set([...cam.eye, 0], 28);
|
|
242
355
|
u.set([M.ambient, M.diffuse, M.specular, M.shininess], 32);
|
|
356
|
+
u.set([filmic ? 1 : 0, filmic ? filmic.exposure : 1, filmic ? filmic.emit : 0, 0], 36);
|
|
243
357
|
device.queue.writeBuffer(uniforms, 0, u);
|
|
244
358
|
const encoder = device.createCommandEncoder();
|
|
245
359
|
const offscreen = readback
|
|
@@ -271,6 +385,13 @@ export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
|
|
|
271
385
|
pass.setVertexBuffer(1, instBuffer);
|
|
272
386
|
pass.draw(protoCount, instCount);
|
|
273
387
|
}
|
|
388
|
+
if (glassCount) {
|
|
389
|
+
// after everything solid (and the instances): see-through faces, far to near
|
|
390
|
+
pass.setPipeline(glassPipeline);
|
|
391
|
+
pass.setBindGroup(0, bind);
|
|
392
|
+
pass.setVertexBuffer(0, glassBuffer);
|
|
393
|
+
pass.draw(glassCount);
|
|
394
|
+
}
|
|
274
395
|
pass.end();
|
|
275
396
|
if (!offscreen) {
|
|
276
397
|
device.queue.submit([encoder.finish()]);
|
|
@@ -308,6 +429,7 @@ export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
|
|
|
308
429
|
const pixels = (mesh, view, options) => draw(mesh, view, options, true);
|
|
309
430
|
const destroy = () => {
|
|
310
431
|
vertexBuffer?.destroy();
|
|
432
|
+
glassBuffer?.destroy();
|
|
311
433
|
protoBuffer?.destroy();
|
|
312
434
|
instBuffer?.destroy();
|
|
313
435
|
depth?.destroy();
|