minecraft-renderer 0.1.78 → 0.1.79

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.78",
3
+ "version": "0.1.79",
4
4
  "description": "The most Modular Minecraft world renderer with Three.js WebGL backend",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -140,6 +140,9 @@ export class ChunkMeshManager {
140
140
  private readonly _legacyCullBoxMin = new THREE.Vector3()
141
141
  private readonly _legacyCullBoxMax = new THREE.Vector3()
142
142
  private readonly _visibleSectionSpans: Array<{ key: string, distSq: number }> = []
143
+ /** Sections with geometry in global legacy opaque and/or blend buffers — cull/raycast scan only these. */
144
+ private readonly legacyCullSections = new Map<string, { worldX: number, worldY: number, worldZ: number }>()
145
+ private _lastCullFingerprint = ''
143
146
  /** Drives per-frame cull + span rebuild; cleared after updateSectionCullAndSort. */
144
147
  cullDirty = true
145
148
  private readonly _lastCullCamPos = new THREE.Vector3()
@@ -384,6 +387,45 @@ export class ChunkMeshManager {
384
387
  return this.activeSections.has(sectionKey)
385
388
  }
386
389
 
390
+ private registerLegacyCullSection (sectionKey: string, worldX: number, worldY: number, worldZ: number): void {
391
+ this.legacyCullSections.set(sectionKey, { worldX, worldY, worldZ })
392
+ }
393
+
394
+ private maybeUnregisterLegacyCullSection (sectionKey: string): void {
395
+ const inOpaque = this.globalLegacyBuffer?.hasSection(sectionKey) ?? false
396
+ const inBlend = this.globalLegacyBlendBuffer?.hasSection(sectionKey) ?? false
397
+ if (!inOpaque && !inBlend) {
398
+ this.legacyCullSections.delete(sectionKey)
399
+ }
400
+ }
401
+
402
+ private updatePooledLegacyCullState (
403
+ cameraWorldX: number,
404
+ cameraWorldY: number,
405
+ cameraWorldZ: number,
406
+ ): void {
407
+ for (const poolEntry of this.activeSections.values()) {
408
+ const sectionKey = poolEntry.sectionKey
409
+ if (!sectionKey) continue
410
+ const sectionObject = this.sectionObjects[sectionKey]
411
+ if (!sectionObject) continue
412
+
413
+ updateLegacySectionCullState(
414
+ poolEntry.mesh,
415
+ sectionObject.worldX ?? 0,
416
+ sectionObject.worldY ?? 0,
417
+ sectionObject.worldZ ?? 0,
418
+ cameraWorldX,
419
+ cameraWorldY,
420
+ cameraWorldZ,
421
+ this._legacyCullFrustum,
422
+ this._legacyCullBox,
423
+ this._legacyCullBoxMin,
424
+ this._legacyCullBoxMax,
425
+ )
426
+ }
427
+ }
428
+
387
429
  /**
388
430
  * Shared section visibility + span groups for global legacy and cube buffers.
389
431
  */
@@ -394,8 +436,9 @@ export class ChunkMeshManager {
394
436
  const visible = this._visibleSectionSpans
395
437
  visible.length = 0
396
438
 
397
- for (const [sectionKey, sectionObject] of Object.entries(this.sectionObjects)) {
398
- if (sectionObject.worldX === undefined || !sectionObject.visible) continue
439
+ for (const [sectionKey] of this.legacyCullSections) {
440
+ const sectionObject = this.sectionObjects[sectionKey]
441
+ if (!sectionObject?.visible || sectionObject.worldX === undefined) continue
399
442
  const { visible: inFrustum, distSq } = sectionIntersectsFrustum(
400
443
  sectionObject.worldX,
401
444
  sectionObject.worldY ?? 0,
@@ -413,13 +456,27 @@ export class ChunkMeshManager {
413
456
  }
414
457
  }
415
458
 
416
- this.globalLegacyBuffer?.updateDrawSpans(visible, 'opaque')
417
- this.globalLegacyBlendBuffer?.updateDrawSpans(visible, 'sortedBlend')
418
-
459
+ const opaqueBuf = this.globalLegacyBuffer
460
+ const blendBuf = this.globalLegacyBlendBuffer
419
461
  const gb = this.globalBlockBuffer
462
+
463
+ const opaqueKeys: string[] = []
464
+ for (const entry of visible) {
465
+ if (opaqueBuf?.hasSection(entry.key)) opaqueKeys.push(entry.key)
466
+ }
467
+ opaqueKeys.sort()
468
+
469
+ const blendVisible = visible.filter(v => blendBuf?.hasSection(v.key))
470
+ blendVisible.sort((a, b) => b.distSq - a.distSq)
471
+ const blendKeys = blendVisible.map(v => v.key)
472
+
473
+ const cubeVisibleKeys: string[] = []
474
+ const visibleSlots: Array<{ start: number, count: number }> = []
420
475
  if (gb) {
421
- const visibleSlots: Array<{ start: number, count: number }> = []
422
476
  gb.forEachSectionSlot((key, slot) => {
477
+ const sectionObject = this.sectionObjects[key]
478
+ // Keep in sync with legacy gate (line 440).
479
+ if (!sectionObject?.visible) return
423
480
  const entry = this.shaderSectionRaycastBoxes.get(key)
424
481
  if (!entry) {
425
482
  return
@@ -439,35 +496,39 @@ export class ChunkMeshManager {
439
496
  if (!inFrustum) {
440
497
  return
441
498
  }
499
+ cubeVisibleKeys.push(key)
442
500
  const drawStart = gb.getSectionDrawStart(key)
443
501
  if (drawStart !== undefined) {
444
502
  visibleSlots.push({ start: drawStart, count: slot.count })
445
503
  }
446
504
  })
447
- const spans = buildVisibleCubeSpans(visibleSlots, gb.getHighWatermark())
448
- gb.setVisibleSpans(spans)
449
505
  }
506
+ cubeVisibleKeys.sort()
450
507
 
451
- for (const poolEntry of this.activeSections.values()) {
452
- const sectionKey = poolEntry.sectionKey
453
- if (!sectionKey) continue
454
- const sectionObject = this.sectionObjects[sectionKey]
455
- if (!sectionObject) continue
508
+ const fingerprint = [
509
+ opaqueKeys.join(','),
510
+ blendKeys.join(','),
511
+ cubeVisibleKeys.join(','),
512
+ opaqueBuf?.getLayoutVersion() ?? 0,
513
+ blendBuf?.getLayoutVersion() ?? 0,
514
+ gb?.getLayoutVersion() ?? 0,
515
+ ].join('|')
456
516
 
457
- updateLegacySectionCullState(
458
- poolEntry.mesh,
459
- sectionObject.worldX ?? 0,
460
- sectionObject.worldY ?? 0,
461
- sectionObject.worldZ ?? 0,
462
- cameraWorldX,
463
- cameraWorldY,
464
- cameraWorldZ,
465
- this._legacyCullFrustum,
466
- this._legacyCullBox,
467
- this._legacyCullBoxMin,
468
- this._legacyCullBoxMax,
469
- )
517
+ if (fingerprint === this._lastCullFingerprint) {
518
+ this.updatePooledLegacyCullState(cameraWorldX, cameraWorldY, cameraWorldZ)
519
+ return
470
520
  }
521
+ this._lastCullFingerprint = fingerprint
522
+
523
+ opaqueBuf?.updateDrawSpans(visible, 'opaque')
524
+ blendBuf?.updateDrawSpans(visible, 'sortedBlend')
525
+
526
+ if (gb) {
527
+ const spans = buildVisibleCubeSpans(visibleSlots, gb.getHighWatermark())
528
+ gb.setVisibleSpans(spans)
529
+ }
530
+
531
+ this.updatePooledLegacyCullState(cameraWorldX, cameraWorldY, cameraWorldZ)
471
532
  }
472
533
 
473
534
  markCullDirty (): void {
@@ -609,13 +670,16 @@ export class ChunkMeshManager {
609
670
  const wy = section.worldY
610
671
  const wz = section.worldZ
611
672
  if (wx !== undefined && wy !== undefined && wz !== undefined) {
612
- this.getGlobalLegacyBuffer().addSection(
673
+ const added = this.getGlobalLegacyBuffer().addSection(
613
674
  sectionKey,
614
675
  { positions, colors, skyLights, blockLights, uvs, indices },
615
676
  wx,
616
677
  wy,
617
678
  wz,
618
679
  )
680
+ if (added) {
681
+ this.registerLegacyCullSection(sectionKey, wx, wy, wz)
682
+ }
619
683
  }
620
684
  delete section.deferredLegacyOpaque
621
685
  }
@@ -626,13 +690,16 @@ export class ChunkMeshManager {
626
690
  const wy = section.worldY
627
691
  const wz = section.worldZ
628
692
  if (wx !== undefined && wy !== undefined && wz !== undefined) {
629
- this.getGlobalLegacyBlendBuffer().addSection(
693
+ const added = this.getGlobalLegacyBlendBuffer().addSection(
630
694
  sectionKey,
631
695
  { positions, colors, skyLights, blockLights, uvs, indices },
632
696
  wx,
633
697
  wy,
634
698
  wz,
635
699
  )
700
+ if (added) {
701
+ this.registerLegacyCullSection(sectionKey, wx, wy, wz)
702
+ }
636
703
  }
637
704
  delete section.deferredLegacyBlend
638
705
  section.hasBlendMesh = false
@@ -660,15 +727,13 @@ export class ChunkMeshManager {
660
727
  const far = raycaster.far
661
728
  const halfExtent = LEGACY_SECTION_HALF_EXTENT + 0.01
662
729
  const candidates: string[] = []
663
- for (const [key, section] of Object.entries(this.sectionObjects)) {
664
- if (section.worldX === undefined) continue
730
+ for (const [key] of this.legacyCullSections) {
731
+ const section = this.sectionObjects[key]
732
+ if (!section || section.worldX === undefined) continue
665
733
  const dx = section.worldX - origin.x
666
734
  const dy = (section.worldY ?? 0) - origin.y
667
735
  const dz = (section.worldZ ?? 0) - origin.z
668
736
  if (dx * dx + dy * dy + dz * dz > maxDistSq) continue
669
- const inOpaque = this.globalLegacyBuffer?.hasSection(key) ?? false
670
- const inBlend = this.globalLegacyBlendBuffer?.hasSection(key) ?? false
671
- if (!inOpaque && !inBlend) continue
672
737
  if (!sectionAabbIntersectsRay(
673
738
  section.worldX,
674
739
  section.worldY ?? 0,
@@ -912,7 +977,9 @@ export class ChunkMeshManager {
912
977
  geometryData.sy,
913
978
  geometryData.sz,
914
979
  )
915
- if (!added) {
980
+ if (added) {
981
+ this.registerLegacyCullSection(sectionKey, geometryData.sx, geometryData.sy, geometryData.sz)
982
+ } else {
916
983
  const poolEntry = this.acquirePooledSectionMesh(sectionKey)
917
984
  if (!poolEntry) return null
918
985
  legacyMesh = this.uploadLegacyPooledMesh(poolEntry, geometryData, geometryData.sx, geometryData.sy, geometryData.sz)
@@ -960,7 +1027,9 @@ export class ChunkMeshManager {
960
1027
  geometryData.sy,
961
1028
  geometryData.sz,
962
1029
  )
963
- if (!added) {
1030
+ if (added) {
1031
+ this.registerLegacyCullSection(sectionKey, geometryData.sx, geometryData.sy, geometryData.sz)
1032
+ } else {
964
1033
  console.warn(`ChunkMeshManager: blend invariant violation for section ${sectionKey}, using pooled mesh fallback`)
965
1034
  const poolEntry = this.acquirePooledSectionMesh(sectionKey)
966
1035
  if (!poolEntry) return null
@@ -1197,6 +1266,7 @@ export class ChunkMeshManager {
1197
1266
  sectionObject.visible = true
1198
1267
  }
1199
1268
  delete this.waitingChunksToDisplay[chunkKey]
1269
+ this.markCullDirty()
1200
1270
  }
1201
1271
 
1202
1272
  // Re-check every parked entry; each has its own grace window via `ageMs`.
@@ -1359,6 +1429,7 @@ export class ChunkMeshManager {
1359
1429
  this.globalBlockBuffer?.removeSection(sectionKey)
1360
1430
  this.globalLegacyBuffer?.removeSection(sectionKey)
1361
1431
  this.globalLegacyBlendBuffer?.removeSection(sectionKey)
1432
+ this.maybeUnregisterLegacyCullSection(sectionKey)
1362
1433
  this.unregisterShaderSectionRaycastBox(sectionKey)
1363
1434
  this.markCullDirty()
1364
1435
  delete sectionObject.deferredLegacyOpaque
@@ -69,6 +69,7 @@ export class GlobalBlockBuffer {
69
69
  private tierCAttrs: TierCAttrState | null = null
70
70
  private tierCGl: WebGL2RenderingContext | null = null
71
71
  private debugOverlay = false
72
+ private layoutVersion = 0
72
73
 
73
74
  constructor (
74
75
  material: THREE.ShaderMaterial,
@@ -225,6 +226,11 @@ export class GlobalBlockBuffer {
225
226
  this.sectionSlots.set(sectionKey, slot)
226
227
  this.markDirty(slot.start, slot.start + faceCount - 1)
227
228
  this.mesh.geometry.instanceCount = this.highWatermark
229
+ this.layoutVersion++
230
+ }
231
+
232
+ getLayoutVersion (): number {
233
+ return this.layoutVersion
228
234
  }
229
235
 
230
236
  hasSection (sectionKey: string): boolean {
@@ -288,6 +294,7 @@ export class GlobalBlockBuffer {
288
294
  this.insertFreeSlot(slot)
289
295
  this.shrinkHighWatermark()
290
296
  this.mesh.geometry.instanceCount = this.highWatermark
297
+ this.layoutVersion++
291
298
  }
292
299
 
293
300
  /** One interior-hole move per frame when fragmentation exceeds threshold; deferred shrink. */
@@ -318,6 +325,7 @@ export class GlobalBlockBuffer {
318
325
  this.sectionSlots.set(section.key, { start: newStart, count: section.count })
319
326
  this.markDirty(newStart, newStart + section.count - 1)
320
327
  this.pendingMove = { key: section.key, oldStart, newStart, count: section.count }
328
+ this.layoutVersion++
321
329
  }
322
330
 
323
331
  uploadDirtyRange (): void {
@@ -1,5 +1,14 @@
1
1
  //@ts-nocheck
2
2
  import * as THREE from 'three'
3
+ import {
4
+ createLegacyMultiDrawScratch,
5
+ detectLegacyMultiDrawCaps,
6
+ drawLegacySpans,
7
+ logLegacyMultiDrawTierOnce,
8
+ type LegacyDrawSpan,
9
+ type LegacyMultiDrawCaps,
10
+ type LegacyMultiDrawScratch,
11
+ } from './legacyMultiDraw'
3
12
  import { computeCameraRelativeUniforms, type RenderOrigin } from './shaders/legacyBlockShader'
4
13
 
5
14
  const VERTS_PER_QUAD = 4
@@ -11,6 +20,9 @@ const FLOATS_PER_LIGHT_VERT = 1
11
20
  const DEFAULT_INITIAL_CAPACITY_QUADS = 128_000
12
21
  const DEFAULT_GROWTH_INCREMENT_QUADS = 128_000
13
22
  const MAX_UPLOAD_QUADS_PER_FRAME = 5_000
23
+ const FRAGMENTATION_THRESHOLD = 0.25
24
+
25
+ type PendingMove = { key: string, oldStart: number, newStart: number, count: number }
14
26
 
15
27
  /** CPU bytes per allocated quad slot (all legacy vertex/index attrs). */
16
28
  export const LEGACY_BYTES_PER_QUAD =
@@ -44,6 +56,8 @@ export type LegacySectionGeometryData = LegacySectionGeometry & {
44
56
  sz: number
45
57
  }
46
58
 
59
+ export type { LegacyDrawSpan } from './legacyMultiDraw'
60
+
47
61
  /**
48
62
  * Single GPU mesh for legacy quads (opaque+cutout or transparent blend).
49
63
  * Camera-relative via per-vertex a_origin (relative to render origin) + u_originDelta uniforms.
@@ -67,6 +81,12 @@ export class GlobalLegacyBuffer {
67
81
  private pendingRanges: Array<{ start: number, end: number }> = []
68
82
  private readonly _spanScratch: Array<{ start: number, count: number }> = []
69
83
  private renderOrigin: RenderOrigin = { x: 0, y: 0, z: 0 }
84
+ private layoutVersion = 0
85
+ private pendingMove: PendingMove | null = null
86
+ private visibleIndexSpans: LegacyDrawSpan[] = []
87
+ private readonly _drawScratch: LegacyMultiDrawScratch = createLegacyMultiDrawScratch()
88
+ private multiDrawCaps: LegacyMultiDrawCaps | null = null
89
+ private debugOverlay = false
70
90
 
71
91
  constructor (
72
92
  material: THREE.ShaderMaterial,
@@ -114,6 +134,38 @@ export class GlobalLegacyBuffer {
114
134
  this.mesh.position.set(0, 0, 0)
115
135
  scene.add(this.mesh)
116
136
  this.syncDefaultDrawGroups()
137
+
138
+ this.mesh.onAfterRender = (renderer, _scene, _camera, _geometry, material) => {
139
+ if (this.visibleIndexSpans.length === 0) return
140
+ const gl = renderer.getContext() as WebGL2RenderingContext
141
+ if (!this.multiDrawCaps) {
142
+ this.multiDrawCaps = detectLegacyMultiDrawCaps(gl)
143
+ logLegacyMultiDrawTierOnce(this.multiDrawCaps.tier, this.debugOverlay)
144
+ }
145
+ drawLegacySpans(gl, this.multiDrawCaps, this.visibleIndexSpans, this._drawScratch)
146
+ }
147
+ }
148
+
149
+ setDebugOverlay (enabled: boolean): void {
150
+ this.debugOverlay = enabled
151
+ }
152
+
153
+ /**
154
+ * Suppress three's full-buffer indexed draw; onAfterRender issues visible spans only.
155
+ * setDrawRange(0,0) skips bindingStates.setup — use a minimal non-zero range so
156
+ * program/VAO/ELEMENT_ARRAY_BUFFER stay bound while three draws ~nothing.
157
+ * Draws one triangle from index 0 (usually harmless; if quad 0 is culled, one stray tri).
158
+ */
159
+ suppressThreeDraw (): void {
160
+ this.mesh.geometry.setDrawRange(0, 3)
161
+ }
162
+
163
+ setVisibleIndexSpans (spans: LegacyDrawSpan[]): void {
164
+ this.visibleIndexSpans = spans
165
+ }
166
+
167
+ getVisibleIndexSpans (): readonly LegacyDrawSpan[] {
168
+ return this.visibleIndexSpans
117
169
  }
118
170
 
119
171
  private syncDefaultDrawGroups (): void {
@@ -171,14 +223,14 @@ export class GlobalLegacyBuffer {
171
223
  this.uvs.set(geo.uvs, dstUvBase)
172
224
 
173
225
  const originOff = dstFloatBase
174
- const rx = this.renderOrigin.x
175
- const ry = this.renderOrigin.y
176
- const rz = this.renderOrigin.z
226
+ const ox = sx - this.renderOrigin.x
227
+ const oy = sy - this.renderOrigin.y
228
+ const oz = sz - this.renderOrigin.z
177
229
  for (let v = 0; v < vertCount; v++) {
178
230
  const o = originOff + v * FLOATS_PER_VERT
179
- this.aOrigin[o] = sx - rx
180
- this.aOrigin[o + 1] = sy - ry
181
- this.aOrigin[o + 2] = sz - rz
231
+ this.aOrigin[o] = ox
232
+ this.aOrigin[o + 1] = oy
233
+ this.aOrigin[o + 2] = oz
182
234
  }
183
235
 
184
236
  const dstIndexBase = slot.start * INDICES_PER_QUAD
@@ -190,15 +242,60 @@ export class GlobalLegacyBuffer {
190
242
  this.sectionSlots.set(sectionKey, slot)
191
243
  this.markDirty(slot.start, slot.start + quadCount - 1)
192
244
  this.syncDefaultDrawGroups()
245
+ this.layoutVersion++
193
246
  return true
194
247
  }
195
248
 
249
+ getLayoutVersion (): number {
250
+ return this.layoutVersion
251
+ }
252
+
253
+ getSectionDrawStart (sectionKey: string): number | undefined {
254
+ const slot = this.sectionSlots.get(sectionKey)
255
+ if (!slot) return undefined
256
+ if (this.pendingMove?.key === sectionKey) return this.pendingMove.oldStart
257
+ return slot.start
258
+ }
259
+
260
+ getPendingMove (): PendingMove | null {
261
+ return this.pendingMove
262
+ }
263
+
264
+ /** One interior-hole move per frame when fragmentation exceeds threshold; deferred shrink. */
265
+ compactStep (): void {
266
+ if (this.pendingMove) {
267
+ const { newStart, count } = this.pendingMove
268
+ if (this.rangeFullyUploaded(newStart, newStart + count - 1)) {
269
+ this.finalizePendingMove()
270
+ }
271
+ return
272
+ }
273
+
274
+ if (this.highWatermark === 0) return
275
+ const interiorFree = this.interiorFreeQuads()
276
+ if (interiorFree / this.highWatermark <= FRAGMENTATION_THRESHOLD) return
277
+
278
+ const section = this.findMovableSection(MAX_UPLOAD_QUADS_PER_FRAME)
279
+ if (!section) return
280
+
281
+ const hole = this.findLowestInteriorHole(section.start, section.count)
282
+ if (!hole) return
283
+
284
+ const reserved = this.reserveFreeSlotAt(hole.index, section.count)
285
+ const oldStart = section.start
286
+ const newStart = reserved.start
287
+
288
+ this.copySectionRange(oldStart, newStart, section.count)
289
+ this.sectionSlots.set(section.key, { start: newStart, count: section.count })
290
+ this.markDirty(newStart, newStart + section.count - 1)
291
+ this.pendingMove = { key: section.key, oldStart, newStart, count: section.count }
292
+ this.layoutVersion++
293
+ }
294
+
196
295
  updateDrawSpans (visible: VisibleSectionSpan[], mode: 'opaque' | 'sortedBlend'): void {
197
- const geometry = this.mesh.geometry
198
- geometry.clearGroups()
296
+ this.visibleIndexSpans = []
199
297
 
200
298
  if (this.highWatermark === 0) {
201
- geometry.setDrawRange(0, 0)
202
299
  return
203
300
  }
204
301
 
@@ -207,21 +304,30 @@ export class GlobalLegacyBuffer {
207
304
  let visibleQuadCount = 0
208
305
 
209
306
  for (const entry of visible) {
307
+ const drawStart = this.getSectionDrawStart(entry.key)
210
308
  const slot = this.sectionSlots.get(entry.key)
211
- if (!slot) continue
212
- spans.push({ start: slot.start, count: slot.count })
309
+ if (drawStart === undefined || !slot) continue
310
+ spans.push({ start: drawStart, count: slot.count })
213
311
  visibleQuadCount += slot.count
214
312
  }
215
313
 
216
314
  if (spans.length === 0) {
217
- geometry.setDrawRange(0, 0)
218
315
  return
219
316
  }
220
317
 
318
+ const pushIndexSpan = (quadStart: number, quadCount: number): void => {
319
+ this.visibleIndexSpans.push({
320
+ indexStart: quadStart * INDICES_PER_QUAD,
321
+ indexCount: quadCount * INDICES_PER_QUAD,
322
+ })
323
+ }
324
+
221
325
  if (mode === 'opaque') {
222
326
  if (visibleQuadCount >= this.highWatermark * FULL_DRAW_VISIBLE_FRACTION) {
223
- geometry.addGroup(0, this.highWatermark * INDICES_PER_QUAD, 0)
224
- geometry.setDrawRange(0, this.highWatermark * INDICES_PER_QUAD)
327
+ this.visibleIndexSpans.push({
328
+ indexStart: 0,
329
+ indexCount: this.highWatermark * INDICES_PER_QUAD,
330
+ })
225
331
  return
226
332
  }
227
333
 
@@ -230,18 +336,17 @@ export class GlobalLegacyBuffer {
230
336
  this.capOpaqueSpans(spans)
231
337
 
232
338
  for (const span of spans) {
233
- geometry.addGroup(span.start * INDICES_PER_QUAD, span.count * INDICES_PER_QUAD, 0)
339
+ pushIndexSpan(span.start, span.count)
234
340
  }
235
341
  } else {
236
342
  visible.sort((a, b) => b.distSq - a.distSq)
237
343
  for (const entry of visible) {
344
+ const drawStart = this.getSectionDrawStart(entry.key)
238
345
  const slot = this.sectionSlots.get(entry.key)
239
- if (!slot) continue
240
- geometry.addGroup(slot.start * INDICES_PER_QUAD, slot.count * INDICES_PER_QUAD, 0)
346
+ if (drawStart === undefined || !slot) continue
347
+ pushIndexSpan(drawStart, slot.count)
241
348
  }
242
349
  }
243
-
244
- geometry.setDrawRange(0, this.highWatermark * INDICES_PER_QUAD)
245
350
  }
246
351
 
247
352
  private mergeOpaqueSpans (spans: Array<{ start: number, count: number }>): void {
@@ -328,6 +433,18 @@ export class GlobalLegacyBuffer {
328
433
  const slot = this.sectionSlots.get(sectionKey)
329
434
  if (!slot) return
330
435
 
436
+ if (this.pendingMove?.key === sectionKey) {
437
+ const { oldStart, count } = this.pendingMove
438
+ const oldIndexBase = oldStart * INDICES_PER_QUAD
439
+ const oldIndexLen = count * INDICES_PER_QUAD
440
+ for (let i = 0; i < oldIndexLen; i++) {
441
+ this.indices[oldIndexBase + i] = 0
442
+ }
443
+ this.markDirty(oldStart, oldStart + count - 1)
444
+ this.insertFreeSlot({ start: oldStart, count })
445
+ this.pendingMove = null
446
+ }
447
+
331
448
  const dstIndexBase = slot.start * INDICES_PER_QUAD
332
449
  const indexLen = slot.count * INDICES_PER_QUAD
333
450
  for (let i = 0; i < indexLen; i++) {
@@ -339,6 +456,7 @@ export class GlobalLegacyBuffer {
339
456
  this.insertFreeSlot(slot)
340
457
  this.shrinkHighWatermark()
341
458
  this.syncDefaultDrawGroups()
459
+ this.layoutVersion++
342
460
  }
343
461
 
344
462
  hasPendingUploads (): boolean {
@@ -505,6 +623,8 @@ export class GlobalLegacyBuffer {
505
623
  this.freeList.length = 0
506
624
  this.highWatermark = 0
507
625
  this.pendingRanges.length = 0
626
+ this.pendingMove = null
627
+ this.visibleIndexSpans = []
508
628
  this.syncDefaultDrawGroups()
509
629
  }
510
630
 
@@ -585,7 +705,125 @@ export class GlobalLegacyBuffer {
585
705
  }
586
706
  }
587
707
 
708
+ private interiorFreeQuads (): number {
709
+ let total = 0
710
+ for (const slot of this.freeList) {
711
+ if (slot.start < this.highWatermark) total += slot.count
712
+ }
713
+ return total
714
+ }
715
+
716
+ private findMovableSection (maxCount: number): { key: string, start: number, count: number } | undefined {
717
+ const sections: Array<{ key: string, start: number, count: number }> = []
718
+ for (const [key, slot] of this.sectionSlots) {
719
+ sections.push({ key, start: slot.start, count: slot.count })
720
+ }
721
+ if (sections.length === 0) return undefined
722
+
723
+ sections.sort((a, b) => {
724
+ if (b.start !== a.start) return b.start - a.start
725
+ return (b.start + b.count) - (a.start + a.count)
726
+ })
727
+
728
+ const tailmost = sections[0]!
729
+ if (tailmost.count <= maxCount && this.findLowestInteriorHole(tailmost.start, tailmost.count)) {
730
+ return tailmost
731
+ }
732
+
733
+ const candidates = sections
734
+ .filter(s => s.count <= maxCount)
735
+ .sort((a, b) => b.count - a.count)
736
+
737
+ for (const s of candidates) {
738
+ if (this.findLowestInteriorHole(s.start, s.count)) return s
739
+ }
740
+ return undefined
741
+ }
742
+
743
+ private findLowestInteriorHole (
744
+ sectionStart: number,
745
+ count: number,
746
+ ): { start: number, count: number, index: number } | undefined {
747
+ for (let i = 0; i < this.freeList.length; i++) {
748
+ const slot = this.freeList[i]!
749
+ if (slot.start < sectionStart && slot.count >= count) {
750
+ return { start: slot.start, count: slot.count, index: i }
751
+ }
752
+ }
753
+ return undefined
754
+ }
755
+
756
+ private reserveFreeSlotAt (index: number, count: number): { start: number, count: number } {
757
+ const slot = this.freeList[index]!
758
+ this.freeList.splice(index, 1)
759
+ if (slot.count === count) return { start: slot.start, count }
760
+ const used = { start: slot.start, count }
761
+ this.insertFreeSlot({ start: slot.start + count, count: slot.count - count })
762
+ return used
763
+ }
764
+
765
+ private copySectionRange (oldStart: number, newStart: number, quadCount: number): void {
766
+ const oldVertBase = oldStart * VERTS_PER_QUAD
767
+ const newVertBase = newStart * VERTS_PER_QUAD
768
+ const vertCount = quadCount * VERTS_PER_QUAD
769
+ const vertDelta = newVertBase - oldVertBase
770
+
771
+ const oldFloatBase = oldVertBase * FLOATS_PER_VERT
772
+ const newFloatBase = newVertBase * FLOATS_PER_VERT
773
+ const floatLen = vertCount * FLOATS_PER_VERT
774
+ this.positions.copyWithin(newFloatBase, oldFloatBase, oldFloatBase + floatLen)
775
+ this.colors.copyWithin(newFloatBase, oldFloatBase, oldFloatBase + floatLen)
776
+ this.aOrigin.copyWithin(newFloatBase, oldFloatBase, oldFloatBase + floatLen)
777
+
778
+ const oldLightBase = oldVertBase * FLOATS_PER_LIGHT_VERT
779
+ const newLightBase = newVertBase * FLOATS_PER_LIGHT_VERT
780
+ const lightLen = vertCount * FLOATS_PER_LIGHT_VERT
781
+ this.skyLights.copyWithin(newLightBase, oldLightBase, oldLightBase + lightLen)
782
+ this.blockLights.copyWithin(newLightBase, oldLightBase, oldLightBase + lightLen)
783
+
784
+ const oldUvBase = oldVertBase * FLOATS_PER_UV_VERT
785
+ const newUvBase = newVertBase * FLOATS_PER_UV_VERT
786
+ const uvLen = vertCount * FLOATS_PER_UV_VERT
787
+ this.uvs.copyWithin(newUvBase, oldUvBase, oldUvBase + uvLen)
788
+
789
+ const oldIndexBase = oldStart * INDICES_PER_QUAD
790
+ const newIndexBase = newStart * INDICES_PER_QUAD
791
+ const indexLen = quadCount * INDICES_PER_QUAD
792
+ for (let i = 0; i < indexLen; i++) {
793
+ this.indices[newIndexBase + i] = this.indices[oldIndexBase + i]! + vertDelta
794
+ }
795
+ }
796
+
797
+ private rangeFullyUploaded (start: number, end: number): boolean {
798
+ for (const r of this.pendingRanges) {
799
+ if (r.start <= end && r.end >= start) return false
800
+ }
801
+ return true
802
+ }
803
+
804
+ private finalizePendingMove (): void {
805
+ const move = this.pendingMove
806
+ if (!move) return
807
+
808
+ const { oldStart, count } = move
809
+ const oldIndexBase = oldStart * INDICES_PER_QUAD
810
+ const oldIndexLen = count * INDICES_PER_QUAD
811
+ for (let i = 0; i < oldIndexLen; i++) {
812
+ this.indices[oldIndexBase + i] = 0
813
+ }
814
+ this.insertFreeSlot({ start: oldStart, count })
815
+ this.shrinkHighWatermark()
816
+ if (oldStart < this.highWatermark) {
817
+ this.markDirty(oldStart, oldStart + count - 1)
818
+ }
819
+ this.syncDefaultDrawGroups()
820
+ this.pendingMove = null
821
+ this.layoutVersion++
822
+ }
823
+
588
824
  private growCapacity (minQuads: number): void {
825
+ if (this.pendingMove) this.finalizePendingMove()
826
+
589
827
  let newCap = this.capacityQuads
590
828
  while (newCap < minQuads) newCap += this.growthIncrementQuads
591
829