@es3d/es3d 1.0.0 → 1.0.2

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.
Files changed (80) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +95 -86
  3. package/docs/SPEC.md +426 -417
  4. package/package.json +1 -1
  5. package/scripts/es3d-cli.mjs +390 -377
  6. package/src/cache/geometry-store.js +392 -392
  7. package/src/cache/mesh-bundle.js +123 -123
  8. package/src/cache/sha256.js +73 -73
  9. package/src/format/ast.js +238 -238
  10. package/src/format/colors.js +93 -93
  11. package/src/format/compact-parser.js +171 -171
  12. package/src/format/compact-tokenizer.js +165 -165
  13. package/src/format/errors.js +247 -247
  14. package/src/format/json.js +55 -55
  15. package/src/format/limits.js +14 -14
  16. package/src/format/parse.js +132 -92
  17. package/src/format/schema.js +617 -617
  18. package/src/format/serializer.js +333 -333
  19. package/src/format/validator.js +330 -330
  20. package/src/format/value-error.js +12 -12
  21. package/src/format/value-format.js +314 -314
  22. package/src/format/values.js +459 -459
  23. package/src/format/xml-parser.js +136 -136
  24. package/src/format/xml-tokenizer.js +237 -237
  25. package/src/geometry/contour.js +86 -86
  26. package/src/geometry/extrude.js +175 -175
  27. package/src/geometry/gear.js +85 -85
  28. package/src/geometry/geometry-cache.js +159 -159
  29. package/src/geometry/lod-error.js +178 -178
  30. package/src/geometry/mesh.js +458 -458
  31. package/src/geometry/primitives.js +299 -299
  32. package/src/geometry/registry.js +645 -645
  33. package/src/geometry/revolve.js +136 -136
  34. package/src/geometry/sweep.js +441 -441
  35. package/src/index.js +47 -47
  36. package/src/io/compression.js +67 -67
  37. package/src/io/convert.js +236 -236
  38. package/src/io/download.js +13 -13
  39. package/src/io/external-scene.js +127 -127
  40. package/src/io/file-loader.js +28 -28
  41. package/src/io/gltf-pack.js +1461 -0
  42. package/src/io/gltf.js +630 -630
  43. package/src/io/mesh-import.js +104 -104
  44. package/src/io/obj.js +352 -352
  45. package/src/io/step-brep.js +439 -439
  46. package/src/io/step-exact.js +1149 -1149
  47. package/src/io/step.js +1418 -1418
  48. package/src/io/stl.js +193 -193
  49. package/src/io/svg-import.js +787 -787
  50. package/src/material/materials.js +154 -154
  51. package/src/math/mat4.js +475 -475
  52. package/src/math/random.js +30 -30
  53. package/src/math/trig.js +65 -65
  54. package/src/math/vec3.js +81 -81
  55. package/src/optimize/optimizer.js +604 -579
  56. package/src/path/path-flatten.js +322 -322
  57. package/src/path/path-parser.js +332 -332
  58. package/src/path/shapes.js +129 -129
  59. package/src/path/triangulate.js +517 -517
  60. package/src/pipeline.js +74 -74
  61. package/src/render/camera.js +168 -168
  62. package/src/render/frame-builder.js +362 -362
  63. package/src/render/geometry-pool.js +149 -149
  64. package/src/render/lighting.js +149 -149
  65. package/src/render/orbit-controls.js +237 -237
  66. package/src/render/renderer.js +258 -249
  67. package/src/render/scroll-timeline.js +104 -104
  68. package/src/render/shaders/glsl.js +285 -285
  69. package/src/render/shaders/wgsl.js +252 -252
  70. package/src/render/stage.js +365 -352
  71. package/src/render/texture-cache.js +40 -40
  72. package/src/render/textures.js +223 -223
  73. package/src/render/webgl2/webgl2-backend.js +586 -532
  74. package/src/render/webgpu/webgpu-backend.js +482 -480
  75. package/src/scene/flatten.js +754 -754
  76. package/src/scene/picking.js +161 -161
  77. package/src/scene/scene-graph.js +320 -320
  78. package/src/transform/transform.js +211 -211
  79. package/src/wasm/kernels-binary.js +3 -3
  80. package/src/wasm/kernels.js +385 -385
