@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,249 +1,258 @@
1
- // Renderer facade: picks a GPU backend (WebGPU preferred, WebGL2 fallback)
2
- // and runs the backend-independent parts of a frame (camera, culling, LOD,
3
- // batching, uniform packing, incremental geometry upload).
4
- //
5
- // The facade is the only renderer API used by the application and the
6
- // benchmark; backends never see the ES3D document, only a RenderScene.
7
-
8
- import { GeometryPool } from './geometry-pool.js';
9
- import { FrameBuilder } from './frame-builder.js';
10
- import { TextureCache } from './texture-cache.js';
11
- import { FRAME_FLOATS, packFrameUniforms } from './lighting.js';
12
- import { Mesh } from '../geometry/mesh.js';
13
- import { WebGL2Backend } from './webgl2/webgl2-backend.js';
14
- import { WebGPUBackend } from './webgpu/webgpu-backend.js';
15
-
16
- const now = () => performance.now();
17
-
18
- function cubeEdges() {
19
- const p = [];
20
- for (let i = 0; i < 8; i++) p.push(i & 1 ? 0.5 : -0.5, i & 2 ? 0.5 : -0.5, i & 4 ? 0.5 : -0.5);
21
- const e = [0, 1, 2, 3, 4, 5, 6, 7, 0, 2, 1, 3, 4, 6, 5, 7, 0, 4, 1, 5, 2, 6, 3, 7];
22
- return new Mesh({ positions: Float32Array.from(p), indices: Uint32Array.from(e), kind: 'lines' });
23
- }
24
-
25
- export const OVERLAY_COLORS = {
26
- wireframe: [0.02, 0.03, 0.05, 0.55],
27
- normals: [1.0, 0.82, 0.15, 0.95],
28
- bbox: [0.35, 0.78, 1.0, 0.75],
29
- selection: [1.0, 0.55, 0.12, 1.0],
30
- };
31
-
32
- // setScene keeps the packed geometry, so a new document that shares meshes
33
- // with the last one (a colour change, an edit elsewhere in the file) uploads
34
- // only what is new. Meshes no longer used stay packed until the pool passes
35
- // this size; then it is repacked from what the scene draws.
36
- const POOL_REPACK_BYTES = 32 << 20;
37
-
38
- /**
39
- * Tessellation budget (ms per frame) for render loops that draw again while
40
- * stats.refining is non-zero: the first frame comes at once with coarser
41
- * stand-ins, full detail a few frames later.
42
- */
43
- export const PROGRESSIVE_BUDGET_MS = 4;
44
-
45
- export class Renderer {
46
- constructor(backend) {
47
- this.backend = backend;
48
- this.kind = backend.kind;
49
- this.pool = new GeometryPool();
50
- this.frames = new FrameBuilder();
51
- this.textures = new TextureCache();
52
- this.frameData = new Float32Array(FRAME_FLOATS);
53
- this.options = {
54
- wireframe: false,
55
- normals: false,
56
- bbox: false,
57
- autoLod: true,
58
- fixedLod: 0,
59
- culling: true,
60
- lodBias: 1,
61
- // Same picture on every screen: the canvas resolution is capped at this
62
- // many device pixels per CSS pixel, and automatic LOD budgets its error in
63
- // CSS pixels, so a phone and a desktop choose the same meshes for the
64
- // same view ('device' spends more triangles on high-density screens).
65
- maxPixelRatio: 2,
66
- lodPixels: 'css',
67
- // Milliseconds of mesh generation per frame. Past it, levels that do not
68
- // exist yet are drawn with a coarser one, and stats.refining counts them:
69
- // render again while it is non-zero and full detail follows within a few
70
- // frames. Infinity (the default) generates everything the frame needs.
71
- tessellationBudget: Infinity,
72
- };
73
- this.selection = new Set();
74
- this.scene = null;
75
- this.meshOf = null;
76
- this.cube = cubeEdges();
77
- this.cubeRange = this.pool.meshRange(this.cube);
78
- this.lastDraw = null;
79
- this.lightInfo = { truncated: 0, usedDefaults: false };
80
- this.stats = { cpuMs: 0, uploadBytes: 0, totalUploadBytes: 0, drawCalls: 0, triangles: 0, visible: 0, instances: 0 };
81
- }
82
-
83
- get info() {
84
- return this.backend.info;
85
- }
86
-
87
- /**
88
- * What this device actually renders with, for checking that two devices
89
- * show the same thing: backend, MSAA samples, texture filtering, limits,
90
- * and whether the scene has more lights than are drawn.
91
- */
92
- get caps() {
93
- return { backend: this.kind, ...this.backend.caps, maxPixelRatio: this.options.maxPixelRatio, lodPixels: this.options.lodPixels, lightsDropped: this.lightInfo.truncated || 0 };
94
- }
95
-
96
- /** Device pixels per CSS pixel actually used for a requested ratio. */
97
- pixelRatio(dpr) {
98
- return Math.min(dpr || 1, this.options.maxPixelRatio || Infinity);
99
- }
100
-
101
- /**
102
- * Replace the scene. Geometry is packed lazily into the shared buffers.
103
- * Meshes already packed are kept (pass keepGeometry: false for a cold start).
104
- */
105
- setScene(scene, meshOf, { keepGeometry = true } = {}) {
106
- this.scene = scene;
107
- this.meshOf = meshOf;
108
- if (!scene || !keepGeometry || this.pool.byteSize > POOL_REPACK_BYTES) {
109
- this.pool.reset();
110
- this.cubeRange = this.pool.meshRange(this.cube);
111
- }
112
- this.backend.releaseMaterials(new Set(scene ? scene.materials : []));
113
- this.stats.totalUploadBytes = 0;
114
- }
115
-
116
- /**
117
- * Render one frame.
118
- * @param {import('./camera.js').Camera} camera
119
- * @param {{width:number, height:number, dpr?:number, time?:number}} view CSS size of the canvas
120
- */
121
- render(camera, view) {
122
- const t0 = now();
123
- const dpr = this.pixelRatio(view.dpr);
124
- const w = Math.max(1, Math.round(view.width * dpr));
125
- const h = Math.max(1, Math.round(view.height * dpr));
126
- this.backend.resize(w, h);
127
- const scene = this.scene;
128
- const bounds = scene ? scene.bounds : { min: [-1, -1, -1], max: [1, 1, 1] };
129
- camera.update(w / h, this.backend.zeroToOne, bounds);
130
- const env = scene && scene.environment ? scene.environment : { background: [0.09, 0.1, 0.12, 1], sky: [0.8, 0.86, 0.95, 1], ground: [0.25, 0.22, 0.2, 1], intensity: 0.5, exposure: 1 };
131
- let draw;
132
- if (scene) {
133
- draw = this.buildFrame(camera, view, h);
134
- } else {
135
- draw = { batches: [], overlays: { wireframe: [], normals: [], bbox: null, selection: null }, instanceData: new Float32Array(16), instanceCount: 0, stats: { instances: 0, visible: 0, culled: 0, batches: 0, drawCalls: 0, triangles: 0, lodHistogram: [0, 0, 0, 0], refining: 0 } };
136
- }
137
- const radius = Math.hypot(bounds.max[0] - bounds.min[0], bounds.max[1] - bounds.min[1], bounds.max[2] - bounds.min[2]) / 2 || 1;
138
- this.lightInfo = packFrameUniforms(this.frameData, {
139
- viewProj: camera.viewProj,
140
- view: camera.view,
141
- cameraPos: camera.position,
142
- time: view.time || 0,
143
- environment: env,
144
- lights: scene ? scene.lights : [],
145
- normalLength: radius * 0.025,
146
- lineBias: 0.0015,
147
- viewportHeight: h,
148
- });
149
- const uploaded = this.backend.syncPool(this.pool);
150
- this.backend.render(draw, this.frameData, {
151
- background: env.background,
152
- textures: this.textures,
153
- overlayColors: OVERLAY_COLORS,
154
- cubeRange: this.cubeRange,
155
- });
156
- this.lastDraw = draw;
157
- const s = this.stats;
158
- s.cpuMs = now() - t0;
159
- s.uploadBytes = uploaded + draw.instanceCount * 64;
160
- s.totalUploadBytes += uploaded;
161
- s.drawCalls = draw.stats.drawCalls;
162
- s.triangles = draw.stats.triangles;
163
- s.visible = draw.stats.visible;
164
- s.instances = draw.stats.instances;
165
- s.culled = draw.stats.culled;
166
- s.batches = draw.stats.batches;
167
- s.lodHistogram = draw.stats.lodHistogram;
168
- s.refining = draw.stats.refining || 0;
169
- s.geometryBytes = this.pool.byteSize;
170
- s.instanceBytes = draw.instanceCount * 64;
171
- s.textureBytes = this.backend.gpuBytes;
172
- return draw;
173
- }
174
-
175
- buildFrame(camera, view, deviceHeight, tessellationBudget = this.options.tessellationBudget) {
176
- const lodHeight = this.options.lodPixels === 'device' ? deviceHeight : Math.max(1, Math.round(view.height));
177
- return this.frames.build({
178
- scene: this.scene,
179
- camera,
180
- pool: this.pool,
181
- meshOf: this.meshOf,
182
- viewportHeight: lodHeight,
183
- options: { ...this.options, selection: this.selection, tessellationBudget },
184
- });
185
- }
186
-
187
- /**
188
- * Generate and pack (but do not draw) every mesh a view needs, so a later
189
- * frame from that view does not stall on tessellation. The upload happens
190
- * with the next render. Used to warm up a scroll-driven camera path.
191
- * @param {import('./camera.js').Camera} camera not modified
192
- * @param {{width:number, height:number, dpr?:number}} view
193
- */
194
- prepare(camera, view) {
195
- if (!this.scene) return;
196
- const dpr = this.pixelRatio(view.dpr);
197
- const w = Math.max(1, Math.round(view.width * dpr));
198
- const h = Math.max(1, Math.round(view.height * dpr));
199
- const cam = camera.clone();
200
- cam.update(w / h, this.backend.zeroToOne, this.scene.bounds);
201
- const keep = this.frames.last;
202
- this.buildFrame(cam, view, h, Infinity);
203
- this.frames.last = keep;
204
- }
205
-
206
- /** Wait for the GPU to finish all submitted work (benchmarks). */
207
- finish() {
208
- return this.backend.finish();
209
- }
210
-
211
- dispose() {
212
- this.textures.clear((g) => this.backend.destroyTexture(g));
213
- this.backend.dispose();
214
- }
215
- }
216
-
217
- /**
218
- * Create a renderer for a canvas.
219
- * @param {HTMLCanvasElement} canvas
220
- * @param {{backend?: 'auto'|'webgpu'|'webgl2', present?: 'canvas'|'readback'}} [opts]
221
- */
222
- export async function createRenderer(canvas, opts = {}) {
223
- const want = opts.backend || 'auto';
224
- const errors = [];
225
- if ((want === 'auto' || want === 'webgpu') && typeof navigator !== 'undefined' && navigator.gpu) {
226
- const b = new WebGPUBackend(canvas, { present: opts.present });
227
- try {
228
- await b.init();
229
- return new Renderer(b);
230
- } catch (e) {
231
- errors.push(e.message);
232
- if (want === 'webgpu') throw e;
233
- }
234
- } else if (want === 'webgpu') {
235
- throw new Error('WebGPU is not available in this browser.');
236
- }
237
- // A canvas that already has a WebGPU context cannot create a WebGL2 context.
238
- let target = canvas;
239
- if (errors.length && canvas.getContext && canvas.parentNode) {
240
- target = canvas.cloneNode(false);
241
- canvas.parentNode.replaceChild(target, canvas);
242
- }
243
- const b = new WebGL2Backend(target);
244
- await b.init();
245
- const r = new Renderer(b);
246
- r.fallbackReason = errors.join('; ');
247
- r.canvas = target;
248
- return r;
249
- }
1
+ // Renderer facade: picks a GPU backend (WebGPU preferred, WebGL2 fallback)
2
+ // and runs the backend-independent parts of a frame (camera, culling, LOD,
3
+ // batching, uniform packing, incremental geometry upload).
4
+ //
5
+ // The facade is the only renderer API used by the application and the
6
+ // benchmark; backends never see the ES3D document, only a RenderScene.
7
+
8
+ import { GeometryPool } from './geometry-pool.js';
9
+ import { FrameBuilder } from './frame-builder.js';
10
+ import { TextureCache } from './texture-cache.js';
11
+ import { FRAME_FLOATS, packFrameUniforms } from './lighting.js';
12
+ import { Mesh } from '../geometry/mesh.js';
13
+ import { WebGL2Backend } from './webgl2/webgl2-backend.js';
14
+ import { WebGPUBackend } from './webgpu/webgpu-backend.js';
15
+
16
+ const now = () => performance.now();
17
+
18
+ function cubeEdges() {
19
+ const p = [];
20
+ for (let i = 0; i < 8; i++) p.push(i & 1 ? 0.5 : -0.5, i & 2 ? 0.5 : -0.5, i & 4 ? 0.5 : -0.5);
21
+ const e = [0, 1, 2, 3, 4, 5, 6, 7, 0, 2, 1, 3, 4, 6, 5, 7, 0, 4, 1, 5, 2, 6, 3, 7];
22
+ return new Mesh({ positions: Float32Array.from(p), indices: Uint32Array.from(e), kind: 'lines' });
23
+ }
24
+
25
+ export const OVERLAY_COLORS = {
26
+ wireframe: [0.02, 0.03, 0.05, 0.55],
27
+ normals: [1.0, 0.82, 0.15, 0.95],
28
+ bbox: [0.35, 0.78, 1.0, 0.75],
29
+ selection: [1.0, 0.55, 0.12, 1.0],
30
+ };
31
+
32
+ // setScene keeps the packed geometry, so a new document that shares meshes
33
+ // with the last one (a colour change, an edit elsewhere in the file) uploads
34
+ // only what is new. Meshes no longer used stay packed until the pool passes
35
+ // this size; then it is repacked from what the scene draws.
36
+ const POOL_REPACK_BYTES = 32 << 20;
37
+
38
+ /**
39
+ * Tessellation budget (ms per frame) for render loops that draw again while
40
+ * stats.refining is non-zero: the first frame comes at once with coarser
41
+ * stand-ins, full detail a few frames later.
42
+ */
43
+ export const PROGRESSIVE_BUDGET_MS = 4;
44
+
45
+ export class Renderer {
46
+ constructor(backend) {
47
+ this.backend = backend;
48
+ this.kind = backend.kind;
49
+ this.pool = new GeometryPool();
50
+ this.frames = new FrameBuilder();
51
+ this.textures = new TextureCache();
52
+ this.frameData = new Float32Array(FRAME_FLOATS);
53
+ this.options = {
54
+ wireframe: false,
55
+ normals: false,
56
+ bbox: false,
57
+ autoLod: true,
58
+ fixedLod: 0,
59
+ culling: true,
60
+ lodBias: 1,
61
+ // Same picture on every screen: the canvas resolution is capped at this
62
+ // many device pixels per CSS pixel, and automatic LOD budgets its error in
63
+ // CSS pixels, so a phone and a desktop choose the same meshes for the
64
+ // same view ('device' spends more triangles on high-density screens).
65
+ maxPixelRatio: 2,
66
+ lodPixels: 'css',
67
+ // Milliseconds of mesh generation per frame. Past it, levels that do not
68
+ // exist yet are drawn with a coarser one, and stats.refining counts them:
69
+ // render again while it is non-zero and full detail follows within a few
70
+ // frames. Infinity (the default) generates everything the frame needs.
71
+ tessellationBudget: Infinity,
72
+ };
73
+ this.selection = new Set();
74
+ this.scene = null;
75
+ this.meshOf = null;
76
+ this.cube = cubeEdges();
77
+ this.cubeRange = this.pool.meshRange(this.cube);
78
+ this.lastDraw = null;
79
+ this.lightInfo = { truncated: 0, usedDefaults: false };
80
+ this.stats = { cpuMs: 0, uploadBytes: 0, totalUploadBytes: 0, drawCalls: 0, triangles: 0, visible: 0, instances: 0 };
81
+ this.gpuEpoch = 0;
82
+ }
83
+
84
+ get info() {
85
+ return this.backend.info;
86
+ }
87
+
88
+ /**
89
+ * What this device actually renders with, for checking that two devices
90
+ * show the same thing: backend, MSAA samples, texture filtering, limits,
91
+ * and whether the scene has more lights than are drawn.
92
+ */
93
+ get caps() {
94
+ return { backend: this.kind, ...this.backend.caps, maxPixelRatio: this.options.maxPixelRatio, lodPixels: this.options.lodPixels, lightsDropped: this.lightInfo.truncated || 0 };
95
+ }
96
+
97
+ /** Device pixels per CSS pixel actually used for a requested ratio. */
98
+ pixelRatio(dpr) {
99
+ return Math.min(dpr || 1, this.options.maxPixelRatio || Infinity);
100
+ }
101
+
102
+ /**
103
+ * Replace the scene. Geometry is packed lazily into the shared buffers.
104
+ * Meshes already packed are kept (pass keepGeometry: false for a cold start).
105
+ */
106
+ setScene(scene, meshOf, { keepGeometry = true } = {}) {
107
+ this.scene = scene;
108
+ this.meshOf = meshOf;
109
+ if (!scene || !keepGeometry || this.pool.byteSize > POOL_REPACK_BYTES) {
110
+ this.pool.reset();
111
+ this.cubeRange = this.pool.meshRange(this.cube);
112
+ }
113
+ this.backend.releaseMaterials(new Set(scene ? scene.materials : []));
114
+ this.stats.totalUploadBytes = 0;
115
+ }
116
+
117
+ /**
118
+ * Render one frame.
119
+ * @param {import('./camera.js').Camera} camera
120
+ * @param {{width:number, height:number, dpr?:number, time?:number}} view CSS size of the canvas
121
+ */
122
+ render(camera, view) {
123
+ const t0 = now();
124
+ const dpr = this.pixelRatio(view.dpr);
125
+ const w = Math.max(1, Math.round(view.width * dpr));
126
+ const h = Math.max(1, Math.round(view.height * dpr));
127
+ this.backend.resize(w, h);
128
+ const scene = this.scene;
129
+ const bounds = scene ? scene.bounds : { min: [-1, -1, -1], max: [1, 1, 1] };
130
+ camera.update(w / h, this.backend.zeroToOne, bounds);
131
+ const env = scene && scene.environment ? scene.environment : { background: [0.09, 0.1, 0.12, 1], sky: [0.8, 0.86, 0.95, 1], ground: [0.25, 0.22, 0.2, 1], intensity: 0.5, exposure: 1 };
132
+ let draw;
133
+ if (scene) {
134
+ draw = this.buildFrame(camera, view, h);
135
+ } else {
136
+ draw = { batches: [], overlays: { wireframe: [], normals: [], bbox: null, selection: null }, instanceData: new Float32Array(16), instanceCount: 0, stats: { instances: 0, visible: 0, culled: 0, batches: 0, drawCalls: 0, triangles: 0, lodHistogram: [0, 0, 0, 0], refining: 0 } };
137
+ }
138
+ const radius = Math.hypot(bounds.max[0] - bounds.min[0], bounds.max[1] - bounds.min[1], bounds.max[2] - bounds.min[2]) / 2 || 1;
139
+ this.lightInfo = packFrameUniforms(this.frameData, {
140
+ viewProj: camera.viewProj,
141
+ view: camera.view,
142
+ cameraPos: camera.position,
143
+ time: view.time || 0,
144
+ environment: env,
145
+ lights: scene ? scene.lights : [],
146
+ normalLength: radius * 0.025,
147
+ lineBias: 0.0015,
148
+ viewportHeight: h,
149
+ });
150
+ const uploaded = this.backend.syncPool(this.pool);
151
+ const epoch = this.backend.resourceEpoch || 0;
152
+ if (epoch !== this.gpuEpoch) {
153
+ this.gpuEpoch = epoch;
154
+ for (const entry of this.textures.entries.values()) entry.gpu = null;
155
+ }
156
+ this.backend.render(draw, this.frameData, {
157
+ background: env.background,
158
+ textures: this.textures,
159
+ overlayColors: OVERLAY_COLORS,
160
+ cubeRange: this.cubeRange,
161
+ });
162
+ this.lastDraw = draw;
163
+ const s = this.stats;
164
+ s.cpuMs = now() - t0;
165
+ s.uploadBytes = uploaded + draw.instanceCount * 64;
166
+ s.totalUploadBytes += uploaded;
167
+ s.drawCalls = draw.stats.drawCalls;
168
+ s.triangles = draw.stats.triangles;
169
+ s.visible = draw.stats.visible;
170
+ s.instances = draw.stats.instances;
171
+ s.culled = draw.stats.culled;
172
+ s.batches = draw.stats.batches;
173
+ s.lodHistogram = draw.stats.lodHistogram;
174
+ s.refining = draw.stats.refining || 0;
175
+ s.geometryBytes = this.pool.byteSize;
176
+ s.instanceBytes = draw.instanceCount * 64;
177
+ s.textureBytes = this.backend.gpuBytes;
178
+ return draw;
179
+ }
180
+
181
+ buildFrame(camera, view, deviceHeight, tessellationBudget = this.options.tessellationBudget) {
182
+ const lodHeight = this.options.lodPixels === 'device' ? deviceHeight : Math.max(1, Math.round(view.height));
183
+ return this.frames.build({
184
+ scene: this.scene,
185
+ camera,
186
+ pool: this.pool,
187
+ meshOf: this.meshOf,
188
+ viewportHeight: lodHeight,
189
+ options: { ...this.options, selection: this.selection, tessellationBudget },
190
+ });
191
+ }
192
+
193
+ /**
194
+ * Generate and pack (but do not draw) every mesh a view needs, so a later
195
+ * frame from that view does not stall on tessellation. The upload happens
196
+ * with the next render. Used to warm up a scroll-driven camera path.
197
+ * @param {import('./camera.js').Camera} camera not modified
198
+ * @param {{width:number, height:number, dpr?:number}} view
199
+ */
200
+ prepare(camera, view) {
201
+ if (!this.scene) return;
202
+ const dpr = this.pixelRatio(view.dpr);
203
+ const w = Math.max(1, Math.round(view.width * dpr));
204
+ const h = Math.max(1, Math.round(view.height * dpr));
205
+ const cam = camera.clone();
206
+ cam.update(w / h, this.backend.zeroToOne, this.scene.bounds);
207
+ const keep = this.frames.last;
208
+ this.buildFrame(cam, view, h, Infinity);
209
+ this.frames.last = keep;
210
+ }
211
+
212
+ /** Wait for the GPU to finish all submitted work (benchmarks). */
213
+ finish() {
214
+ return this.backend.finish();
215
+ }
216
+
217
+ dispose() {
218
+ this.textures.clear((g) => this.backend.destroyTexture(g));
219
+ this.backend.dispose();
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Create a renderer for a canvas.
225
+ * @param {HTMLCanvasElement} canvas
226
+ * @param {{backend?: 'auto'|'webgpu'|'webgl2', present?: 'canvas'|'readback'}} [opts]
227
+ */
228
+ export async function createRenderer(canvas, opts = {}) {
229
+ const want = opts.backend || 'auto';
230
+ const errors = [];
231
+ let canvasUsed = false;
232
+ if ((want === 'auto' || want === 'webgpu') && typeof navigator !== 'undefined' && navigator.gpu) {
233
+ const b = new WebGPUBackend(canvas, { present: opts.present });
234
+ try {
235
+ await b.init();
236
+ return new Renderer(b);
237
+ } catch (e) {
238
+ errors.push(e.message);
239
+ canvasUsed = !!b.canvasUsed;
240
+ try { b.dispose(); } catch {}
241
+ if (want === 'webgpu') throw e;
242
+ }
243
+ } else if (want === 'webgpu') {
244
+ throw new Error('WebGPU is not available in this browser.');
245
+ }
246
+ // A canvas that already has a WebGPU context cannot create a WebGL2 context.
247
+ let target = canvas;
248
+ if (canvasUsed && canvas.getContext && canvas.parentNode) {
249
+ target = canvas.cloneNode(false);
250
+ canvas.parentNode.replaceChild(target, canvas);
251
+ }
252
+ const b = new WebGL2Backend(target);
253
+ await b.init();
254
+ const r = new Renderer(b);
255
+ r.fallbackReason = errors.join('; ');
256
+ r.canvas = target;
257
+ return r;
258
+ }