3draft 26.11.4010 → 26.11.11010

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/gpu.js CHANGED
@@ -3,15 +3,149 @@
3
3
  // the cross marks and the framing all stay valid. The pure helpers (viewProjection,
4
4
  // buildVertices) need no GPU and are unit tested; createGpu() needs a browser.
5
5
  import { dot, norm } from './vec.js';
6
- import { faceNormal } from './mesh.js';
6
+ import { bounds, faceNormal } from './mesh.js';
7
+ import { triangulate } from './triangulate.js';
7
8
  import { makeCamera } from './camera.js';
8
9
  import { surfaceOf } from './material.js';
9
- const FLOATS = 13; // position(3) normal(3) color(3) material(4: metal, rough, alpha, emit)
10
+ /** Floats of one mark segment: a (xyz), b (xyz), colour (rgba), width in CSS pixels, one spare. */
11
+ export const MARK_FLOATS = 12;
12
+ const colorOf = (css) => {
13
+ const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec((css ?? '#0b7285').trim());
14
+ const h = m ? (m[1].length === 3 ? [...m[1]].map((c) => c + c).join('') : m[1]) : '0b7285';
15
+ return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255);
16
+ };
17
+ /** One instance per segment of every polyline of every mark (a zero-length segment is a round point). */
18
+ export const buildMarks = (marks) => {
19
+ let n = 0;
20
+ for (const m of marks)
21
+ for (const l of m.lines)
22
+ n += Math.max(1, l.length - 1);
23
+ const out = new Float32Array(n * MARK_FLOATS);
24
+ let o = 0;
25
+ for (const m of marks) {
26
+ const [r, g, b] = colorOf(m.color);
27
+ for (const l of m.lines) {
28
+ const segs = l.length > 1 ? l.length - 1 : 1;
29
+ for (let i = 0; i < segs; i++) {
30
+ const a = l[i];
31
+ const c = l[Math.min(i + 1, l.length - 1)];
32
+ out.set([a[0], a[1], a[2], c[0], c[1], c[2], r, g, b, m.opacity ?? 1, m.width ?? 2], o);
33
+ o += MARK_FLOATS;
34
+ }
35
+ }
36
+ }
37
+ return out;
38
+ };
39
+ /** Line-list endpoints (x y z, x y z) of every edge of every face, each pair of vertices once. */
40
+ export const buildEdges = (mesh) => {
41
+ const seen = new Set();
42
+ const n = mesh.positions.length;
43
+ const out = [];
44
+ for (const f of mesh.faces)
45
+ f.v.forEach((a, i) => {
46
+ const b = f.v[(i + 1) % f.v.length];
47
+ const k = a < b ? a * n + b : b * n + a;
48
+ if (seen.has(k))
49
+ return;
50
+ seen.add(k);
51
+ out.push(...mesh.positions[a], ...mesh.positions[b]);
52
+ });
53
+ return new Float32Array(out);
54
+ };
55
+ const EDGE_LIMIT = 300000; // faces: a bigger scene (a galaxy of super-stars) is drawn without edge lines
56
+ const partEdges = new WeakMap();
57
+ /** The edge lines of a scene: of its parts (kept while a part stays the same Mesh) or of the whole mesh. Empty when the scene is huge. */
58
+ export const sceneEdges = (mesh, parts) => {
59
+ if (parts && parts.length) {
60
+ if (parts.reduce((n, m) => n + m.faces.length, 0) > EDGE_LIMIT)
61
+ return new Float32Array(0);
62
+ const built = parts.map((m) => {
63
+ let e = partEdges.get(m);
64
+ if (!e)
65
+ partEdges.set(m, (e = buildEdges(m)));
66
+ return e;
67
+ });
68
+ const all = new Float32Array(built.reduce((n, e) => n + e.length, 0));
69
+ let at = 0;
70
+ for (const e of built)
71
+ (all.set(e, at), (at += e.length));
72
+ return all;
73
+ }
74
+ return mesh.faces.length > EDGE_LIMIT ? new Float32Array(0) : buildEdges(mesh);
75
+ };
76
+ /** Floats of one glow light: position (3), reach (1), colour times intensity (3), spare (1). */
77
+ export const LIGHT_FLOATS = 8;
78
+ export const MAX_LIGHTS = 16;
79
+ const emitters = new WeakMap();
80
+ /** Every emitting face of a mesh: centre (3), area times emit (1), colour (3), area (1). */
81
+ const emittersOf = (mesh, palette) => {
82
+ const hit = emitters.get(mesh);
83
+ if (hit && hit.palette === palette)
84
+ return hit.data;
85
+ const rows = [];
86
+ for (const face of mesh.faces) {
87
+ const s = surfaceOf(face, palette);
88
+ if (s.emit < 0.05)
89
+ continue;
90
+ const pts = face.v.map((k) => mesh.positions[k]);
91
+ const area = Math.hypot(...(faceNormal(pts))) || 0;
92
+ if (!area)
93
+ continue;
94
+ const c = [0, 1, 2].map((k) => pts.reduce((a, p) => a + p[k], 0) / pts.length);
95
+ const [r, g, b] = colorOf(s.color);
96
+ rows.push(...c, area * s.emit, r, g, b, area);
97
+ }
98
+ const data = new Float32Array(rows);
99
+ emitters.set(mesh, { palette, data });
100
+ return data;
101
+ };
102
+ /**
103
+ * Glow: the emitting faces of a scene act as local light sources. Faces close to each other (one cell of a grid) merge into
104
+ * one light at their weighted centre; the brightest MAX_LIGHTS stay. Each light has a reach that grows with the emitting area.
105
+ * Float32Array, LIGHT_FLOATS per light.
106
+ */
107
+ export const sceneLights = (mesh, parts, palette) => {
108
+ const list = (parts && parts.length ? parts : [mesh]).map((m) => emittersOf(m, palette)).filter((e) => e.length);
109
+ if (!list.length)
110
+ return new Float32Array(0);
111
+ const lo = [Infinity, Infinity, Infinity];
112
+ const hi = [-Infinity, -Infinity, -Infinity];
113
+ for (const e of list)
114
+ for (let i = 0; i < e.length; i += 8)
115
+ for (let k = 0; k < 3; k++)
116
+ (lo[k] = Math.min(lo[k], e[i + k]), (hi[k] = Math.max(hi[k], e[i + k])));
117
+ const cell = Math.max((Math.max(hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]) / 6) || 1, 1e-3);
118
+ const cells = new Map();
119
+ for (const e of list) {
120
+ for (let i = 0; i < e.length; i += 8) {
121
+ const key = [0, 1, 2].map((k) => Math.floor((e[i + k] - lo[k]) / cell)).join(',');
122
+ const c = cells.get(key) ?? { p: 0, x: 0, y: 0, z: 0, r: 0, g: 0, b: 0, a: 0 };
123
+ const w = e[i + 3];
124
+ c.p += w;
125
+ c.x += e[i] * w;
126
+ c.y += e[i + 1] * w;
127
+ c.z += e[i + 2] * w;
128
+ c.r += e[i + 4] * w;
129
+ c.g += e[i + 5] * w;
130
+ c.b += e[i + 6] * w;
131
+ c.a += e[i + 7];
132
+ cells.set(key, c);
133
+ }
134
+ }
135
+ const top = [...cells.values()].sort((a, b) => b.p - a.p).slice(0, MAX_LIGHTS);
136
+ const out = new Float32Array(top.length * LIGHT_FLOATS);
137
+ top.forEach((c, i) => {
138
+ const reach = Math.max(2.5 * Math.sqrt(c.p), 0.05); // a 1 m x 1 m lamp at emit 1 reaches 2.5 m
139
+ out.set([c.x / c.p, c.y / c.p, c.z / c.p, reach, c.r / c.p, c.g / c.p, c.b / c.p, 0], i * LIGHT_FLOATS);
140
+ });
141
+ return out;
142
+ };
143
+ const FLOATS = 14; // position(3) normal(3) color(3) material(4: metal, rough, alpha, emit) soft(1)
10
144
  /**
11
145
  * Column-major 4x4 matrix (for WebGPU, z in 0..1) that maps world points exactly
12
146
  * like camera.project(): the same pixels, plus a depth between near and far.
13
147
  */
