minecraft-renderer 0.1.65 → 0.1.67

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minecraft-renderer",
3
- "version": "0.1.65",
3
+ "version": "0.1.67",
4
4
  "description": "The most Modular Minecraft world renderer with Three.js WebGL backend",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -3,9 +3,16 @@ import * as THREE from 'three'
3
3
  import { VERTICES_PER_FACE } from './shaders/cubeBlockShader'
4
4
  import { packWord2Empty } from '../wasm-mesher/bridge/shaderCubeBridge'
5
5
 
6
- const INITIAL_CAPACITY_FACES = 2_000_000
6
+ // Linear growth (NOT doubling) to keep iOS allocation spikes bounded to one increment.
7
+ // Reference: prismarine-web-client PR #90 (webgl) and #120 (webgpu) both grow by +1M faces.
8
+ const INITIAL_CAPACITY_FACES = 512_000 // ~8 MB up front (4 words × 4 B), well under 1M
9
+ const GROWTH_INCREMENT_FACES = 1_000_000 // +16 MB per growth step instead of doubling
10
+ const MAX_UPLOAD_FACES_PER_FRAME = 15_000 // face-indexed budget (chunksStorage uses 10k blocks)
11
+ const FRAGMENTATION_THRESHOLD = 0.25
7
12
  const EMPTY_W2 = packWord2Empty()
8
13
 
