@ifc-lite/renderer 1.35.2 → 1.37.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.
- package/dist/chunk-grid.d.ts +60 -0
- package/dist/chunk-grid.d.ts.map +1 -0
- package/dist/chunk-grid.js +47 -0
- package/dist/chunk-grid.js.map +1 -0
- package/dist/contribution-cull.d.ts +94 -0
- package/dist/contribution-cull.d.ts.map +1 -0
- package/dist/contribution-cull.js +111 -0
- package/dist/contribution-cull.js.map +1 -0
- package/dist/device.d.ts.map +1 -1
- package/dist/device.js +24 -1
- package/dist/device.js.map +1 -1
- package/dist/index.d.ts +28 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +233 -14
- package/dist/index.js.map +1 -1
- package/dist/instanced-render.d.ts +31 -0
- package/dist/instanced-render.d.ts.map +1 -1
- package/dist/instanced-render.js +45 -0
- package/dist/instanced-render.js.map +1 -1
- package/dist/lod-simplify.d.ts +36 -0
- package/dist/lod-simplify.d.ts.map +1 -0
- package/dist/lod-simplify.js +101 -0
- package/dist/lod-simplify.js.map +1 -0
- package/dist/pipeline.d.ts +13 -0
- package/dist/pipeline.d.ts.map +1 -1
- package/dist/pipeline.js +63 -2
- package/dist/pipeline.js.map +1 -1
- package/dist/quantize.d.ts +49 -0
- package/dist/quantize.d.ts.map +1 -0
- package/dist/quantize.js +137 -0
- package/dist/quantize.js.map +1 -0
- package/dist/render-stats.d.ts +95 -0
- package/dist/render-stats.d.ts.map +1 -0
- package/dist/render-stats.js +31 -0
- package/dist/render-stats.js.map +1 -0
- package/dist/residency.d.ts +52 -0
- package/dist/residency.d.ts.map +1 -0
- package/dist/residency.js +28 -0
- package/dist/residency.js.map +1 -0
- package/dist/scene.d.ts +190 -1
- package/dist/scene.d.ts.map +1 -1
- package/dist/scene.js +799 -42
- package/dist/scene.js.map +1 -1
- package/dist/shaders/main.wgsl.d.ts +1 -1
- package/dist/shaders/main.wgsl.d.ts.map +1 -1
- package/dist/shaders/main.wgsl.js +53 -0
- package/dist/shaders/main.wgsl.js.map +1 -1
- package/dist/types.d.ts +47 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -3
package/dist/scene.js
CHANGED
|
@@ -4,13 +4,20 @@
|
|
|
4
4
|
import { BATCH_CONSTANTS } from './constants.js';
|
|
5
5
|
import { prepareRayDirInv, raycastBoundingBoxes, raycastTriangles, rayIntersectsBox, } from './scene-raycaster.js';
|
|
6
6
|
import { mergeGeometry, splitMeshDataForBufferLimit, colorSaltByte, packEntityLane } from './scene-geometry.js';
|
|
7
|
+
import { sumResidentGpuBytes } from './render-stats.js';
|
|
8
|
+
import { simplifyIndicesByClustering, lodCellSizeForBounds, LOD_MIN_TRIANGLES } from './lod-simplify.js';
|
|
9
|
+
import { quantizeInterleaved } from './quantize.js';
|
|
10
|
+
import { bucketBaseKeyFor } from './chunk-grid.js';
|
|
11
|
+
import { selectEvictions } from './residency.js';
|
|
7
12
|
import { OPAQUE_ALPHA_CUTOFF } from './overlay-routing.js';
|
|
8
|
-
import { prepareInstancedRender, INSTANCE_STRIDE_BYTES, INSTANCE_COLOR_OFFSET, INSTANCE_FLAGS_OFFSET, INSTANCE_FLAG_SELECTED, INSTANCE_FLAG_HIDDEN, } from './instanced-render.js';
|
|
13
|
+
import { prepareInstancedRender, foldOccurrenceWorldBox, INSTANCE_STRIDE_BYTES, INSTANCE_COLOR_OFFSET, INSTANCE_FLAGS_OFFSET, INSTANCE_FLAG_SELECTED, INSTANCE_FLAG_HIDDEN, } from './instanced-render.js';
|
|
9
14
|
function destroyGpuResources(m) {
|
|
10
15
|
m.vertexBuffer.destroy();
|
|
11
16
|
m.indexBuffer.destroy();
|
|
12
17
|
if (m.uniformBuffer)
|
|
13
18
|
m.uniformBuffer.destroy();
|
|
19
|
+
if (m.lod1IndexBuffer)
|
|
20
|
+
m.lod1IndexBuffer.destroy();
|
|
14
21
|
}
|
|
15
22
|
/** Shared empty result for getInstancedTemplates() when the instanced pass is hidden. */
|
|
16
23
|
const EMPTY_INSTANCED_TEMPLATES = [];
|
|
@@ -40,6 +47,40 @@ export class Scene {
|
|
|
40
47
|
// sub-bucket with a suffixed key (e.g. "500|500|500|1000#1"). This keeps
|
|
41
48
|
// all downstream maps single-valued and the rendering code unchanged.
|
|
42
49
|
activeBucketKey = new Map(); // base colorKey -> current active bucket key
|
|
50
|
+
// Spatial chunking (issue #1682 phase 2): when set, bucket base keys gain a
|
|
51
|
+
// grid-cell prefix so batches are spatially compact and cullable. Null = off
|
|
52
|
+
// (plain colour bucketing, the historical behaviour).
|
|
53
|
+
spatialChunking = null;
|
|
54
|
+
// GPU residency budget (issue #1682 phase 3a): when set, bucket-owned
|
|
55
|
+
// batches not drawn recently are evicted (GPU buffers destroyed, CPU
|
|
56
|
+
// meshData + metadata shell kept) once their combined bytes exceed the
|
|
57
|
+
// budget, and rebuilt on demand when the draw loop wants them again.
|
|
58
|
+
gpuBudgetBytes = null;
|
|
59
|
+
residencyFrame = 0; // bumped once per render()
|
|
60
|
+
lastDrawnFrame = new Map(); // batch.id -> residencyFrame
|
|
61
|
+
residencyRestoreQueue = new Set(); // bucket keys awaiting re-upload
|
|
62
|
+
residencyOverBudgetWarned = false;
|
|
63
|
+
// Cold tier (issue #1682 phase 3b): warm buckets (GPU-evicted, CPU kept)
|
|
64
|
+
// can additionally drop their CPU meshData when a HOST budget is set and a
|
|
65
|
+
// cold-storage provider (v13 cache chunks) can restore it on demand.
|
|
66
|
+
// hot = GPU+CPU, warm = CPU only, cold = metadata shell only.
|
|
67
|
+
coldProvider = null;
|
|
68
|
+
hostBudgetBytes = null;
|
|
69
|
+
coldBuckets = new Set(); // CPU dropped, provider-restorable
|
|
70
|
+
dirtyBuckets = new Set(); // diverged from disk (recolour/move/remove)
|
|
71
|
+
coldRestoresInFlight = new Map();
|
|
72
|
+
hostOverBudgetWarned = false;
|
|
73
|
+
hostEnforceCountdown = 0;
|
|
74
|
+
// LOD1 builds (issue #1682 phase 5): off unless the app enables them.
|
|
75
|
+
lodBuildsEnabled = false;
|
|
76
|
+
// 12-byte quantized batch vertices (issue #1682 phase 6): off unless the
|
|
77
|
+
// renderer probed its quantized pipelines and enabled it.
|
|
78
|
+
quantizedBatchesEnabled = false;
|
|
79
|
+
// True while a (possibly time-sliced) finalize rebuild is running. The
|
|
80
|
+
// preamble clears streamingFragments synchronously, so hasStreamingFragments
|
|
81
|
+
// alone under-reports "still settling" — settle-sensitive consumers
|
|
82
|
+
// (post-load telemetry) must also check this.
|
|
83
|
+
finalizeInProgress = false;
|
|
43
84
|
nextSplitId = 0; // Monotonic counter for sub-bucket keys
|
|
44
85
|
nextBatchId = 0; // Monotonic counter for unique batch identifiers
|
|
45
86
|
// Shared local-frame origin for ALL batches (set from the first batch's world
|
|
@@ -103,6 +144,446 @@ export class Scene {
|
|
|
103
144
|
getSharedFrameOrigin() {
|
|
104
145
|
return this.sharedFrameOrigin;
|
|
105
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* Enable/disable spatial chunk bucketing (issue #1682 phase 2). When set,
|
|
149
|
+
* colour buckets are additionally partitioned by world grid cell, making
|
|
150
|
+
* batches spatially compact so per-batch frustum/contribution culling
|
|
151
|
+
* fires at chunk granularity. Pure reorganization: same triangles, same
|
|
152
|
+
* shared frame origin, same draw path — only the batch partition changes.
|
|
153
|
+
*
|
|
154
|
+
* Set BEFORE geometry loads. Existing buckets keep their keys (keys are
|
|
155
|
+
* opaque downstream), so flipping mid-model only affects meshes routed
|
|
156
|
+
* afterwards; the next finalize/recolour re-groups stragglers.
|
|
157
|
+
*/
|
|
158
|
+
setSpatialChunking(config) {
|
|
159
|
+
if (config && !(Number.isFinite(config.cellSize) && config.cellSize > 0)) {
|
|
160
|
+
console.warn('[Scene] ignoring invalid spatial chunking cellSize:', config.cellSize);
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
this.spatialChunking = config;
|
|
164
|
+
}
|
|
165
|
+
getSpatialChunking() {
|
|
166
|
+
return this.spatialChunking;
|
|
167
|
+
}
|
|
168
|
+
// ─── GPU residency (issue #1682 phase 3a) ──────────────────────────────
|
|
169
|
+
// The budget applies to bucket-owned colour/chunk batches (the evictable
|
|
170
|
+
// set). Streaming fragments, partial sub-batches, overlay batches,
|
|
171
|
+
// textured meshes and instanced templates are never evicted: fragments are
|
|
172
|
+
// transient, the rest are small or lack a rebuild source. Enforcement
|
|
173
|
+
// no-ops while geometry is released or in ephemeral streaming mode (no CPU
|
|
174
|
+
// meshData to rebuild from — that is phase 3b's evict-to-disk territory).
|
|
175
|
+
/** Set (or clear) the GPU residency budget in bytes. */
|
|
176
|
+
setGpuResidencyBudget(bytes) {
|
|
177
|
+
if (bytes !== null && !(Number.isFinite(bytes) && bytes > 0)) {
|
|
178
|
+
console.warn('[Scene] ignoring invalid GPU residency budget:', bytes);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
this.gpuBudgetBytes = bytes;
|
|
182
|
+
this.residencyOverBudgetWarned = false;
|
|
183
|
+
}
|
|
184
|
+
getGpuResidencyBudget() {
|
|
185
|
+
return this.gpuBudgetBytes;
|
|
186
|
+
}
|
|
187
|
+
/** Called once at the start of every Renderer.render() — residency ages
|
|
188
|
+
* are measured in RENDERED frames, so idle scenes never age out. */
|
|
189
|
+
beginResidencyFrame() {
|
|
190
|
+
this.residencyFrame++;
|
|
191
|
+
}
|
|
192
|
+
/** Record that the draw loop drew this batch this frame. */
|
|
193
|
+
recordBatchDrawn(batch) {
|
|
194
|
+
if (this.gpuBudgetBytes === null)
|
|
195
|
+
return;
|
|
196
|
+
this.lastDrawnFrame.set(batch.id, this.residencyFrame);
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* The draw loop wants an evicted batch back on the GPU. Queues its bucket
|
|
200
|
+
* for a time-budgeted rebuild in processResidencyRestores (driven by the
|
|
201
|
+
* app's animation loop) — the batch is skipped this frame and pops back in
|
|
202
|
+
* within a frame or two.
|
|
203
|
+
*/
|
|
204
|
+
requestBatchResidency(batch) {
|
|
205
|
+
const bucket = this.buckets.get(batch.colorKey);
|
|
206
|
+
if (!bucket || bucket.batchedMesh !== batch)
|
|
207
|
+
return;
|
|
208
|
+
// Warm (CPU kept) OR cold (disk-restorable) — both are restorable.
|
|
209
|
+
if (bucket.meshData.length > 0 || this.coldBuckets.has(bucket.key)) {
|
|
210
|
+
this.residencyRestoreQueue.add(bucket.key);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
hasResidencyRestoreWork() {
|
|
214
|
+
return this.residencyRestoreQueue.size > 0;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Rebuild evicted batches from their buckets' CPU meshData, up to
|
|
218
|
+
* `budgetMs` per call (same time-slicing philosophy as flushPending).
|
|
219
|
+
* Returns the number of batches restored.
|
|
220
|
+
*/
|
|
221
|
+
processResidencyRestores(device, pipeline, budgetMs = 6) {
|
|
222
|
+
if (this.residencyRestoreQueue.size === 0)
|
|
223
|
+
return 0;
|
|
224
|
+
const start = performance.now();
|
|
225
|
+
let restored = 0;
|
|
226
|
+
for (const key of this.residencyRestoreQueue) {
|
|
227
|
+
this.residencyRestoreQueue.delete(key);
|
|
228
|
+
const bucket = this.buckets.get(key);
|
|
229
|
+
const old = bucket?.batchedMesh;
|
|
230
|
+
// Only restore a still-evicted bucket batch — a recolour/finalize may
|
|
231
|
+
// have rebuilt (or emptied) it in the meantime.
|
|
232
|
+
if (!bucket || !old || old.gpuResident !== false)
|
|
233
|
+
continue;
|
|
234
|
+
// Cold bucket: geometry is on disk — kick off the async provider fetch
|
|
235
|
+
// (it re-queues the key as warm when the meshes land).
|
|
236
|
+
if (bucket.meshData.length === 0) {
|
|
237
|
+
if (this.coldBuckets.has(key))
|
|
238
|
+
this.startColdRestore(key);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
const rebuilt = this.createBatchedMesh(bucket.meshData, bucket.meshData[0].color, device, pipeline, key);
|
|
242
|
+
bucket.batchedMesh = rebuilt;
|
|
243
|
+
const idx = this.batchedMeshes.indexOf(old);
|
|
244
|
+
if (idx >= 0)
|
|
245
|
+
this.batchedMeshes[idx] = rebuilt;
|
|
246
|
+
else
|
|
247
|
+
this.batchedMeshes.push(rebuilt);
|
|
248
|
+
this.lastDrawnFrame.delete(old.id);
|
|
249
|
+
// Seed as just-drawn so the budget pass can't evict it before the
|
|
250
|
+
// frame that asked for it gets to draw it.
|
|
251
|
+
this.lastDrawnFrame.set(rebuilt.id, this.residencyFrame);
|
|
252
|
+
restored++;
|
|
253
|
+
if (performance.now() - start >= budgetMs)
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
return restored;
|
|
257
|
+
}
|
|
258
|
+
// ─── Cold tier (issue #1682 phase 3b) ──────────────────────────────────
|
|
259
|
+
/** Wire the cold-storage source (v13 cache chunks). Null disables the tier. */
|
|
260
|
+
setColdGeometryProvider(provider) {
|
|
261
|
+
this.coldProvider = provider;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Enable LOD1 builds (issue #1682 phase 5): bucket batches built from now
|
|
265
|
+
* on (finalize, rebuild, residency restore) get a simplified second index
|
|
266
|
+
* range when it pays. Set BEFORE geometry loads; streaming fragments and
|
|
267
|
+
* partial/overlay sub-batches never build LOD.
|
|
268
|
+
*/
|
|
269
|
+
setLodBuildsEnabled(enabled) {
|
|
270
|
+
this.lodBuildsEnabled = enabled;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Enable 12-byte lattice-quantized batch vertices (issue #1682 phase 6).
|
|
274
|
+
* ONLY call after the renderer verified its quantized pipeline variants
|
|
275
|
+
* exist (see Renderer.enableQuantizedBatches) — quantized buffers are
|
|
276
|
+
* undrawable without them. Applies to batches built from now on; every
|
|
277
|
+
* createBatchedMesh output (buckets, fragments, partial + override
|
|
278
|
+
* batches) quantizes onto the SAME 2^-10 lattice, so depth-equal overlay
|
|
279
|
+
* matching and cross-batch coincidence are preserved bit-exactly. Batches
|
|
280
|
+
* whose extent exceeds the u16 lattice range fall back to f32 silently.
|
|
281
|
+
*/
|
|
282
|
+
setQuantizedBatches(enabled) {
|
|
283
|
+
this.quantizedBatchesEnabled = enabled;
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Whether THIS mesh's source batch renders quantized — drives the
|
|
287
|
+
* hydrated-mesh lattice snap in createMeshFromData (a mesh whose batch
|
|
288
|
+
* fell back to f32, e.g. >64m extent, must NOT snap). Falls back to the
|
|
289
|
+
* global flag when the mesh isn't bucketed (mid-stream hydration).
|
|
290
|
+
*/
|
|
291
|
+
isMeshQuantized(meshData) {
|
|
292
|
+
if (!this.quantizedBatchesEnabled)
|
|
293
|
+
return false;
|
|
294
|
+
const bucket = this.meshDataBucket.get(meshData);
|
|
295
|
+
if (bucket?.batchedMesh)
|
|
296
|
+
return bucket.batchedMesh.quantized !== undefined;
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
/** Set (or clear) the HOST budget in bytes for bucket CPU geometry. */
|
|
300
|
+
setHostResidencyBudget(bytes) {
|
|
301
|
+
if (bytes !== null && !(Number.isFinite(bytes) && bytes > 0)) {
|
|
302
|
+
console.warn('[Scene] ignoring invalid host residency budget:', bytes);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
this.hostBudgetBytes = bytes;
|
|
306
|
+
this.hostOverBudgetWarned = false;
|
|
307
|
+
}
|
|
308
|
+
/** CPU bytes held by bucket meshData (positions + normals + indices). */
|
|
309
|
+
getResidentCpuBytes() {
|
|
310
|
+
let total = 0;
|
|
311
|
+
for (const bucket of this.buckets.values()) {
|
|
312
|
+
for (const md of bucket.meshData) {
|
|
313
|
+
total += md.positions.byteLength + md.normals.byteLength + md.indices.byteLength;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return total;
|
|
317
|
+
}
|
|
318
|
+
/** A bucket whose content diverged from what the cache entry holds
|
|
319
|
+
* (recolour / move / removal) must never be cold-evicted: restoring it
|
|
320
|
+
* from disk would resurrect the pre-edit geometry. */
|
|
321
|
+
markBucketDirty(key) {
|
|
322
|
+
this.dirtyBuckets.add(key);
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Demote warm buckets (GPU-evicted, CPU kept) to cold (shell only) until
|
|
326
|
+
* bucket CPU bytes fit the host budget. Same LRU policy as the GPU tier.
|
|
327
|
+
* Eligibility is strict: pristine, non-overflow ("#N" sub-buckets are
|
|
328
|
+
* excluded — their piece membership cannot be re-derived unambiguously),
|
|
329
|
+
* GPU-evicted, provider present. Cold eviction removes the bucket's meshes
|
|
330
|
+
* from meshDataMap/meshDataBucket too — that is what actually frees the
|
|
331
|
+
* typed arrays.
|
|
332
|
+
*/
|
|
333
|
+
enforceHostBudget() {
|
|
334
|
+
const budget = this.hostBudgetBytes;
|
|
335
|
+
if (budget === null || !this.coldProvider)
|
|
336
|
+
return;
|
|
337
|
+
if (this.geometryReleased || this.ephemeralStreamingMode)
|
|
338
|
+
return;
|
|
339
|
+
if (this.streamingFragments.length > 0)
|
|
340
|
+
return;
|
|
341
|
+
const residentBytes = this.getResidentCpuBytes();
|
|
342
|
+
if (residentBytes <= budget)
|
|
343
|
+
return;
|
|
344
|
+
const shells = [];
|
|
345
|
+
for (const bucket of this.buckets.values()) {
|
|
346
|
+
const b = bucket.batchedMesh;
|
|
347
|
+
if (!b || b.gpuResident !== false)
|
|
348
|
+
continue; // hot buckets stay warm-skippable
|
|
349
|
+
if (bucket.meshData.length === 0)
|
|
350
|
+
continue; // already cold
|
|
351
|
+
if (bucket.key.includes('#'))
|
|
352
|
+
continue; // overflow sub-bucket
|
|
353
|
+
if (this.dirtyBuckets.has(bucket.key))
|
|
354
|
+
continue; // diverged from disk
|
|
355
|
+
// Colour-merged meshes (per-vertex entityIds) are registered in
|
|
356
|
+
// meshDataMap under EVERY contained id; evicting only the primary id's
|
|
357
|
+
// entry would leave the typed arrays reachable (no memory freed) and a
|
|
358
|
+
// later restore would duplicate the object. Ineligible.
|
|
359
|
+
let colorMerged = false;
|
|
360
|
+
let bytes = 0;
|
|
361
|
+
for (const md of bucket.meshData) {
|
|
362
|
+
if (md.entityIds && md.entityIds.length > 0) {
|
|
363
|
+
colorMerged = true;
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
bytes += md.positions.byteLength + md.normals.byteLength + md.indices.byteLength;
|
|
367
|
+
}
|
|
368
|
+
if (colorMerged)
|
|
369
|
+
continue;
|
|
370
|
+
shells.push({
|
|
371
|
+
key: bucket.key,
|
|
372
|
+
bytes,
|
|
373
|
+
lastDrawnFrame: this.lastDrawnFrame.get(b.id) ?? -1,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
const evictKeys = selectEvictions(shells, residentBytes, budget, this.residencyFrame);
|
|
377
|
+
let evictedBytes = 0;
|
|
378
|
+
for (const key of evictKeys) {
|
|
379
|
+
const bucket = this.buckets.get(key);
|
|
380
|
+
if (!bucket || bucket.meshData.length === 0)
|
|
381
|
+
continue;
|
|
382
|
+
for (const md of bucket.meshData) {
|
|
383
|
+
evictedBytes += md.positions.byteLength + md.normals.byteLength + md.indices.byteLength;
|
|
384
|
+
this.meshDataBucket.delete(md);
|
|
385
|
+
// Remove THIS object from the entity's piece list (identity match:
|
|
386
|
+
// other pieces of the entity may live in other, still-warm buckets).
|
|
387
|
+
const pieces = this.meshDataMap.get(md.expressId);
|
|
388
|
+
if (pieces) {
|
|
389
|
+
const idx = pieces.indexOf(md);
|
|
390
|
+
if (idx >= 0)
|
|
391
|
+
pieces.splice(idx, 1);
|
|
392
|
+
if (pieces.length === 0)
|
|
393
|
+
this.meshDataMap.delete(md.expressId);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
bucket.meshData = [];
|
|
397
|
+
bucket.vertexBytes = 0;
|
|
398
|
+
this.coldBuckets.add(key);
|
|
399
|
+
}
|
|
400
|
+
if (residentBytes - evictedBytes > budget && !this.hostOverBudgetWarned) {
|
|
401
|
+
this.hostOverBudgetWarned = true;
|
|
402
|
+
console.warn(`[Scene] host residency budget ${(budget / 1048576).toFixed(0)}MB exceeded ` +
|
|
403
|
+
`(${((residentBytes - evictedBytes) / 1048576).toFixed(0)}MB CPU resident) — ` +
|
|
404
|
+
`remaining buckets are hot, dirty, or overflow sub-buckets. Rendering is unaffected.`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Restore EVERY cold bucket to warm (used before the cold provider goes
|
|
409
|
+
* away, e.g. a federated add invalidates the entry-backed provider while
|
|
410
|
+
* primary chunks are cold — without this they would be stranded shells).
|
|
411
|
+
* Resolves when all in-flight restores settle; failures are logged by the
|
|
412
|
+
* per-bucket restore path and leave those buckets cold.
|
|
413
|
+
*/
|
|
414
|
+
async drainColdTier() {
|
|
415
|
+
if (this.coldBuckets.size === 0)
|
|
416
|
+
return;
|
|
417
|
+
for (const key of Array.from(this.coldBuckets)) {
|
|
418
|
+
this.startColdRestore(key);
|
|
419
|
+
}
|
|
420
|
+
await Promise.all(Array.from(this.coldRestoresInFlight.values()));
|
|
421
|
+
}
|
|
422
|
+
/** Kick off the async disk restore for a cold bucket the draw loop wants.
|
|
423
|
+
* On completion the bucket is warm again and re-queued for GPU rebuild. */
|
|
424
|
+
startColdRestore(key) {
|
|
425
|
+
if (this.geometryReleased || this.ephemeralStreamingMode)
|
|
426
|
+
return;
|
|
427
|
+
if (this.coldRestoresInFlight.has(key))
|
|
428
|
+
return;
|
|
429
|
+
const bucket = this.buckets.get(key);
|
|
430
|
+
const shell = bucket?.batchedMesh;
|
|
431
|
+
const provider = this.coldProvider;
|
|
432
|
+
if (!bucket || !shell || !shell.bounds || !provider)
|
|
433
|
+
return;
|
|
434
|
+
const promise = provider
|
|
435
|
+
.loadMeshesInBounds(shell.bounds.min, shell.bounds.max)
|
|
436
|
+
.then((meshes) => {
|
|
437
|
+
// Re-validate: a clear()/finalize may have replaced the world.
|
|
438
|
+
const current = this.buckets.get(key);
|
|
439
|
+
if (!current || current !== bucket || !this.coldBuckets.has(key))
|
|
440
|
+
return;
|
|
441
|
+
const baseKey = this.baseColorKey(key);
|
|
442
|
+
const idSet = new Set(shell.expressIds);
|
|
443
|
+
const members = meshes.filter((m) => idSet.has(m.expressId) && this.bucketBaseKey(m) === baseKey);
|
|
444
|
+
for (const m of members) {
|
|
445
|
+
bucket.meshData.push(m);
|
|
446
|
+
bucket.vertexBytes += (m.positions.length / 3) * BATCH_CONSTANTS.BYTES_PER_VERTEX;
|
|
447
|
+
this.meshDataBucket.set(m, bucket);
|
|
448
|
+
this.addMeshData(m);
|
|
449
|
+
}
|
|
450
|
+
this.coldBuckets.delete(key);
|
|
451
|
+
if (members.length > 0) {
|
|
452
|
+
// Warm now — re-queue so the next restore tick rebuilds the GPU batch.
|
|
453
|
+
this.residencyRestoreQueue.add(key);
|
|
454
|
+
}
|
|
455
|
+
else {
|
|
456
|
+
console.warn(`[Scene] cold restore for ${key} found no members — bucket stays a shell`);
|
|
457
|
+
}
|
|
458
|
+
})
|
|
459
|
+
.catch((err) => {
|
|
460
|
+
console.warn('[Scene] cold restore failed (bucket stays cold, will retry on demand):', err);
|
|
461
|
+
})
|
|
462
|
+
.finally(() => {
|
|
463
|
+
this.coldRestoresInFlight.delete(key);
|
|
464
|
+
});
|
|
465
|
+
this.coldRestoresInFlight.set(key, promise);
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Synchronously rebuild EVERY evicted bucket batch (no time budget) —
|
|
469
|
+
* for one-shot capture renders (IDS/clash/BCF snapshots) whose isolation
|
|
470
|
+
* options may reveal batches that aged out under the budget. The live
|
|
471
|
+
* view never needs this: visible batches are never evicted. The budget
|
|
472
|
+
* pass re-evicts unused batches after the usual idle age.
|
|
473
|
+
* Returns the number of batches restored.
|
|
474
|
+
*/
|
|
475
|
+
restoreAllEvicted(device, pipeline) {
|
|
476
|
+
if (this.geometryReleased || this.ephemeralStreamingMode)
|
|
477
|
+
return 0;
|
|
478
|
+
let restored = 0;
|
|
479
|
+
for (const bucket of this.buckets.values()) {
|
|
480
|
+
const old = bucket.batchedMesh;
|
|
481
|
+
if (!old || old.gpuResident !== false || bucket.meshData.length === 0)
|
|
482
|
+
continue;
|
|
483
|
+
const rebuilt = this.createBatchedMesh(bucket.meshData, bucket.meshData[0].color, device, pipeline, bucket.key);
|
|
484
|
+
bucket.batchedMesh = rebuilt;
|
|
485
|
+
const idx = this.batchedMeshes.indexOf(old);
|
|
486
|
+
if (idx >= 0)
|
|
487
|
+
this.batchedMeshes[idx] = rebuilt;
|
|
488
|
+
else
|
|
489
|
+
this.batchedMeshes.push(rebuilt);
|
|
490
|
+
this.lastDrawnFrame.delete(old.id);
|
|
491
|
+
this.lastDrawnFrame.set(rebuilt.id, this.residencyFrame);
|
|
492
|
+
this.residencyRestoreQueue.delete(bucket.key);
|
|
493
|
+
restored++;
|
|
494
|
+
}
|
|
495
|
+
return restored;
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Evict least-recently-drawn bucket batches until the resident set fits
|
|
499
|
+
* the budget. Called after each frame's submit; destroying just-submitted
|
|
500
|
+
* buffers is safe (WebGPU defers destruction past in-flight work). Never
|
|
501
|
+
* evicts a batch drawn this frame — a visible set larger than the budget
|
|
502
|
+
* renders correctly and stays over budget (warned once).
|
|
503
|
+
*/
|
|
504
|
+
enforceGpuBudget() {
|
|
505
|
+
// Host (CPU) tier rides the same post-submit hook on a slow cadence —
|
|
506
|
+
// warm->cold demotion is not latency-sensitive and the CPU-bytes walk is
|
|
507
|
+
// O(total meshes).
|
|
508
|
+
if (this.hostBudgetBytes !== null && --this.hostEnforceCountdown <= 0) {
|
|
509
|
+
this.hostEnforceCountdown = 120;
|
|
510
|
+
this.enforceHostBudget();
|
|
511
|
+
}
|
|
512
|
+
const budget = this.gpuBudgetBytes;
|
|
513
|
+
if (budget === null)
|
|
514
|
+
return;
|
|
515
|
+
if (this.geometryReleased || this.ephemeralStreamingMode)
|
|
516
|
+
return;
|
|
517
|
+
// During streaming the batch set churns (fragments + finalize rebuild
|
|
518
|
+
// everything anyway) — start enforcing once the scene is stable.
|
|
519
|
+
if (this.streamingFragments.length > 0)
|
|
520
|
+
return;
|
|
521
|
+
let residentBytes = 0;
|
|
522
|
+
const shells = [];
|
|
523
|
+
for (const bucket of this.buckets.values()) {
|
|
524
|
+
const b = bucket.batchedMesh;
|
|
525
|
+
if (!b || b.gpuResident === false)
|
|
526
|
+
continue;
|
|
527
|
+
const bytes = b.vertexBuffer.size + b.indexBuffer.size + (b.uniformBuffer?.size ?? 0)
|
|
528
|
+
+ (b.lod1IndexBuffer?.size ?? 0);
|
|
529
|
+
residentBytes += bytes;
|
|
530
|
+
const lastDrawn = this.lastDrawnFrame.get(b.id) ?? -1;
|
|
531
|
+
if (lastDrawn === this.residencyFrame)
|
|
532
|
+
continue; // drawn this frame: not evictable
|
|
533
|
+
if (bucket.meshData.length === 0)
|
|
534
|
+
continue; // no rebuild source: keep resident
|
|
535
|
+
shells.push({ key: bucket.key, bytes, lastDrawnFrame: lastDrawn });
|
|
536
|
+
}
|
|
537
|
+
if (residentBytes <= budget)
|
|
538
|
+
return;
|
|
539
|
+
const evictKeys = selectEvictions(shells, residentBytes, budget, this.residencyFrame);
|
|
540
|
+
let evictedBytes = 0;
|
|
541
|
+
for (const key of evictKeys) {
|
|
542
|
+
const bucket = this.buckets.get(key);
|
|
543
|
+
const batch = bucket?.batchedMesh;
|
|
544
|
+
if (!bucket || !batch || batch.gpuResident === false)
|
|
545
|
+
continue;
|
|
546
|
+
destroyGpuResources(batch);
|
|
547
|
+
batch.gpuResident = false;
|
|
548
|
+
evictedBytes += batch.vertexBuffer.size + batch.indexBuffer.size + (batch.uniformBuffer?.size ?? 0)
|
|
549
|
+
+ (batch.lod1IndexBuffer?.size ?? 0);
|
|
550
|
+
this.lastDrawnFrame.delete(batch.id);
|
|
551
|
+
this.dropPartialCacheForBatch(batch);
|
|
552
|
+
}
|
|
553
|
+
if (residentBytes - evictedBytes > budget && !this.residencyOverBudgetWarned) {
|
|
554
|
+
this.residencyOverBudgetWarned = true;
|
|
555
|
+
console.warn(`[Scene] GPU residency budget ${(budget / 1048576).toFixed(0)}MB exceeded by the ` +
|
|
556
|
+
`recently-drawn set (${((residentBytes - evictedBytes) / 1048576).toFixed(0)}MB resident) — ` +
|
|
557
|
+
`nothing old enough to evict. Rendering is unaffected.`);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
/** Destroy + drop cached partial sub-batches derived from `batch` (their
|
|
561
|
+
* sourceBatchKeys embed the batch id, so they are stale once it is
|
|
562
|
+
* evicted/replaced). */
|
|
563
|
+
dropPartialCacheForBatch(batch) {
|
|
564
|
+
const prefix = `${batch.colorKey}:${batch.id}`;
|
|
565
|
+
for (const [sourceBatchKey, cacheKey] of this.partialBatchCacheKeys) {
|
|
566
|
+
if (!sourceBatchKey.startsWith(prefix))
|
|
567
|
+
continue;
|
|
568
|
+
const cached = this.partialBatchCache.get(cacheKey);
|
|
569
|
+
if (cached) {
|
|
570
|
+
destroyGpuResources(cached);
|
|
571
|
+
this.partialBatchCache.delete(cacheKey);
|
|
572
|
+
}
|
|
573
|
+
this.partialBatchCacheKeys.delete(sourceBatchKey);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* Bucket BASE key for a mesh: colour key, prefixed with the mesh's grid
|
|
578
|
+
* cell when spatial chunking is on. EVERY bucket-key derivation
|
|
579
|
+
* (streaming append, fragment grouping, finalize re-group, recolour move,
|
|
580
|
+
* partial-batch piece filter) must go through this so a mesh always
|
|
581
|
+
* resolves to the same bucket. `color` overrides the mesh's own colour for
|
|
582
|
+
* recolour routing.
|
|
583
|
+
*/
|
|
584
|
+
bucketBaseKey(meshData, color) {
|
|
585
|
+
return bucketBaseKeyFor(meshData, this.colorKey(color ?? meshData.color), this.spatialChunking);
|
|
586
|
+
}
|
|
106
587
|
/**
|
|
107
588
|
* Store MeshData for lazy GPU buffer creation (used for selection highlighting)
|
|
108
589
|
* This avoids creating 2x GPU buffers during streaming
|
|
@@ -430,9 +911,10 @@ export class Scene {
|
|
|
430
911
|
}
|
|
431
912
|
}
|
|
432
913
|
}
|
|
433
|
-
// Route each mesh into a size-aware bucket for its color
|
|
914
|
+
// Route each mesh into a size-aware bucket for its color (and, with
|
|
915
|
+
// spatial chunking on, its grid cell)
|
|
434
916
|
for (const meshData of renderable) {
|
|
435
|
-
const baseKey = this.
|
|
917
|
+
const baseKey = this.bucketBaseKey(meshData);
|
|
436
918
|
const bucketKey = this.resolveActiveBucket(baseKey, meshData);
|
|
437
919
|
if (retainStreamingGeometry || !isStreaming) {
|
|
438
920
|
// Accumulate mesh data in the bucket when we need later rebatching or
|
|
@@ -571,6 +1053,8 @@ export class Scene {
|
|
|
571
1053
|
bucket.vertexBytes = Math.max(0, bucket.vertexBytes - bytes);
|
|
572
1054
|
}
|
|
573
1055
|
affectedKeys.add(bucket.key);
|
|
1056
|
+
// Entity removal diverges the bucket from the cache entry.
|
|
1057
|
+
this.markBucketDirty(bucket.key);
|
|
574
1058
|
}
|
|
575
1059
|
this.meshDataBucket.delete(meshData);
|
|
576
1060
|
}
|
|
@@ -621,6 +1105,12 @@ export class Scene {
|
|
|
621
1105
|
this.instancedHidden.add(expressId);
|
|
622
1106
|
this.writeInstanceFlags(device, expressId);
|
|
623
1107
|
}
|
|
1108
|
+
// Release the contribution-cull exemption BEFORE forgetting the occurrence
|
|
1109
|
+
// locations — deleting the map entry first would leak selectedCount and
|
|
1110
|
+
// leave the templates permanently uncullable.
|
|
1111
|
+
if (this.instancedSelected.has(expressId)) {
|
|
1112
|
+
this.bumpTemplateSelectedCount(expressId, -1);
|
|
1113
|
+
}
|
|
624
1114
|
this.instancedEntityMap.delete(expressId);
|
|
625
1115
|
this.instancedSelected.delete(expressId);
|
|
626
1116
|
this.instancedHidden.delete(expressId);
|
|
@@ -681,6 +1171,47 @@ export class Scene {
|
|
|
681
1171
|
* Translate every flat (non-instanced) mesh for `expressId` by `delta`. See
|
|
682
1172
|
* {@link translateMeshesForEntity} for the full contract; this is the flat half.
|
|
683
1173
|
*/
|
|
1174
|
+
/**
|
|
1175
|
+
* Mark a mesh's bucket for rebuild after its positions were mutated in
|
|
1176
|
+
* place (move/rotate), migrating it to a new bucket when spatial chunking
|
|
1177
|
+
* is on and the mesh crossed a grid-cell boundary. Without the migration
|
|
1178
|
+
* the mesh would keep its stale cell key, so the partial-batch piece
|
|
1179
|
+
* filter (which re-derives keys from CURRENT positions) would silently
|
|
1180
|
+
* drop it under hide/isolate. Same move mechanics as updateMeshColors.
|
|
1181
|
+
*/
|
|
1182
|
+
rebucketMovedMesh(meshData, affectedKeys) {
|
|
1183
|
+
const bucket = this.meshDataBucket.get(meshData);
|
|
1184
|
+
if (bucket) {
|
|
1185
|
+
affectedKeys.add(bucket.key);
|
|
1186
|
+
// Moved geometry diverges from the cache entry — see markBucketDirty.
|
|
1187
|
+
this.markBucketDirty(bucket.key);
|
|
1188
|
+
}
|
|
1189
|
+
if (!this.spatialChunking || !bucket)
|
|
1190
|
+
return;
|
|
1191
|
+
const newBaseKey = this.bucketBaseKey(meshData);
|
|
1192
|
+
if (this.baseColorKey(bucket.key) === newBaseKey)
|
|
1193
|
+
return;
|
|
1194
|
+
const newBucketKey = this.resolveActiveBucket(newBaseKey, meshData);
|
|
1195
|
+
this.markBucketDirty(newBucketKey);
|
|
1196
|
+
// Swap-remove from the old bucket + decrement its byte accounting
|
|
1197
|
+
const idx = bucket.meshData.indexOf(meshData);
|
|
1198
|
+
if (idx >= 0) {
|
|
1199
|
+
const last = bucket.meshData.length - 1;
|
|
1200
|
+
if (idx !== last)
|
|
1201
|
+
bucket.meshData[idx] = bucket.meshData[last];
|
|
1202
|
+
bucket.meshData.pop();
|
|
1203
|
+
}
|
|
1204
|
+
const meshBytes = (meshData.positions.length / 3) * BATCH_CONSTANTS.BYTES_PER_VERTEX;
|
|
1205
|
+
bucket.vertexBytes = Math.max(0, bucket.vertexBytes - meshBytes);
|
|
1206
|
+
// Deliberately KEEP an emptied bucket in the map: rebuildPendingBatches
|
|
1207
|
+
// destroys its batchedMesh and deletes the shell. Removing it here would
|
|
1208
|
+
// orphan the live GPU buffers (rebuild skips keys it can't find).
|
|
1209
|
+
// resolveActiveBucket already created the target bucket + tracked bytes
|
|
1210
|
+
const newBucket = this.buckets.get(newBucketKey);
|
|
1211
|
+
newBucket.meshData.push(meshData);
|
|
1212
|
+
this.meshDataBucket.set(meshData, newBucket);
|
|
1213
|
+
affectedKeys.add(newBucketKey);
|
|
1214
|
+
}
|
|
684
1215
|
translateFlatMeshesForEntity(expressId, delta) {
|
|
685
1216
|
const meshDataList = this.meshDataMap.get(expressId);
|
|
686
1217
|
if (!meshDataList || meshDataList.length === 0)
|
|
@@ -715,9 +1246,7 @@ export class Scene {
|
|
|
715
1246
|
pos[i + 1] += dy;
|
|
716
1247
|
pos[i + 2] += dz;
|
|
717
1248
|
}
|
|
718
|
-
|
|
719
|
-
if (bucket)
|
|
720
|
-
affectedKeys.add(bucket.key);
|
|
1249
|
+
this.rebucketMovedMesh(meshData, affectedKeys);
|
|
721
1250
|
anyMoved = true;
|
|
722
1251
|
}
|
|
723
1252
|
if (!anyMoved)
|
|
@@ -823,7 +1352,15 @@ export class Scene {
|
|
|
823
1352
|
if (!cpu)
|
|
824
1353
|
continue;
|
|
825
1354
|
const dv = new DataView(cpu.instanceData);
|
|
826
|
-
this.unionInstancedWorldAabb(expressId, dv, occ.byteOffset, cpu.localMin[0], cpu.localMin[1], cpu.localMin[2], cpu.localMax[0], cpu.localMax[1], cpu.localMax[2]);
|
|
1355
|
+
const w = this.unionInstancedWorldAabb(expressId, dv, occ.byteOffset, cpu.localMin[0], cpu.localMin[1], cpu.localMin[2], cpu.localMax[0], cpu.localMax[1], cpu.localMax[2]);
|
|
1356
|
+
// GROW the template's cull union so a moved occurrence (Exploded mode,
|
|
1357
|
+
// #1289) can't be frustum/contribution-culled by its pre-move bounds.
|
|
1358
|
+
// The pre-move region stays in the union — monotonic growth only ever
|
|
1359
|
+
// culls LESS — and translation never changes an occurrence's size, so
|
|
1360
|
+
// maxOccRadius needs no update.
|
|
1361
|
+
const template = this.instancedTemplates[occ.templateIndex];
|
|
1362
|
+
if (template)
|
|
1363
|
+
foldOccurrenceWorldBox(template, w);
|
|
827
1364
|
}
|
|
828
1365
|
}
|
|
829
1366
|
/** Drop the per-entity selection-highlight meshes for `expressId` (frozen
|
|
@@ -909,9 +1446,7 @@ export class Scene {
|
|
|
909
1446
|
nrm[i + 2] = -nx * sin + nz * cos;
|
|
910
1447
|
}
|
|
911
1448
|
}
|
|
912
|
-
|
|
913
|
-
if (bucket)
|
|
914
|
-
affectedKeys.add(bucket.key);
|
|
1449
|
+
this.rebucketMovedMesh(meshData, affectedKeys);
|
|
915
1450
|
anyMoved = true;
|
|
916
1451
|
}
|
|
917
1452
|
if (!anyMoved)
|
|
@@ -974,6 +1509,12 @@ export class Scene {
|
|
|
974
1509
|
hasStreamingFragments() {
|
|
975
1510
|
return this.streamingFragments.length > 0;
|
|
976
1511
|
}
|
|
1512
|
+
/** True while a finalize rebuild (sync or time-sliced) is mid-flight —
|
|
1513
|
+
* the fragment list is already cleared then, so settle-sensitive callers
|
|
1514
|
+
* must check BOTH this and hasStreamingFragments(). */
|
|
1515
|
+
isFinalizeInProgress() {
|
|
1516
|
+
return this.finalizeInProgress;
|
|
1517
|
+
}
|
|
977
1518
|
/** True when streaming runs in ephemeral mode (huge files) — fragments render
|
|
978
1519
|
* directly from GPU and geometry is NOT retained for re-batch, so callers
|
|
979
1520
|
* must NOT finalize (there's nothing to rebuild the batches from). */
|
|
@@ -1048,11 +1589,14 @@ export class Scene {
|
|
|
1048
1589
|
createStreamingFragments(meshDataArray, device, pipeline) {
|
|
1049
1590
|
if (meshDataArray.length === 0)
|
|
1050
1591
|
return;
|
|
1051
|
-
// Group new meshes by color for efficient
|
|
1592
|
+
// Group new meshes by color (and grid cell, when chunking) for efficient
|
|
1593
|
+
// fragment batches. Fragments of one mesh share the PARENT's key: they
|
|
1594
|
+
// are vertex subsets of the same element, and the mesh-never-splits rule
|
|
1595
|
+
// applies to cells exactly like it does to buckets.
|
|
1052
1596
|
const colorGroups = new Map();
|
|
1053
1597
|
for (const meshData of meshDataArray) {
|
|
1598
|
+
const key = this.bucketBaseKey(meshData);
|
|
1054
1599
|
for (const fragment of this.splitMeshForStreaming(meshData)) {
|
|
1055
|
-
const key = this.colorKey(fragment.color);
|
|
1056
1600
|
let group = colorGroups.get(key);
|
|
1057
1601
|
if (!group) {
|
|
1058
1602
|
group = [];
|
|
@@ -1134,15 +1678,33 @@ export class Scene {
|
|
|
1134
1678
|
finalizeStreaming(device, pipeline) {
|
|
1135
1679
|
if (this.streamingFragments.length === 0)
|
|
1136
1680
|
return;
|
|
1681
|
+
this.finalizeInProgress = true;
|
|
1682
|
+
try {
|
|
1683
|
+
this.finalizeStreamingInner(device, pipeline);
|
|
1684
|
+
}
|
|
1685
|
+
finally {
|
|
1686
|
+
this.finalizeInProgress = false;
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
finalizeStreamingInner(device, pipeline) {
|
|
1137
1690
|
// Save references to old fragments/batches — keep them rendering
|
|
1138
1691
|
// until the new proper batches are fully built (no visual gap).
|
|
1139
1692
|
const oldFragments = this.streamingFragments;
|
|
1140
1693
|
const oldBatches = this.batchedMeshes;
|
|
1141
1694
|
const fragmentSet = new Set(oldFragments);
|
|
1142
1695
|
this.streamingFragments = [];
|
|
1143
|
-
// 1. Collect ALL accumulated meshData before clearing state
|
|
1696
|
+
// 1. Collect ALL accumulated meshData before clearing state.
|
|
1697
|
+
// Cold buckets (issue #1682 phase 3b) hold NO meshData — their
|
|
1698
|
+
// geometry lives on disk — so they are carried through the rebuild as
|
|
1699
|
+
// sealed shells instead of being re-grouped (re-grouping would
|
|
1700
|
+
// silently drop them).
|
|
1144
1701
|
const allMeshData = [];
|
|
1145
|
-
|
|
1702
|
+
const carriedCold = [];
|
|
1703
|
+
for (const [key, bucket] of this.buckets) {
|
|
1704
|
+
if (this.coldBuckets.has(key) && bucket.meshData.length === 0 && bucket.batchedMesh) {
|
|
1705
|
+
carriedCold.push([key, bucket]);
|
|
1706
|
+
continue;
|
|
1707
|
+
}
|
|
1146
1708
|
for (const md of bucket.meshData)
|
|
1147
1709
|
allMeshData.push(md);
|
|
1148
1710
|
}
|
|
@@ -1152,18 +1714,24 @@ export class Scene {
|
|
|
1152
1714
|
this.buckets.clear();
|
|
1153
1715
|
this.meshDataBucket = new Map();
|
|
1154
1716
|
this.activeBucketKey.clear();
|
|
1717
|
+
this.lastDrawnFrame.clear();
|
|
1718
|
+
this.residencyRestoreQueue.clear();
|
|
1155
1719
|
this.pendingBatchKeys.clear();
|
|
1156
1720
|
// Destroy cached partial batches — their colorKeys are now stale
|
|
1157
1721
|
for (const batch of this.partialBatchCache.values())
|
|
1158
1722
|
destroyGpuResources(batch);
|
|
1159
1723
|
this.partialBatchCache.clear();
|
|
1160
1724
|
this.partialBatchCacheKeys.clear();
|
|
1161
|
-
//
|
|
1725
|
+
// Re-seat the carried cold shells in the fresh bucket map (their GPU
|
|
1726
|
+
// shells re-enter the flat array via rebuildPendingBatches below).
|
|
1727
|
+
for (const [key, bucket] of carriedCold)
|
|
1728
|
+
this.buckets.set(key, bucket);
|
|
1729
|
+
// 3. Re-group ALL meshData by their CURRENT color (and grid cell).
|
|
1162
1730
|
// meshData.color may have been mutated in-place since the mesh was
|
|
1163
1731
|
// first bucketed, so the original bucket key is stale. Re-grouping
|
|
1164
1732
|
// by current color ensures batches render with correct colors.
|
|
1165
1733
|
for (const meshData of allMeshData) {
|
|
1166
|
-
const baseKey = this.
|
|
1734
|
+
const baseKey = this.bucketBaseKey(meshData);
|
|
1167
1735
|
const bucketKey = this.resolveActiveBucket(baseKey, meshData);
|
|
1168
1736
|
let bucket = this.buckets.get(bucketKey);
|
|
1169
1737
|
if (!bucket) {
|
|
@@ -1203,14 +1771,24 @@ export class Scene {
|
|
|
1203
1771
|
}
|
|
1204
1772
|
if (this.streamingFragments.length === 0)
|
|
1205
1773
|
return Promise.resolve();
|
|
1774
|
+
// Mark the rebuild as in-flight: the preamble empties streamingFragments
|
|
1775
|
+
// synchronously, so settle-sensitive consumers need this flag until the
|
|
1776
|
+
// time-sliced rebuild swaps the new batch array in.
|
|
1777
|
+
this.finalizeInProgress = true;
|
|
1206
1778
|
// --- Synchronous preamble (fast O(N) bookkeeping) ---
|
|
1207
1779
|
const oldFragments = this.streamingFragments;
|
|
1208
1780
|
const oldBatches = this.batchedMeshes;
|
|
1209
1781
|
const fragmentSet = new Set(oldFragments);
|
|
1210
1782
|
this.streamingFragments = [];
|
|
1211
|
-
// 1. Collect ALL accumulated meshData
|
|
1783
|
+
// 1. Collect ALL accumulated meshData (cold buckets carried as sealed
|
|
1784
|
+
// shells — see the sync finalize for the rationale)
|
|
1212
1785
|
const allMeshData = [];
|
|
1213
|
-
|
|
1786
|
+
const carriedCold = [];
|
|
1787
|
+
for (const [key, bucket] of this.buckets) {
|
|
1788
|
+
if (this.coldBuckets.has(key) && bucket.meshData.length === 0 && bucket.batchedMesh) {
|
|
1789
|
+
carriedCold.push([key, bucket]);
|
|
1790
|
+
continue;
|
|
1791
|
+
}
|
|
1214
1792
|
for (const md of bucket.meshData)
|
|
1215
1793
|
allMeshData.push(md);
|
|
1216
1794
|
}
|
|
@@ -1218,14 +1796,19 @@ export class Scene {
|
|
|
1218
1796
|
this.buckets.clear();
|
|
1219
1797
|
this.meshDataBucket = new Map();
|
|
1220
1798
|
this.activeBucketKey.clear();
|
|
1799
|
+
this.lastDrawnFrame.clear();
|
|
1800
|
+
this.residencyRestoreQueue.clear();
|
|
1221
1801
|
this.pendingBatchKeys.clear();
|
|
1222
1802
|
for (const batch of this.partialBatchCache.values())
|
|
1223
1803
|
destroyGpuResources(batch);
|
|
1224
1804
|
this.partialBatchCache.clear();
|
|
1225
1805
|
this.partialBatchCacheKeys.clear();
|
|
1226
|
-
//
|
|
1806
|
+
// Re-seat the carried cold shells in the fresh bucket map.
|
|
1807
|
+
for (const [key, bucket] of carriedCold)
|
|
1808
|
+
this.buckets.set(key, bucket);
|
|
1809
|
+
// 3. Re-group meshData by current color (and grid cell) — fast
|
|
1227
1810
|
for (const meshData of allMeshData) {
|
|
1228
|
-
const baseKey = this.
|
|
1811
|
+
const baseKey = this.bucketBaseKey(meshData);
|
|
1229
1812
|
const bucketKey = this.resolveActiveBucket(baseKey, meshData);
|
|
1230
1813
|
let bucket = this.buckets.get(bucketKey);
|
|
1231
1814
|
if (!bucket) {
|
|
@@ -1264,6 +1847,13 @@ export class Scene {
|
|
|
1264
1847
|
return;
|
|
1265
1848
|
}
|
|
1266
1849
|
}
|
|
1850
|
+
// Carried cold shells stay drawable-when-restored: keep them in the
|
|
1851
|
+
// flat array (their buffers are already destroyed; the draw loop
|
|
1852
|
+
// skips gpuResident === false and the restore path revives them).
|
|
1853
|
+
for (const [, bucket] of carriedCold) {
|
|
1854
|
+
if (bucket.batchedMesh)
|
|
1855
|
+
newBatches.push(bucket.batchedMesh);
|
|
1856
|
+
}
|
|
1267
1857
|
// All batches built — atomic swap so renderer never sees an empty array
|
|
1268
1858
|
scene.batchedMeshes = newBatches;
|
|
1269
1859
|
// Destroy old fragment/batch GPU resources
|
|
@@ -1273,6 +1863,7 @@ export class Scene {
|
|
|
1273
1863
|
if (!fragmentSet.has(batch))
|
|
1274
1864
|
destroyGpuResources(batch);
|
|
1275
1865
|
}
|
|
1866
|
+
scene.finalizeInProgress = false;
|
|
1276
1867
|
resolve();
|
|
1277
1868
|
}
|
|
1278
1869
|
// Start first chunk immediately (no setTimeout delay)
|
|
@@ -1328,6 +1919,10 @@ export class Scene {
|
|
|
1328
1919
|
// AABBs already live in boundingBoxes, so bbox-raycast still finds instanced ids.
|
|
1329
1920
|
this.instancedTemplateCpu = [];
|
|
1330
1921
|
this.activeBucketKey.clear();
|
|
1922
|
+
this.lastDrawnFrame.clear();
|
|
1923
|
+
this.residencyRestoreQueue.clear();
|
|
1924
|
+
this.coldBuckets.clear();
|
|
1925
|
+
this.dirtyBuckets.clear();
|
|
1331
1926
|
this.pendingBatchKeys.clear();
|
|
1332
1927
|
for (const batch of this.partialBatchCache.values())
|
|
1333
1928
|
destroyGpuResources(batch);
|
|
@@ -1410,6 +2005,12 @@ export class Scene {
|
|
|
1410
2005
|
}
|
|
1411
2006
|
this.meshDataBucket = new Map();
|
|
1412
2007
|
this.activeBucketKey.clear();
|
|
2008
|
+
this.lastDrawnFrame.clear();
|
|
2009
|
+
this.residencyRestoreQueue.clear();
|
|
2010
|
+
// Released mode has no restore source at all — drop the cold tier state
|
|
2011
|
+
// (the geometryReleased guards stop any further cold activity).
|
|
2012
|
+
this.coldBuckets.clear();
|
|
2013
|
+
this.dirtyBuckets.clear();
|
|
1413
2014
|
// 3. Clear partial batch cache (would need mesh data to rebuild)
|
|
1414
2015
|
for (const batch of this.partialBatchCache.values())
|
|
1415
2016
|
destroyGpuResources(batch);
|
|
@@ -1450,11 +2051,15 @@ export class Scene {
|
|
|
1450
2051
|
const meshDataList = this.meshDataMap.get(expressId);
|
|
1451
2052
|
if (!meshDataList)
|
|
1452
2053
|
continue;
|
|
1453
|
-
const newBaseKey = this.colorKey(newColor);
|
|
1454
2054
|
for (const meshData of meshDataList) {
|
|
2055
|
+
// Per-mesh, not per-entity: with spatial chunking the base key
|
|
2056
|
+
// carries the mesh's grid cell, which differs between an entity's
|
|
2057
|
+
// pieces. A recolour changes the colour part only — the mesh stays
|
|
2058
|
+
// in its cell.
|
|
2059
|
+
const newBaseKey = this.bucketBaseKey(meshData, newColor);
|
|
1455
2060
|
// Use reverse-map for O(1) old bucket lookup
|
|
1456
2061
|
const oldBucket = this.meshDataBucket.get(meshData);
|
|
1457
|
-
const oldBucketKey = oldBucket?.key ?? this.
|
|
2062
|
+
const oldBucketKey = oldBucket?.key ?? this.bucketBaseKey(meshData);
|
|
1458
2063
|
// Derive old color from bucket key, NOT meshData.color.
|
|
1459
2064
|
// meshData.color may have been mutated in-place by external code
|
|
1460
2065
|
// (applyColorUpdatesToMeshes), making it unreliable for change detection.
|
|
@@ -1464,6 +2069,10 @@ export class Scene {
|
|
|
1464
2069
|
const newBucketKey = this.resolveActiveBucket(newBaseKey, meshData);
|
|
1465
2070
|
affectedOldKeys.add(oldBucketKey);
|
|
1466
2071
|
affectedNewKeys.add(newBucketKey);
|
|
2072
|
+
// Both buckets now diverge from the cache entry: never cold-evict
|
|
2073
|
+
// them (a disk restore would resurrect the pre-recolour geometry).
|
|
2074
|
+
this.markBucketDirty(oldBucketKey);
|
|
2075
|
+
this.markBucketDirty(newBucketKey);
|
|
1467
2076
|
// Remove from old bucket data using indexOf (O(N) within one color bucket, typically <100 items)
|
|
1468
2077
|
if (oldBucket) {
|
|
1469
2078
|
const idx = oldBucket.meshData.indexOf(meshData);
|
|
@@ -1475,9 +2084,12 @@ export class Scene {
|
|
|
1475
2084
|
}
|
|
1476
2085
|
oldBucket.meshData.pop();
|
|
1477
2086
|
}
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
2087
|
+
// Do NOT delete an emptied bucket here: it is queued in
|
|
2088
|
+
// affectedOldKeys, and rebuildPendingBatches both destroys its
|
|
2089
|
+
// batchedMesh GPU buffers and removes the shell. Deleting the
|
|
2090
|
+
// map entry early orphaned those buffers (rebuild skips keys it
|
|
2091
|
+
// can't resolve) — a GPU memory leak on every recolour that
|
|
2092
|
+
// emptied a colour group.
|
|
1481
2093
|
}
|
|
1482
2094
|
// Decrease old bucket size tracking
|
|
1483
2095
|
const meshBytes = (meshData.positions.length / 3) * BATCH_CONSTANTS.BYTES_PER_VERTEX;
|
|
@@ -1532,13 +2144,36 @@ export class Scene {
|
|
|
1532
2144
|
// Create vertex buffer (interleaved positions + normals)
|
|
1533
2145
|
// Use mappedAtCreation to avoid a separate writeBuffer IPC round-trip
|
|
1534
2146
|
// (significant win on Chrome/Dawn where each writeBuffer is a Mojo IPC call)
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
vertexBuffer
|
|
2147
|
+
// Quantized path (issue #1682 phase 6): 12-byte lattice records instead
|
|
2148
|
+
// of the 28-byte f32 layout. Falls back to f32 when the batch exceeds
|
|
2149
|
+
// the u16 lattice range. Order note: the LOD build further down reads
|
|
2150
|
+
// merged.vertexData (the CPU f32 copy) and produces INDICES only, which
|
|
2151
|
+
// are valid for either vertex format.
|
|
2152
|
+
let quantized;
|
|
2153
|
+
let vertexBuffer;
|
|
2154
|
+
const quantizedData = this.quantizedBatchesEnabled
|
|
2155
|
+
? quantizeInterleaved(merged.vertexData, BATCH_CONSTANTS.BYTES_PER_VERTEX / 4)
|
|
2156
|
+
: null;
|
|
2157
|
+
if (quantizedData) {
|
|
2158
|
+
vertexBuffer = device.createBuffer({
|
|
2159
|
+
size: Math.max(4, quantizedData.vertexData.byteLength),
|
|
2160
|
+
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
|
2161
|
+
mappedAtCreation: true,
|
|
2162
|
+
});
|
|
2163
|
+
new Uint8Array(vertexBuffer.getMappedRange())
|
|
2164
|
+
.set(new Uint8Array(quantizedData.vertexData));
|
|
2165
|
+
vertexBuffer.unmap();
|
|
2166
|
+
quantized = { min: quantizedData.quantMin, step: quantizedData.step };
|
|
2167
|
+
}
|
|
2168
|
+
else {
|
|
2169
|
+
vertexBuffer = device.createBuffer({
|
|
2170
|
+
size: merged.vertexData.byteLength,
|
|
2171
|
+
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
|
|
2172
|
+
mappedAtCreation: true,
|
|
2173
|
+
});
|
|
2174
|
+
new Float32Array(vertexBuffer.getMappedRange()).set(merged.vertexData);
|
|
2175
|
+
vertexBuffer.unmap();
|
|
2176
|
+
}
|
|
1542
2177
|
// Create index buffer
|
|
1543
2178
|
const indexBuffer = device.createBuffer({
|
|
1544
2179
|
size: merged.indices.byteLength,
|
|
@@ -1562,6 +2197,31 @@ export class Scene {
|
|
|
1562
2197
|
},
|
|
1563
2198
|
],
|
|
1564
2199
|
});
|
|
2200
|
+
// LOD1 (issue #1682 phase 5): simplified second index range over the SAME
|
|
2201
|
+
// vertex buffer. Bucket-owned batches only (`bucketKey` present) — the
|
|
2202
|
+
// transient streaming fragments and partial/overlay sub-batches never pay
|
|
2203
|
+
// the build. Positions in `merged.vertexData` are relative to the batch
|
|
2204
|
+
// origin, which is fine: clustering is translation-invariant as long as
|
|
2205
|
+
// the cell size comes from the same-space bounds extent.
|
|
2206
|
+
let lod1IndexBuffer;
|
|
2207
|
+
let lod1IndexCount;
|
|
2208
|
+
if (this.lodBuildsEnabled &&
|
|
2209
|
+
bucketKey !== undefined &&
|
|
2210
|
+
merged.bounds &&
|
|
2211
|
+
merged.indices.length >= LOD_MIN_TRIANGLES * 3) {
|
|
2212
|
+
const cellSize = lodCellSizeForBounds(merged.bounds.min, merged.bounds.max);
|
|
2213
|
+
const lodIndices = simplifyIndicesByClustering(merged.vertexData, BATCH_CONSTANTS.BYTES_PER_VERTEX / 4, merged.indices, cellSize);
|
|
2214
|
+
if (lodIndices) {
|
|
2215
|
+
lod1IndexBuffer = device.createBuffer({
|
|
2216
|
+
size: lodIndices.byteLength,
|
|
2217
|
+
usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
|
|
2218
|
+
mappedAtCreation: true,
|
|
2219
|
+
});
|
|
2220
|
+
new Uint32Array(lod1IndexBuffer.getMappedRange()).set(lodIndices);
|
|
2221
|
+
lod1IndexBuffer.unmap();
|
|
2222
|
+
lod1IndexCount = lodIndices.length;
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
1565
2225
|
return {
|
|
1566
2226
|
id: this.nextBatchId++,
|
|
1567
2227
|
colorKey: bucketKey ?? this.colorKey(color),
|
|
@@ -1576,6 +2236,8 @@ export class Scene {
|
|
|
1576
2236
|
// Per-batch local frame: positions are stored relative to this; the draw
|
|
1577
2237
|
// loop applies model = translate(origin) so they land in world space.
|
|
1578
2238
|
origin: merged.origin,
|
|
2239
|
+
...(lod1IndexBuffer ? { lod1IndexBuffer, lod1IndexCount } : {}),
|
|
2240
|
+
...(quantized ? { quantized } : {}),
|
|
1579
2241
|
};
|
|
1580
2242
|
}
|
|
1581
2243
|
/**
|
|
@@ -1610,6 +2272,21 @@ export class Scene {
|
|
|
1610
2272
|
const bucket = this.buckets.get(bucketKey);
|
|
1611
2273
|
const currentBytes = bucket?.vertexBytes ?? 0;
|
|
1612
2274
|
const meshBytes = (meshData.positions.length / 3) * BATCH_CONSTANTS.BYTES_PER_VERTEX;
|
|
2275
|
+
// A COLD bucket is sealed (its content lives on disk and its shell's
|
|
2276
|
+
// expressIds are the restore contract) — route new arrivals (e.g. a
|
|
2277
|
+
// federated add landing in the same cell+colour) to an overflow
|
|
2278
|
+
// sub-bucket instead of corrupting the sealed one.
|
|
2279
|
+
if (bucket && this.coldBuckets.has(bucketKey)) {
|
|
2280
|
+
bucketKey = `${baseColorKey}#${this.nextSplitId++}`;
|
|
2281
|
+
this.activeBucketKey.set(baseColorKey, bucketKey);
|
|
2282
|
+
let target = this.buckets.get(bucketKey);
|
|
2283
|
+
if (!target) {
|
|
2284
|
+
target = { key: bucketKey, meshData: [], batchedMesh: null, vertexBytes: 0 };
|
|
2285
|
+
this.buckets.set(bucketKey, target);
|
|
2286
|
+
}
|
|
2287
|
+
target.vertexBytes += meshBytes;
|
|
2288
|
+
return bucketKey;
|
|
2289
|
+
}
|
|
1613
2290
|
if (currentBytes > 0 && currentBytes + meshBytes > this.cachedMaxBufferSize) {
|
|
1614
2291
|
// Overflow — create a new sub-bucket
|
|
1615
2292
|
bucketKey = `${baseColorKey}#${this.nextSplitId++}`;
|
|
@@ -1676,8 +2353,11 @@ export class Scene {
|
|
|
1676
2353
|
}
|
|
1677
2354
|
}
|
|
1678
2355
|
// Collect MeshData for visible elements
|
|
1679
|
-
// Use base
|
|
1680
|
-
// meshData stores the original color, not the bucket key.
|
|
2356
|
+
// Use the base key (strip "#N" bucket suffix) for piece filtering, since
|
|
2357
|
+
// meshData stores the original color, not the bucket key. Pieces are
|
|
2358
|
+
// matched through bucketBaseKey so the comparison stays correct with
|
|
2359
|
+
// spatial chunking on (base key = "cell~colour" then, and a piece only
|
|
2360
|
+
// belongs to this batch when BOTH its cell and colour match).
|
|
1681
2361
|
const baseKey = this.baseColorKey(colorKey);
|
|
1682
2362
|
const visibleMeshData = [];
|
|
1683
2363
|
for (const expressId of visibleIds) {
|
|
@@ -1685,8 +2365,8 @@ export class Scene {
|
|
|
1685
2365
|
if (pieces) {
|
|
1686
2366
|
// Add all pieces for this element
|
|
1687
2367
|
for (const piece of pieces) {
|
|
1688
|
-
// Only include pieces that match this batch's color
|
|
1689
|
-
if (this.
|
|
2368
|
+
// Only include pieces that match this batch's cell + color
|
|
2369
|
+
if (this.bucketBaseKey(piece) === baseKey) {
|
|
1690
2370
|
visibleMeshData.push(piece);
|
|
1691
2371
|
}
|
|
1692
2372
|
}
|
|
@@ -1813,6 +2493,27 @@ export class Scene {
|
|
|
1813
2493
|
getTexturedMeshes() {
|
|
1814
2494
|
return this.texturedMeshes;
|
|
1815
2495
|
}
|
|
2496
|
+
/**
|
|
2497
|
+
* GPU bytes currently held by the scene's mesh collections (issue #1682
|
|
2498
|
+
* observability). Sums actual `GPUBuffer.size` values across colour batches
|
|
2499
|
+
* (streaming fragments are members of `batchedMeshes`, so they are counted
|
|
2500
|
+
* exactly once), cached partial sub-batches, hydrated individual meshes,
|
|
2501
|
+
* textured meshes (plus a 4 B/texel texture estimate) and instanced
|
|
2502
|
+
* templates. Instanced templates are counted even while hidden in the Types
|
|
2503
|
+
* view: hiding does not free their buffers. O(collections) walk with no GPU
|
|
2504
|
+
* calls, intended for on-demand telemetry, not per-frame use.
|
|
2505
|
+
*/
|
|
2506
|
+
getResidentGpuBytes() {
|
|
2507
|
+
return sumResidentGpuBytes({
|
|
2508
|
+
// Evicted batches are metadata shells — their destroyed buffers still
|
|
2509
|
+
// report .size, so they must be excluded from the resident sum.
|
|
2510
|
+
batches: this.batchedMeshes.filter((b) => b.gpuResident !== false),
|
|
2511
|
+
partialBatches: this.partialBatchCache.values(),
|
|
2512
|
+
meshes: this.meshes,
|
|
2513
|
+
textured: this.texturedMeshes,
|
|
2514
|
+
instanced: this.instancedTemplates,
|
|
2515
|
+
});
|
|
2516
|
+
}
|
|
1816
2517
|
/**
|
|
1817
2518
|
* Toggle the instanced draw pass. Instanced geometry is class-0 occurrences
|
|
1818
2519
|
* (the Model view); hide it in the Types view mode, where the flat path shows
|
|
@@ -1848,6 +2549,9 @@ export class Scene {
|
|
|
1848
2549
|
addInstancedShard(device, shard) {
|
|
1849
2550
|
this.instancedDevice = device; // cached for per-instance selection/overlay writeBuffer
|
|
1850
2551
|
const prepared = prepareInstancedRender(shard);
|
|
2552
|
+
// Selected ids whose occurrences arrived in THIS shard (selection recorded
|
|
2553
|
+
// before the shard streamed in) — their flags are written after upload.
|
|
2554
|
+
const lateSelectedEids = new Set();
|
|
1851
2555
|
for (const t of prepared) {
|
|
1852
2556
|
const vcount = Math.floor(t.positions.length / 3);
|
|
1853
2557
|
if (vcount === 0 || t.indices.length === 0 || t.instanceCount === 0)
|
|
@@ -1893,13 +2597,17 @@ export class Scene {
|
|
|
1893
2597
|
new Uint8Array(instanceBuffer.getMappedRange()).set(new Uint8Array(t.instanceBuffer, 0, instSize));
|
|
1894
2598
|
instanceBuffer.unmap();
|
|
1895
2599
|
const templateIndex = this.instancedTemplates.length;
|
|
1896
|
-
|
|
2600
|
+
const template = {
|
|
1897
2601
|
vertexBuffer,
|
|
1898
2602
|
indexBuffer,
|
|
1899
2603
|
indexCount: t.indices.length,
|
|
1900
2604
|
instanceBuffer,
|
|
1901
2605
|
instanceCount: t.instanceCount,
|
|
1902
|
-
|
|
2606
|
+
bounds: null,
|
|
2607
|
+
maxOccRadius: 0,
|
|
2608
|
+
selectedCount: 0,
|
|
2609
|
+
};
|
|
2610
|
+
this.instancedTemplates.push(template);
|
|
1903
2611
|
// Template-local AABB (used to derive per-occurrence world AABBs cheaply).
|
|
1904
2612
|
let lmnx = Infinity, lmny = Infinity, lmnz = Infinity;
|
|
1905
2613
|
let lmxx = -Infinity, lmxy = -Infinity, lmxz = -Infinity;
|
|
@@ -1953,11 +2661,31 @@ export class Scene {
|
|
|
1953
2661
|
this.instancedEntityMap.set(eid, arr);
|
|
1954
2662
|
}
|
|
1955
2663
|
arr.push({ templateIndex, byteOffset, originalColor });
|
|
2664
|
+
// A shard can stream in AFTER a selection was recorded (its ids may
|
|
2665
|
+
// exist in earlier shards or the flat path). setInstancedSelection
|
|
2666
|
+
// diffs by id and would early-return on the unchanged set, so seed the
|
|
2667
|
+
// late occurrences here: count them for the contribution-cull
|
|
2668
|
+
// exemption and remember the id to write its selected flag below.
|
|
2669
|
+
if (this.instancedSelected.has(eid)) {
|
|
2670
|
+
template.selectedCount++;
|
|
2671
|
+
lateSelectedEids.add(eid);
|
|
2672
|
+
}
|
|
1956
2673
|
if (haveBox) {
|
|
1957
|
-
this.unionInstancedWorldAabb(eid, cdv, byteOffset, lmnx, lmny, lmnz, lmxx, lmxy, lmxz);
|
|
2674
|
+
const w = this.unionInstancedWorldAabb(eid, cdv, byteOffset, lmnx, lmny, lmnz, lmxx, lmxy, lmxz);
|
|
2675
|
+
// Fold the occurrence's world box into the template's cull metadata
|
|
2676
|
+
// (union bounds + largest occurrence bounding-sphere radius) for the
|
|
2677
|
+
// per-frame instanced frustum/contribution culls. Non-finite boxes
|
|
2678
|
+
// poison the template so it fails OPEN (never culled).
|
|
2679
|
+
foldOccurrenceWorldBox(template, w);
|
|
1958
2680
|
}
|
|
1959
2681
|
}
|
|
1960
2682
|
}
|
|
2683
|
+
// Write the selected flag for ids whose occurrences arrived after the
|
|
2684
|
+
// selection was recorded (idempotent for their pre-existing occurrences),
|
|
2685
|
+
// so the highlight shows on late-streamed geometry too.
|
|
2686
|
+
for (const eid of lateSelectedEids) {
|
|
2687
|
+
this.writeInstanceFlags(device, eid);
|
|
2688
|
+
}
|
|
1961
2689
|
// New occurrences default to flags=0 (visible). Force the next setInstancedVisibility
|
|
1962
2690
|
// to recompute so an already-active isolate/hide also applies to geometry that
|
|
1963
2691
|
// streamed in after the visibility was set.
|
|
@@ -1965,7 +2693,8 @@ export class Scene {
|
|
|
1965
2693
|
}
|
|
1966
2694
|
/** Transform a template's local AABB by an occurrence's column-major mat4 (read
|
|
1967
2695
|
* from the packed instance record at `matOffset`) and union the world box into
|
|
1968
|
-
* boundingBoxes[eid].
|
|
2696
|
+
* boundingBoxes[eid]. Returns the occurrence's world box so the caller can also
|
|
2697
|
+
* fold it into the template's cull metadata. */
|
|
1969
2698
|
unionInstancedWorldAabb(eid, dv, matOffset, lmnx, lmny, lmnz, lmxx, lmxy, lmxz) {
|
|
1970
2699
|
const m0 = dv.getFloat32(matOffset + 0, true), m1 = dv.getFloat32(matOffset + 4, true), m2 = dv.getFloat32(matOffset + 8, true);
|
|
1971
2700
|
const m4 = dv.getFloat32(matOffset + 16, true), m5 = dv.getFloat32(matOffset + 20, true), m6 = dv.getFloat32(matOffset + 24, true);
|
|
@@ -2002,6 +2731,7 @@ export class Scene {
|
|
|
2002
2731
|
else {
|
|
2003
2732
|
this.boundingBoxes.set(eid, { min: { x: minX, y: minY, z: minZ }, max: { x: maxX, y: maxY, z: maxZ } });
|
|
2004
2733
|
}
|
|
2734
|
+
return { minX, minY, minZ, maxX, maxY, maxZ };
|
|
2005
2735
|
}
|
|
2006
2736
|
/** True if `expressId` is a GPU-instanced occurrence (lives only in the instanced
|
|
2007
2737
|
* shard, not the flat meshDataMap). CPU consumers use this to decide whether to
|
|
@@ -2014,6 +2744,12 @@ export class Scene {
|
|
|
2014
2744
|
getInstancedEntityIds() {
|
|
2015
2745
|
return this.instancedEntityMap.keys();
|
|
2016
2746
|
}
|
|
2747
|
+
/** Number of distinct GPU-instanced entities. O(1) — for size heuristics
|
|
2748
|
+
* (e.g. the orbit-pivot raycast skip) that must not miss instanced-heavy
|
|
2749
|
+
* models where the flat mesh/batch census reads deceptively small. */
|
|
2750
|
+
getInstancedEntityCount() {
|
|
2751
|
+
return this.instancedEntityMap.size;
|
|
2752
|
+
}
|
|
2017
2753
|
/** Materialize EVERY instanced occurrence as world-space MeshData. Transient + not
|
|
2018
2754
|
* retained — for one-shot full-geometry consumers (glTF / IFC5 export) that must
|
|
2019
2755
|
* include the instanced occurrences absent from geometryResult.meshes. Returns []
|
|
@@ -2118,12 +2854,29 @@ export class Scene {
|
|
|
2118
2854
|
const prev = this.instancedSelected;
|
|
2119
2855
|
this.instancedSelected = new Set(expressIds);
|
|
2120
2856
|
for (const eid of prev) {
|
|
2121
|
-
if (!expressIds.has(eid))
|
|
2857
|
+
if (!expressIds.has(eid)) {
|
|
2122
2858
|
this.writeInstanceFlags(device, eid);
|
|
2859
|
+
this.bumpTemplateSelectedCount(eid, -1);
|
|
2860
|
+
}
|
|
2123
2861
|
}
|
|
2124
2862
|
for (const eid of expressIds) {
|
|
2125
|
-
if (!prev.has(eid))
|
|
2863
|
+
if (!prev.has(eid)) {
|
|
2126
2864
|
this.writeInstanceFlags(device, eid);
|
|
2865
|
+
this.bumpTemplateSelectedCount(eid, +1);
|
|
2866
|
+
}
|
|
2867
|
+
}
|
|
2868
|
+
}
|
|
2869
|
+
/** Keep each template's selectedCount in sync with selection flips so the
|
|
2870
|
+
* render loop can exempt templates with selected occurrences from
|
|
2871
|
+
* contribution culling (the highlight must not vanish on the user's focus). */
|
|
2872
|
+
bumpTemplateSelectedCount(eid, delta) {
|
|
2873
|
+
const occurrences = this.instancedEntityMap.get(eid);
|
|
2874
|
+
if (!occurrences)
|
|
2875
|
+
return;
|
|
2876
|
+
for (const occ of occurrences) {
|
|
2877
|
+
const t = this.instancedTemplates[occ.templateIndex];
|
|
2878
|
+
if (t)
|
|
2879
|
+
t.selectedCount = Math.max(0, t.selectedCount + delta);
|
|
2127
2880
|
}
|
|
2128
2881
|
}
|
|
2129
2882
|
/**
|
|
@@ -2388,6 +3141,10 @@ export class Scene {
|
|
|
2388
3141
|
this.meshDataMap.clear();
|
|
2389
3142
|
this.boundingBoxes.clear();
|
|
2390
3143
|
this.activeBucketKey.clear();
|
|
3144
|
+
this.lastDrawnFrame.clear();
|
|
3145
|
+
this.residencyRestoreQueue.clear();
|
|
3146
|
+
this.coldBuckets.clear();
|
|
3147
|
+
this.dirtyBuckets.clear();
|
|
2391
3148
|
this.cachedMaxBufferSize = 0;
|
|
2392
3149
|
this.pendingBatchKeys.clear();
|
|
2393
3150
|
this.partialBatchCache.clear();
|