14
- export const viewProjection = (cam, near = 0.03, far = 1000) => {
148
+ export const viewProjection = (cam, near = 0.03, far = 1e9) => {
15
149
  const kk = cam.k / (cam.size / 2);
16
150
  const row = (v, s) => [v[0] * s, v[1] * s, v[2] * s, -dot(v, cam.eye) * s];
17
151
  const a = cam.ortho ? 1 / (far - near) : far / (far - near);
@@ -22,6 +156,21 @@ export const viewProjection = (cam, near = 0.03, far = 1000) => {
22
156
  rows.forEach((r, i) => r.forEach((v, j) => (m[j * 4 + i] = v)));
23
157
  return m;
24
158
  };
159
+ /**
160
+ * Near plane for a scene seen from `cam`: the usual 0.03, unless everything is very far (a galaxy seen from outside, the
161
+ * nearest point thousands of units away), where a near plane that close would leave no depth precision at all.
162
+ */
163
+ export const nearPlane = (cam, mesh) => {
164
+ const [ex, ey, ez] = cam.eye;
165
+ const [fx, fy, fz] = cam.f;
166
+ let nearest = Infinity;
167
+ for (const p of mesh.positions) {
168
+ const d = (p[0] - ex) * fx + (p[1] - ey) * fy + (p[2] - ez) * fz;
169
+ if (d > 1e-6 && d < nearest)
170
+ nearest = d;
171
+ }
172
+ return nearest > 1000 ? nearest * 0.25 : 0.03;
173
+ };
25
174
  const rgb = (color) => {
26
175
  const h = String(color ?? '').replace('#', '');
27
176
  const full = h.length === 3 ? [...h].map((c) => c + c).join('') : h;
@@ -29,26 +178,67 @@ const rgb = (color) => {
29
178
  return [0.78, 0.78, 0.78];
30
179
  return [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16) / 255);
31
180
  };
32
- /** Triangle list for the GPU: flat face normals, face colors and materials. Float32Array, 13 floats per vertex. */
33
- export const buildVertices = (mesh, palette) => {
181
+ /**
182
+ * Smooth shading: a normal for every corner of every face. The faces that meet at a point (welded by position, so a
183
+ * crack in the indices does not break it) are averaged, weighted by area, but only those within `crease` degrees of
184
+ * this face: a 90 degree box corner stays sharp, a faceted fuselage becomes round.
185
+ */
186
+ export const cornerNormals = (mesh, crease, soft) => {
187
+ // the material's soft decides how far its edges may be smoothed: 0 keeps every edge hard, from 0.5 up the whole crease angle is allowed;
188
+ // between two faces the harder one rules
189
+ const cosAt = (i, j) => {
190
+ const k = soft ? Math.min(1, 2 * Math.min(soft[i], soft[j])) : 1;
191
+ return Math.cos((crease * k * Math.PI) / 180);
192
+ };
193
+ const area = mesh.faces.map((f) => faceNormal(f.v.map((k) => mesh.positions[k]))); // length = area
194
+ const unit = area.map((n) => (Math.hypot(...n) > 0 ? norm(n) : [0, 1, 0]));
195
+ const q = (bounds(mesh).radius || 1) * 1e-6;
196
+ const key = (p) => `${Math.round(p[0] / q)},${Math.round(p[1] / q)},${Math.round(p[2] / q)}`;
197
+ const at = new Map();
198
+ mesh.faces.forEach((f, i) => f.v.forEach((k) => {
199
+ const id = key(mesh.positions[k]);
200
+ const list = at.get(id) ?? [];
201
+ if (list[list.length - 1] !== i)
202
+ list.push(i);
203
+ at.set(id, list);
204
+ }));
205
+ return mesh.faces.map((f, i) => f.v.map((k) => {
206
+ const sum = [0, 0, 0];
207
+ for (const j of at.get(key(mesh.positions[k])) ?? []) {
208
+ if (i !== j && (soft ? Math.min(soft[i], soft[j]) <= 0 : false))
209
+ continue;
210
+ if (dot(unit[i], unit[j]) < cosAt(i, j) - 1e-12)
211
+ continue;
212
+ for (let c = 0; c < 3; c++)
213
+ sum[c] += area[j][c];
214
+ }
215
+ return Math.hypot(...sum) > 0 ? norm(sum) : unit[i];
216
+ }));
217
+ };
218
+ /**
219
+ * Triangle list for the GPU: face normals (flat, or smoothed with `smooth` degrees), face colors and materials.
220
+ * Float32Array, 14 floats per vertex.
221
+ */
222
+ export const buildVertices = (mesh, palette, { smooth = 0 } = {}) => {
34
223
  let tris = 0;
35
224
  for (const f of mesh.faces)
36
225
  tris += Math.max(0, f.v.length - 2);
37
226
  const out = new Float32Array(tris * 3 * FLOATS);
227
+ const corners = smooth > 0 ? cornerNormals(mesh, smooth, mesh.faces.map((f) => surfaceOf(f, palette).soft)) : null;
38
228
  let o = 0;
39
- for (const face of mesh.faces) {
229
+ mesh.faces.forEach((face, fi) => {
40
230
  const pts = face.v.map((k) => mesh.positions[k]);
41
- const n = norm(faceNormal(pts));
231
+ const flat = norm(faceNormal(pts));
42
232
  const s = surfaceOf(face, palette);
43
233
  const c = rgb(s.color);
44
234
  const mat = [s.metal, s.rough, s.alpha, s.emit];
45
- for (let j = 1; j < pts.length - 1; j++) {
46
- for (const p of [pts[0], pts[j], pts[j + 1]]) {
47
- out.set([...p, ...n, ...c, ...mat], o);
235
+ for (const tri of triangulate(pts)) {
236
+ for (const k of tri) {
237
+ out.set([...pts[k], ...(corners ? corners[fi][k] : flat), ...c, ...mat, s.soft], o);
48
238
  o += FLOATS;
49
239
  }
50
240
  }
51
- }
241
+ });
52
242
  return out;
53
243
  };
54
244
  const INSTANCE_FLOATS = 15; // at(3) ex(3) ey(3) ez(3) color(3)
@@ -71,17 +261,23 @@ struct U {
71
261
  ground : vec4<f32>,
72
262
  eye : vec4<f32>,
73
263
  mat : vec4<f32>, // ambient, diffuse, specular, shininess
74
- fx : vec4<f32>, // filmic (0 or 1), exposure, emission gain
264
+ fx : vec4<f32>, // filmic (0 or 1), exposure, emission gain, studio (0 or 1)
265
+ fill : vec4<f32>, // studio: xyz direction towards the fill light, w intensity
266
+ rim : vec4<f32>, // studio: xyz direction towards the rim light, w intensity
267
+ res : vec4<f32>, // marks: canvas pixels (x, y), device pixels per CSS pixel (z); w: number of glow lights
268
+ lpos : array<vec4<f32>, 16>, // glow lights: xyz position, w reach
269
+ lcol : array<vec4<f32>, 16>, // rgb colour times intensity
75
270
  };
76
271
  @group(0) @binding(0) var<uniform> u : U;
77
272
 
78
- struct VIn { @location(0) pos : vec3<f32>, @location(1) nrm : vec3<f32>, @location(2) col : vec3<f32>, @location(8) mat : vec4<f32> };
273
+ struct VIn { @location(0) pos : vec3<f32>, @location(1) nrm : vec3<f32>, @location(2) col : vec3<f32>, @location(8) mat : vec4<f32>, @location(9) soft : f32 };
79
274
  struct VOut {
80
275
  @builtin(position) p : vec4<f32>,
81
276
  @location(0) n : vec3<f32>,
82
277
  @location(1) c : vec3<f32>,
83
278
  @location(2) w : vec3<f32>,
84
279
  @location(3) m : vec4<f32>, // metal, rough, alpha, emit
280
+ @location(4) s : f32, // soft
85
281
  };
86
282
 
87
283
  @vertex fn vs(i : VIn) -> VOut {
@@ -91,6 +287,7 @@ struct VOut {
91
287
  o.c = i.col;
92
288
  o.w = i.pos;
93
289
  o.m = i.mat;
290
+ o.s = i.soft;
94
291
  return o;
95
292
  }
96
293
 
@@ -110,8 +307,61 @@ struct IIn {
110
307
  o.c = i.col;
111
308
  o.w = w;
112
309
  o.m = vec4<f32>(0.0, 0.7, 1.0, 0.0);
310
+ o.s = 0.0;
311
+ return o;
312
+ }
313
+
314
+ // the edge lines: 1 pixel, nudged a hair towards the eye so they win against the faces they lie on
315
+ @vertex fn vsl(@location(0) pos : vec3<f32>) -> @builtin(position) vec4<f32> {
316
+ var p = u.vp * vec4<f32>(pos, 1.0);
317
+ p.z = p.z - 0.0002 * p.w;
318
+ return p;
319
+ }
320
+ @fragment fn fsl() -> @location(0) vec4<f32> {
321
+ // dark lines on the lit picture; with no light the picture is dark, so the lines turn light
322
+ let g = select(0.0, 0.85, u.light.w > 0.5);
323
+ return vec4<f32>(g, g, g, select(0.4, 0.55, u.light.w > 0.5));
324
+ }
325
+
326
+ // the marks: thick round-capped segments, expanded to screen-space quads, drawn over everything (no depth test)
327
+ struct MOut { @builtin(position) p : vec4<f32>, @location(0) q : vec2<f32>, @location(1) c : vec4<f32>, @location(2) h : vec3<f32> };
328
+ @vertex fn vsm(@builtin(vertex_index) vi : u32, @location(0) a : vec3<f32>, @location(1) b : vec3<f32>, @location(2) col : vec4<f32>, @location(3) wd : f32) -> MOut {
329
+ var o : MOut;
330
+ var ca = u.vp * vec4<f32>(a, 1.0);
331
+ var cb = u.vp * vec4<f32>(b, 1.0);
332
+ let e = 0.001;
333
+ o.p = vec4<f32>(2.0, 2.0, 2.0, 1.0);
334
+ if (ca.w < e && cb.w < e) { return o; }
335
+ if (ca.w < e) { ca = mix(ca, cb, (e - ca.w) / (cb.w - ca.w)); }
336
+ if (cb.w < e) { cb = mix(cb, ca, (e - cb.w) / (ca.w - cb.w)); }
337
+ let half = vec2<f32>(u.res.x, u.res.y) * 0.5;
338
+ let pa = ca.xy / ca.w * half;
339
+ let pb = cb.xy / cb.w * half;
340
+ let len = length(pb - pa);
341
+ var d = vec2<f32>(1.0, 0.0);
342
+ if (len > 0.0001) { d = (pb - pa) / len; }
343
+ let hw = wd * u.res.z * 0.5;
344
+ let n = vec2<f32>(-d.y, d.x);
345
+ let cx = array<f32, 6>(0.0, 1.0, 0.0, 1.0, 1.0, 0.0);
346
+ let cy = array<f32, 6>(-1.0, -1.0, 1.0, -1.0, 1.0, 1.0);
347
+ let t = cx[vi];
348
+ let s = cy[vi];
349
+ let ext = hw + 1.0;
350
+ let along = mix(-ext, len + ext, t);
351
+ let across = s * ext;
352
+ let pos = pa + d * along + n * across;
353
+ o.p = vec4<f32>(pos / half, 0.0, 1.0);
354
+ o.q = vec2<f32>(along, across);
355
+ o.c = col;
356
+ o.h = vec3<f32>(len, hw, 0.0);
113
357
  return o;
114
358
  }
359
+ @fragment fn fsm(i : MOut) -> @location(0) vec4<f32> {
360
+ let dx = max(max(-i.q.x, i.q.x - i.h.x), 0.0);
361
+ let dist = length(vec2<f32>(dx, i.q.y));
362
+ let cov = clamp(i.h.y + 0.5 - dist, 0.0, 1.0);
363
+ return vec4<f32>(i.c.rgb, i.c.a * cov);
364
+ }
115
365
 
116
366
  fn toLinear(c : vec3<f32>) -> vec3<f32> {
117
367
  return select(pow((c + 0.055) / 1.055, vec3<f32>(2.4)), c / 12.92, c <= vec3<f32>(0.04045));
@@ -124,16 +374,57 @@ fn aces(x : vec3<f32>) -> vec3<f32> {
124
374
  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
375
  }
126
376
 
377
+ // Direct light, shared by both looks: x diffuse, y highlight, z scattered light (soft materials).
378
+ // Studio: the key light is u.light (placed by the camera), plus a softer fill and a rim light that only edges the shape.
379
+ fn lights(n : vec3<f32>, v : vec3<f32>, shin : f32, soft : f32) -> vec3<f32> {
380
+ let key = normalize(u.light.xyz);
381
+ let ndl = dot(n, key);
382
+ // soft: the light wraps round the form, and a warm band of scattered light sits where light turns to shade
383
+ var diff = max((ndl + soft) / (1.0 + soft), 0.0);
384
+ let h = normalize(key + v);
385
+ var spec = pow(max(dot(n, h), 0.0), shin) * step(0.0, ndl);
386
+ let scat = soft * exp(-ndl * ndl * 5.0);
387
+ if (u.fx.w > 0.5) {
388
+ diff = diff * 0.85; // three lights add up: the key takes a little less
389
+ let fl = normalize(u.fill.xyz);
390
+ diff = diff + max((dot(n, fl) + soft) / (1.0 + soft), 0.0) * u.fill.w;
391
+ let rl = normalize(u.rim.xyz);
392
+ let edge = pow(1.0 - max(dot(n, v), 0.0), 2.5);
393
+ diff = diff + max(dot(n, rl), 0.0) * edge * u.rim.w * 2.0;
394
+ let rh = normalize(rl + v);
395
+ spec = spec + pow(max(dot(n, rh), 0.0), shin) * step(0.0, dot(n, rl)) * u.rim.w * 0.6 * edge;
396
+ }
397
+ return vec3<f32>(diff, spec, scat);
398
+ }
399
+
400
+ // Glow: every emitting cluster is a local light with a smooth falloff to zero at its reach, a little wrap so shade edges soften
401
+ fn glowAt(p : vec3<f32>, n : vec3<f32>) -> vec3<f32> {
402
+ var sum = vec3<f32>(0.0);
403
+ let cnt = u32(u.res.w);
404
+ for (var k = 0u; k < cnt; k = k + 1u) {
405
+ let d = u.lpos[k].xyz - p;
406
+ let dist = max(length(d), 0.001);
407
+ let reach = u.lpos[k].w;
408
+ let x = dist / reach;
409
+ var win = clamp(1.0 - x * x * x * x, 0.0, 1.0);
410
+ win = win * win;
411
+ let s = dist / (reach * 0.25);
412
+ let wrap = max((dot(n, d / dist) + 0.3) / 1.3, 0.0);
413
+ sum = sum + u.lcol[k].rgb * (win / (1.0 + s * s)) * wrap * 1.6;
414
+ }
415
+ return sum;
416
+ }
417
+
127
418
  // The filmic path: the same light model, but in linear colour with HDR light and emission, then tone mapped.
128
419
  fn filmic(i : VOut, n : vec3<f32>, l : vec3<f32>, v : vec3<f32>) -> vec4<f32> {
129
420
  let metal = i.m.x;
130
421
  let rough = i.m.y;
131
422
  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);
423
+ let hemi = mix(toLinear(u.ground.rgb), toLinear(u.sky.rgb), n.y * 0.5 + 0.5) * u.mat.x * select(1.0, 0.7, u.fx.w > 0.5);
135
424
  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;
425
+ let lt = lights(n, v, shin, i.s);
426
+ let diff = lt.x * u.mat.y * 1.6;
427
+ let spec = lt.y * mix(u.mat.z, 1.0, metal) * mix(1.0, 0.35, rough) * 2.5;
137
428
  let r = reflect(-v, n);
138
429
  // Schlick: edges mirror more, metal keeps its tint
139
430
  let f0 = mix(vec3<f32>(0.04), albedo, metal);
@@ -144,6 +435,8 @@ fn filmic(i : VOut, n : vec3<f32>, l : vec3<f32>, v : vec3<f32>) -> vec4<f32> {
144
435
  + env * fres * (1.0 - rough) * 0.5
145
436
  + vec3<f32>(spec) * mix(vec3<f32>(1.0), albedo, metal);
146
437
  // emission adds light, so a lamp can go above 1.0 and still keep its colour after tone mapping
438
+ lit = lit + albedo * vec3<f32>(1.0, 0.38, 0.26) * lt.z * 0.5 * (1.0 - metal); // soft: the warm scattered band
439
+ lit = lit + albedo * toLinear(vec3<f32>(1.0)) * glowAt(i.w, n) * (1.0 - metal);
147
440
  lit = lit + albedo * i.m.w * u.fx.z;
148
441
  return vec4<f32>(toSrgb(aces(lit * u.fx.y)), i.m.z);
149
442
  }
@@ -153,21 +446,29 @@ fn filmic(i : VOut, n : vec3<f32>, l : vec3<f32>, v : vec3<f32>) -> vec4<f32> {
153
446
  if (!front) { n = -n; }
154
447
  let l = normalize(u.light.xyz);
155
448
  let v = normalize(u.eye.xyz - i.w);
449
+ if (u.light.w > 0.5) {
450
+ // unlit: no light in the scene, only what glows
451
+ let g = glowAt(i.w, n) * (1.0 - i.m.x);
452
+ if (u.fx.x > 0.5) { return vec4<f32>(toSrgb(aces((toLinear(i.c) * (i.m.w * u.fx.z + g)) * u.fx.y)), i.m.z); }
453
+ return vec4<f32>(clamp(i.c * (min(i.m.w, 1.0) + g), vec3<f32>(0.0), vec3<f32>(1.0)), i.m.z);
454
+ }
156
455
  if (u.fx.x > 0.5) { return filmic(i, n, l, v); }
157
456
  let metal = i.m.x;
158
457
  let rough = i.m.y;
159
- let hemi = mix(u.ground.rgb, u.sky.rgb, n.y * 0.5 + 0.5) * u.mat.x;
160
- let diff = max(dot(n, l), 0.0) * u.mat.y;
161
- let h = normalize(l + v);
458
+ let hemi = mix(u.ground.rgb, u.sky.rgb, n.y * 0.5 + 0.5) * u.mat.x * select(1.0, 0.7, u.fx.w > 0.5);
162
459
  // roughness widens the highlight; metal makes it strong and tinted by the color
163
460
  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));
461
+ let lt = lights(n, v, shin, i.s);
462
+ let diff = lt.x * u.mat.y;
463
+ let spec = lt.y * mix(u.mat.z, 1.0, metal) * mix(1.0, 0.35, rough);
165
464
  // metal mirrors the sky above and the ground below, blurred by roughness
166
465
  let r = reflect(-v, n);
167
466
  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
467
  var lit = i.c * (hemi + vec3<f32>(diff)) * (1.0 - metal)
169
468
  + i.c * env * metal * (1.0 - 0.6 * rough)
170
- + vec3<f32>(spec) * mix(vec3<f32>(1.0), i.c, metal);
469
+ + vec3<f32>(spec) * mix(vec3<f32>(1.0), i.c, metal)
470
+ + i.c * vec3<f32>(1.0, 0.38, 0.26) * lt.z * 0.25 * (1.0 - metal);
471
+ lit = lit + i.c * glowAt(i.w, n) * (1.0 - metal);
171
472
  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
473
  return vec4<f32>(clamp(lit, vec3<f32>(0.0), vec3<f32>(1.0)), i.m.z);
173
474
  }
@@ -179,14 +480,18 @@ fn filmic(i : VOut, n : vec3<f32>, l : vec3<f32>, v : vec3<f32>) -> vec4<f32> {
179
480
  * prototype mesh, data from buildInstances; drawn after the mesh), background [r,g,b] 0..1,
180
481
  * light { direction, sky, ground }, material { ambient, diffuse, specular, shininess }, dpr.
181
482
  */
182
- export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
483
+ export const createGpu = async (canvas, { sampleCount = 4, onError } = {}) => {
183
484
  if (!globalThis.navigator?.gpu)
184
485
  throw new Error('WebGPU is not available in this browser');
185
486
  const adapter = await navigator.gpu.requestAdapter();
186
487
  if (!adapter)
187
488
  throw new Error('No WebGPU adapter found');
188
489
  const device = await adapter.requestDevice();
189
- device.addEventListener('uncapturederror', (e) => console.error('WebGPU:', e.error?.message ?? e));
490
+ device.addEventListener('uncapturederror', (e) => {
491
+ const message = String(e.error?.message ?? e);
492
+ console.error('WebGPU:', message);
493
+ onError?.(message);
494
+ });
190
495
  device.lost.then((info) => console.error('WebGPU device lost:', info.reason, info.message));
191
496
  const context = canvas.getContext('webgpu');
192
497
  if (!context)
@@ -194,6 +499,12 @@ export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
194
499
  const format = navigator.gpu.getPreferredCanvasFormat();
195
500
  context.configure({ device, format, alphaMode: 'opaque', });
196
501
  const module = device.createShaderModule({ code: SHADER });
502
+ // shader compile errors are reported to onError too (the browser only logs them to the console)
503
+ module.getCompilationInfo?.().then((info) => {
504
+ for (const m of info.messages ?? [])
505
+ if (m.type === 'error')
506
+ onError?.(`shader line ${m.lineNum}: ${m.message}`);
507
+ });
197
508
  const meshLayout = {
198
509
  arrayStride: FLOATS * 4,
199
510
  attributes: [
@@ -201,6 +512,7 @@ export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
201
512
  { shaderLocation: 1, offset: 12, format: 'float32x3' },
202
513
  { shaderLocation: 2, offset: 24, format: 'float32x3' },
203
514
  { shaderLocation: 8, offset: 36, format: 'float32x4' },
515
+ { shaderLocation: 9, offset: 52, format: 'float32' },
204
516
  ],
205
517
  };
206
518
  const instanceLayout = {
@@ -208,8 +520,14 @@ export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
208
520
  stepMode: 'instance',
209
521
  attributes: [3, 4, 5, 6, 7].map((loc, k) => ({ shaderLocation: loc, offset: k * 12, format: 'float32x3' })),
210
522
  };
523
+ // ONE explicit layout for every pipeline: with 'auto' each pipeline gets its own, and a bind group made for one is refused by the others
524
+ const bindLayout = device.createBindGroupLayout({
525
+ // visibility 1 | 2 = GPUShaderStage.VERTEX | FRAGMENT
526
+ entries: [{ binding: 0, visibility: 1 | 2, buffer: { type: 'uniform' } }],
527
+ });
528
+ const pipelineLayout = device.createPipelineLayout({ bindGroupLayouts: [bindLayout] });
211
529
  const make = (entry, buffers, glass = false) => device.createRenderPipeline({
212
- layout: 'auto',
530
+ layout: pipelineLayout,
213
531
  vertex: { module, entryPoint: entry, buffers },
214
532
  fragment: {
215
533
  module,
@@ -235,12 +553,55 @@ export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
235
553
  const glassPipeline = make('vs', [meshLayout], true);
236
554
  // the instanced pipeline reads position and normal only from the prototype (locations 0, 1)
237
555
  const instPipeline = make('vsi', [{ ...meshLayout, attributes: meshLayout.attributes.slice(0, 2) }, instanceLayout]);
238
- const uniforms = device.createBuffer({ size: 160, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
239
- const bind = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: uniforms } }] });
240
- const instBind = device.createBindGroup({ layout: instPipeline.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: uniforms } }] });
556
+ const linePipeline = device.createRenderPipeline({
557
+ layout: pipelineLayout,
558
+ vertex: { module, entryPoint: 'vsl', buffers: [{ arrayStride: 12, attributes: [{ shaderLocation: 0, offset: 0, format: 'float32x3' }] }] },
559
+ fragment: {
560
+ module,
561
+ entryPoint: 'fsl',
562
+ targets: [{ format, blend: { color: { srcFactor: 'src-alpha', dstFactor: 'one-minus-src-alpha', operation: 'add' }, alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' } } }],
563
+ },
564
+ primitive: { topology: 'line-list' },
565
+ depthStencil: { format: 'depth24plus', depthWriteEnabled: false, depthCompare: 'less-equal' },
566
+ multisample: { count: sampleCount },
567
+ });
568
+ const markPipeline = device.createRenderPipeline({
569
+ layout: pipelineLayout,
570
+ vertex: {
571
+ module,
572
+ entryPoint: 'vsm',
573
+ buffers: [
574
+ {
575
+ arrayStride: MARK_FLOATS * 4,
576
+ stepMode: 'instance',
577
+ attributes: [
578
+ { shaderLocation: 0, offset: 0, format: 'float32x3' },
579
+ { shaderLocation: 1, offset: 12, format: 'float32x3' },
580
+ { shaderLocation: 2, offset: 24, format: 'float32x4' },
581
+ { shaderLocation: 3, offset: 40, format: 'float32' },
582
+ ],
583
+ },
584
+ ],
585
+ },
586
+ fragment: {
587
+ module,
588
+ entryPoint: 'fsm',
589
+ targets: [{ format, blend: { color: { srcFactor: 'src-alpha', dstFactor: 'one-minus-src-alpha', operation: 'add' }, alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' } } }],
590
+ },
591
+ primitive: { topology: 'triangle-list', cullMode: 'none' },
592
+ depthStencil: { format: 'depth24plus', depthWriteEnabled: false, depthCompare: 'always' },
593
+ multisample: { count: sampleCount },
594
+ });
595
+ const uniforms = device.createBuffer({ size: 720, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
596
+ const bind = device.createBindGroup({ layout: bindLayout, entries: [{ binding: 0, resource: { buffer: uniforms } }] });
597
+ const instBind = device.createBindGroup({ layout: bindLayout, entries: [{ binding: 0, resource: { buffer: uniforms } }] });
241
598
  // Geometry that did not change since the last frame is not rebuilt or uploaded again.
242
599
  let uploaded = null; // the mesh now in vertexBuffer
243
600
  let uploadedPalette; // and the palette it was built with
601
+ let uploadedSmooth = 0; // and the smoothing
602
+ let partCache = new WeakMap(); // vertices per part mesh (see `parts`)
603
+ let partPalette;
604
+ let partSmooth = 0;
244
605
  let vertexCount = 0;
245
606
  let protoMesh = null;
246
607
  let protoBuffer = null;
@@ -251,6 +612,15 @@ export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
251
612
  let instCount = 0;
252
613
  let vertexBuffer = null;
253
614
  let capacity = 0;
615
+ let markBuffer = null;
616
+ let markCapacity = 0;
617
+ let markCount = 0;
618
+ let edgeBuffer = null;
619
+ let edgeCapacity = 0;
620
+ let edgeCount = 0; // vertices (two per line)
621
+ let edgeMesh = null;
622
+ let edgeParts;
623
+ let edgeOn = false;
254
624
  let glassBuffer = null; // the see-through triangles, sorted far to near for the eye
255
625
  let glassCapacity = 0;
256
626
  let glassTris = []; // centroid and offset (in floats) in glassSource
@@ -271,30 +641,55 @@ export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
271
641
  depth = device.createTexture({ size: [n, n], format: 'depth24plus', sampleCount, usage: GPUTextureUsage.RENDER_ATTACHMENT });
272
642
  color = sampleCount > 1 ? device.createTexture({ size: [n, n], format, sampleCount, usage: GPUTextureUsage.RENDER_ATTACHMENT }) : null;
273
643
  };
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) => {
644
+ const draw = (mesh, view, { size = 400, fit, background = [0.98, 0.98, 0.97], light = {}, material = {}, palette, dpr = globalThis.devicePixelRatio || 1, instances, parts, tonemap = false, smooth = false, studio = false, edges = false, marks, unlit = false, glow = false } = {}, readback = false) => {
275
645
  const L = { ...defaultLight, ...light };
276
646
  const filmic = tonemap ? { exposure: 1, emit: 2, ...(tonemap === true ? {} : tonemap) } : null;
277
647
  const M = { ...defaultMaterial, ...material };
278
648
  const cam = makeCamera(view, mesh, size, fit);
279
649
  targets(Math.max(1, Math.round(size * dpr)));
280
- if (mesh !== uploaded || palette !== uploadedPalette) {
281
- const all = buildVertices(mesh, palette);
650
+ const crease = smooth === true ? 40 : smooth || 0;
651
+ if (mesh !== uploaded || palette !== uploadedPalette || crease !== uploadedSmooth) {
652
+ uploadedSmooth = crease;
653
+ let all;
654
+ if (parts && parts.length) {
655
+ if (palette !== partPalette || crease !== partSmooth) {
656
+ partCache = new WeakMap();
657
+ partPalette = palette;
658
+ partSmooth = crease;
659
+ }
660
+ const built = parts.map((m) => {
661
+ let v = partCache.get(m);
662
+ if (!v)
663
+ partCache.set(m, (v = buildVertices(m, palette, { smooth: crease })));
664
+ return v;
665
+ });
666
+ all = new Float32Array(built.reduce((n, v) => n + v.length, 0));
667
+ let at = 0;
668
+ for (const v of built) {
669
+ all.set(v, at);
670
+ at += v.length;
671
+ }
672
+ }
673
+ else
674
+ all = buildVertices(mesh, palette, { smooth: crease });
282
675
  // split: solid triangles are drawn first; triangles with alpha < 1 are kept apart and blended afterwards
283
676
  const tri = 3 * FLOATS;
284
- const solid = [];
677
+ const solid = new Float32Array(all.length);
678
+ let solidLen = 0;
285
679
  glassTris = [];
286
680
  for (let o = 0; o < all.length; o += tri) {
287
681
  if (all[o + 11] < 1) {
288
682
  const c = [0, 1, 2].map((k) => (all[o + k] + all[o + FLOATS + k] + all[o + 2 * FLOATS + k]) / 3);
289
683
  glassTris.push({ c, at: o });
290
684
  }
291
- else
292
- for (let k = 0; k < tri; k++)
293
- solid.push(all[o + k]);
685
+ else {
686
+ solid.set(all.subarray(o, o + tri), solidLen);
687
+ solidLen += tri;
688
+ }
294
689
  }
295
690
  glassSource = all;
296
691
  glassEye = '';
297
- const verts = Float32Array.from(solid);
692
+ const verts = solid.subarray(0, solidLen);
298
693
  if (verts.byteLength > capacity) {
299
694
  vertexBuffer?.destroy();
300
695
  capacity = Math.max(verts.byteLength, 4096);
@@ -306,6 +701,22 @@ export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
306
701
  uploaded = mesh;
307
702
  uploadedPalette = palette;
308
703
  }
704
+ if (!edges)
705
+ edgeCount = 0;
706
+ else if (mesh !== edgeMesh || parts !== edgeParts || !edgeOn) {
707
+ const e = sceneEdges(mesh, parts);
708
+ if (e.byteLength > edgeCapacity) {
709
+ edgeBuffer?.destroy();
710
+ edgeCapacity = Math.max(e.byteLength, 4096);
711
+ edgeBuffer = device.createBuffer({ size: edgeCapacity, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
712
+ }
713
+ if (e.byteLength)
714
+ device.queue.writeBuffer(edgeBuffer, 0, e);
715
+ edgeCount = e.length / 3;
716
+ edgeMesh = mesh;
717
+ edgeParts = parts;
718
+ }
719
+ edgeOn = edges;
309
720
  if (instances?.mesh && instances.mesh !== protoMesh) {
310
721
  const v = buildVertices(instances.mesh);
311
722
  protoBuffer?.destroy();
@@ -346,15 +757,36 @@ export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
346
757
  }
347
758
  if (!glassTris.length)
348
759
  glassCount = 0;
349
- const u = new Float32Array(40);
350
- u.set(viewProjection(cam), 0);
351
- u.set([...(norm(L.direction)), 0], 16);
760
+ // studio rig, relative to the camera: key upper left in front, fill from the right, rim from behind and above
761
+ const rig = (f, r, up) => norm([0, 1, 2].map((k) => -cam.f[k] * f + cam.r[k] * r + cam.u[k] * up));
762
+ const S = studio ? { fill: 0.4, rim: 1, ...(studio === true ? {} : studio) } : null;
763
+ const u = new Float32Array(52 + MAX_LIGHTS * 8);
764
+ u.set(viewProjection(cam, nearPlane(cam, mesh)), 0);
765
+ u.set([...(S ? rig(0.7, -0.5, 0.6) : norm(L.direction)), unlit ? 1 : 0], 16);
352
766
  u.set([...L.sky, 0], 20);
353
767
  u.set([...L.ground, 0], 24);
354
768
  u.set([...cam.eye, 0], 28);
355
769
  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);
770
+ u.set([filmic ? 1 : 0, filmic ? filmic.exposure : 1, filmic ? filmic.emit : 0, S ? 1 : 0], 36);
771
+ u.set([...rig(0.6, 0.8, 0.1), S ? S.fill : 0], 40);
772
+ u.set([...rig(-0.8, 0.3, 0.5), S ? S.rim : 0], 44);
773
+ const lights = glow ? sceneLights(mesh, parts, palette) : new Float32Array(0);
774
+ u.set([px, px, px / size, lights.length / LIGHT_FLOATS], 48);
775
+ for (let k = 0; k < lights.length / LIGHT_FLOATS; k++) {
776
+ u.set(lights.subarray(k * LIGHT_FLOATS, k * LIGHT_FLOATS + 4), 52 + k * 4);
777
+ u.set([...lights.subarray(k * LIGHT_FLOATS + 4, k * LIGHT_FLOATS + 7), 0], 52 + MAX_LIGHTS * 4 + k * 4);
778
+ }
357
779
  device.queue.writeBuffer(uniforms, 0, u);
780
+ const markData = marks?.length ? buildMarks(marks) : null;
781
+ markCount = markData ? markData.length / MARK_FLOATS : 0;
782
+ if (markData && markData.byteLength) {
783
+ if (markData.byteLength > markCapacity) {
784
+ markBuffer?.destroy();
785
+ markCapacity = Math.ceil(markData.byteLength * 1.5);
786
+ markBuffer = device.createBuffer({ size: markCapacity, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
787
+ }
788
+ device.queue.writeBuffer(markBuffer, 0, markData);
789
+ }
358
790
  const encoder = device.createCommandEncoder();
359
791
  const offscreen = readback
360
792
  ? device.createTexture({ size: [px, px], format, usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC })
@@ -385,6 +817,12 @@ export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
385
817
  pass.setVertexBuffer(1, instBuffer);
386
818
  pass.draw(protoCount, instCount);
387
819
  }
820
+ if (edgeCount) {
821
+ pass.setPipeline(linePipeline);
822
+ pass.setBindGroup(0, bind);
823
+ pass.setVertexBuffer(0, edgeBuffer);
824
+ pass.draw(edgeCount);
825
+ }
388
826
  if (glassCount) {
389
827
  // after everything solid (and the instances): see-through faces, far to near
390
828
  pass.setPipeline(glassPipeline);
@@ -392,6 +830,12 @@ export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
392
830
  pass.setVertexBuffer(0, glassBuffer);
393
831
  pass.draw(glassCount);
394
832
  }
833
+ if (markCount) {
834
+ pass.setPipeline(markPipeline);
835
+ pass.setBindGroup(0, bind);
836
+ pass.setVertexBuffer(0, markBuffer);
837
+ pass.draw(6, markCount);
838
+ }
395
839
  pass.end();
396
840
  if (!offscreen) {
397
841
  device.queue.submit([encoder.finish()]);
@@ -430,6 +874,7 @@ export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
430
874
  const destroy = () => {
431
875
  vertexBuffer?.destroy();
432
876
  glassBuffer?.destroy();
877
+ markBuffer?.destroy();
433
878
  protoBuffer?.destroy();
434
879
  instBuffer?.destroy();
435
880
  depth?.destroy();