@@ -1,362 +1,362 @@
1
- // Backend-independent per-frame work: explicit LOD switches, frustum culling,
2
- // automatic level-of-detail selection, batching and GPU instance data.
3
- //
4
- // Visible instances are grouped by (geometry, LOD level, material, mirrored)
5
- // and every group becomes ONE instanced draw call. Instance matrices are
6
- // written contiguously per batch into a single Float32Array, which the
7
- // backend uploads with one buffer write per frame.
8
-
9
- import { colorToLinear } from '../format/colors.js';
10
-
11
- // A level is used only while its surface stays within this many pixels of the
12
- // true shape. Each geometry carries that distance in canonical units
13
- // (lodDeviation); the frame scales it by the instance and the projection.
14
- export const SURFACE_ERROR_PX = 1;
15
-
16
- // Math.hypot is variadic. V8 builds an argument list for every call, about
17
- // 100 bytes, which at 1e5 instances is ~10 MB of garbage per frame. Scene
18
- // distances are nowhere near the overflow range hypot exists to protect, and
19
- // the level-of-detail use below already clamps tiny distances.
20
- function length3(x, y, z) {
21
- return Math.sqrt(x * x + y * y + z * z);
22
- }
23
-
24
- /**
25
- * The level to draw while `lod` is not generated yet: the nearest coarser
26
- * level that exists, else the nearest finer one, else the coarsest level
27
- * (the cheapest to generate now).
28
- */
29
- function standIn(meshes, lod) {
30
- for (let l = lod + 1; l < meshes.length; l++) if (meshes[l]) return l;
31
- for (let l = lod - 1; l >= 0; l--) if (meshes[l]) return l;
32
- return meshes.length - 1;
33
- }
34
-
35
- /** Longest column of an instance matrix: a conservative world scale. */
36
- function axisScale(M, i) {
37
- const o = i * 16;
38
- let m = 0;
39
- for (let c = 0; c < 3; c++) {
40
- const s = length3(M[o + c * 4], M[o + c * 4 + 1], M[o + c * 4 + 2]);
41
- if (s > m) m = s;
42
- }
43
- return m || 1;
44
- }
45
-
46
- /**
47
- * Coarsest level whose chordal error projects to at most one pixel.
48
- * `deviation[level]` is the error in canonical units. `pixelsPerUnit` is the
49
- * projected size of one canonical unit after the instance scale is applied
50
- * separately via `scale`. `bias` above 1 tightens the budget.
51
- */
52
- export function selectLod(deviation, lods, scale, pixelsPerUnit, bias) {
53
- if (!(lods > 1) || !deviation) return 0;
54
- const budget = SURFACE_ERROR_PX / (bias > 0 ? bias : 1);
55
- const k = scale * pixelsPerUnit;
56
- let lod = 0;
57
- const last = Math.min(lods, deviation.length) - 1;
58
- for (let level = last; level >= 1; level--) {
59
- if (deviation[level] * k <= budget) {
60
- lod = level;
61
- break;
62
- }
63
- }
64
- return lod;
65
- }
66
-
67
- function planesFrom(m) {
68
- // rows of the column-major view-projection matrix
69
- const r = (i) => [m[i], m[4 + i], m[8 + i], m[12 + i]];
70
- const r0 = r(0), r1 = r(1), r3 = r(3), r2 = r(2);
71
- const planes = [
72
- [r3[0] + r0[0], r3[1] + r0[1], r3[2] + r0[2], r3[3] + r0[3]],
73
- [r3[0] - r0[0], r3[1] - r0[1], r3[2] - r0[2], r3[3] - r0[3]],
74
- [r3[0] + r1[0], r3[1] + r1[1], r3[2] + r1[2], r3[3] + r1[3]],
75
- [r3[0] - r1[0], r3[1] - r1[1], r3[2] - r1[2], r3[3] - r1[3]],
76
- [r3[0] - r2[0], r3[1] - r2[1], r3[2] - r2[2], r3[3] - r2[3]],
77
- ];
78
- for (const p of planes) {
79
- const l = Math.hypot(p[0], p[1], p[2]) || 1;
80
- p[0] /= l;
81
- p[1] /= l;
82
- p[2] /= l;
83
- p[3] /= l;
84
- }
85
- return planes;
86
- }
87
-
88
- export class FrameBuilder {
89
- constructor() {
90
- this.instanceData = new Float32Array(16 * 1024);
91
- this.keys = new Int32Array(1024);
92
- this.lods = new Uint8Array(1024);
93
- this.last = null;
94
- this.lastSignature = '';
95
- }
96
-
97
- ensure(n) {
98
- if (this.instanceData.length < n * 16) {
99
- let cap = this.instanceData.length / 16;
100
- while (cap < n) cap *= 2;
101
- this.instanceData = new Float32Array(cap * 16);
102
- }
103
- if (this.keys.length < n) {
104
- let cap = this.keys.length;
105
- while (cap < n) cap *= 2;
106
- this.keys = new Int32Array(cap);
107
- this.lods = new Uint8Array(cap);
108
- }
109
- }
110
-
111
- /** Explicit <lod> switch states for the camera position. */
112
- lodStates(scene, camPos, dist) {
113
- const sw = scene.lodSwitches;
114
- const active = new Int32Array(sw.length);
115
- const visible = new Uint8Array(sw.length);
116
- for (let s = 0; s < sw.length; s++) {
117
- const w = sw[s];
118
- const d = dist(w.pos);
119
- let c = 0;
120
- while (c < w.distances.length && d >= w.distances[c]) c++;
121
- active[s] = c;
122
- visible[s] = w.parent < 0 ? 1 : visible[w.parent] && active[w.parent] === w.parentChild ? 1 : 0;
123
- }
124
- return { active, visible };
125
- }
126
-
127
- /**
128
- * @param {object} p
129
- * @param {import('../scene/flatten.js').RenderScene} p.scene
130
- * @param {import('./camera.js').Camera} p.camera (matrices already updated)
131
- * @param {number} p.viewportHeight
132
- * @param {(entry:object, lod:number)=>object} p.meshOf
133
- * @param {import('./geometry-pool.js').GeometryPool} p.pool
134
- * @param {object} p.options {autoLod, fixedLod, culling, lodBias, bbox, wireframe, normals, selection:Set<number>,
135
- * tessellationBudget: ms of mesh generation per frame before levels are drawn coarser and refined later}
136
- */
137
- build(p) {
138
- const { scene, camera, pool, meshOf, options } = p;
139
- const n = scene.count;
140
- // reserve instance rows for: batches (<= n), bounding boxes (<= n), selection boxes
141
- let selCount = 0;
142
- if (options.selection && options.selection.size) {
143
- for (let i = 0; i < n; i++) if (options.selection.has(scene.uid[i])) selCount++;
144
- }
145
- this.ensure(n + (options.bbox ? n : 0) + selCount + 64);
146
- const camPos = camera.position;
147
- const dist = (c) => Math.hypot(c[0] - camPos[0], c[1] - camPos[1], c[2] - camPos[2]);
148
- const sw = this.lodStates(scene, camPos, dist);
149
- const planes = options.culling === false ? null : planesFrom(camera.viewProj);
150
- const S = scene.spheres;
151
- const keys = this.keys;
152
- const lods = this.lods;
153
- const batchMap = new Map();
154
- const batches = [];
155
- const nMat = Math.max(1, scene.materials.length);
156
- const lodHist = [0, 0, 0, 0];
157
- let visibleCount = 0;
158
- let culled = 0;
159
- const vh = p.viewportHeight;
160
- const bias = options.lodBias || 1;
161
- const fixedLod = options.autoLod === false ? options.fixedLod || 0 : -1;
162
- // pixels per world unit at distance 1 (perspective) or everywhere (orthographic)
163
- const ppuFactor = camera.pixelsPerUnit(1, vh);
164
-
165
- for (let i = 0; i < n; i++) {
166
- keys[i] = -1;
167
- const ln = scene.lodNode[i];
168
- if (ln >= 0 && !(sw.visible[ln] && sw.active[ln] === scene.lodChild[i])) continue;
169
- const cx = S[i * 4], cy = S[i * 4 + 1], cz = S[i * 4 + 2], r = S[i * 4 + 3];
170
- if (planes) {
171
- let out = false;
172
- for (let k = 0; k < 5; k++) {
173
- const pl = planes[k];
174
- if (pl[0] * cx + pl[1] * cy + pl[2] * cz + pl[3] < -r) {
175
- out = true;
176
- break;
177
- }
178
- }
179
- if (out) {
180
- culled++;
181
- continue;
182
- }
183
- }
184
- const gi = scene.geom[i];
185
- const entry = scene.geometries[gi];
186
- let lod = 0;
187
- if (entry.lods > 1) {
188
- if (fixedLod >= 0) lod = Math.min(fixedLod, entry.lods - 1);
189
- else {
190
- const dx = cx - camPos[0], dy = cy - camPos[1], dz = cz - camPos[2];
191
- const d = length3(dx, dy, dz);
192
- const ppu = camera.type === 'orthographic' ? ppuFactor : ppuFactor / Math.max(d, 1e-6);
193
- lod = selectLod(entry.lodDeviation, entry.lods, axisScale(scene.matrices, i), ppu, bias);
194
- }
195
- }
196
- lods[i] = lod;
197
- lodHist[lod]++;
198
- const mirrored = scene.flags[i] & 1;
199
- const mi = scene.mat[i];
200
- const key = ((gi * 4 + lod) * nMat + mi) * 2 + mirrored;
201
- let b = batchMap.get(key);
202
- if (b === undefined) {
203
- b = batches.length;
204
- batchMap.set(key, b);
205
- const material = scene.materials[mi];
206
- batches.push({ gi, entry, lod, mi, material, mirrored: !!mirrored, count: 0, firstInstance: 0, depth: 0, instances: null });
207
- }
208
- batches[b].count++;
209
- keys[i] = b;
210
- visibleCount++;
211
- }
212
-
213
- // resolve meshes / pool ranges. Once this frame has spent its tessellation
214
- // budget, a level that is not generated yet is drawn with a stand-in and
215
- // refined on a later frame: the first frame comes at once, full detail
216
- // follows within a few frames (each frame generates at least one).
217
- const budget = options.tessellationBudget;
218
- const progressive = budget >= 0 && budget < Infinity;
219
- const start = progressive ? performance.now() : 0;
220
- let generated = 0;
221
- let refining = 0;
222
- let triangles = 0;
223
- for (const b of batches) {
224
- let lod = b.lod;
225
- const have = b.entry.meshes;
226
- if (progressive && have && !have[lod]) {
227
- if (generated && performance.now() - start >= budget) {
228
- lod = standIn(have, lod);
229
- refining++;
230
- } else generated++;
231
- }
232
- const mesh = meshOf(b.entry, lod);
233
- b.mesh = mesh;
234
- b.kind = mesh.kind;
235
- b.range = pool.meshRange(mesh);
236
- b.doubleSided = !!(b.entry.doubleSided || b.material.doubleSided);
237
- b.transparent = b.material.transparent;
238
- if (b.kind === 'triangles') triangles += (b.range.indexCount / 3) * b.count;
239
- }
240
-
241
- // order: opaque by pipeline then material, transparent back to front
242
- let offset = 0;
243
- const order = batches.slice().sort((a, b) => {
244
- if (a.transparent !== b.transparent) return a.transparent ? 1 : -1;
245
- if (a.kind !== b.kind) return a.kind === 'triangles' ? -1 : 1;
246
- if (a.doubleSided !== b.doubleSided) return a.doubleSided ? 1 : -1;
247
- if (a.mirrored !== b.mirrored) return a.mirrored ? 1 : -1;
248
- return a.mi - b.mi;
249
- });
250
- for (const b of order) {
251
- b.firstInstance = offset;
252
- offset += b.count;
253
- b.cursor = b.firstInstance;
254
- }
255
- const data = this.instanceData;
256
- const M = scene.matrices;
257
- const transparentInstances = [];
258
- for (let i = 0; i < n; i++) {
259
- const k = keys[i];
260
- if (k < 0) continue;
261
- const b = batches[k];
262
- if (b.transparent) {
263
- transparentInstances.push(i);
264
- continue;
265
- }
266
- const o = b.cursor++ * 16;
267
- for (let j = 0; j < 16; j++) data[o + j] = M[i * 16 + j];
268
- }
269
- if (transparentInstances.length) {
270
- const depthOf = (i) => -length3(S[i * 4] - camPos[0], S[i * 4 + 1] - camPos[1], S[i * 4 + 2] - camPos[2]);
271
- transparentInstances.sort((a, b) => depthOf(a) - depthOf(b));
272
- for (const i of transparentInstances) {
273
- const b = batches[keys[i]];
274
- const o = b.cursor++ * 16;
275
- for (let j = 0; j < 16; j++) data[o + j] = M[i * 16 + j];
276
- b.depth = Math.max(b.depth, -depthOf(i));
277
- }
278
- // farthest transparent batches first
279
- const opaque = order.filter((b) => !b.transparent);
280
- const transp = order.filter((b) => b.transparent).sort((a, b) => b.depth - a.depth);
281
- order.length = 0;
282
- order.push(...opaque, ...transp);
283
- }
284
-
285
- // overlays
286
- const overlays = { wireframe: [], normals: [], bbox: null, selection: null };
287
- if (options.wireframe || options.normals) {
288
- for (const b of order) {
289
- if (b.kind !== 'triangles') continue;
290
- if (options.wireframe) overlays.wireframe.push({ range: pool.edgeRange(b.mesh), firstInstance: b.firstInstance, count: b.count, mirrored: b.mirrored });
291
- if (options.normals) overlays.normals.push({ range: pool.normalRange(b.mesh), firstInstance: b.firstInstance, count: b.count });
292
- }
293
- }
294
- const boxMatrix = (i, o) => {
295
- const e = scene.geometries[scene.geom[i]];
296
- const bb = e.bounds;
297
- const cx = (bb.min[0] + bb.max[0]) / 2, cy = (bb.min[1] + bb.max[1]) / 2, cz = (bb.min[2] + bb.max[2]) / 2;
298
- const sx = Math.max(bb.max[0] - bb.min[0], 1e-4), sy = Math.max(bb.max[1] - bb.min[1], 1e-4), sz = Math.max(bb.max[2] - bb.min[2], 1e-4);
299
- const m = M.subarray(i * 16, i * 16 + 16);
300
- // world * T(c) * S(s)
301
- for (let k = 0; k < 3; k++) {
302
- data[o + k] = m[k] * sx;
303
- data[o + 4 + k] = m[4 + k] * sy;
304
- data[o + 8 + k] = m[8 + k] * sz;
305
- data[o + 12 + k] = m[k] * cx + m[4 + k] * cy + m[8 + k] * cz + m[12 + k];
306
- }
307
- data[o + 3] = 0;
308
- data[o + 7] = 0;
309
- data[o + 11] = 0;
310
- data[o + 15] = 1;
311
- };
312
- if (options.bbox) {
313
- const first = offset;
314
- for (let i = 0; i < n; i++) {
315
- if (keys[i] < 0) continue;
316
- boxMatrix(i, offset * 16);
317
- offset++;
318
- }
319
- overlays.bbox = { firstInstance: first, count: offset - first };
320
- }
321
- if (selCount) {
322
- const first = offset;
323
- const sel = options.selection;
324
- for (let i = 0; i < n; i++) {
325
- if (!sel.has(scene.uid[i])) continue;
326
- // hidden level-of-detail alternatives are not highlighted
327
- if (keys[i] < 0 && scene.lodNode[i] >= 0) continue;
328
- boxMatrix(i, offset * 16);
329
- offset++;
330
- }
331
- overlays.selection = { firstInstance: first, count: offset - first };
332
- }
333
-
334
- const result = {
335
- batches: order,
336
- overlays,
337
- instanceData: this.instanceData,
338
- instanceCount: offset,
339
- lights: scene.lights,
340
- environment: scene.environment,
341
- stats: {
342
- instances: n,
343
- visible: visibleCount,
344
- culled,
345
- batches: order.length,
346
- drawCalls: order.length + overlays.wireframe.length + overlays.normals.length + (overlays.bbox ? 1 : 0) + (overlays.selection ? 1 : 0),
347
- triangles,
348
- lodHistogram: lodHist,
349
- refining,
350
- },
351
- };
352
- this.last = result;
353
- return result;
354
- }
355
- }
356
-
357
- /** Environment defaults in linear space for the shaders. */
358
- export function environmentUniforms(env) {
359
- const sky = colorToLinear(env.sky);
360
- const ground = colorToLinear(env.ground);
361
- return { sky, ground, intensity: env.intensity, exposure: env.exposure, background: env.background };
362
- }
1
+ // Backend-independent per-frame work: explicit LOD switches, frustum culling,
2
+ // automatic level-of-detail selection, batching and GPU instance data.
3
+ //
4
+ // Visible instances are grouped by (geometry, LOD level, material, mirrored)
5
+ // and every group becomes ONE instanced draw call. Instance matrices are
6
+ // written contiguously per batch into a single Float32Array, which the
7
+ // backend uploads with one buffer write per frame.
8
+
9
+ import { colorToLinear } from '../format/colors.js';
10
+
11
+ // A level is used only while its surface stays within this many pixels of the
12
+ // true shape. Each geometry carries that distance in canonical units
13
+ // (lodDeviation); the frame scales it by the instance and the projection.
14
+ export const SURFACE_ERROR_PX = 1;
15
+
16
+ // Math.hypot is variadic. V8 builds an argument list for every call, about
17
+ // 100 bytes, which at 1e5 instances is ~10 MB of garbage per frame. Scene
18
+ // distances are nowhere near the overflow range hypot exists to protect, and
19
+ // the level-of-detail use below already clamps tiny distances.
20
+ function length3(x, y, z) {
21
+ return Math.sqrt(x * x + y * y + z * z);
22
+ }
23
+
24
+ /**
25
+ * The level to draw while `lod` is not generated yet: the nearest coarser
26
+ * level that exists, else the nearest finer one, else the coarsest level
27
+ * (the cheapest to generate now).
28
+ */
29
+ function standIn(meshes, lod) {
30
+ for (let l = lod + 1; l < meshes.length; l++) if (meshes[l]) return l;
31
+ for (let l = lod - 1; l >= 0; l--) if (meshes[l]) return l;
32
+ return meshes.length - 1;
33
+ }
34
+
35
+ /** Longest column of an instance matrix: a conservative world scale. */
36
+ function axisScale(M, i) {
37
+ const o = i * 16;
38
+ let m = 0;
39
+ for (let c = 0; c < 3; c++) {
40
+ const s = length3(M[o + c * 4], M[o + c * 4 + 1], M[o + c * 4 + 2]);
41
+ if (s > m) m = s;
42
+ }
43
+ return m || 1;
44
+ }
45
+
46
+ /**
47
+ * Coarsest level whose chordal error projects to at most one pixel.
48
+ * `deviation[level]` is the error in canonical units. `pixelsPerUnit` is the
49
+ * projected size of one canonical unit after the instance scale is applied
50
+ * separately via `scale`. `bias` above 1 tightens the budget.
51
+ */
52
+ export function selectLod(deviation, lods, scale, pixelsPerUnit, bias) {
53
+ if (!(lods > 1) || !deviation) return 0;
54
+ const budget = SURFACE_ERROR_PX / (bias > 0 ? bias : 1);
55
+ const k = scale * pixelsPerUnit;
56
+ let lod = 0;
57
+ const last = Math.min(lods, deviation.length) - 1;
58
+ for (let level = last; level >= 1; level--) {
59
+ if (deviation[level] * k <= budget) {
60
+ lod = level;
61
+ break;
62
+ }
63
+ }
64
+ return lod;
65
+ }
66
+
67
+ function planesFrom(m) {
68
+ // rows of the column-major view-projection matrix
69
+ const r = (i) => [m[i], m[4 + i], m[8 + i], m[12 + i]];
70
+ const r0 = r(0), r1 = r(1), r3 = r(3), r2 = r(2);
71
+ const planes = [
72
+ [r3[0] + r0[0], r3[1] + r0[1], r3[2] + r0[2], r3[3] + r0[3]],
73
+ [r3[0] - r0[0], r3[1] - r0[1], r3[2] - r0[2], r3[3] - r0[3]],
74
+ [r3[0] + r1[0], r3[1] + r1[1], r3[2] + r1[2], r3[3] + r1[3]],
75
+ [r3[0] - r1[0], r3[1] - r1[1], r3[2] - r1[2], r3[3] - r1[3]],
76
+ [r3[0] - r2[0], r3[1] - r2[1], r3[2] - r2[2], r3[3] - r2[3]],
77
+ ];
78
+ for (const p of planes) {
79
+ const l = Math.hypot(p[0], p[1], p[2]) || 1;
80
+ p[0] /= l;
81
+ p[1] /= l;
82
+ p[2] /= l;
83
+ p[3] /= l;
84
+ }
85
+ return planes;
86
+ }
87
+
88
+ export class FrameBuilder {
89
+ constructor() {
90
+ this.instanceData = new Float32Array(16 * 1024);
91
+ this.keys = new Int32Array(1024);
92
+ this.lods = new Uint8Array(1024);
93
+ this.last = null;
94
+ this.lastSignature = '';
95
+ }
96
+
97
+ ensure(n) {
98
+ if (this.instanceData.length < n * 16) {
99
+ let cap = this.instanceData.length / 16;
100
+ while (cap < n) cap *= 2;
101
+ this.instanceData = new Float32Array(cap * 16);
102
+ }
103
+ if (this.keys.length < n) {
104
+ let cap = this.keys.length;
105
+ while (cap < n) cap *= 2;
106
+ this.keys = new Int32Array(cap);
107
+ this.lods = new Uint8Array(cap);
108
+ }
109
+ }
110
+
111
+ /** Explicit <lod> switch states for the camera position. */
112
+ lodStates(scene, camPos, dist) {
113
+ const sw = scene.lodSwitches;
114
+ const active = new Int32Array(sw.length);
115
+ const visible = new Uint8Array(sw.length);
116
+ for (let s = 0; s < sw.length; s++) {
117
+ const w = sw[s];
118
+ const d = dist(w.pos);
119
+ let c = 0;
120
+ while (c < w.distances.length && d >= w.distances[c]) c++;
121
+ active[s] = c;
122
+ visible[s] = w.parent < 0 ? 1 : visible[w.parent] && active[w.parent] === w.parentChild ? 1 : 0;
123
+ }
124
+ return { active, visible };
125
+ }
126
+
127
+ /**
128
+ * @param {object} p
129
+ * @param {import('../scene/flatten.js').RenderScene} p.scene
130
+ * @param {import('./camera.js').Camera} p.camera (matrices already updated)
131
+ * @param {number} p.viewportHeight
132
+ * @param {(entry:object, lod:number)=>object} p.meshOf
133
+ * @param {import('./geometry-pool.js').GeometryPool} p.pool
134
+ * @param {object} p.options {autoLod, fixedLod, culling, lodBias, bbox, wireframe, normals, selection:Set<number>,
135
+ * tessellationBudget: ms of mesh generation per frame before levels are drawn coarser and refined later}
136
+ */
137
+ build(p) {
138
+ const { scene, camera, pool, meshOf, options } = p;
139
+ const n = scene.count;
140
+ // reserve instance rows for: batches (<= n), bounding boxes (<= n), selection boxes
141
+ let selCount = 0;
142
+ if (options.selection && options.selection.size) {
143
+ for (let i = 0; i < n; i++) if (options.selection.has(scene.uid[i])) selCount++;
144
+ }
145
+ this.ensure(n + (options.bbox ? n : 0) + selCount + 64);
146
+ const camPos = camera.position;
147
+ const dist = (c) => Math.hypot(c[0] - camPos[0], c[1] - camPos[1], c[2] - camPos[2]);
148
+ const sw = this.lodStates(scene, camPos, dist);
149
+ const planes = options.culling === false ? null : planesFrom(camera.viewProj);
150
+ const S = scene.spheres;
151
+ const keys = this.keys;
152
+ const lods = this.lods;
153
+ const batchMap = new Map();
154
+ const batches = [];
155
+ const nMat = Math.max(1, scene.materials.length);
156
+ const lodHist = [0, 0, 0, 0];
157
+ let visibleCount = 0;
158
+ let culled = 0;
159
+ const vh = p.viewportHeight;
160
+ const bias = options.lodBias || 1;
161
+ const fixedLod = options.autoLod === false ? options.fixedLod || 0 : -1;
162
+ // pixels per world unit at distance 1 (perspective) or everywhere (orthographic)
163
+ const ppuFactor = camera.pixelsPerUnit(1, vh);
164
+
165
+ for (let i = 0; i < n; i++) {
166
+ keys[i] = -1;
167
+ const ln = scene.lodNode[i];
168
+ if (ln >= 0 && !(sw.visible[ln] && sw.active[ln] === scene.lodChild[i])) continue;
169
+ const cx = S[i * 4], cy = S[i * 4 + 1], cz = S[i * 4 + 2], r = S[i * 4 + 3];
170
+ if (planes) {
171
+ let out = false;
172
+ for (let k = 0; k < 5; k++) {
173
+ const pl = planes[k];
174
+ if (pl[0] * cx + pl[1] * cy + pl[2] * cz + pl[3] < -r) {
175
+ out = true;
176
+ break;
177
+ }
178
+ }
179
+ if (out) {
180
+ culled++;
181
+ continue;
182
+ }
183
+ }
184
+ const gi = scene.geom[i];
185
+ const entry = scene.geometries[gi];
186
+ let lod = 0;
187
+ if (entry.lods > 1) {
188
+ if (fixedLod >= 0) lod = Math.min(fixedLod, entry.lods - 1);
189
+ else {
190
+ const dx = cx - camPos[0], dy = cy - camPos[1], dz = cz - camPos[2];
191
+ const d = length3(dx, dy, dz);
192
+ const ppu = camera.type === 'orthographic' ? ppuFactor : ppuFactor / Math.max(d, 1e-6);
193
+ lod = selectLod(entry.lodDeviation, entry.lods, axisScale(scene.matrices, i), ppu, bias);
194
+ }
195
+ }
196
+ lods[i] = lod;
197
+ lodHist[lod]++;
198
+ const mirrored = scene.flags[i] & 1;
199
+ const mi = scene.mat[i];
200
+ const key = ((gi * 4 + lod) * nMat + mi) * 2 + mirrored;
201
+ let b = batchMap.get(key);
202
+ if (b === undefined) {
203
+ b = batches.length;
204
+ batchMap.set(key, b);
205
+ const material = scene.materials[mi];
206
+ batches.push({ gi, entry, lod, mi, material, mirrored: !!mirrored, count: 0, firstInstance: 0, depth: 0, instances: null });
207
+ }
208
+ batches[b].count++;
209
+ keys[i] = b;
210
+ visibleCount++;
211
+ }
212
+
213
+ // resolve meshes / pool ranges. Once this frame has spent its tessellation
214
+ // budget, a level that is not generated yet is drawn with a stand-in and
215
+ // refined on a later frame: the first frame comes at once, full detail
216
+ // follows within a few frames (each frame generates at least one).
217
+ const budget = options.tessellationBudget;
218
+ const progressive = budget >= 0 && budget < Infinity;
219
+ const start = progressive ? performance.now() : 0;
220
+ let generated = 0;
221
+ let refining = 0;
222
+ let triangles = 0;
223
+ for (const b of batches) {
224
+ let lod = b.lod;
225
+ const have = b.entry.meshes;
226
+ if (progressive && have && !have[lod]) {
227
+ if (generated && performance.now() - start >= budget) {
228
+ lod = standIn(have, lod);
229
+ refining++;
230
+ } else generated++;
231
+ }
232
+ const mesh = meshOf(b.entry, lod);
233
+ b.mesh = mesh;
234
+ b.kind = mesh.kind;
235
+ b.range = pool.meshRange(mesh);
236
+ b.doubleSided = !!(b.entry.doubleSided || b.material.doubleSided);
237
+ b.transparent = b.material.transparent;
238
+ if (b.kind === 'triangles') triangles += (b.range.indexCount / 3) * b.count;
239
+ }
240
+
241
+ // order: opaque by pipeline then material, transparent back to front
242
+ let offset = 0;
243
+ const order = batches.slice().sort((a, b) => {
244
+ if (a.transparent !== b.transparent) return a.transparent ? 1 : -1;
245
+ if (a.kind !== b.kind) return a.kind === 'triangles' ? -1 : 1;
246
+ if (a.doubleSided !== b.doubleSided) return a.doubleSided ? 1 : -1;
247
+ if (a.mirrored !== b.mirrored) return a.mirrored ? 1 : -1;
248
+ return a.mi - b.mi;
249
+ });
250
+ for (const b of order) {
251
+ b.firstInstance = offset;
252
+ offset += b.count;
253
+ b.cursor = b.firstInstance;
254
+ }
255
+ const data = this.instanceData;
256
+ const M = scene.matrices;
257
+ const transparentInstances = [];
258
+ for (let i = 0; i < n; i++) {
259
+ const k = keys[i];
260
+ if (k < 0) continue;
261
+ const b = batches[k];
262
+ if (b.transparent) {
263
+ transparentInstances.push(i);
264
+ continue;
265
+ }
266
+ const o = b.cursor++ * 16;
267
+ for (let j = 0; j < 16; j++) data[o + j] = M[i * 16 + j];
268
+ }
269
+ if (transparentInstances.length) {
270
+ const depthOf = (i) => -length3(S[i * 4] - camPos[0], S[i * 4 + 1] - camPos[1], S[i * 4 + 2] - camPos[2]);
271
+ transparentInstances.sort((a, b) => depthOf(a) - depthOf(b));
272
+ for (const i of transparentInstances) {
273
+ const b = batches[keys[i]];
274
+ const o = b.cursor++ * 16;
275
+ for (let j = 0; j < 16; j++) data[o + j] = M[i * 16 + j];
276
+ b.depth = Math.max(b.depth, -depthOf(i));
277
+ }
278
+ // farthest transparent batches first
279
+ const opaque = order.filter((b) => !b.transparent);
280
+ const transp = order.filter((b) => b.transparent).sort((a, b) => b.depth - a.depth);
281
+ order.length = 0;
282
+ order.push(...opaque, ...transp);
283
+ }
284
+
285
+ // overlays
286
+ const overlays = { wireframe: [], normals: [], bbox: null, selection: null };
287
+ if (options.wireframe || options.normals) {
288
+ for (const b of order) {
289
+ if (b.kind !== 'triangles') continue;
290
+ if (options.wireframe) overlays.wireframe.push({ range: pool.edgeRange(b.mesh), firstInstance: b.firstInstance, count: b.count, mirrored: b.mirrored });
291
+ if (options.normals) overlays.normals.push({ range: pool.normalRange(b.mesh), firstInstance: b.firstInstance, count: b.count });
292
+ }
293
+ }
294
+ const boxMatrix = (i, o) => {
295
+ const e = scene.geometries[scene.geom[i]];
296
+ const bb = e.bounds;
297
+ const cx = (bb.min[0] + bb.max[0]) / 2, cy = (bb.min[1] + bb.max[1]) / 2, cz = (bb.min[2] + bb.max[2]) / 2;
298
+ const sx = Math.max(bb.max[0] - bb.min[0], 1e-4), sy = Math.max(bb.max[1] - bb.min[1], 1e-4), sz = Math.max(bb.max[2] - bb.min[2], 1e-4);
299
+ const m = M.subarray(i * 16, i * 16 + 16);
300
+ // world * T(c) * S(s)
301
+ for (let k = 0; k < 3; k++) {
302
+ data[o + k] = m[k] * sx;
303
+ data[o + 4 + k] = m[4 + k] * sy;
304
+ data[o + 8 + k] = m[8 + k] * sz;
305
+ data[o + 12 + k] = m[k] * cx + m[4 + k] * cy + m[8 + k] * cz + m[12 + k];
306
+ }
307
+ data[o + 3] = 0;
308
+ data[o + 7] = 0;
309
+ data[o + 11] = 0;
310
+ data[o + 15] = 1;
311
+ };
312
+ if (options.bbox) {
313
+ const first = offset;
314
+ for (let i = 0; i < n; i++) {
315
+ if (keys[i] < 0) continue;
316
+ boxMatrix(i, offset * 16);
317
+ offset++;
318
+ }
319
+ overlays.bbox = { firstInstance: first, count: offset - first };
320
+ }
321
+ if (selCount) {
322
+ const first = offset;
323
+ const sel = options.selection;
324
+ for (let i = 0; i < n; i++) {
325
+ if (!sel.has(scene.uid[i])) continue;
326
+ // hidden level-of-detail alternatives are not highlighted
327
+ if (keys[i] < 0 && scene.lodNode[i] >= 0) continue;
328
+ boxMatrix(i, offset * 16);
329
+ offset++;
330
+ }
331
+ overlays.selection = { firstInstance: first, count: offset - first };
332
+ }
333
+
334
+ const result = {
335
+ batches: order,
336
+ overlays,
337
+ instanceData: this.instanceData,
338
+ instanceCount: offset,
339
+ lights: scene.lights,
340
+ environment: scene.environment,
341
+ stats: {
342
+ instances: n,
343
+ visible: visibleCount,
344
+ culled,
345
+ batches: order.length,
346
+ drawCalls: order.length + overlays.wireframe.length + overlays.normals.length + (overlays.bbox ? 1 : 0) + (overlays.selection ? 1 : 0),
347
+ triangles,
348
+ lodHistogram: lodHist,
349
+ refining,
350
+ },
351
+ };
352
+ this.last = result;
353
+ return result;
354
+ }
355
+ }
356
+
357
+ /** Environment defaults in linear space for the shaders. */
358
+ export function environmentUniforms(env) {
359
+ const sky = colorToLinear(env.sky);
360
+ const ground = colorToLinear(env.ground);
361
+ return { sky, ground, intensity: env.intensity, exposure: env.exposure, background: env.background };
362
+ }