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/APP.md +48 -7
- package/README.md +65 -5
- package/dist/csg.d.ts +27 -2
- package/dist/csg.js +99 -8
- package/dist/gpu.d.ts +37 -3
- package/dist/gpu.js +223 -32
- package/dist/index.d.ts +6 -4
- package/dist/index.js +4 -3
- package/dist/material.d.ts +7 -2
- package/dist/material.js +8 -6
- package/dist/wire.d.ts +57 -0
- package/dist/wire.js +244 -0
- package/package.json +1 -1
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 =
|
|
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
|
-
/**
|
|
33
|
-
|
|
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
|
-
|
|
73
|
+
mesh.faces.forEach((face, fi) => {
|
|
40
74
|
const pts = face.v.map((k) => mesh.positions[k]);
|
|
41
|
-
const
|
|
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
|
|
47
|
-
out.set([...
|
|
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
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
175
|
-
|
|
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: {
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
multisample:
|
|
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:
|
|
187
|
-
const bind = device.createBindGroup({ layout:
|
|
188
|
-
const instBind = device.createBindGroup({ layout:
|
|
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
|
-
|
|
222
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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';
|
package/dist/material.d.ts
CHANGED
|
@@ -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.
|
|
9
|
+
/** 1 = solid, 0 = invisible. Blended over what is behind it (WebGPU: a second pass, far to near). */
|
|
10
10
|
alpha?: number;
|
|
11
|
-
/** 0..
|
|
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
|
|
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:
|
|
13
|
-
rough:
|
|
14
|
-
alpha:
|
|
15
|
-
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;
|