@es3d/es3d 1.1.1 → 1.2.0

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.
@@ -0,0 +1,143 @@
1
+ // The stage's compiler thread.
2
+ //
3
+ // The stage is one file. A page that calls stage.prepare() starts a worker
4
+ // from that same file; there it finds itself in a worker and serves compile
5
+ // requests instead of drawing (render/stage.js calls serveCompiler). The
6
+ // worker runs preparePayload (scene/prepared.js): parsing, validation,
7
+ // building, flattening and mesh generation, and sends the result back as
8
+ // transferred buffers.
9
+ //
10
+ // A worker script must come from the page's origin. A stage loaded from a CDN
11
+ // is started through a small module made in the page (a blob URL) that
12
+ // imports the stage by its URL, which the CDN's CORS headers allow. Where
13
+ // neither works (a content security policy without worker-src blob:, an old
14
+ // browser, Node), every request is compiled on the main thread instead, and
15
+ // the page does not need to know.
16
+
17
+ import { preparePayload } from '../scene/prepared.js';
18
+ import { early, inWorker } from './worker-boot.js';
19
+
20
+ /** True inside a dedicated worker. */
21
+ export function isWorkerScope() {
22
+ return inWorker;
23
+ }
24
+
25
+ // a worker that has not answered its first request by then is given up on
26
+ const FIRST_ANSWER_MS = 10000;
27
+
28
+ // keys whose meshes this worker sent; capped, so a long session does not grow it forever
29
+ const SENT_LIMIT = 200000;
30
+
31
+ /**
32
+ * Answer compile requests posted to this worker.
33
+ * @param {object} scope the worker's global scope
34
+ */
35
+ export function serveCompiler(scope) {
36
+ const sent = new Set();
37
+ const answer = (e) => {
38
+ const { id, input, time, progress, quality } = e.data || {};
39
+ if (sent.size > SENT_LIMIT) sent.clear();
40
+ let reply;
41
+ try {
42
+ reply = preparePayload(input, { time, progress, quality, sent });
43
+ } catch (err) {
44
+ scope.postMessage({ id, error: String(err && err.message ? err.message : err) });
45
+ return;
46
+ }
47
+ try {
48
+ scope.postMessage({ id, ...reply.message }, reply.transfer);
49
+ } catch (err) {
50
+ // something in the scene could not be cloned: the main thread compiles it
51
+ scope.postMessage({ id, ok: reply.message.ok, diagnostics: reply.message.diagnostics, hostOnly: reply.message.hostOnly, timings: reply.message.timings, lineText: reply.message.lineText, lineIndex: reply.message.lineIndex, main: true });
52
+ }
53
+ };
54
+ scope.onmessage = answer;
55
+ // requests that came while the module was still loading (worker-boot.js)
56
+ for (const e of early.splice(0)) answer(e);
57
+ }
58
+
59
+ /** Start a module worker running this file, or null where none can start. */
60
+ function startWorker(url) {
61
+ if (typeof Worker !== 'function' || !url) return null;
62
+ const sameOrigin = typeof location === 'undefined' || new URL(url).origin === location.origin;
63
+ try {
64
+ if (sameOrigin) return new Worker(url, { type: 'module' });
65
+ } catch {
66
+ // fall through to the blob module
67
+ }
68
+ try {
69
+ const blob = new Blob([`import ${JSON.stringify(url)};`], { type: 'text/javascript' });
70
+ return new Worker(URL.createObjectURL(blob), { type: 'module' });
71
+ } catch {
72
+ return null;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * The page's side: posts documents to the worker and resolves each request
78
+ * with its payload. A failed worker (it did not load, or it crashed) answers
79
+ * every waiting and later request with null, which means: compile here.
80
+ */
81
+ export class CompilerClient {
82
+ /** @param {string} url this module's file (the stage bundle's import.meta.url) */
83
+ constructor(url) {
84
+ this.worker = startWorker(url);
85
+ this.failed = !this.worker;
86
+ this.pending = new Map();
87
+ this.next = 1;
88
+ if (!this.worker) return;
89
+ this.answered = false;
90
+ this.watchdog = 0;
91
+ this.worker.onmessage = (e) => {
92
+ const msg = e.data;
93
+ this.answered = true;
94
+ clearTimeout(this.watchdog);
95
+ const done = this.pending.get(msg.id);
96
+ if (!done) return;
97
+ this.pending.delete(msg.id);
98
+ done(msg.error ? null : msg);
99
+ };
100
+ const fail = () => {
101
+ clearTimeout(this.watchdog);
102
+ this.failed = true;
103
+ for (const done of this.pending.values()) done(null);
104
+ this.pending.clear();
105
+ try { this.worker.terminate(); } catch {}
106
+ };
107
+ this.worker.onerror = (e) => {
108
+ if (e && e.preventDefault) e.preventDefault();
109
+ fail();
110
+ };
111
+ this.worker.onmessageerror = fail;
112
+ this.fail = fail;
113
+ }
114
+
115
+ /**
116
+ * @param {string|ArrayBuffer|Uint8Array} input copied to the worker, not transferred
117
+ * @param {{time?: number, progress?: number, quality?: number}} opts
118
+ * @returns {Promise<object|null>} the payload, or null: compile on this thread
119
+ */
120
+ compile(input, opts) {
121
+ if (this.failed) return Promise.resolve(null);
122
+ const id = this.next++;
123
+ return new Promise((resolve) => {
124
+ this.pending.set(id, resolve);
125
+ // a worker that never starts (blocked, or its module failed silently)
126
+ // must not hold the page's documents: compile them here instead
127
+ if (!this.answered && !this.watchdog) this.watchdog = setTimeout(() => this.fail(), FIRST_ANSWER_MS);
128
+ try {
129
+ this.worker.postMessage({ id, input, time: opts.time, progress: opts.progress, quality: opts.quality });
130
+ } catch {
131
+ this.pending.delete(id);
132
+ resolve(null);
133
+ }
134
+ });
135
+ }
136
+
137
+ dispose() {
138
+ if (this.worker) this.worker.terminate();
139
+ for (const done of this.pending.values()) done(null);
140
+ this.pending.clear();
141
+ this.failed = true;
142
+ }
143
+ }
@@ -21,6 +21,41 @@ function length3(x, y, z) {
21
21
  return Math.sqrt(x * x + y * y + z * z);
22
22
  }
23
23
 
24
+ /**
25
+ * Per-frame constants for `instancePixelsPerUnit`.
26
+ * `ppuFactor` is F = viewportHeight / (2 tan(verticalFov / 2)) for a perspective
27
+ * camera, or the constant scale for an orthographic one. `cornerStretch` is
28
+ * 1/cos^2 of the angle to the frustum's corner.
29
+ */
30
+ export function lodFrameConstants(camera, viewportHeight) {
31
+ const aspect = camera.aspect || 1;
32
+ const tanV = Math.tan(camera.verticalFov(aspect) / 2);
33
+ return {
34
+ orthographic: camera.type === 'orthographic',
35
+ ppuFactor: camera.pixelsPerUnit(1, viewportHeight),
36
+ view: camera.view,
37
+ cornerStretch: 1 + tanV * tanV * (1 + aspect * aspect),
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Largest projected size, in CSS pixels, of a unit length at the centre
43
+ * (cx, cy, cz) of an instance's bounding sphere; `d` is the distance from the
44
+ * eye to that centre. A flat image plane stretches a displacement at angle a
45
+ * from the view axis by up to 1/cos^2(a) = d^2/z^2 (z = depth along the axis)
46
+ * radially. Inside the view that never exceeds its value at the frustum's
47
+ * corners, which is also used when the centre is at or behind the eye plane
48
+ * while the sphere is still visible. Orthographic views do not stretch.
49
+ * Allocation-free: it runs once per instance.
50
+ */
51
+ export function instancePixelsPerUnit(k, cx, cy, cz, d) {
52
+ if (k.orthographic) return k.ppuFactor;
53
+ const V = k.view;
54
+ const z = -(V[2] * cx + V[6] * cy + V[10] * cz + V[14]);
55
+ const stretch = z > 0 ? Math.min((d * d) / (z * z), k.cornerStretch) : k.cornerStretch;
56
+ return (k.ppuFactor * stretch) / Math.max(d, 1e-6);
57
+ }
58
+
24
59
  /**
25
60
  * The level to draw while `lod` is not generated yet: the nearest coarser
26
61
  * level that exists, else the nearest finer one, else the coarsest level
@@ -136,78 +171,98 @@ export class FrameBuilder {
136
171
  */
137
172
  build(p) {
138
173
  const { scene, camera, pool, meshOf, options } = p;
139
- const n = scene.count;
174
+ // A SceneSet draws several instance tables (the host and the fragments,
175
+ // scene-set.js); a RenderScene is one part whose indices need no map.
176
+ const parts = scene.parts ? scene.drawnParts() : [{ scene, geomMap: null, matMap: null, selectable: true }];
177
+ let n = 0;
178
+ for (const part of parts) n += part.scene.count;
140
179
  // reserve instance rows for: batches (<= n), bounding boxes (<= n), selection boxes
141
180
  let selCount = 0;
142
181
  if (options.selection && options.selection.size) {
143
- for (let i = 0; i < n; i++) if (options.selection.has(scene.uid[i])) selCount++;
182
+ for (const part of parts) {
183
+ if (!part.selectable) continue;
184
+ const P = part.scene;
185
+ for (let i = 0; i < P.count; i++) if (options.selection.has(P.uid[i])) selCount++;
186
+ }
144
187
  }
145
188
  this.ensure(n + (options.bbox ? n : 0) + selCount + 64);
146
189
  const camPos = camera.position;
147
190
  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
191
  const planes = options.culling === false ? null : planesFrom(camera.viewProj);
150
- const S = scene.spheres;
151
192
  const keys = this.keys;
152
193
  const lods = this.lods;
153
194
  const batchMap = new Map();
154
195
  const batches = [];
155
- const nMat = Math.max(1, scene.materials.length);
196
+ const geometries = scene.geometries;
197
+ const materials = scene.materials;
198
+ const nMat = Math.max(1, materials.length);
156
199
  const lodHist = [0, 0, 0, 0];
157
200
  let visibleCount = 0;
158
201
  let culled = 0;
159
202
  const vh = p.viewportHeight;
160
203
  const bias = options.lodBias || 1;
161
204
  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);
205
+ // pixels per world unit: depth based for perspective, constant for orthographic
206
+ const lodK = lodFrameConstants(camera, vh);
164
207
 
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;
208
+ // rows are numbered across the parts: part k's row r is base(k) + r
209
+ let base = 0;
210
+ for (const part of parts) {
211
+ const P = part.scene;
212
+ const gm = part.geomMap;
213
+ const mm = part.matMap;
214
+ const pn = P.count;
215
+ const sw = this.lodStates(P, camPos, dist);
216
+ const S = P.spheres;
217
+ for (let r = 0; r < pn; r++) {
218
+ const i = base + r;
219
+ keys[i] = -1;
220
+ const ln = P.lodNode[r];
221
+ if (ln >= 0 && !(sw.visible[ln] && sw.active[ln] === P.lodChild[r])) continue;
222
+ const cx = S[r * 4], cy = S[r * 4 + 1], cz = S[r * 4 + 2], rad = S[r * 4 + 3];
223
+ if (planes) {
224
+ let out = false;
225
+ for (let k = 0; k < 5; k++) {
226
+ const pl = planes[k];
227
+ if (pl[0] * cx + pl[1] * cy + pl[2] * cz + pl[3] < -rad) {
228
+ out = true;
229
+ break;
230
+ }
231
+ }
232
+ if (out) {
233
+ culled++;
234
+ continue;
177
235
  }
178
236
  }
179
- if (out) {
180
- culled++;
181
- continue;
237
+ const gi = gm ? gm[P.geom[r]] : P.geom[r];
238
+ const entry = geometries[gi];
239
+ let lod = 0;
240
+ if (entry.lods > 1) {
241
+ if (fixedLod >= 0) lod = Math.min(fixedLod, entry.lods - 1);
242
+ else {
243
+ const dx = cx - camPos[0], dy = cy - camPos[1], dz = cz - camPos[2];
244
+ const d = length3(dx, dy, dz);
245
+ const ppu = instancePixelsPerUnit(lodK, cx, cy, cz, d);
246
+ lod = selectLod(entry.lodDeviation, entry.lods, axisScale(P.matrices, r), ppu, bias);
247
+ }
182
248
  }
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);
249
+ lods[i] = lod;
250
+ lodHist[lod]++;
251
+ const mirrored = P.flags[r] & 1;
252
+ const mi = mm ? mm[P.mat[r]] : P.mat[r];
253
+ const key = ((gi * 4 + lod) * nMat + mi) * 2 + mirrored;
254
+ let b = batchMap.get(key);
255
+ if (b === undefined) {
256
+ b = batches.length;
257
+ batchMap.set(key, b);
258
+ const material = materials[mi];
259
+ batches.push({ gi, entry, lod, mi, material, mirrored: !!mirrored, count: 0, firstInstance: 0, depth: 0, instances: null });
194
260
  }
261
+ batches[b].count++;
262
+ keys[i] = b;
263
+ visibleCount++;
195
264
  }
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++;
265
+ base += pn;
211
266
  }
212
267
 
213
268
  // resolve meshes / pool ranges. Once this frame has spent its tessellation
@@ -253,27 +308,32 @@ export class FrameBuilder {
253
308
  b.cursor = b.firstInstance;
254
309
  }
255
310
  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;
311
+ const transparent = []; // {M, r, b, depth}: drawn back to front after the opaque rows
312
+ base = 0;
313
+ for (const part of parts) {
314
+ const P = part.scene;
315
+ const M = P.matrices;
316
+ const S = P.spheres;
317
+ for (let r = 0; r < P.count; r++) {
318
+ const k = keys[base + r];
319
+ if (k < 0) continue;
320
+ const b = batches[k];
321
+ if (b.transparent) {
322
+ transparent.push({ M, r, b, depth: -length3(S[r * 4] - camPos[0], S[r * 4 + 1] - camPos[1], S[r * 4 + 2] - camPos[2]) });
323
+ continue;
324
+ }
325
+ const o = b.cursor++ * 16;
326
+ for (let j = 0; j < 16; j++) data[o + j] = M[r * 16 + j];
265
327
  }
266
- const o = b.cursor++ * 16;
267
- for (let j = 0; j < 16; j++) data[o + j] = M[i * 16 + j];
328
+ base += P.count;
268
329
  }
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]];
330
+ if (transparent.length) {
331
+ transparent.sort((a, b) => a.depth - b.depth);
332
+ for (const t of transparent) {
333
+ const b = t.b;
274
334
  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));
335
+ for (let j = 0; j < 16; j++) data[o + j] = t.M[t.r * 16 + j];
336
+ b.depth = Math.max(b.depth, -t.depth);
277
337
  }
278
338
  // farthest transparent batches first
279
339
  const opaque = order.filter((b) => !b.transparent);
@@ -291,12 +351,13 @@ export class FrameBuilder {
291
351
  if (options.normals) overlays.normals.push({ range: pool.normalRange(b.mesh), firstInstance: b.firstInstance, count: b.count });
292
352
  }
293
353
  }
