@es3d/es3d 0.0.0-stage → 1.0.1

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 (82) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +85 -2
  3. package/docs/REFERENCE.md +581 -0
  4. package/docs/SPEC.md +417 -0
  5. package/package.json +50 -4
  6. package/scripts/es3d-cli.mjs +377 -0
  7. package/src/cache/geometry-store.js +392 -0
  8. package/src/cache/mesh-bundle.js +123 -0
  9. package/src/cache/sha256.js +73 -0
  10. package/src/format/ast.js +238 -0
  11. package/src/format/colors.js +93 -0
  12. package/src/format/compact-parser.js +171 -0
  13. package/src/format/compact-tokenizer.js +165 -0
  14. package/src/format/errors.js +247 -0
  15. package/src/format/json.js +55 -0
  16. package/src/format/limits.js +14 -0
  17. package/src/format/parse.js +92 -0
  18. package/src/format/schema.js +617 -0
  19. package/src/format/serializer.js +333 -0
  20. package/src/format/validator.js +330 -0
  21. package/src/format/value-error.js +12 -0
  22. package/src/format/value-format.js +314 -0
  23. package/src/format/values.js +459 -0
  24. package/src/format/xml-parser.js +136 -0
  25. package/src/format/xml-tokenizer.js +237 -0
  26. package/src/geometry/contour.js +86 -0
  27. package/src/geometry/extrude.js +175 -0
  28. package/src/geometry/gear.js +85 -0
  29. package/src/geometry/geometry-cache.js +159 -0
  30. package/src/geometry/lod-error.js +178 -0
  31. package/src/geometry/mesh.js +458 -0
  32. package/src/geometry/primitives.js +299 -0
  33. package/src/geometry/registry.js +645 -0
  34. package/src/geometry/revolve.js +136 -0
  35. package/src/geometry/sweep.js +441 -0
  36. package/src/index.js +47 -0
  37. package/src/io/compression.js +67 -0
  38. package/src/io/convert.js +236 -0
  39. package/src/io/download.js +13 -0
  40. package/src/io/external-scene.js +127 -0
  41. package/src/io/file-loader.js +28 -0
  42. package/src/io/gltf.js +630 -0
  43. package/src/io/mesh-import.js +104 -0
  44. package/src/io/obj.js +352 -0
  45. package/src/io/step-brep.js +439 -0
  46. package/src/io/step-exact.js +1149 -0
  47. package/src/io/step.js +1418 -0
  48. package/src/io/stl.js +193 -0
  49. package/src/io/svg-import.js +787 -0
  50. package/src/material/materials.js +154 -0
  51. package/src/math/mat4.js +475 -0
  52. package/src/math/random.js +30 -0
  53. package/src/math/trig.js +65 -0
  54. package/src/math/vec3.js +81 -0
  55. package/src/optimize/optimizer.js +579 -0
  56. package/src/path/path-flatten.js +322 -0
  57. package/src/path/path-parser.js +332 -0
  58. package/src/path/shapes.js +129 -0
  59. package/src/path/triangulate.js +517 -0
  60. package/src/pipeline.js +74 -0
  61. package/src/render/camera.js +168 -0
  62. package/src/render/frame-builder.js +362 -0
  63. package/src/render/geometry-pool.js +149 -0
  64. package/src/render/lighting.js +149 -0
  65. package/src/render/orbit-controls.js +237 -0
  66. package/src/render/renderer.js +258 -0
  67. package/src/render/scroll-timeline.js +104 -0
  68. package/src/render/shaders/glsl.js +285 -0
  69. package/src/render/shaders/wgsl.js +252 -0
  70. package/src/render/stage.js +364 -0
  71. package/src/render/texture-cache.js +40 -0
  72. package/src/render/textures.js +223 -0
  73. package/src/render/webgl2/webgl2-backend.js +586 -0
  74. package/src/render/webgpu/webgpu-backend.js +482 -0
  75. package/src/scene/animation.js +171 -0
  76. package/src/scene/camera-constraints.js +117 -0
  77. package/src/scene/flatten.js +754 -0
  78. package/src/scene/picking.js +161 -0
  79. package/src/scene/scene-graph.js +320 -0
  80. package/src/transform/transform.js +211 -0
  81. package/src/wasm/kernels-binary.js +3 -0
  82. package/src/wasm/kernels.js +385 -0
