3draft 26.11.3001 → 26.11.5001

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,10 +3,10 @@
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
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
+ const FLOATS = 14; // position(3) normal(3) color(3) material(4: metal, rough, alpha, emit) soft(1)
10
10
  /**
11
11
  * Column-major 4x4 matrix (for WebGPU, z in 0..1) that maps world points exactly
12
12
  * like camera.project(): the same pixels, plus a depth between near and far.
@@ -29,26 +29,60 @@ const rgb = (color) => {
29
29
  return [0.78, 0.78, 0.78];
30
30
  return [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16) / 255);
31
31
  };
32
- /** Triangle list for the GPU: flat face normals, face colors and materials. Float32Array, 13 floats per vertex. */
33
- export const buildVertices = (mesh, palette) => {
32
+ /**
33
+ * Smooth shading: a normal for every corner of every face. The faces that meet at a point (welded by position, so a
34
+ * crack in the indices does not break it) are averaged, weighted by area, but only those within `crease` degrees of
35
+ * this face: a 90 degree box corner stays sharp, a faceted fuselage becomes round.
36
+ */
37
+ export const cornerNormals = (mesh, crease) => {
38
+ const cosC = Math.cos((crease * Math.PI) / 180);
39
+ const area = mesh.faces.map((f) => faceNormal(f.v.map((k) => mesh.positions[k]))); // length = area
40
+ const unit = area.map((n) => (Math.hypot(...n) > 0 ? norm(n) : [0, 1, 0]));
41
+ const q = (bounds(mesh).radius || 1) * 1e-6;
42
+ const key = (p) => `${Math.round(p[0] / q)},${Math.round(p[1] / q)},${Math.round(p[2] / q)}`;
43
+ const at = new Map();
44
+ mesh.faces.forEach((f, i) => f.v.forEach((k) => {
45
+ const id = key(mesh.positions[k]);
46
+ const list = at.get(id) ?? [];
47
+ if (list[list.length - 1] !== i)
48
+ list.push(i);
49
+ at.set(id, list);
50
+ }));
51
+ return mesh.faces.map((f, i) => f.v.map((k) => {
52
+ const sum = [0, 0, 0];
53
+ for (const j of at.get(key(mesh.positions[k])) ?? []) {
54
+ if (dot(unit[i], unit[j]) < cosC)
55
+ continue;
56
+ for (let c = 0; c < 3; c++)
57
+ sum[c] += area[j][c];
58
+ }
59
+ return Math.hypot(...sum) > 0 ? norm(sum) : unit[i];
60
+ }));
61
+ };
62
+ /**
63
+ * Triangle list for the GPU: face normals (flat, or smoothed with `smooth` degrees), face colors and materials.
64
+ * Float32Array, 14 floats per vertex.
65
+ */
66
+ export const buildVertices = (mesh, palette, { smooth = 0 } = {}) => {
34
67
  let tris = 0;
35
68
  for (const f of mesh.faces)
36
69
  tris += Math.max(0, f.v.length - 2);
37
70
  const out = new Float32Array(tris * 3 * FLOATS);
71
+ const corners = smooth > 0 ? cornerNormals(mesh, smooth) : null;
38
72
  let o = 0;
39
- for (const face of mesh.faces) {
73
+ mesh.faces.forEach((face, fi) => {
40
74
  const pts = face.v.map((k) => mesh.positions[k]);
41
- const n = norm(faceNormal(pts));
75
+ const flat = norm(faceNormal(pts));
42
76
  const s = surfaceOf(face, palette);
43
77
  const c = rgb(s.color);
44
78
  const mat = [s.metal, s.rough, s.alpha, s.emit];
45
79
  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);
80
+ for (const k of [0, j, j + 1]) {
81
+ out.set([...pts[k], ...(corners ? corners[fi][k] : flat), ...c, ...mat, s.soft], o);
48
82
  o += FLOATS;
49
83
  }
50
84
  }
51
- }
85
+ });
52
86
  return out;
53
87
  };
54
88
  const INSTANCE_FLOATS = 15; // at(3) ex(3) ey(3) ez(3) color(3)
@@ -71,16 +105,20 @@ struct U {
71
105
  ground : vec4<f32>,
72
106
  eye : vec4<f32>,
73
107
  mat : vec4<f32>, // ambient, diffuse, specular, shininess
108
+ fx : vec4<f32>, // filmic (0 or 1), exposure, emission gain, studio (0 or 1)
109
+ fill : vec4<f32>, // studio: xyz direction towards the fill light, w intensity
110
+ rim : vec4<f32>, // studio: xyz direction towards the rim light, w intensity
74
111
  };