294
- const boxMatrix = (i, o) => {
295
- const e = scene.geometries[scene.geom[i]];
354
+ const boxMatrix = (part, r, o) => {
355
+ const P = part.scene;
356
+ const e = geometries[part.geomMap ? part.geomMap[P.geom[r]] : P.geom[r]];
296
357
  const bb = e.bounds;
297
358
  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
359
  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);
360
+ const m = P.matrices.subarray(r * 16, r * 16 + 16);
300
361
  // world * T(c) * S(s)
301
362
  for (let k = 0; k < 3; k++) {
302
363
  data[o + k] = m[k] * sx;
@@ -311,22 +372,33 @@ export class FrameBuilder {
311
372
  };
312
373
  if (options.bbox) {
313
374
  const first = offset;
314
- for (let i = 0; i < n; i++) {
315
- if (keys[i] < 0) continue;
316
- boxMatrix(i, offset * 16);
317
- offset++;
375
+ base = 0;
376
+ for (const part of parts) {
377
+ for (let r = 0; r < part.scene.count; r++) {
378
+ if (keys[base + r] < 0) continue;
379
+ boxMatrix(part, r, offset * 16);
380
+ offset++;
381
+ }
382
+ base += part.scene.count;
318
383
  }
319
384
  overlays.bbox = { firstInstance: first, count: offset - first };
320
385
  }
321
386
  if (selCount) {
322
387
  const first = offset;
323
388
  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++;
389
+ base = 0;
390
+ for (const part of parts) {
391
+ const P = part.scene;
392
+ if (part.selectable) {
393
+ for (let r = 0; r < P.count; r++) {
394
+ if (!sel.has(P.uid[r])) continue;
395
+ // hidden level-of-detail alternatives are not highlighted
396
+ if (keys[base + r] < 0 && P.lodNode[r] >= 0) continue;
397
+ boxMatrix(part, r, offset * 16);
398
+ offset++;
399
+ }
400
+ }
401
+ base += P.count;
330
402
  }
331
403
  overlays.selection = { firstInstance: first, count: offset - first };
332
404
  }
@@ -5,7 +5,7 @@
5
5
  // The facade is the only renderer API used by the application and the
6
6
  // benchmark; backends never see the ES3D document, only a RenderScene.
7
7
 
8
- import { GeometryPool } from './geometry-pool.js';
8
+ import { GeometryPool, VERTEX_BYTES } from './geometry-pool.js';
9
9
  import { FrameBuilder } from './frame-builder.js';
10
10
  import { TextureCache } from './texture-cache.js';
11
11
  import { FRAME_FLOATS, packFrameUniforms } from './lighting.js';
@@ -106,14 +106,40 @@ export class Renderer {
106
106
  setScene(scene, meshOf, { keepGeometry = true } = {}) {
107
107
  this.scene = scene;
108
108
  this.meshOf = meshOf;
109
- if (!scene || !keepGeometry || this.pool.byteSize > POOL_REPACK_BYTES) {
109
+ if (!scene || !keepGeometry || (!scene.parts && this.pool.byteSize > POOL_REPACK_BYTES)) {
110
110
  this.pool.reset();
111
+ this.pool.unusedBytes = 0;
111
112
  this.cubeRange = this.pool.meshRange(this.cube);
112
113
  }
113
114
  this.backend.releaseMaterials(new Set(scene ? scene.materials : []));
114
115
  this.stats.totalUploadBytes = 0;
115
116
  }
116
117
 
118
+ /**
119
+ * The scene set being drawn changed (a fragment placed, hidden or removed):
120
+ * keep every packed mesh, free the materials no part uses, and repack the
121
+ * pool only once most of what it holds is geometry no part draws any more.
122
+ * Cheap: nothing here is proportional to the scene.
123
+ * @param {import('../scene/scene-set.js').SceneSet} set
124
+ */
125
+ updateScene(set) {
126
+ for (const entry of set.takeReleased()) {
127
+ if (set.geomIndex.has(entry)) continue; // taken again by another part
128
+ for (const mesh of entry.meshes) {
129
+ if (!mesh) continue;
130
+ const r = this.pool.ranges.get(mesh);
131
+ if (r) this.pool.unusedBytes = (this.pool.unusedBytes || 0) + mesh.vertexCount * VERTEX_BYTES + r.indexCount * 4;
132
+ }
133
+ }
134
+ if (this.pool.byteSize > POOL_REPACK_BYTES && (this.pool.unusedBytes || 0) * 2 > this.pool.byteSize) {
135
+ this.pool.reset();
136
+ this.pool.unusedBytes = 0;
137
+ this.cubeRange = this.pool.meshRange(this.cube);
138
+ }
139
+ if (this.materialsSeen !== set.materials.length || set.freeMaterials.length) this.backend.releaseMaterials(new Set(set.materials));
140
+ this.materialsSeen = set.materials.length;
141
+ }
142
+
117
143
  /**
118
144
  * Render one frame.
119
145
  * @param {import('./camera.js').Camera} camera