@ifc-lite/renderer 1.14.4 → 1.14.6

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/scene.js CHANGED
@@ -8,20 +8,16 @@ let warnedEntityIdRange = false;
8
8
  export class Scene {
9
9
  meshes = [];
10
10
  instancedMeshes = [];
11
- batchedMeshes = [];
12
- batchedMeshMap = new Map(); // Map bucketKey -> BatchedMesh
13
- batchedMeshData = new Map(); // Map bucketKey -> accumulated MeshData[]
14
- batchedMeshIndex = new Map(); // Map bucketKey -> index in batchedMeshes array (O(1) lookup)
11
+ batchedMeshes = []; // flat render array (rebuilt from buckets)
12
+ buckets = new Map(); // bucketKey -> consolidated bucket state
13
+ meshDataBucket = new Map(); // reverse lookup: MeshData -> owning bucket
15
14
  meshDataMap = new Map(); // Map expressId -> MeshData[] (for lazy buffer creation, accumulates multiple pieces)
16
- meshDataBatchKey = new Map(); // Reverse lookup: MeshData -> bucketKey (O(1) removal in updateMeshColors)
17
- meshDataBatchIdx = new Map(); // Reverse lookup: MeshData -> index in bucket array (O(1) removal)
18
15
  boundingBoxes = new Map(); // Map expressId -> bounding box (computed lazily)
19
16
  // Buffer-size-aware bucket splitting: when a single color group's geometry
20
17
  // would exceed the GPU maxBufferSize, overflow is directed to a new
21
18
  // sub-bucket with a suffixed key (e.g. "500|500|500|1000#1"). This keeps
22
19
  // all downstream maps single-valued and the rendering code unchanged.
23
20
  activeBucketKey = new Map(); // base colorKey -> current active bucket key
24
- bucketVertexBytes = new Map(); // bucket key -> accumulated vertex buffer bytes
25
21
  nextSplitId = 0; // Monotonic counter for sub-bucket keys
26
22
  cachedMaxBufferSize = 0; // device.limits.maxBufferSize * safety factor (set on first use)
27
23
  // Sub-batch cache for partially visible batches (PERFORMANCE FIX)
@@ -39,6 +35,11 @@ export class Scene {
39
35
  // Temporary fragment batches created during streaming for immediate rendering.
40
36
  // Destroyed and replaced by proper merged batches in finalizeStreaming().
41
37
  streamingFragments = [];
38
+ // ─── Mesh command queue ────────────────────────────────────────────
39
+ // Decouples React state updates from GPU work. Callers push meshes
40
+ // via queueMeshes() (instant, no GPU), and the animation loop drains
41
+ // the queue via flushPending() with a per-frame time budget.
42
+ meshQueue = [];
42
43
  // ─── GPU-resident mode ──────────────────────────────────────────────
43
44
  // After releaseGeometryData(), JS-side typed arrays are freed.
44
45
  // Only lightweight metadata is retained for operations that don't need
@@ -222,15 +223,14 @@ export class Scene {
222
223
  const baseKey = this.colorKey(meshData.color);
223
224
  const bucketKey = this.resolveActiveBucket(baseKey, meshData);
224
225
  // Accumulate mesh data in the bucket (always — needed for final merge)
225
- let bucket = this.batchedMeshData.get(bucketKey);
226
+ let bucket = this.buckets.get(bucketKey);
226
227
  if (!bucket) {
227
- bucket = [];
228
- this.batchedMeshData.set(bucketKey, bucket);
228
+ bucket = { key: bucketKey, meshData: [], batchedMesh: null, vertexBytes: 0 };
229
+ this.buckets.set(bucketKey, bucket);
229
230
  }
230
- this.meshDataBatchIdx.set(meshData, bucket.length);
231
- bucket.push(meshData);
232
- // Track reverse mapping for O(1) batch removal in updateMeshColors
233
- this.meshDataBatchKey.set(meshData, bucketKey);
231
+ bucket.meshData.push(meshData);
232
+ // Track reverse mapping for O(1) bucket lookup in updateMeshColors
233
+ this.meshDataBucket.set(meshData, bucket);
234
234
  // Also store individual mesh data for visibility filtering
235
235
  this.addMeshData(meshData);
236
236
  // Track pending keys for non-streaming rebuild only
@@ -259,48 +259,31 @@ export class Scene {
259
259
  if (this.pendingBatchKeys.size === 0)
260
260
  return;
261
261
  for (const key of this.pendingBatchKeys) {
262
- const meshDataForKey = this.batchedMeshData.get(key);
263
- const existingBatch = this.batchedMeshMap.get(key);
264
- if (existingBatch) {
265
- // Destroy old batch buffers
266
- existingBatch.vertexBuffer.destroy();
267
- existingBatch.indexBuffer.destroy();
268
- if (existingBatch.uniformBuffer) {
269
- existingBatch.uniformBuffer.destroy();
262
+ const bucket = this.buckets.get(key);
263
+ // Destroy old GPU batch if it exists
264
+ if (bucket?.batchedMesh) {
265
+ bucket.batchedMesh.vertexBuffer.destroy();
266
+ bucket.batchedMesh.indexBuffer.destroy();
267
+ if (bucket.batchedMesh.uniformBuffer) {
268
+ bucket.batchedMesh.uniformBuffer.destroy();
270
269
  }
270
+ bucket.batchedMesh = null;
271
271
  }
272
- if (!meshDataForKey || meshDataForKey.length === 0) {
272
+ if (!bucket || bucket.meshData.length === 0) {
273
273
  // Bucket is empty — clean up
274
- this.batchedMeshMap.delete(key);
275
- this.batchedMeshData.delete(key);
276
- this.bucketVertexBytes.delete(key);
277
- // Swap-remove from flat array using O(1) index lookup
278
- const arrayIdx = this.batchedMeshIndex.get(key);
279
- if (arrayIdx !== undefined) {
280
- const lastIdx = this.batchedMeshes.length - 1;
281
- if (arrayIdx !== lastIdx) {
282
- const lastBatch = this.batchedMeshes[lastIdx];
283
- this.batchedMeshes[arrayIdx] = lastBatch;
284
- this.batchedMeshIndex.set(lastBatch.colorKey, arrayIdx);
285
- }
286
- this.batchedMeshes.pop();
287
- this.batchedMeshIndex.delete(key);
288
- }
274
+ this.buckets.delete(key);
289
275
  continue;
290
276
  }
291
277
  // Create new batch with all accumulated meshes for this bucket
292
- const color = meshDataForKey[0].color;
293
- const batchedMesh = this.createBatchedMesh(meshDataForKey, color, device, pipeline, key);
294
- this.batchedMeshMap.set(key, batchedMesh);
295
- // Update array using O(1) index lookup instead of O(N) findIndex
296
- const existingIndex = this.batchedMeshIndex.get(key);
297
- if (existingIndex !== undefined) {
298
- this.batchedMeshes[existingIndex] = batchedMesh;
299
- }
300
- else {
301
- const newIndex = this.batchedMeshes.length;
302
- this.batchedMeshes.push(batchedMesh);
303
- this.batchedMeshIndex.set(key, newIndex);
278
+ const color = bucket.meshData[0].color;
279
+ const batchedMesh = this.createBatchedMesh(bucket.meshData, color, device, pipeline, key);
280
+ bucket.batchedMesh = batchedMesh;
281
+ }
282
+ // Rebuild the flat render array from all buckets (148 max batches — not perf critical)
283
+ this.batchedMeshes = [];
284
+ for (const bucket of this.buckets.values()) {
285
+ if (bucket.batchedMesh) {
286
+ this.batchedMeshes.push(bucket.batchedMesh);
304
287
  }
305
288
  }
306
289
  this.pendingBatchKeys.clear();
@@ -311,6 +294,39 @@ export class Scene {
311
294
  hasPendingBatches() {
312
295
  return this.pendingBatchKeys.size > 0;
313
296
  }
297
+ // ─── Mesh command queue ──────────────────────────────────────────────
298
+ /**
299
+ * Queue meshes for deferred GPU upload.
300
+ * Instant (no GPU work) — safe to call from React effects.
301
+ * The animation loop calls flushPending() each frame to drain the queue.
302
+ */
303
+ queueMeshes(meshes) {
304
+ for (let i = 0; i < meshes.length; i++) {
305
+ this.meshQueue.push(meshes[i]);
306
+ }
307
+ }
308
+ /** True if the mesh queue has pending work. */
309
+ hasQueuedMeshes() {
310
+ return this.meshQueue.length > 0;
311
+ }
312
+ /**
313
+ * Drain the mesh queue with a per-frame time budget.
314
+ * Processes queued meshes through appendToBatches in streaming mode
315
+ * (creates lightweight fragment batches for immediate rendering).
316
+ *
317
+ * @returns true if any meshes were processed (caller should render)
318
+ */
319
+ flushPending(device, pipeline) {
320
+ if (this.meshQueue.length === 0)
321
+ return false;
322
+ // Drain the entire queue in one appendToBatches call.
323
+ // The queue coalesces multiple React batches into a single GPU upload,
324
+ // which is already bounded by the WASM→JS batch interval (~50-200ms).
325
+ const meshes = this.meshQueue;
326
+ this.meshQueue = [];
327
+ this.appendToBatches(meshes, device, pipeline, true);
328
+ return true;
329
+ }
314
330
  /**
315
331
  * Create lightweight fragment batches from a single streaming batch.
316
332
  * Fragments are grouped by color and added to batchedMeshes for immediate
@@ -355,18 +371,63 @@ export class Scene {
355
371
  finalizeStreaming(device, pipeline) {
356
372
  if (this.streamingFragments.length === 0)
357
373
  return;
358
- // 1. Destroy all fragment GPU resources
359
- const fragmentSet = new Set(this.streamingFragments);
360
- for (const fragment of this.streamingFragments) {
374
+ // Save references to old fragments/batches — keep them rendering
375
+ // until the new proper batches are fully built (no visual gap).
376
+ const oldFragments = this.streamingFragments;
377
+ const oldBatches = this.batchedMeshes;
378
+ const fragmentSet = new Set(oldFragments);
379
+ this.streamingFragments = [];
380
+ // 1. Collect ALL accumulated meshData before clearing state
381
+ const allMeshData = [];
382
+ for (const bucket of this.buckets.values()) {
383
+ for (const md of bucket.meshData)
384
+ allMeshData.push(md);
385
+ }
386
+ // 2. Clear all bucket/batch state for a clean rebuild
387
+ // NOTE: batchedMeshes keeps the OLD array reference — the renderer
388
+ // continues to draw from it until we swap in the new array below.
389
+ this.buckets.clear();
390
+ this.meshDataBucket = new Map();
391
+ this.activeBucketKey.clear();
392
+ this.pendingBatchKeys.clear();
393
+ // Destroy cached partial batches — their colorKeys are now stale
394
+ for (const batch of this.partialBatchCache.values()) {
395
+ batch.vertexBuffer.destroy();
396
+ batch.indexBuffer.destroy();
397
+ if (batch.uniformBuffer) {
398
+ batch.uniformBuffer.destroy();
399
+ }
400
+ }
401
+ this.partialBatchCache.clear();
402
+ this.partialBatchCacheKeys.clear();
403
+ // 3. Re-group ALL meshData by their CURRENT color.
404
+ // meshData.color may have been mutated in-place since the mesh was
405
+ // first bucketed, so the original bucket key is stale. Re-grouping
406
+ // by current color ensures batches render with correct colors.
407
+ for (const meshData of allMeshData) {
408
+ const baseKey = this.colorKey(meshData.color);
409
+ const bucketKey = this.resolveActiveBucket(baseKey, meshData);
410
+ let bucket = this.buckets.get(bucketKey);
411
+ if (!bucket) {
412
+ bucket = { key: bucketKey, meshData: [], batchedMesh: null, vertexBytes: 0 };
413
+ this.buckets.set(bucketKey, bucket);
414
+ }
415
+ bucket.meshData.push(meshData);
416
+ this.meshDataBucket.set(meshData, bucket);
417
+ this.pendingBatchKeys.add(bucketKey);
418
+ }
419
+ // 4. Build new proper batches into a fresh array
420
+ this.batchedMeshes = [];
421
+ this.rebuildPendingBatches(device, pipeline);
422
+ // 5. NOW destroy old fragment/batch GPU resources (new batches are live)
423
+ for (const fragment of oldFragments) {
361
424
  fragment.vertexBuffer.destroy();
362
425
  fragment.indexBuffer.destroy();
363
426
  if (fragment.uniformBuffer) {
364
427
  fragment.uniformBuffer.destroy();
365
428
  }
366
429
  }
367
- this.streamingFragments = [];
368
- // 2. Destroy any pre-existing proper batches (non-fragments)
369
- for (const batch of this.batchedMeshes) {
430
+ for (const batch of oldBatches) {
370
431
  if (!fragmentSet.has(batch)) {
371
432
  batch.vertexBuffer.destroy();
372
433
  batch.indexBuffer.destroy();
@@ -375,51 +436,108 @@ export class Scene {
375
436
  }
376
437
  }
377
438
  }
378
- // 3. Collect ALL accumulated meshData before clearing state
439
+ }
440
+ /**
441
+ * Time-sliced version of finalizeStreaming.
442
+ * Re-groups mesh data and rebuilds GPU batches in small chunks,
443
+ * yielding to the event loop between chunks so orbit/pan stays responsive.
444
+ * Streaming fragments continue rendering until each new batch replaces them.
445
+ *
446
+ * @param device GPU device
447
+ * @param pipeline Render pipeline
448
+ * @param budgetMs Max milliseconds per chunk (default 8 — half a 60fps frame)
449
+ * @returns Promise that resolves when all batches are rebuilt
450
+ */
451
+ finalizeStreamingAsync(device, pipeline, budgetMs = 8) {
452
+ if (this.streamingFragments.length === 0)
453
+ return Promise.resolve();
454
+ // --- Synchronous preamble (fast O(N) bookkeeping) ---
455
+ const oldFragments = this.streamingFragments;
456
+ const oldBatches = this.batchedMeshes;
457
+ const fragmentSet = new Set(oldFragments);
458
+ this.streamingFragments = [];
459
+ // 1. Collect ALL accumulated meshData
379
460
  const allMeshData = [];
380
- for (const data of this.batchedMeshData.values()) {
381
- for (const md of data)
461
+ for (const bucket of this.buckets.values()) {
462
+ for (const md of bucket.meshData)
382
463
  allMeshData.push(md);
383
464
  }
384
- // 4. Clear all bucket/batch state for a clean rebuild
385
- this.batchedMeshes = [];
386
- this.batchedMeshMap.clear();
387
- this.batchedMeshIndex.clear();
388
- this.batchedMeshData.clear();
389
- this.meshDataBatchKey.clear();
390
- this.meshDataBatchIdx.clear();
465
+ // 2. Clear bucket/batch state
466
+ this.buckets.clear();
467
+ this.meshDataBucket = new Map();
391
468
  this.activeBucketKey.clear();
392
- this.bucketVertexBytes.clear();
393
469
  this.pendingBatchKeys.clear();
394
- // Destroy cached partial batches — their colorKeys are now stale
395
470
  for (const batch of this.partialBatchCache.values()) {
396
471
  batch.vertexBuffer.destroy();
397
472
  batch.indexBuffer.destroy();
398
- if (batch.uniformBuffer) {
473
+ if (batch.uniformBuffer)
399
474
  batch.uniformBuffer.destroy();
400
- }
401
475
  }
402
476
  this.partialBatchCache.clear();
403
477
  this.partialBatchCacheKeys.clear();
404
- // 5. Re-group ALL meshData by their CURRENT color.
405
- // meshData.color may have been mutated in-place since the mesh was
406
- // first bucketed, so the original bucket key is stale. Re-grouping
407
- // by current color ensures batches render with correct colors.
478
+ // 3. Re-group meshData by current color (fast)
408
479
  for (const meshData of allMeshData) {
409
480
  const baseKey = this.colorKey(meshData.color);
410
481
  const bucketKey = this.resolveActiveBucket(baseKey, meshData);
411
- let bucket = this.batchedMeshData.get(bucketKey);
482
+ let bucket = this.buckets.get(bucketKey);
412
483
  if (!bucket) {
413
- bucket = [];
414
- this.batchedMeshData.set(bucketKey, bucket);
484
+ bucket = { key: bucketKey, meshData: [], batchedMesh: null, vertexBytes: 0 };
485
+ this.buckets.set(bucketKey, bucket);
415
486
  }
416
- this.meshDataBatchIdx.set(meshData, bucket.length);
417
- bucket.push(meshData);
418
- this.meshDataBatchKey.set(meshData, bucketKey);
487
+ bucket.meshData.push(meshData);
488
+ this.meshDataBucket.set(meshData, bucket);
419
489
  this.pendingBatchKeys.add(bucketKey);
420
490
  }
421
- // 6. One O(N) full rebuild from correctly-grouped data
422
- this.rebuildPendingBatches(device, pipeline);
491
+ // Build new batches into a temporary array so the old batchedMeshes
492
+ // (streaming fragments) keep rendering until the swap is complete.
493
+ const newBatches = [];
494
+ const pendingKeys = Array.from(this.pendingBatchKeys);
495
+ this.pendingBatchKeys.clear();
496
+ // --- Async: rebuild batches in time-sliced chunks ---
497
+ let keyIdx = 0;
498
+ const scene = this;
499
+ return new Promise((resolve) => {
500
+ function processChunk() {
501
+ const chunkStart = performance.now();
502
+ while (keyIdx < pendingKeys.length) {
503
+ const key = pendingKeys[keyIdx++];
504
+ const bucket = scene.buckets.get(key);
505
+ if (!bucket || bucket.meshData.length === 0) {
506
+ scene.buckets.delete(key);
507
+ continue;
508
+ }
509
+ const color = bucket.meshData[0].color;
510
+ const batchedMesh = scene.createBatchedMesh(bucket.meshData, color, device, pipeline, key);
511
+ bucket.batchedMesh = batchedMesh;
512
+ newBatches.push(batchedMesh);
513
+ // Check time budget — yield if exceeded
514
+ if (performance.now() - chunkStart >= budgetMs) {
515
+ setTimeout(processChunk, 0);
516
+ return;
517
+ }
518
+ }
519
+ // All batches built — atomic swap so renderer never sees an empty array
520
+ scene.batchedMeshes = newBatches;
521
+ // Destroy old fragment/batch GPU resources
522
+ for (const fragment of oldFragments) {
523
+ fragment.vertexBuffer.destroy();
524
+ fragment.indexBuffer.destroy();
525
+ if (fragment.uniformBuffer)
526
+ fragment.uniformBuffer.destroy();
527
+ }
528
+ for (const batch of oldBatches) {
529
+ if (!fragmentSet.has(batch)) {
530
+ batch.vertexBuffer.destroy();
531
+ batch.indexBuffer.destroy();
532
+ if (batch.uniformBuffer)
533
+ batch.uniformBuffer.destroy();
534
+ }
535
+ }
536
+ resolve();
537
+ }
538
+ // Start first chunk immediately (no setTimeout delay)
539
+ processChunk();
540
+ });
423
541
  }
424
542
  /**
425
543
  * Release JS-side mesh geometry data (positions, normals, indices) after
@@ -428,7 +546,7 @@ export class Scene {
428
546
  *
429
547
  * After calling this method:
430
548
  * - Bounding boxes are precomputed and cached for all entities
431
- * - meshDataMap and batchedMeshData are cleared (typed arrays become GC-eligible)
549
+ * - meshDataMap and bucket meshData arrays are cleared (typed arrays become GC-eligible)
432
550
  * - Color updates (updateMeshColors) are no longer available
433
551
  * - Partial batch creation and color overlays are no longer available
434
552
  * - CPU raycasting falls back to bounding-box-only (no triangle intersection)
@@ -479,10 +597,13 @@ export class Scene {
479
597
  }
480
598
  // 2. Clear the heavy data structures — typed arrays become GC-eligible
481
599
  this.meshDataMap.clear();
482
- this.batchedMeshData.clear();
483
- this.meshDataBatchKey = new Map();
600
+ // Clear meshData arrays in each bucket (typed arrays become GC-eligible)
601
+ // but keep the bucket shells so batchedMesh references remain valid
602
+ for (const bucket of this.buckets.values()) {
603
+ bucket.meshData = [];
604
+ }
605
+ this.meshDataBucket = new Map();
484
606
  this.activeBucketKey.clear();
485
- this.bucketVertexBytes.clear();
486
607
  // 3. Clear partial batch cache (would need mesh data to rebuild)
487
608
  for (const batch of this.partialBatchCache.values()) {
488
609
  batch.vertexBuffer.destroy();
@@ -506,7 +627,7 @@ export class Scene {
506
627
  * Update colors for existing meshes and rebuild affected batches
507
628
  * Call this when deferred color parsing completes
508
629
  *
509
- * OPTIMIZATION: Uses meshDataBatchKey reverse-map for O(1) batch removal
630
+ * OPTIMIZATION: Uses meshDataBucket reverse-map for O(1) batch lookup
510
631
  * instead of O(N) indexOf scan per mesh. Critical for bulk IDS validation updates.
511
632
  */
512
633
  updateMeshColors(updates, device, pipeline) {
@@ -529,8 +650,9 @@ export class Scene {
529
650
  continue;
530
651
  const newBaseKey = this.colorKey(newColor);
531
652
  for (const meshData of meshDataList) {
532
- // Use reverse-map for O(1) old bucket key lookup
533
- const oldBucketKey = this.meshDataBatchKey.get(meshData) ?? this.colorKey(meshData.color);
653
+ // Use reverse-map for O(1) old bucket lookup
654
+ const oldBucket = this.meshDataBucket.get(meshData);
655
+ const oldBucketKey = oldBucket?.key ?? this.colorKey(meshData.color);
534
656
  // Derive old color from bucket key, NOT meshData.color.
535
657
  // meshData.color may have been mutated in-place by external code
536
658
  // (applyColorUpdatesToMeshes), making it unreliable for change detection.
@@ -540,40 +662,37 @@ export class Scene {
540
662
  const newBucketKey = this.resolveActiveBucket(newBaseKey, meshData);
541
663
  affectedOldKeys.add(oldBucketKey);
542
664
  affectedNewKeys.add(newBucketKey);
543
- // Remove from old bucket data using swap-remove for O(1)
544
- const oldBatchData = this.batchedMeshData.get(oldBucketKey);
545
- if (oldBatchData) {
546
- const idx = this.meshDataBatchIdx.get(meshData);
547
- if (idx !== undefined && idx < oldBatchData.length && oldBatchData[idx] === meshData) {
548
- const last = oldBatchData.length - 1;
665
+ // Remove from old bucket data using indexOf (O(N) within one color bucket, typically <100 items)
666
+ if (oldBucket) {
667
+ const idx = oldBucket.meshData.indexOf(meshData);
668
+ if (idx >= 0) {
669
+ // Swap-remove for O(1)
670
+ const last = oldBucket.meshData.length - 1;
549
671
  if (idx !== last) {
550
- const swapped = oldBatchData[last];
551
- oldBatchData[idx] = swapped;
552
- this.meshDataBatchIdx.set(swapped, idx);
672
+ oldBucket.meshData[idx] = oldBucket.meshData[last];
553
673
  }
554
- oldBatchData.pop();
555
- this.meshDataBatchIdx.delete(meshData);
674
+ oldBucket.meshData.pop();
556
675
  }
557
- if (oldBatchData.length === 0) {
558
- this.batchedMeshData.delete(oldBucketKey);
676
+ if (oldBucket.meshData.length === 0) {
677
+ this.buckets.delete(oldBucketKey);
559
678
  }
560
679
  }
561
680
  // Decrease old bucket size tracking
562
681
  const meshBytes = (meshData.positions.length / 3) * BATCH_CONSTANTS.BYTES_PER_VERTEX;
563
- const oldSize = this.bucketVertexBytes.get(oldBucketKey) ?? 0;
564
- this.bucketVertexBytes.set(oldBucketKey, Math.max(0, oldSize - meshBytes));
682
+ if (oldBucket) {
683
+ oldBucket.vertexBytes = Math.max(0, oldBucket.vertexBytes - meshBytes);
684
+ }
565
685
  // Update mesh color
566
686
  meshData.color = newColor;
567
687
  // Add to new bucket data (resolveActiveBucket already updated size tracking)
568
- let newBucket = this.batchedMeshData.get(newBucketKey);
688
+ let newBucket = this.buckets.get(newBucketKey);
569
689
  if (!newBucket) {
570
- newBucket = [];
571
- this.batchedMeshData.set(newBucketKey, newBucket);
690
+ newBucket = { key: newBucketKey, meshData: [], batchedMesh: null, vertexBytes: 0 };
691
+ this.buckets.set(newBucketKey, newBucket);
572
692
  }
573
- this.meshDataBatchIdx.set(meshData, newBucket.length);
574
- newBucket.push(meshData);
693
+ newBucket.meshData.push(meshData);
575
694
  // Update reverse mapping
576
- this.meshDataBatchKey.set(meshData, newBucketKey);
695
+ this.meshDataBucket.set(meshData, newBucket);
577
696
  }
578
697
  }
579
698
  }
@@ -594,7 +713,7 @@ export class Scene {
594
713
  * Create a new batched mesh from mesh data array.
595
714
  * @param bucketKey - Optional unique key for this batch. When omitted the
596
715
  * base color key is used (fine for overlay / partial batches that don't
597
- * participate in the main batchedMeshMap).
716
+ * participate in the main buckets map).
598
717
  */
599
718
  createBatchedMesh(meshDataArray, color, device, pipeline, bucketKey) {
600
719
  const merged = this.mergeGeometry(meshDataArray);
@@ -782,15 +901,21 @@ export class Scene {
782
901
  */
783
902
  resolveActiveBucket(baseColorKey, meshData) {
784
903
  let bucketKey = this.activeBucketKey.get(baseColorKey) ?? baseColorKey;
785
- const currentBytes = this.bucketVertexBytes.get(bucketKey) ?? 0;
904
+ const bucket = this.buckets.get(bucketKey);
905
+ const currentBytes = bucket?.vertexBytes ?? 0;
786
906
  const meshBytes = (meshData.positions.length / 3) * BATCH_CONSTANTS.BYTES_PER_VERTEX;
787
907
  if (currentBytes > 0 && currentBytes + meshBytes > this.cachedMaxBufferSize) {
788
908
  // Overflow — create a new sub-bucket
789
909
  bucketKey = `${baseColorKey}#${this.nextSplitId++}`;
790
910
  this.activeBucketKey.set(baseColorKey, bucketKey);
791
911
  }
792
- // Update size tracking
793
- this.bucketVertexBytes.set(bucketKey, (this.bucketVertexBytes.get(bucketKey) ?? 0) + meshBytes);
912
+ // Update size tracking on the bucket (create if needed)
913
+ let targetBucket = this.buckets.get(bucketKey);
914
+ if (!targetBucket) {
915
+ targetBucket = { key: bucketKey, meshData: [], batchedMesh: null, vertexBytes: 0 };
916
+ this.buckets.set(bucketKey, targetBucket);
917
+ }
918
+ targetBucket.vertexBytes += meshBytes;
794
919
  return bucketKey;
795
920
  }
796
921
  /**
@@ -1009,19 +1134,16 @@ export class Scene {
1009
1134
  this.meshes = [];
1010
1135
  this.instancedMeshes = [];
1011
1136
  this.batchedMeshes = [];
1012
- this.batchedMeshMap.clear();
1013
- this.batchedMeshData.clear();
1014
- this.batchedMeshIndex.clear();
1137
+ this.buckets.clear();
1138
+ this.meshDataBucket = new Map();
1015
1139
  this.meshDataMap.clear();
1016
- this.meshDataBatchKey.clear();
1017
- this.meshDataBatchIdx.clear();
1018
1140
  this.boundingBoxes.clear();
1019
1141
  this.activeBucketKey.clear();
1020
- this.bucketVertexBytes.clear();
1021
1142
  this.cachedMaxBufferSize = 0;
1022
1143
  this.pendingBatchKeys.clear();
1023
1144
  this.partialBatchCache.clear();
1024
1145
  this.partialBatchCacheKeys.clear();
1146
+ this.meshQueue = [];
1025
1147
  this.geometryReleased = false;
1026
1148
  }
1027
1149
  /**