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/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 and face colors. Float32Array, 9 floats per vertex. */
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
- const FLOATS = 9; // position(3) normal(3) color(3)
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 and face colors. Float32Array, 9 floats per vertex. */
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 c = rgb(face.color);
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
- let spec = pow(max(dot(n, h), 0.0), u.mat.w) * u.mat.z * step(0.0, dot(n, l));
118
- let lit = i.c * (hemi + vec3<f32>(diff)) + vec3<f32>(spec);
119
- return vec4<f32>(clamp(lit, vec3<f32>(0.0), vec3<f32>(1.0)), 1.0);
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: { module, entryPoint: 'fs', targets: [{ format }] },
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
- depthStencil: { format: 'depth24plus', depthWriteEnabled: true, depthCompare: 'less' },
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: 144, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
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 verts = buildVertices(mesh);
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 u = new Float32Array(36);
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();