@@ -0,0 +1,74 @@
1
+ // The ES3D processing pipeline used by the application, the benchmark, the CLI
2
+ // and the tests:
3
+ //
4
+ // text/binary --parse--> AST --validate--> typed AST --build--> scene graph
5
+ // --flatten--> RenderScene (instance table) --lazy--> meshes
6
+ //
7
+ // Each stage is timed so the benchmark and the statistics panel can report
8
+ // where time is spent.
9
+
10
+ import { parse } from './format/parse.js';
11
+ import { Diagnostic } from './format/errors.js';
12
+ import { buildSceneGraph } from './scene/scene-graph.js';
13
+ import { flattenScene } from './scene/flatten.js';
14
+ import { GeometryCache } from './geometry/geometry-cache.js';
15
+
16
+ export const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
17
+
18
+ /** Convert an element-attached diagnostic into a located Diagnostic. */
19
+ export function elementDiagnostic(doc, d) {
20
+ const el = d.element;
21
+ const diag = new Diagnostic({
22
+ severity: d.severity,
23
+ phase: d.phase || 'geometry',
24
+ code: d.code,
25
+ message: el ? `<${el.name}${el.values && el.values.id ? ` id="${el.values.id}"` : ''}>: ${d.message}` : d.message,
26
+ offset: el && el.nameStart >= 0 ? el.nameStart : -1,
27
+ length: el && el.nameStart >= 0 ? el.nameEnd - el.nameStart : 0,
28
+ uid: el ? el.uid : 0,
29
+ });
30
+ diag.locate(doc && doc.lineIndex);
31
+ return diag;
32
+ }
33
+
34
+ /**
35
+ * Parse, validate, build and flatten a document.
36
+ * @param {string|ArrayBuffer|Uint8Array} input
37
+ * @param {{cache?: GeometryCache, time?: number, progress?: number, maxInstances?: number}} [opts]
38
+ */
39
+ export function compile(input, opts = {}) {
40
+ const t0 = now();
41
+ const doc = parse(input);
42
+ const t1 = now();
43
+ const timings = { parse: t1 - t0, build: 0, flatten: 0 };
44
+ if (!doc.ok) {
45
+ return { ok: false, doc, graph: null, scene: null, cache: opts.cache || null, diagnostics: doc.diagnostics, timings };
46
+ }
47
+ const cache = opts.cache || new GeometryCache();
48
+ const graph = buildSceneGraph(doc, cache, opts);
49
+ const t2 = now();
50
+ const scene = flattenScene(graph, { time: opts.time || 0, progress: opts.progress || 0 });
51
+ const t3 = now();
52
+ timings.build = t2 - t1;
53
+ timings.flatten = t3 - t2;
54
+ const extra = graph.diagnostics.map((d) => elementDiagnostic(doc, d));
55
+ const diagnostics = [...doc.diagnostics, ...extra];
56
+ const ok = !diagnostics.some((d) => d.severity === 'error');
57
+ return { ok, doc, graph, scene, cache, diagnostics, timings };
58
+ }
59
+
60
+ /** Generate every mesh needed for LOD 0 of a compiled scene (benchmarks, exports). */
61
+ export function generateGeometry(result, lod = 0) {
62
+ const t0 = now();
63
+ const { scene, cache } = result;
64
+ let triangles = 0;
65
+ let vertices = 0;
66
+ for (const entry of scene.geometries) {
67
+ const m = cache.mesh(entry, lod);
68
+ vertices += m.vertexCount;
69
+ triangles += m.kind === 'triangles' ? m.primitiveCount : 0;
70
+ }
71
+ return { ms: now() - t0, uniqueVertices: vertices, uniqueTriangles: triangles };
72
+ }
73
+
74
+ export { GeometryCache };
@@ -0,0 +1,168 @@
1
+ // Camera model shared by both GPU backends.
2
+
3
+ import * as mat4 from '../math/mat4.js';
4
+
5
+ export class Camera {
6
+ constructor() {
7
+ this.type = 'perspective';
8
+ this.position = [6, 4, 8];
9
+ this.target = [0, 0, 0];
10
+ this.up = [0, 1, 0];
11
+ this.fov = 45; // vertical, degrees
12
+ this.orthoHeight = 10;
13
+ this.near = null; // null = automatic from scene bounds
14
+ this.far = null;
15
+ // fov (or orthoHeight) is the vertical extent at this reference aspect ratio;
16
+ // fit says how other aspect ratios are derived from it (see verticalFov)
17
+ this.refAspect = 1;
18
+ this.fit = 'contain';
19
+ this.view = mat4.create();
20
+ this.proj = mat4.create();
21
+ this.viewProj = mat4.create();
22
+ this.invViewProj = mat4.create();
23
+ this.currentNear = 0.1;
24
+ this.currentFar = 1000;
25
+ }
26
+
27
+ /** Configure from a camera description produced by the flattener. */
28
+ fromSpec(c) {
29
+ this.type = c.type || 'perspective';
30
+ this.position = c.position.slice();
31
+ this.target = c.target.slice();
32
+ this.up = c.up.slice();
33
+ this.fov = c.fov || 45;
34
+ this.orthoHeight = c.height || 10;
35
+ this.near = c.near || null;
36
+ this.far = c.far || null;
37
+ this.refAspect = c.aspect || 1;
38
+ this.fit = c.fit || 'contain';
39
+ return this;
40
+ }
41
+
42
+ clone() {
43
+ const c = new Camera();
44
+ c.type = this.type;
45
+ c.position = this.position.slice();
46
+ c.target = this.target.slice();
47
+ c.up = this.up.slice();
48
+ c.fov = this.fov;
49
+ c.orthoHeight = this.orthoHeight;
50
+ c.near = this.near;
51
+ c.far = this.far;
52
+ c.refAspect = this.refAspect;
53
+ c.fit = this.fit;
54
+ return c;
55
+ }
56
+
57
+ /**
58
+ * Factor that turns the reference vertical extent into the one for a viewport
59
+ * aspect ratio. The reference view is refAspect wide and 1 high:
60
+ * contain shows all of it (like SVG's "meet"), cover fills the viewport with
61
+ * it ("slice"), width keeps its width and height keeps its height.
62
+ */
63
+ extentScale(aspect) {
64
+ const A = this.refAspect > 0 ? this.refAspect : 1;
65
+ const keepWidth = A / aspect; // vertical extent that keeps the reference width
66
+ switch (this.fit) {
67
+ case 'cover':
68
+ return Math.min(1, keepWidth);
69
+ case 'width':
70
+ return keepWidth;
71
+ case 'height':
72
+ return 1;
73
+ default:
74
+ return Math.max(1, keepWidth);
75
+ }
76
+ }
77
+
78
+ get distance() {
79
+ return Math.hypot(this.position[0] - this.target[0], this.position[1] - this.target[1], this.position[2] - this.target[2]);
80
+ }
81
+
82
+ /** Frame an axis aligned bounding box. */
83
+ frame(bounds, dir = [0.62, 0.5, 0.75]) {
84
+ const c = [(bounds.min[0] + bounds.max[0]) / 2, (bounds.min[1] + bounds.max[1]) / 2, (bounds.min[2] + bounds.max[2]) / 2];
85
+ const r = Math.max(1e-3, Math.hypot(bounds.max[0] - bounds.min[0], bounds.max[1] - bounds.min[1], bounds.max[2] - bounds.min[2]) / 2);
86
+ const l = Math.hypot(...dir);
87
+ const d = this.type === 'orthographic' ? r * 3 : (r / Math.sin(((this.fov / 2) * Math.PI) / 180)) * 1.05;
88
+ this.target = c;
89
+ this.position = [c[0] + (dir[0] / l) * d, c[1] + (dir[1] / l) * d, c[2] + (dir[2] / l) * d];
90
+ this.up = [0, 1, 0];
91
+ this.orthoHeight = r * 2.2;
92
+ // the distance above assumes fov applies to the shorter viewport side
93
+ this.refAspect = 1;
94
+ this.fit = 'contain';
95
+ return this;
96
+ }
97
+
98
+ /**
99
+ * Update view / projection matrices.
100
+ * @param {number} aspect
101
+ * @param {boolean} zeroToOne WebGPU depth range
102
+ * @param {{min:number[],max:number[]}} [bounds] scene bounds for automatic near/far
103
+ */
104
+ update(aspect, zeroToOne, bounds) {
105
+ mat4.lookAt(this.view, this.position, this.target, this.up);
106
+ let near = this.near;
107
+ let far = this.far;
108
+ if (near === null || far === null) {
109
+ let autoNear = 0.05;
110
+ let autoFar = 2000;
111
+ if (bounds) {
112
+ const c = [(bounds.min[0] + bounds.max[0]) / 2, (bounds.min[1] + bounds.max[1]) / 2, (bounds.min[2] + bounds.max[2]) / 2];
113
+ const R = Math.max(1e-3, Math.hypot(bounds.max[0] - bounds.min[0], bounds.max[1] - bounds.min[1], bounds.max[2] - bounds.min[2]) / 2);
114
+ const d = Math.hypot(this.position[0] - c[0], this.position[1] - c[1], this.position[2] - c[2]);
115
+ autoFar = (d + R) * 1.5 + 1e-3;
116
+ // outside the bounds the near plane can sit just before the scene;
117
+ // inside it is a small fraction of the scene size
118
+ autoNear = Math.max((d - R) * 0.5, R * 0.002, autoFar / 50000, 1e-4);
119
+ }
120
+ if (near === null) near = autoNear;
121
+ if (far === null) far = Math.max(autoFar, near * 10);
122
+ }
123
+ this.currentNear = near;
124
+ this.currentFar = far;
125
+ // Like SVG's preserveAspectRatio, the framed view (fov or the orthographic
126
+ // height at refAspect) is fitted to the viewport, so what the author framed
127
+ // stays framed on every screen shape. The default (contain, aspect 1)
128
+ // applies fov to the shorter side.
129
+ if (this.type === 'orthographic') {
130
+ const h = (this.orthoHeight * this.extentScale(aspect)) / 2;
131
+ const w = h * aspect;
132
+ mat4.ortho(this.proj, -w, w, -h, h, this.near === null ? -far : near, far, zeroToOne);
133
+ } else {
134
+ mat4.perspective(this.proj, this.verticalFov(aspect), aspect, near, far, zeroToOne);
135
+ }
136
+ this.aspect = aspect;
137
+ mat4.multiply(this.viewProj, this.proj, this.view);
138
+ mat4.invert(this.invViewProj, this.viewProj);
139
+ }
140
+
141
+ /** Vertical field of view in radians for a viewport aspect ratio. */
142
+ verticalFov(aspect) {
143
+ const f = (this.fov * Math.PI) / 180;
144
+ const s = this.extentScale(aspect);
145
+ return s === 1 ? f : 2 * Math.atan(Math.tan(f / 2) * s);
146
+ }
147
+
148
+ /** World-space ray through a pixel (x, y in CSS pixels from the top-left). */
149
+ ray(x, y, width, height, zeroToOne = false) {
150
+ const nx = (x / width) * 2 - 1;
151
+ const ny = 1 - (y / height) * 2;
152
+ const z0 = zeroToOne ? 0 : -1;
153
+ const a = [0, 0, 0];
154
+ const b = [0, 0, 0];
155
+ mat4.transformPoint(a, this.invViewProj, nx, ny, z0);
156
+ mat4.transformPoint(b, this.invViewProj, nx, ny, 1);
157
+ const d = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
158
+ const l = Math.hypot(d[0], d[1], d[2]) || 1;
159
+ return { origin: a, dir: [d[0] / l, d[1] / l, d[2] / l] };
160
+ }
161
+
162
+ /** Pixels per world unit at a distance (used for level-of-detail selection). */
163
+ pixelsPerUnit(distance, viewportHeight) {
164
+ const aspect = this.aspect || 1;
165
+ if (this.type === 'orthographic') return viewportHeight / (this.orthoHeight * this.extentScale(aspect));
166
+ return viewportHeight / (2 * Math.max(distance, 1e-6) * Math.tan(this.verticalFov(aspect) / 2));
167
+ }
168
+ }
@@ -0,0 +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
+ }