14
+ type PendingMove = { key: string, oldStart: number, newStart: number, count: number }
15
+
9
16
  export type GlobalBlockBufferShaderData = {
10
17
  words: Uint32Array
11
18
  count: number
@@ -26,8 +33,8 @@ export class GlobalBlockBuffer {
26
33
  private readonly sectionSlots = new Map<string, { start: number, count: number }>()
27
34
  private freeList: Array<{ start: number, count: number }> = []
28
35
  private highWatermark = 0
29
- private dirtyMin = Infinity
30
- private dirtyMax = -1
36
+ private pendingRanges: Array<{ start: number, end: number }> = []
37
+ private pendingMove: PendingMove | null = null
31
38
 
32
39
  constructor (
33
40
  material: THREE.ShaderMaterial,
@@ -38,6 +45,7 @@ export class GlobalBlockBuffer {
38
45
  this.w1 = new Uint32Array(this.capacityFaces)
39
46
  this.w2 = new Uint32Array(this.capacityFaces)
40
47
  this.w3 = new Uint32Array(this.capacityFaces)
48
+ this.w2.fill(EMPTY_W2)
41
49
 
42
50
  const geometry = new THREE.InstancedBufferGeometry()
43
51
  const positions = new Float32Array(VERTICES_PER_FACE * 3)
@@ -138,6 +146,19 @@ export class GlobalBlockBuffer {
138
146
  const slot = this.sectionSlots.get(sectionKey)
139
147
  if (!slot) return
140
148
 
149
+ if (this.pendingMove?.key === sectionKey) {
150
+ const { oldStart, count } = this.pendingMove
151
+ for (let i = oldStart; i < oldStart + count; i++) {
152
+ this.w0[i] = 0
153
+ this.w1[i] = 0
154
+ this.w2[i] = EMPTY_W2
155
+ this.w3[i] = 0
156
+ }
157
+ this.markDirty(oldStart, oldStart + count - 1)
158
+ this.insertFreeSlot({ start: oldStart, count })
159
+ this.pendingMove = null
160
+ }
161
+
141
162
  for (let i = slot.start; i < slot.start + slot.count; i++) {
142
163
  this.w0[i] = 0
143
164
  this.w1[i] = 0
@@ -152,11 +173,42 @@ export class GlobalBlockBuffer {
152
173
  this.mesh.geometry.instanceCount = this.highWatermark
153
174
  }
154
175
 
176
+ /** One interior-hole move per frame when fragmentation exceeds threshold; deferred shrink. */
177
+ compactStep (): void {
178
+ if (this.pendingMove) {
179
+ const { newStart, count } = this.pendingMove
180
+ if (this.rangeFullyUploaded(newStart, newStart + count - 1)) {
181
+ this.finalizePendingMove()
182
+ }
183
+ return
184
+ }
185
+
186
+ if (this.highWatermark === 0) return
187
+ const interiorFree = this.interiorFreeFaces()
188
+ if (interiorFree / this.highWatermark <= FRAGMENTATION_THRESHOLD) return
189
+
190
+ const section = this.findMovableSection(MAX_UPLOAD_FACES_PER_FRAME)
191
+ if (!section) return
192
+
193
+ const hole = this.findLowestInteriorHole(section.start, section.count)
194
+ if (!hole) return
195
+
196
+ const reserved = this.reserveFreeSlotAt(hole.index, section.count)
197
+ const oldStart = section.start
198
+ const newStart = reserved.start
199
+
200
+ this.copySectionRange(oldStart, newStart, section.count)
201
+ this.sectionSlots.set(section.key, { start: newStart, count: section.count })
202
+ this.markDirty(newStart, newStart + section.count - 1)
203
+ this.pendingMove = { key: section.key, oldStart, newStart, count: section.count }
204
+ }
205
+
155
206
  uploadDirtyRange (): void {
156
- if (this.dirtyMin > this.dirtyMax) return
207
+ const r = this.pendingRanges[0]
208
+ if (!r) return
157
209
 
158
- const offset = this.dirtyMin
159
- const count = this.dirtyMax - this.dirtyMin + 1
210
+ const offset = r.start
211
+ const count = Math.min(r.end - r.start + 1, MAX_UPLOAD_FACES_PER_FRAME)
160
212
  const geometry = this.mesh.geometry
161
213
 
162
214
  for (const name of ['a_w0', 'a_w1', 'a_w2', 'a_w3'] as const) {
@@ -166,8 +218,8 @@ export class GlobalBlockBuffer {
166
218
  attr.needsUpdate = true
167
219
  }
168
220
 
169
- this.dirtyMin = Infinity
170
- this.dirtyMax = -1
221
+ if (offset + count > r.end) this.pendingRanges.shift()
222
+ else r.start = offset + count
171
223
  }
172
224
 
173
225
  setCameraOrigin (x: number, y: number, z: number): void {
@@ -189,8 +241,8 @@ export class GlobalBlockBuffer {
189
241
  this.sectionSlots.clear()
190
242
  this.freeList.length = 0
191
243
  this.highWatermark = 0
192
- this.dirtyMin = Infinity
193
- this.dirtyMax = -1
244
+ this.pendingRanges.length = 0
245
+ this.pendingMove = null
194
246
  this.w0.fill(0)
195
247
  this.w1.fill(0)
196
248
  this.w2.fill(EMPTY_W2)
@@ -205,8 +257,26 @@ export class GlobalBlockBuffer {
205
257
  }
206
258
 
207
259
  private markDirty (start: number, end: number): void {
208
- if (start < this.dirtyMin) this.dirtyMin = start
209
- if (end > this.dirtyMax) this.dirtyMax = end
260
+ this.pendingRanges.push({ start, end })
261
+ this.pendingRanges.sort((a, b) => a.start - b.start)
262
+ this.mergePendingRanges()
263
+ }
264
+
265
+ private mergePendingRanges (): void {
266
+ if (this.pendingRanges.length < 2) return
267
+ const merged: Array<{ start: number, end: number }> = []
268
+ let cur = this.pendingRanges[0]!
269
+ for (let i = 1; i < this.pendingRanges.length; i++) {
270
+ const next = this.pendingRanges[i]!
271
+ if (next.start <= cur.end + 1) {
272
+ cur = { start: cur.start, end: Math.max(cur.end, next.end) }
273
+ } else {
274
+ merged.push(cur)
275
+ cur = next
276
+ }
277
+ }
278
+ merged.push(cur)
279
+ this.pendingRanges = merged
210
280
  }
211
281
 
212
282
  private takeFreeSlot (count: number): { start: number, count: number } | undefined {
@@ -246,6 +316,97 @@ export class GlobalBlockBuffer {
246
316
  this.freeList = merged
247
317
  }
248
318
 
319
+ private interiorFreeFaces (): number {
320
+ let total = 0
321
+ for (const slot of this.freeList) {
322
+ if (slot.start < this.highWatermark) total += slot.count
323
+ }
324
+ return total
325
+ }
326
+
327
+ private findMovableSection (maxCount: number): { key: string, start: number, count: number } | undefined {
328
+ const sections: Array<{ key: string, start: number, count: number }> = []
329
+ for (const [key, slot] of this.sectionSlots) {
330
+ sections.push({ key, start: slot.start, count: slot.count })
331
+ }
332
+ if (sections.length === 0) return undefined
333
+
334
+ sections.sort((a, b) => {
335
+ if (b.start !== a.start) return b.start - a.start
336
+ return (b.start + b.count) - (a.start + a.count)
337
+ })
338
+
339
+ const tailmost = sections[0]!
340
+ if (tailmost.count <= maxCount && this.findLowestInteriorHole(tailmost.start, tailmost.count)) {
341
+ return tailmost
342
+ }
343
+
344
+ const candidates = sections
345
+ .filter(s => s.count <= maxCount)
346
+ .sort((a, b) => b.count - a.count)
347
+
348
+ for (const s of candidates) {
349
+ if (this.findLowestInteriorHole(s.start, s.count)) return s
350
+ }
351
+ return undefined
352
+ }
353
+
354
+ private findLowestInteriorHole (
355
+ sectionStart: number,
356
+ count: number,
357
+ ): { start: number, count: number, index: number } | undefined {
358
+ for (let i = 0; i < this.freeList.length; i++) {
359
+ const slot = this.freeList[i]!
360
+ if (slot.start < sectionStart && slot.count >= count) {
361
+ return { start: slot.start, count: slot.count, index: i }
362
+ }
363
+ }
364
+ return undefined
365
+ }
366
+
367
+ private reserveFreeSlotAt (index: number, count: number): { start: number, count: number } {
368
+ const slot = this.freeList[index]!
369
+ this.freeList.splice(index, 1)
370
+ if (slot.count === count) return { start: slot.start, count }
371
+ const used = { start: slot.start, count }
372
+ this.insertFreeSlot({ start: slot.start + count, count: slot.count - count })
373
+ return used
374
+ }
375
+
376
+ private copySectionRange (oldStart: number, newStart: number, count: number): void {
377
+ this.w0.copyWithin(newStart, oldStart, oldStart + count)
378
+ this.w1.copyWithin(newStart, oldStart, oldStart + count)
379
+ this.w2.copyWithin(newStart, oldStart, oldStart + count)
380
+ this.w3.copyWithin(newStart, oldStart, oldStart + count)
381
+ }
382
+
383
+ private rangeFullyUploaded (start: number, end: number): boolean {
384
+ for (const r of this.pendingRanges) {
385
+ if (r.start <= end && r.end >= start) return false
386
+ }
387
+ return true
388
+ }
389
+
390
+ private finalizePendingMove (): void {
391
+ const move = this.pendingMove
392
+ if (!move) return
393
+
394
+ const { oldStart, count } = move
395
+ for (let i = oldStart; i < oldStart + count; i++) {
396
+ this.w0[i] = 0
397
+ this.w1[i] = 0
398
+ this.w2[i] = EMPTY_W2
399
+ this.w3[i] = 0
400
+ }
401
+ this.insertFreeSlot({ start: oldStart, count })
402
+ this.shrinkHighWatermark()
403
+ if (oldStart < this.highWatermark) {
404
+ this.markDirty(oldStart, oldStart + count - 1)
405
+ }
406
+ this.mesh.geometry.instanceCount = this.highWatermark
407
+ this.pendingMove = null
408
+ }
409
+
249
410
  private shrinkHighWatermark (): void {
250
411
  while (this.highWatermark > 0) {
251
412
  const tail = this.highWatermark - 1
@@ -258,8 +419,13 @@ export class GlobalBlockBuffer {
258
419
  }
259
420
 
260
421
  private growCapacity (minFaces: number): void {
422
+ // Moved CPU data at newStart survives nw*.set(); pendingRanges cleared below anyway.
423
+ if (this.pendingMove) this.finalizePendingMove()
424
+
425
+ console.warn('[globalBlockBuffer] growing faces', this.capacityFaces, '->', '(need', minFaces, ')')
261
426
  let newCap = this.capacityFaces
262
- while (newCap < minFaces) newCap *= 2
427
+ while (newCap < minFaces) newCap += GROWTH_INCREMENT_FACES
428
+ console.warn('[globalBlockBuffer] growing faces', this.capacityFaces, '->', newCap)
263
429
 
264
430
  const nw0 = new Uint32Array(newCap)
265
431
  const nw1 = new Uint32Array(newCap)
@@ -295,7 +461,6 @@ export class GlobalBlockBuffer {
295
461
  mkAttr(this.w2, 'a_w2')
296
462
  mkAttr(this.w3, 'a_w3')
297
463
 
298
- this.dirtyMin = 0
299
- this.dirtyMax = this.highWatermark - 1
464
+ this.pendingRanges.length = 0
300
465
  }
301
466
  }
@@ -1284,6 +1284,7 @@ export class WorldRendererThree extends WorldRendererCommon {
1284
1284
  const globalBuffer = this.chunkMeshManager.globalBlockBuffer
1285
1285
  if (globalBuffer) {
1286
1286
  globalBuffer.setCameraOrigin(this.cameraWorldPos.x, this.cameraWorldPos.y, this.cameraWorldPos.z)
1287
+ globalBuffer.compactStep()
1287
1288
  globalBuffer.uploadDirtyRange()
1288
1289
  }
1289
1290
  this.renderer.render(this.scene, cam)
@@ -359,6 +359,325 @@ test('GlobalBlockBuffer: free-list reuses slot with EMPTY sentinel', () => {
359
359
  mat.dispose()
360
360
  })
361
361
 
362
+ type BufferInternals = {
363
+ pendingRanges: Array<{ start: number, end: number }>
364
+ pendingMove: { key: string, oldStart: number, newStart: number, count: number } | null
365
+ }
366
+
367
+ /** True if some queued (not-yet-uploaded) dirty range covers [start, end] — i.e. it WILL hit the GPU. */
368
+ function rangeQueuedForUpload (buffer: GlobalBlockBuffer, start: number, end: number): boolean {
369
+ return getBufferInternals(buffer).pendingRanges.some(r => r.start <= end && r.end >= start)
370
+ }
371
+
372
+ function getBufferInternals (buffer: GlobalBlockBuffer): BufferInternals {
373
+ return buffer as unknown as BufferInternals
374
+ }
375
+
376
+ function drainAllUploads (buffer: GlobalBlockBuffer): void {
377
+ while (getBufferInternals(buffer).pendingRanges.length) buffer.uploadDirtyRange()
378
+ }
379
+
380
+ function makeSectionWords (faceW0: number[]): Uint32Array {
381
+ const words = new Uint32Array(faceW0.length * 4)
382
+ for (let i = 0; i < faceW0.length; i++) {
383
+ words[i * 4] = faceW0[i]!
384
+ words[i * 4 + 1] = 0
385
+ words[i * 4 + 2] = 0
386
+ words[i * 4 + 3] = packWord3(0, 0)
387
+ }
388
+ return words
389
+ }
390
+
391
+ function readSectionFaceWords (buffer: GlobalBlockBuffer, key: string): number[] {
392
+ const slot = buffer.getSectionSlot(key)
393
+ if (!slot) throw new Error(`missing section ${key}`)
394
+ const geo = buffer.mesh.geometry
395
+ const w0 = (geo.getAttribute('a_w0') as THREE.InstancedBufferAttribute).array as Uint32Array
396
+ const w1 = (geo.getAttribute('a_w1') as THREE.InstancedBufferAttribute).array as Uint32Array
397
+ const w2 = (geo.getAttribute('a_w2') as THREE.InstancedBufferAttribute).array as Uint32Array
398
+ const w3 = (geo.getAttribute('a_w3') as THREE.InstancedBufferAttribute).array as Uint32Array
399
+ const out: number[] = []
400
+ for (let i = 0; i < slot.count; i++) {
401
+ const idx = slot.start + i
402
+ out.push(w0[idx]!, w1[idx]!, w2[idx]!, w3[idx]!)
403
+ }
404
+ return out
405
+ }
406
+
407
+ function finishCurrentMove (buffer: GlobalBlockBuffer): void {
408
+ drainAllUploads(buffer)
409
+ buffer.compactStep()
410
+ }
411
+
412
+ function isEmptyFace (buffer: GlobalBlockBuffer, index: number): boolean {
413
+ const w2 = (buffer.mesh.geometry.getAttribute('a_w2') as THREE.InstancedBufferAttribute).array as Uint32Array
414
+ return (w2[index]! & (1 << WORD2.EMPTY_SHIFT)) !== 0
415
+ }
416
+
417
+ test('GlobalBlockBuffer: compaction lowers watermark after interior-hole churn', () => {
418
+ const scene = new THREE.Scene()
419
+ const mat = createCubeBlockMaterial()
420
+ const buffer = new GlobalBlockBuffer(mat, scene)
421
+
422
+ buffer.addSection('a', makeSectionWords([10]), 1)
423
+ buffer.addSection('b', makeSectionWords([20]), 1)
424
+ buffer.addSection('c', makeSectionWords([30]), 1)
425
+ expect(buffer.mesh.geometry.instanceCount).toBe(3)
426
+
427
+ buffer.removeSection('b')
428
+ drainAllUploads(buffer)
429
+ expect(buffer.mesh.geometry.instanceCount).toBe(3)
430
+
431
+ buffer.compactStep()
432
+ finishCurrentMove(buffer)
433
+
434
+ expect(buffer.mesh.geometry.instanceCount).toBe(2)
435
+ expect(buffer.getSectionSlot('c')).toEqual({ start: 1, count: 1 })
436
+ expect(readSectionFaceWords(buffer, 'c')[0]).toBe(30)
437
+ expect(readSectionFaceWords(buffer, 'a')[0]).toBe(10)
438
+
439
+ buffer.dispose()
440
+ mat.dispose()
441
+ })
442
+
443
+ test('GlobalBlockBuffer: compaction preserves surviving section data', () => {
444
+ const scene = new THREE.Scene()
445
+ const mat = createCubeBlockMaterial()
446
+ const buffer = new GlobalBlockBuffer(mat, scene)
447
+
448
+ buffer.addSection('a', makeSectionWords([11, 12]), 2)
449
+ buffer.addSection('b', makeSectionWords([21]), 1)
450
+ buffer.addSection('c', makeSectionWords([31, 32, 33]), 3)
451
+ const expectedA = readSectionFaceWords(buffer, 'a')
452
+ const expectedC = readSectionFaceWords(buffer, 'c')
453
+
454
+ buffer.removeSection('b')
455
+ drainAllUploads(buffer)
456
+ buffer.compactStep()
457
+ finishCurrentMove(buffer)
458
+
459
+ expect(readSectionFaceWords(buffer, 'a')).toEqual(expectedA)
460
+ expect(readSectionFaceWords(buffer, 'c')).toEqual(expectedC)
461
+
462
+ buffer.dispose()
463
+ mat.dispose()
464
+ })
465
+
466
+ test('GlobalBlockBuffer: compaction defers instanceCount shrink until upload completes', () => {
467
+ const scene = new THREE.Scene()
468
+ const mat = createCubeBlockMaterial()
469
+ const buffer = new GlobalBlockBuffer(mat, scene)
470
+
471
+ buffer.addSection('a', makeSectionWords([10]), 1)
472
+ buffer.addSection('b', makeSectionWords([20]), 1)
473
+ buffer.addSection('c', makeSectionWords([30]), 1)
474
+ buffer.removeSection('b')
475
+ drainAllUploads(buffer)
476
+
477
+ buffer.compactStep()
478
+ expect(getBufferInternals(buffer).pendingMove).not.toBeNull()
479
+ expect(buffer.mesh.geometry.instanceCount).toBe(3)
480
+
481
+ finishCurrentMove(buffer)
482
+ expect(getBufferInternals(buffer).pendingMove).toBeNull()
483
+ expect(buffer.mesh.geometry.instanceCount).toBe(2)
484
+
485
+ buffer.dispose()
486
+ mat.dispose()
487
+ })
488
+
489
+ test('GlobalBlockBuffer: compaction runs one move at a time', () => {
490
+ const scene = new THREE.Scene()
491
+ const mat = createCubeBlockMaterial()
492
+ const buffer = new GlobalBlockBuffer(mat, scene)
493
+
494
+ buffer.addSection('a', makeSectionWords([10]), 1)
495
+ buffer.addSection('b', makeSectionWords([20]), 1)
496
+ buffer.addSection('c', makeSectionWords([30]), 1)
497
+ buffer.addSection('d', makeSectionWords([40]), 1)
498
+ buffer.addSection('e', makeSectionWords([50]), 1)
499
+ buffer.removeSection('b')
500
+ buffer.removeSection('d')
501
+ drainAllUploads(buffer)
502
+
503
+ buffer.compactStep()
504
+ const moveAfterFirst = getBufferInternals(buffer).pendingMove
505
+ expect(moveAfterFirst).not.toBeNull()
506
+
507
+ buffer.compactStep()
508
+ expect(getBufferInternals(buffer).pendingMove).toEqual(moveAfterFirst)
509
+
510
+ buffer.dispose()
511
+ mat.dispose()
512
+ })
513
+
514
+ test('GlobalBlockBuffer: compaction skips when fragmentation is below threshold', () => {
515
+ const scene = new THREE.Scene()
516
+ const mat = createCubeBlockMaterial()
517
+ const buffer = new GlobalBlockBuffer(mat, scene)
518
+
519
+ buffer.addSection('a', makeSectionWords([10]), 1)
520
+ buffer.addSection('b', makeSectionWords([20]), 1)
521
+ buffer.addSection('c', makeSectionWords([30]), 1)
522
+ buffer.addSection('d', makeSectionWords([40]), 1)
523
+ buffer.removeSection('b')
524
+ drainAllUploads(buffer)
525
+ expect(buffer.mesh.geometry.instanceCount).toBe(4)
526
+
527
+ buffer.compactStep()
528
+ expect(getBufferInternals(buffer).pendingMove).toBeNull()
529
+ expect(buffer.mesh.geometry.instanceCount).toBe(4)
530
+
531
+ buffer.dispose()
532
+ mat.dispose()
533
+ })
534
+
535
+ test('GlobalBlockBuffer: interior-fallback move uploads EMPTY over old slot still in draw range', () => {
536
+ const scene = new THREE.Scene()
537
+ const mat = createCubeBlockMaterial()
538
+ const buffer = new GlobalBlockBuffer(mat, scene)
539
+
540
+ // A[0,2) B[2,2) C[4,3) — remove A; C (count 3) cannot fit hole [0,2), so B moves down.
541
+ buffer.addSection('a', makeSectionWords([10, 11]), 2)
542
+ buffer.addSection('b', makeSectionWords([20, 21]), 2)
543
+ buffer.addSection('c', makeSectionWords([30, 31, 32]), 3)
544
+ expect(buffer.mesh.geometry.instanceCount).toBe(7)
545
+
546
+ buffer.removeSection('a')
547
+ drainAllUploads(buffer)
548
+
549
+ buffer.compactStep()
550
+ expect(getBufferInternals(buffer).pendingMove?.key).toBe('b')
551
+ expect(getBufferInternals(buffer).pendingMove?.oldStart).toBe(2)
552
+ finishCurrentMove(buffer)
553
+
554
+ // Guards the High fix: the vacated old slot [2,3] is still inside the draw range, so it MUST be
555
+ // queued for GPU upload (markDirty). Checking isEmptyFace alone is insufficient — the CPU-backed
556
+ // attribute array is cleared regardless; only the pending upload range proves the GPU is told.
557
+ expect(rangeQueuedForUpload(buffer, 2, 3)).toBe(true)
558
+
559
+ expect(buffer.mesh.geometry.instanceCount).toBe(7)
560
+ expect(buffer.getSectionSlot('b')).toEqual({ start: 0, count: 2 })
561
+ expect(readSectionFaceWords(buffer, 'b')[0]).toBe(20)
562
+ expect(isEmptyFace(buffer, 2)).toBe(true)
563
+ expect(isEmptyFace(buffer, 3)).toBe(true)
564
+
565
+ buffer.dispose()
566
+ mat.dispose()
567
+ })
568
+
569
+ test('GlobalBlockBuffer: removeSection during pending move clears old GPU copy', () => {
570
+ const scene = new THREE.Scene()
571
+ const mat = createCubeBlockMaterial()
572
+ const buffer = new GlobalBlockBuffer(mat, scene)
573
+
574
+ buffer.addSection('a', makeSectionWords([10]), 1)
575
+ buffer.addSection('b', makeSectionWords([20]), 1)
576
+ buffer.addSection('c', makeSectionWords([30]), 1)
577
+ buffer.removeSection('b')
578
+ drainAllUploads(buffer)
579
+
580
+ buffer.compactStep()
581
+ expect(getBufferInternals(buffer).pendingMove?.key).toBe('c')
582
+
583
+ buffer.removeSection('c')
584
+ expect(getBufferInternals(buffer).pendingMove).toBeNull()
585
+ // The in-flight old copy at index 2 must be queued for upload, not just cleared in CPU memory.
586
+ expect(rangeQueuedForUpload(buffer, 2, 2)).toBe(true)
587
+ drainAllUploads(buffer)
588
+
589
+ expect(buffer.hasSection('c')).toBe(false)
590
+ expect(isEmptyFace(buffer, 2)).toBe(true)
591
+
592
+ buffer.dispose()
593
+ mat.dispose()
594
+ })
595
+
596
+ test('GlobalBlockBuffer: takeSectionData during pending move clears old GPU copy', () => {
597
+ const scene = new THREE.Scene()
598
+ const mat = createCubeBlockMaterial()
599
+ const buffer = new GlobalBlockBuffer(mat, scene)
600
+
601
+ buffer.addSection('a', makeSectionWords([10, 11]), 2)
602
+ buffer.addSection('b', makeSectionWords([20, 21]), 2)
603
+ buffer.addSection('c', makeSectionWords([30, 31, 32]), 3)
604
+ buffer.removeSection('a')
605
+ drainAllUploads(buffer)
606
+
607
+ buffer.compactStep()
608
+ expect(getBufferInternals(buffer).pendingMove?.key).toBe('b')
609
+
610
+ const taken = buffer.takeSectionData('b')
611
+ expect(taken?.words[0]).toBe(20)
612
+ expect(taken?.words[4]).toBe(21)
613
+ expect(getBufferInternals(buffer).pendingMove).toBeNull()
614
+ // The in-flight old copy at [2,3] must be queued for upload, not just cleared in CPU memory.
615
+ expect(rangeQueuedForUpload(buffer, 2, 3)).toBe(true)
616
+ drainAllUploads(buffer)
617
+
618
+ expect(buffer.hasSection('b')).toBe(false)
619
+ expect(isEmptyFace(buffer, 2)).toBe(true)
620
+ expect(isEmptyFace(buffer, 3)).toBe(true)
621
+
622
+ buffer.dispose()
623
+ mat.dispose()
624
+ })
625
+
626
+ test('GlobalBlockBuffer: takeSectionData reads relocated section slot', () => {
627
+ const scene = new THREE.Scene()
628
+ const mat = createCubeBlockMaterial()
629
+ const buffer = new GlobalBlockBuffer(mat, scene)
630
+
631
+ const cWords = makeSectionWords([30, 31])
632
+ buffer.addSection('a', makeSectionWords([10]), 1)
633
+ buffer.addSection('b', makeSectionWords([20]), 1)
634
+ buffer.addSection('c', cWords, 2)
635
+ buffer.removeSection('b')
636
+ drainAllUploads(buffer)
637
+ buffer.compactStep()
638
+ finishCurrentMove(buffer)
639
+
640
+ const taken = buffer.takeSectionData('c')
641
+ expect(taken?.count).toBe(2)
642
+ expect(taken?.words[0]).toBe(30)
643
+ expect(taken?.words[4]).toBe(31)
644
+
645
+ buffer.dispose()
646
+ mat.dispose()
647
+ })
648
+
649
+ test('GlobalBlockBuffer: uploadDirtyRange budgets large dirty span across frames', () => {
650
+ const scene = new THREE.Scene()
651
+ const mat = createCubeBlockMaterial()
652
+ const buffer = new GlobalBlockBuffer(mat, scene)
653
+
654
+ const faceCount = 20_000
655
+ const words = new Uint32Array(faceCount * 4)
656
+ for (let i = 0; i < faceCount; i++) {
657
+ const src = i * 4
658
+ words[src] = i + 1
659
+ words[src + 1] = 0
660
+ words[src + 2] = 0
661
+ words[src + 3] = packWord3(0, 0)
662
+ }
663
+ buffer.addSection('big', words, faceCount)
664
+
665
+ const w0Attr = buffer.mesh.geometry.getAttribute('a_w0') as THREE.InstancedBufferAttribute
666
+ buffer.uploadDirtyRange()
667
+ expect(w0Attr.updateRange.offset).toBe(0)
668
+ expect(w0Attr.updateRange.count).toBe(15_000)
669
+
670
+ buffer.uploadDirtyRange()
671
+ expect(w0Attr.updateRange.offset).toBe(15_000)
672
+ expect(w0Attr.updateRange.count).toBe(5_000)
673
+
674
+ buffer.uploadDirtyRange()
675
+ expect((buffer as unknown as { pendingRanges: unknown[] }).pendingRanges).toHaveLength(0)
676
+
677
+ buffer.dispose()
678
+ mat.dispose()
679
+ })
680
+
362
681
  test('getShaderCubeResources: returns null without loadedData.tints (no throw)', () => {
363
682
  const prev = (globalThis as any).loadedData
364
683
  ;(globalThis as any).loadedData = {}