75
112
  @group(0) @binding(0) var<uniform> u : U;
76
113
 
77
- struct VIn { @location(0) pos : vec3<f32>, @location(1) nrm : vec3<f32>, @location(2) col : vec3<f32>, @location(8) mat : vec4<f32> };
114
+ 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 };
78
115
  struct VOut {
79
116
  @builtin(position) p : vec4<f32>,
80
117
  @location(0) n : vec3<f32>,
81
118
  @location(1) c : vec3<f32>,
82
119
  @location(2) w : vec3<f32>,
83
120
  @location(3) m : vec4<f32>, // metal, rough, alpha, emit
121
+ @location(4) s : f32, // soft
84
122
  };
85
123
 
86
124
  @vertex fn vs(i : VIn) -> VOut {
@@ -90,6 +128,7 @@ struct VOut {
90
128
  o.c = i.col;
91
129
  o.w = i.pos;
92
130
  o.m = i.mat;
131
+ o.s = i.soft;
93
132
  return o;
94
133
  }
95
134
 
@@ -109,29 +148,91 @@ struct IIn {
109
148
  o.c = i.col;
110
149
  o.w = w;
111
150
  o.m = vec4<f32>(0.0, 0.7, 1.0, 0.0);
151
+ o.s = 0.0;
112
152
  return o;
113
153
  }
114
154
 
155
+ fn toLinear(c : vec3<f32>) -> vec3<f32> {
156
+ return select(pow((c + 0.055) / 1.055, vec3<f32>(2.4)), c / 12.92, c <= vec3<f32>(0.04045));
157
+ }
158
+ fn toSrgb(c : vec3<f32>) -> vec3<f32> {
159
+ return select(1.055 * pow(c, vec3<f32>(1.0 / 2.4)) - 0.055, c * 12.92, c <= vec3<f32>(0.0031308));
160
+ }
161
+ // ACES filmic curve (Narkowicz fit): highlights roll off softly instead of clipping
162
+ fn aces(x : vec3<f32>) -> vec3<f32> {
163
+ return clamp((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14), vec3<f32>(0.0), vec3<f32>(1.0));
164
+ }
165
+
166
+ // Direct light, shared by both looks: x diffuse, y highlight, z scattered light (soft materials).
167
+ // Studio: the key light is u.light (placed by the camera), plus a softer fill and a rim light that only edges the shape.
168
+ fn lights(n : vec3<f32>, v : vec3<f32>, shin : f32, soft : f32) -> vec3<f32> {
169
+ let key = normalize(u.light.xyz);
170
+ let ndl = dot(n, key);
171
+ // soft: the light wraps round the form, and a warm band of scattered light sits where light turns to shade
172
+ var diff = max((ndl + soft) / (1.0 + soft), 0.0);
173
+ let h = normalize(key + v);
174
+ var spec = pow(max(dot(n, h), 0.0), shin) * step(0.0, ndl);
175
+ let scat = soft * exp(-ndl * ndl * 5.0);
176
+ if (u.fx.w > 0.5) {
177
+ diff = diff * 0.85; // three lights add up: the key takes a little less
178
+ let fl = normalize(u.fill.xyz);
179
+ diff = diff + max((dot(n, fl) + soft) / (1.0 + soft), 0.0) * u.fill.w;
180
+ let rl = normalize(u.rim.xyz);
181
+ let edge = pow(1.0 - max(dot(n, v), 0.0), 2.5);
182
+ diff = diff + max(dot(n, rl), 0.0) * edge * u.rim.w * 2.0;
183
+ let rh = normalize(rl + v);
184
+ spec = spec + pow(max(dot(n, rh), 0.0), shin) * step(0.0, dot(n, rl)) * u.rim.w * 0.6 * edge;
185
+ }
186
+ return vec3<f32>(diff, spec, scat);
187
+ }
188
+
189
+ // The filmic path: the same light model, but in linear colour with HDR light and emission, then tone mapped.
190
+ fn filmic(i : VOut, n : vec3<f32>, l : vec3<f32>, v : vec3<f32>) -> vec4<f32> {
191
+ let metal = i.m.x;
192
+ let rough = i.m.y;
193
+ let albedo = toLinear(i.c);
194
+ 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);
195
+ let shin = mix(u.mat.w * 7.0, u.mat.w * 0.15, rough);
196
+ let lt = lights(n, v, shin, i.s);
197
+ let diff = lt.x * u.mat.y * 1.6;
198
+ let spec = lt.y * mix(u.mat.z, 1.0, metal) * mix(1.0, 0.35, rough) * 2.5;
199
+ let r = reflect(-v, n);
200
+ // Schlick: edges mirror more, metal keeps its tint
201
+ let f0 = mix(vec3<f32>(0.04), albedo, metal);
202
+ let fres = f0 + (vec3<f32>(1.0) - f0) * pow(1.0 - max(dot(n, v), 0.0), 5.0) * (1.0 - rough);
203
+ 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));
204
+ var lit = albedo * (hemi + vec3<f32>(diff)) * (1.0 - metal)
205
+ + albedo * env * metal * (1.0 - 0.6 * rough)
206
+ + env * fres * (1.0 - rough) * 0.5
207
+ + vec3<f32>(spec) * mix(vec3<f32>(1.0), albedo, metal);
208
+ // emission adds light, so a lamp can go above 1.0 and still keep its colour after tone mapping
209
+ lit = lit + albedo * vec3<f32>(1.0, 0.38, 0.26) * lt.z * 0.5 * (1.0 - metal); // soft: the warm scattered band
210
+ lit = lit + albedo * i.m.w * u.fx.z;
211
+ return vec4<f32>(toSrgb(aces(lit * u.fx.y)), i.m.z);
212
+ }
213
+
115
214
  @fragment fn fs(i : VOut, @builtin(front_facing) front : bool) -> @location(0) vec4<f32> {
116
215
  var n = normalize(i.n);
117
216
  if (!front) { n = -n; }
118
217
  let l = normalize(u.light.xyz);
119
218
  let v = normalize(u.eye.xyz - i.w);
219
+ if (u.fx.x > 0.5) { return filmic(i, n, l, v); }
120
220
  let metal = i.m.x;
121
221
  let rough = i.m.y;
122
- let hemi = mix(u.ground.rgb, u.sky.rgb, n.y * 0.5 + 0.5) * u.mat.x;
123
- let diff = max(dot(n, l), 0.0) * u.mat.y;
124
- let h = normalize(l + v);
222
+ 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);
125
223
  // roughness widens the highlight; metal makes it strong and tinted by the color
126
224
  let shin = mix(u.mat.w * 7.0, u.mat.w * 0.15, rough);
127
- 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));
225
+ let lt = lights(n, v, shin, i.s);
226
+ let diff = lt.x * u.mat.y;
227
+ let spec = lt.y * mix(u.mat.z, 1.0, metal) * mix(1.0, 0.35, rough);
128
228
  // metal mirrors the sky above and the ground below, blurred by roughness
129
229
  let r = reflect(-v, n);
130
230
  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));
131
231
  var lit = i.c * (hemi + vec3<f32>(diff)) * (1.0 - metal)
132
232
  + i.c * env * metal * (1.0 - 0.6 * rough)
133
- + vec3<f32>(spec) * mix(vec3<f32>(1.0), i.c, metal);
134
- lit = mix(lit, i.c * 1.15, i.m.w);
233
+ + vec3<f32>(spec) * mix(vec3<f32>(1.0), i.c, metal)
234
+ + i.c * vec3<f32>(1.0, 0.38, 0.26) * lt.z * 0.25 * (1.0 - metal);
235
+ 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
135
236
  return vec4<f32>(clamp(lit, vec3<f32>(0.0), vec3<f32>(1.0)), i.m.z);
136
237
  }
137
238
  `;
@@ -142,14 +243,18 @@ struct IIn {
142
243
  * prototype mesh, data from buildInstances; drawn after the mesh), background [r,g,b] 0..1,
143
244
  * light { direction, sky, ground }, material { ambient, diffuse, specular, shininess }, dpr.
144
245
  */
145
- export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
246
+ export const createGpu = async (canvas, { sampleCount = 4, onError } = {}) => {
146
247
  if (!globalThis.navigator?.gpu)
147
248
  throw new Error('WebGPU is not available in this browser');
148
249
  const adapter = await navigator.gpu.requestAdapter();
149
250
  if (!adapter)
150
251
  throw new Error('No WebGPU adapter found');
151
252
  const device = await adapter.requestDevice();
152
- device.addEventListener('uncapturederror', (e) => console.error('WebGPU:', e.error?.message ?? e));
253
+ device.addEventListener('uncapturederror', (e) => {
254
+ const message = String(e.error?.message ?? e);
255
+ console.error('WebGPU:', message);
256
+ onError?.(message);
257
+ });
153
258
  device.lost.then((info) => console.error('WebGPU device lost:', info.reason, info.message));
154
259
  const context = canvas.getContext('webgpu');
155
260
  if (!context)
@@ -157,6 +262,12 @@ export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
157
262
  const format = navigator.gpu.getPreferredCanvasFormat();
158
263
  context.configure({ device, format, alphaMode: 'opaque', });
159
264
  const module = device.createShaderModule({ code: SHADER });
265
+ // shader compile errors are reported to onError too (the browser only logs them to the console)
266
+ module.getCompilationInfo?.().then((info) => {
267
+ for (const m of info.messages ?? [])
268
+ if (m.type === 'error')
269
+ onError?.(`shader line ${m.lineNum}: ${m.message}`);
270
+ });
160
271
  const meshLayout = {
161
272
  arrayStride: FLOATS * 4,
162
273
  attributes: [
@@ -164,6 +275,7 @@ export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
164
275
  { shaderLocation: 1, offset: 12, format: 'float32x3' },
165
276
  { shaderLocation: 2, offset: 24, format: 'float32x3' },
166
277
  { shaderLocation: 8, offset: 36, format: 'float32x4' },
278
+ { shaderLocation: 9, offset: 52, format: 'float32' },
167
279
  ],
168
280
  };
169
281
  const instanceLayout = {
@@ -171,24 +283,46 @@ export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
171
283
  stepMode: 'instance',
172
284
  attributes: [3, 4, 5, 6, 7].map((loc, k) => ({ shaderLocation: loc, offset: k * 12, format: 'float32x3' })),
173
285
  };
174
- const make = (entry, buffers) => device.createRenderPipeline({
175
- layout: 'auto',
286
+ // 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
287
+ const bindLayout = device.createBindGroupLayout({
288
+ // visibility 1 | 2 = GPUShaderStage.VERTEX | FRAGMENT
289
+ entries: [{ binding: 0, visibility: 1 | 2, buffer: { type: 'uniform' } }],
290
+ });
291
+ const pipelineLayout = device.createPipelineLayout({ bindGroupLayouts: [bindLayout] });
292
+ const make = (entry, buffers, glass = false) => device.createRenderPipeline({
293
+ layout: pipelineLayout,
176
294
  vertex: { module, entryPoint: entry, buffers },
177
- fragment: { module, entryPoint: 'fs', targets: [{ format }] },
295
+ fragment: {
296
+ module,
297
+ entryPoint: 'fs',
298
+ targets: [
299
+ glass
300
+ ? {
301
+ format,
302
+ blend: {
303
+ color: { srcFactor: 'src-alpha', dstFactor: 'one-minus-src-alpha', operation: 'add' },
304
+ alpha: { srcFactor: 'one', dstFactor: 'one-minus-src-alpha', operation: 'add' },
305
+ },
306
+ }
307
+ : { format },
308
+ ],
309
+ },
178
310
  primitive: { topology: 'triangle-list', cullMode: 'none' },
179
- depthStencil: { format: 'depth24plus', depthWriteEnabled: true, depthCompare: 'less' },
180
- // the fragment alpha becomes coverage: a half transparent face covers half of the samples (one pass, no sorting)
181
- multisample: sampleCount > 1 ? { count: sampleCount, alphaToCoverageEnabled: true } : { count: sampleCount },
311
+ // see-through faces (alpha < 1) are blended over what is behind them: they test depth but do not write it
312
+ depthStencil: { format: 'depth24plus', depthWriteEnabled: !glass, depthCompare: 'less' },
313
+ multisample: { count: sampleCount },
182
314
  });
183
315
  const pipeline = make('vs', [meshLayout]);
316
+ const glassPipeline = make('vs', [meshLayout], true);
184
317
  // the instanced pipeline reads position and normal only from the prototype (locations 0, 1)
185
318
  const instPipeline = make('vsi', [{ ...meshLayout, attributes: meshLayout.attributes.slice(0, 2) }, instanceLayout]);
186
- const uniforms = device.createBuffer({ size: 144, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
187
- const bind = device.createBindGroup({ layout: pipeline.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: uniforms } }] });
188
- const instBind = device.createBindGroup({ layout: instPipeline.getBindGroupLayout(0), entries: [{ binding: 0, resource: { buffer: uniforms } }] });
319
+ const uniforms = device.createBuffer({ size: 192, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
320
+ const bind = device.createBindGroup({ layout: bindLayout, entries: [{ binding: 0, resource: { buffer: uniforms } }] });
321
+ const instBind = device.createBindGroup({ layout: bindLayout, entries: [{ binding: 0, resource: { buffer: uniforms } }] });
189
322
  // Geometry that did not change since the last frame is not rebuilt or uploaded again.
190
323
  let uploaded = null; // the mesh now in vertexBuffer
191
324
  let uploadedPalette; // and the palette it was built with
325
+ let uploadedSmooth = 0; // and the smoothing
192
326
  let vertexCount = 0;
193
327
  let protoMesh = null;
194
328
  let protoBuffer = null;
@@ -199,6 +333,12 @@ export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
199
333
  let instCount = 0;
200
334
  let vertexBuffer = null;
201
335
  let capacity = 0;
336
+ let glassBuffer = null; // the see-through triangles, sorted far to near for the eye
337
+ let glassCapacity = 0;
338
+ let glassTris = []; // centroid and offset (in floats) in glassSource
339
+ let glassSource = new Float32Array(0);
340
+ let glassCount = 0;
341
+ let glassEye = '';
202
342
  let px = 0;
203
343
  let depth = null;
204
344
  let color = null;
@@ -213,13 +353,32 @@ export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
213
353
  depth = device.createTexture({ size: [n, n], format: 'depth24plus', sampleCount, usage: GPUTextureUsage.RENDER_ATTACHMENT });
214
354
  color = sampleCount > 1 ? device.createTexture({ size: [n, n], format, sampleCount, usage: GPUTextureUsage.RENDER_ATTACHMENT }) : null;
215
355
  };
216
- const draw = (mesh, view, { size = 400, fit, background = [0.98, 0.98, 0.97], light = {}, material = {}, palette, dpr = globalThis.devicePixelRatio || 1, instances } = {}, readback = false) => {
356
+ const draw = (mesh, view, { size = 400, fit, background = [0.98, 0.98, 0.97], light = {}, material = {}, palette, dpr = globalThis.devicePixelRatio || 1, instances, tonemap = false, smooth = false, studio = false } = {}, readback = false) => {
217
357
  const L = { ...defaultLight, ...light };
358
+ const filmic = tonemap ? { exposure: 1, emit: 2, ...(tonemap === true ? {} : tonemap) } : null;
218
359
  const M = { ...defaultMaterial, ...material };
219
360
  const cam = makeCamera(view, mesh, size, fit);
220
361
  targets(Math.max(1, Math.round(size * dpr)));
221
- if (mesh !== uploaded || palette !== uploadedPalette) {
222
- const verts = buildVertices(mesh, palette);
362
+ const crease = smooth === true ? 40 : smooth || 0;
363
+ if (mesh !== uploaded || palette !== uploadedPalette || crease !== uploadedSmooth) {
364
+ uploadedSmooth = crease;
365
+ const all = buildVertices(mesh, palette, { smooth: crease });
366
+ // split: solid triangles are drawn first; triangles with alpha < 1 are kept apart and blended afterwards
367
+ const tri = 3 * FLOATS;
368
+ const solid = [];
369
+ glassTris = [];
370
+ for (let o = 0; o < all.length; o += tri) {
371
+ if (all[o + 11] < 1) {
372
+ const c = [0, 1, 2].map((k) => (all[o + k] + all[o + FLOATS + k] + all[o + 2 * FLOATS + k]) / 3);
373
+ glassTris.push({ c, at: o });
374
+ }
375
+ else
376
+ for (let k = 0; k < tri; k++)
377
+ solid.push(all[o + k]);
378
+ }
379
+ glassSource = all;
380
+ glassEye = '';
381
+ const verts = Float32Array.from(solid);
223
382
  if (verts.byteLength > capacity) {
224
383
  vertexBuffer?.destroy();
225
384
  capacity = Math.max(verts.byteLength, 4096);
@@ -253,13 +412,37 @@ export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
253
412
  }
254
413
  if (!instances)
255
414
  instCount = 0;
256
- const u = new Float32Array(36);
415
+ const eyeKey = cam.eye.join(',');
416
+ if (glassTris.length && eyeKey !== glassEye) {
417
+ glassEye = eyeKey;
418
+ const [ex, ey, ez] = cam.eye;
419
+ const far = (t) => (t.c[0] - ex) ** 2 + (t.c[1] - ey) ** 2 + (t.c[2] - ez) ** 2;
420
+ const sorted = [...glassTris].sort((a, b) => far(b) - far(a)); // far first
421
+ const out = new Float32Array(sorted.length * 3 * FLOATS);
422
+ sorted.forEach((t, k) => out.set(glassSource.subarray(t.at, t.at + 3 * FLOATS), k * 3 * FLOATS));
423
+ if (out.byteLength > glassCapacity) {
424
+ glassBuffer?.destroy();
425
+ glassCapacity = Math.max(out.byteLength, 4096);
426
+ glassBuffer = device.createBuffer({ size: glassCapacity, usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST });
427
+ }
428
+ device.queue.writeBuffer(glassBuffer, 0, out);
429
+ glassCount = sorted.length * 3;
430
+ }
431
+ if (!glassTris.length)
432
+ glassCount = 0;
433
+ // studio rig, relative to the camera: key upper left in front, fill from the right, rim from behind and above
434
+ const rig = (f, r, up) => norm([0, 1, 2].map((k) => -cam.f[k] * f + cam.r[k] * r + cam.u[k] * up));
435
+ const S = studio ? { fill: 0.4, rim: 1, ...(studio === true ? {} : studio) } : null;
436
+ const u = new Float32Array(48);
257
437
  u.set(viewProjection(cam), 0);
258
- u.set([...(norm(L.direction)), 0], 16);
438
+ u.set([...(S ? rig(0.7, -0.5, 0.6) : norm(L.direction)), 0], 16);
259
439
  u.set([...L.sky, 0], 20);
260
440
  u.set([...L.ground, 0], 24);
261
441
  u.set([...cam.eye, 0], 28);
262
442
  u.set([M.ambient, M.diffuse, M.specular, M.shininess], 32);
443
+ u.set([filmic ? 1 : 0, filmic ? filmic.exposure : 1, filmic ? filmic.emit : 0, S ? 1 : 0], 36);
444
+ u.set([...rig(0.6, 0.8, 0.1), S ? S.fill : 0], 40);
445
+ u.set([...rig(-0.8, 0.3, 0.5), S ? S.rim : 0], 44);
263
446
  device.queue.writeBuffer(uniforms, 0, u);
264
447
  const encoder = device.createCommandEncoder();
265
448
  const offscreen = readback
@@ -291,6 +474,13 @@ export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
291
474
  pass.setVertexBuffer(1, instBuffer);
292
475
  pass.draw(protoCount, instCount);
293
476
  }
477
+ if (glassCount) {
478
+ // after everything solid (and the instances): see-through faces, far to near
479
+ pass.setPipeline(glassPipeline);
480
+ pass.setBindGroup(0, bind);
481
+ pass.setVertexBuffer(0, glassBuffer);
482
+ pass.draw(glassCount);
483
+ }
294
484
  pass.end();
295
485
  if (!offscreen) {
296
486
  device.queue.submit([encoder.finish()]);
@@ -328,6 +518,7 @@ export const createGpu = async (canvas, { sampleCount = 4 } = {}) => {
328
518
  const pixels = (mesh, view, options) => draw(mesh, view, options, true);
329
519
  const destroy = () => {
330
520
  vertexBuffer?.destroy();
521
+ glassBuffer?.destroy();
331
522
  protoBuffer?.destroy();
332
523
  instBuffer?.destroy();
333
524
  depth?.destroy();
package/dist/index.d.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  export declare const name: "3draft";
2
- export declare const version = "26.11.3001";
2
+ export declare const version = "26.11.5001";
3
3
  export type { Vec3, Face, Mesh, Axes, ViewName, ViewSpec, Fit, Camera, Hit, Mark } from './types.js';
4
4
  export { box, extrude, merge, rotate, bounds, faceNormal, faceCenter } from './mesh.js';
5
5
  export type { BoxOptions } from './mesh.js';
6
6
  export { validate } from './check.js';
7
- export { boolean, union, subtract, intersect } from './csg.js';
8
- export type { BooleanOp } from './csg.js';
7
+ export { boolean, union, subtract, intersect, health } from './csg.js';
8
+ export type { BooleanOp, Health, Part } from './csg.js';
9
9
  export type { Validation } from './check.js';
10
10
  export { extrudeFace, moveVertices } from './edit.js';
11
11
  export { VIEW_ORDER, makeCamera, orbit, project, ray } from './camera.js';
@@ -14,7 +14,7 @@ export { standOn } from './place.js';
14
14
  export type { StandOptions, Frame } from './place.js';
15
15
  export { paintOrder } from './order.js';
16
16
  export type { PaintItem } from './order.js';
17
- export { createGpu, viewProjection, buildVertices, buildInstances, defaultLight, defaultMaterial } from './gpu.js';
17
+ export { createGpu, viewProjection, buildVertices, cornerNormals, buildInstances, defaultLight, defaultMaterial } from './gpu.js';
18
18
  export type { Gpu, GpuLight, GpuMaterial, GpuRenderOptions, Instance, Pixels } from './gpu.js';
19
19
  export { renderView, renderViews, renderSheet } from './svg.js';
20
20
  export type { RenderOptions } from './svg.js';
@@ -31,3 +31,5 @@ export interface Info {
31
31
  export declare const info: () => Info;
32
32
  export { initKernel } from './kernel.js';
33
33
  export type { Kernel } from './kernel.js';
34
+ export { insertVertex, cutFace, moveVertex, fillLoop, addLine, findLoop, nearestSurface, wireFromSegments, moveVertexSet } from './wire.js';
35
+ export type { Wire } from './wire.js';
package/dist/index.js CHANGED
@@ -4,16 +4,16 @@
4
4
  // a target cross that lies on the surface under the cursor. No textures, no
5
5
  // shading yet.
6
6
  export const name = '3draft';
7
- export const version = '26.11.3001';
7
+ export const version = '26.11.5001';
8
8
  export { box, extrude, merge, rotate, bounds, faceNormal, faceCenter } from './mesh.js';
9
9
  export { validate } from './check.js';
10
- export { boolean, union, subtract, intersect } from './csg.js';
10
+ export { boolean, union, subtract, intersect, health } from './csg.js';
11
11
  export { extrudeFace, moveVertices } from './edit.js';
12
12
  export { VIEW_ORDER, makeCamera, orbit, project, ray } from './camera.js';
13
13
  export { pick, crosshair } from './pick.js';
14
14
  export { standOn } from './place.js';
15
15
  export { paintOrder } from './order.js';
16
- export { createGpu, viewProjection, buildVertices, buildInstances, defaultLight, defaultMaterial } from './gpu.js';
16
+ export { createGpu, viewProjection, buildVertices, cornerNormals, buildInstances, defaultLight, defaultMaterial } from './gpu.js';
17
17
  export { renderView, renderViews, renderSheet } from './svg.js';
18
18
  export { surfaceOf, DEFAULTS as MATERIAL_DEFAULTS } from './material.js';
19
19
  export { displayVersion } from './version.js';
@@ -30,3 +30,4 @@ export const info = () => ({
30
30
  ],
31
31
  });
32
32
  export { initKernel } from './kernel.js';
33
+ export { insertVertex, cutFace, moveVertex, fillLoop, addLine, findLoop, nearestSurface, wireFromSegments, moveVertexSet } from './wire.js';
@@ -6,10 +6,12 @@ export interface Material {
6
6
  metal?: number;
7
7
  /** 0 = mirror-like, 1 = matte; widens the highlight. */
8
8
  rough?: number;
9
- /** 1 = solid, 0 = invisible. Drawn with alpha-to-coverage: one pass, no sorting. */
9
+ /** 1 = solid, 0 = invisible. Blended over what is behind it (WebGPU: a second pass, far to near). */
10
10
  alpha?: number;
11
- /** 0..1: how much the color glows on its own, unlit. */
11
+ /** 0..5: how much the color glows on its own, unlit. Up to 1 it fades from lit to flat colour; above 1 (WebGPU `tonemap`) it is a light source: that many times brighter. */
12
12
  emit?: number;
13
+ /** 0..1: soft, skin-like light. The light wraps round the form and a warm, scattered band shows where light turns to shade. */
14
+ soft?: number;
13
15
  }
14
16
  export type Palette = Record<string, Material>;
15
17
  /** What a face is made of, all numbers filled in. */
@@ -19,13 +21,16 @@ export interface Surface {
19
21
  rough: number;
20
22
  alpha: number;
21
23
  emit: number;
24
+ soft: number;
22
25
  }
26
+ export declare const EMIT_MAX = 5;
23
27
  export declare const FALLBACK_COLOR = "#c8c8c8";
24
28
  export declare const DEFAULTS: {
25
29
  readonly metal: 0;
26
30
  readonly rough: 0.7;
27
31
  readonly alpha: 1;
28
32
  readonly emit: 0;
33
+ readonly soft: 0;
29
34
  };
30
35
  /** The surface of a face: its palette entry when it names one, else just its color with default properties. */
31
36
  export declare const surfaceOf: (face: Pick<Face, "color" | "mat">, palette?: Palette) => Surface;
package/dist/material.js CHANGED
@@ -1,17 +1,19 @@
1
1
  // Materials are data. A palette is a plain object { name: Material }; a face points at an entry by name
2
2
  // (`face.mat`) and keeps its own `color` as a fallback, so a mesh still looks right without the palette.
3
3
  // One shader draws all of them; the numbers below are the only knobs.
4
- const clamp01 = (n, fallback) => (typeof n === 'number' && Number.isFinite(n) ? Math.max(0, Math.min(1, n)) : fallback);
4
+ const clamp = (n, fallback, max = 1) => (typeof n === 'number' && Number.isFinite(n) ? Math.max(0, Math.min(max, n)) : fallback);
5
+ export const EMIT_MAX = 5;
5
6
  export const FALLBACK_COLOR = '#c8c8c8';
6
- export const DEFAULTS = { metal: 0, rough: 0.7, alpha: 1, emit: 0 };
7
+ export const DEFAULTS = { metal: 0, rough: 0.7, alpha: 1, emit: 0, soft: 0 };
7
8
  /** The surface of a face: its palette entry when it names one, else just its color with default properties. */
8
9
  export const surfaceOf = (face, palette) => {
9
10
  const m = face.mat !== undefined ? palette?.[face.mat] : undefined;
10
11
  return {
11
12
  color: m?.color ?? face.color ?? FALLBACK_COLOR,
12
- metal: clamp01(m?.metal, DEFAULTS.metal),
13
- rough: clamp01(m?.rough, DEFAULTS.rough),
14
- alpha: clamp01(m?.alpha, DEFAULTS.alpha),
15
- emit: clamp01(m?.emit, DEFAULTS.emit),
13
+ metal: clamp(m?.metal, DEFAULTS.metal),
14
+ rough: clamp(m?.rough, DEFAULTS.rough),
15
+ alpha: clamp(m?.alpha, DEFAULTS.alpha),
16
+ emit: clamp(m?.emit, DEFAULTS.emit, EMIT_MAX),
17
+ soft: clamp(m?.soft, DEFAULTS.soft),
16
18
  };
17
19
  };
package/dist/wire.d.ts ADDED
@@ -0,0 +1,57 @@
1
+ import type { Mesh, Vec3, Face } from './types.js';
2
+ /** Lines drawn on / off a mesh: pairs of vertex indices. */
3
+ export type Wire = [number, number][];
4
+ /**
5
+ * Puts a vertex at `point`, which lies on face `face`. On an existing vertex it returns that one; on an edge it is
6
+ * added to both faces that share the edge (no crack); inside the face it splits the face into triangles around it.
7
+ * { mesh, index }
8
+ */
9
+ export declare const insertVertex: (mesh: Mesh, face: number, point: Vec3) => {
10
+ mesh: Mesh;
11
+ index: number;
12
+ };
13
+ /**
14
+ * Cuts the face that holds vertices `a` and `b` (not next to each other) along the line a-b: two convex faces.
15
+ * `cut` is false when no face holds both, or they are already neighbours on every face.
16
+ */
17
+ export declare const cutFace: (mesh: Mesh, a: number, b: number) => {
18
+ mesh: Mesh;
19
+ cut: boolean;
20
+ };
21
+ /** Moves vertex `i` to `to`. Polygons that use it become triangles, so every face stays planar. */
22
+ export declare const moveVertex: (mesh: Mesh, i: number, to: Vec3) => Mesh;
23
+ /**
24
+ * A face from a closed loop of vertices. It is wound to point away from `awayFrom` (default: the middle of the
25
+ * mesh), which is what a patch closing a gap in a surface wants; 4 or more vertices become a fan of triangles.
26
+ */
27
+ export declare const fillLoop: (mesh: Mesh, loop: number[], face?: Partial<Face>, awayFrom?: Vec3) => Mesh;
28
+ /** Adds the line a-b (not twice, not a point). */
29
+ export declare const addLine: (wire: Wire, a: number, b: number) => Wire;
30
+ /**
31
+ * The shortest closed loop of lines through line [a, b], at most `max` vertices: [a, ..., b], or null.
32
+ * Close it with fillLoop.
33
+ */
34
+ export declare const findLoop: (wire: Wire, [a, b]: [number, number], max?: number) => number[] | null;
35
+ /**
36
+ * The surface point of the mesh nearest to `p`, if it is within `tolerance`: { face, point } (point lies on the face),
37
+ * otherwise null. For placing a point that was drawn near a surface on the surface.
38
+ */
39
+ export declare const nearestSurface: (mesh: Mesh, p: Vec3, tolerance: number) => {
40
+ face: number;
41
+ point: Vec3;
42
+ } | null;
43
+ /**
44
+ * Turns line segments (pairs of points) into wire on the mesh: each end becomes a vertex (an existing one within
45
+ * `tolerance`, a new one on the surface when it is near it, otherwise a free point in the air), each segment a line,
46
+ * and two ends on one face cut that face. Returns the new mesh and wire.
47
+ */
48
+ export declare const wireFromSegments: (mesh: Mesh, wire: Wire, segments: [Vec3, Vec3][], tolerance: number) => {
49
+ mesh: Mesh;
50
+ wire: Wire;
51
+ };
52
+ /**
53
+ * Moves several vertices at once: `moves` is [[index, newPosition], ...]. A polygon whose vertices all moved by the
54
+ * same step (a face dragged as a whole) stays as it is; any other polygon that uses a moved vertex becomes triangles,
55
+ * so every face stays planar.
56
+ */
57
+ export declare const moveVertexSet: (mesh: Mesh, moves: [number, Vec3][]) => Mesh;