minecraft-renderer 0.1.87 → 0.1.89

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.87",
3
+ "version": "0.1.89",
4
4
  "description": "The most Modular Minecraft world renderer with Three.js WebGL backend",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -253,19 +253,21 @@ function getBlockFromStateId(version: string, stateId: number) {
253
253
  return Block.fromStateId(stateId, 1)
254
254
  }
255
255
 
256
- function getAllShapesForState(version: string, stateId: number, blockProvider: WorldBlockProvider): Uint16Array[] {
257
- const cacheKey = `${version}:${stateId}`
258
- const cached = shapeCache.get(cacheKey)
259
- if (cached) return cached
260
-
261
- const shapes = DIR_KEYS.map(() => emptyShape())
262
- const blockObj = getBlockFromStateId(version, stateId)
263
- if (!blockObj || !blockRendersSolid(blockObj)) {
264
- shapeCache.set(cacheKey, shapes)
265
- return shapes
256
+ function isFullCubeRenderModel(models: BlockModelPartsResolved | undefined): boolean {
257
+ try {
258
+ if (!models?.length || models.length !== 1) return false
259
+ return models[0]!.every(v =>
260
+ (v.elements ?? []).every(e => {
261
+ return e.from[0] === 0 && e.from[1] === 0 && e.from[2] === 0 && e.to[0] === 16 && e.to[1] === 16 && e.to[2] === 16
262
+ })
263
+ )
264
+ } catch {
265
+ return false
266
266
  }
267
+ }
267
268
 
268
- const models = blockProvider.getAllResolvedModels0_1({ name: blockObj.name, properties: blockObj.getProperties() }, false) as BlockModelPartsResolved
269
+ function buildShapesFromRenderModel(models: BlockModelPartsResolved | undefined): Uint16Array[] {
270
+ const shapes = DIR_KEYS.map(() => emptyShape())
269
271
 
270
272
  for (const modelVars of models ?? []) {
271
273
  const model = modelVars[0]
@@ -289,10 +291,52 @@ function getAllShapesForState(version: string, stateId: number, blockProvider: W
289
291
  }
290
292
  }
291
293
 
292
- shapeCache.set(cacheKey, shapes)
293
294
  return shapes
294
295
  }
295
296
 
297
+ function buildShapesFromCollisionBoxes(boxes: number[][]): Uint16Array[] {
298
+ const shapes = DIR_KEYS.map(() => emptyShape())
299
+
300
+ for (const box of boxes) {
301
+ const x0 = snapBlockUnit(box[0]! * 16)
302
+ const y0 = snapBlockUnit(box[1]! * 16)
303
+ const z0 = snapBlockUnit(box[2]! * 16)
304
+ const x1 = snapBlockUnit(box[3]! * 16)
305
+ const y1 = snapBlockUnit(box[4]! * 16)
306
+ const z1 = snapBlockUnit(box[5]! * 16)
307
+
308
+ if (y1 === 16) orRectIntoShape(x0, z0, x1, z1, shapes[dirIndex([0, 1, 0])]!)
309
+ if (y0 === 0) orRectIntoShape(x0, z0, x1, z1, shapes[dirIndex([0, -1, 0])]!)
310
+ if (x1 === 16) orRectIntoShape(z0, y0, z1, y1, shapes[dirIndex([1, 0, 0])]!)
311
+ if (x0 === 0) orRectIntoShape(z0, y0, z1, y1, shapes[dirIndex([-1, 0, 0])]!)
312
+ if (z1 === 16) orRectIntoShape(x0, y0, x1, y1, shapes[dirIndex([0, 0, 1])]!)
313
+ if (z0 === 0) orRectIntoShape(x0, y0, x1, y1, shapes[dirIndex([0, 0, -1])]!)
314
+ }
315
+
316
+ return shapes
317
+ }
318
+
319
+ function getAllShapesForState(version: string, stateId: number, blockProvider: WorldBlockProvider): Uint16Array[] {
320
+ const cacheKey = `${version}:${stateId}`
321
+ const cached = shapeCache.get(cacheKey)
322
+ if (cached) return cached
323
+
324
+ const shapes = DIR_KEYS.map(() => emptyShape())
325
+ const blockObj = getBlockFromStateId(version, stateId)
326
+ if (!blockObj || !blockRendersSolid(blockObj)) {
327
+ shapeCache.set(cacheKey, shapes)
328
+ return shapes
329
+ }
330
+
331
+ const models = blockProvider.getAllResolvedModels0_1({ name: blockObj.name, properties: blockObj.getProperties() }, false) as BlockModelPartsResolved
332
+ const boxes = blockObj.shapes as number[][] | undefined
333
+
334
+ const resolvedShapes = isFullCubeRenderModel(models) ? buildShapesFromRenderModel(models) : boxes?.length ? buildShapesFromCollisionBoxes(boxes) : shapes
335
+
336
+ shapeCache.set(cacheKey, resolvedShapes)
337
+ return resolvedShapes
338
+ }
339
+
296
340
  export function getOcclusionShape(version: string, stateId: number, worldDir: CardinalDir, blockProvider: WorldBlockProvider): Uint16Array {
297
341
  return getAllShapesForState(version, stateId, blockProvider)[dirIndex(worldDir)]!
298
342
  }
@@ -73,6 +73,42 @@ test('getOcclusionShape: full cube covers entire plane', () => {
73
73
  }
74
74
  })
75
75
 
76
+ test('getOcclusionShape: cactus down occlusion is inset, not full footprint', () => {
77
+ const mcData = MinecraftData(VERSION)
78
+ const cactusId = mcData.blocksByName.cactus!.defaultState
79
+ const downShape = getOcclusionShape(VERSION, cactusId, [0, -1, 0], blockProvider())
80
+ expect(downShape[0]! & 1).toBe(0)
81
+ expect(downShape[0]! & (1 << 15)).toBe(0)
82
+ let covered = 0
83
+ for (let row = 0; row < 16; row++) {
84
+ for (let col = 0; col < 16; col++) {
85
+ if (downShape[row]! & (1 << col)) covered++
86
+ }
87
+ }
88
+ expect(covered).toBe(14 * 14)
89
+ expect(getOcclusionShape(VERSION, cactusId, [0, 1, 0], blockProvider())[0]).toBe(0)
90
+ })
91
+
92
+ test('faceIsCulled: grass top not culled by cactus above', () => {
93
+ const mcData = MinecraftData(VERSION)
94
+ const grassId = mcData.blocksByName.grass_block!.defaultState
95
+ const cactusId = mcData.blocksByName.cactus!.defaultState
96
+ const provider = blockProvider()
97
+ const blockObj = PrismarineBlockLoader(VERSION).fromStateId(grassId, 1)
98
+ const models = provider.getAllResolvedModels0_1({ name: blockObj.name, properties: blockObj.getProperties() }, false)
99
+ const element = models[0]![0]!.elements![0]!
100
+ expect(faceIsCulled(VERSION, element, 'up', cactusId, { stateId: grassId, name: 'grass_block' }, provider, [0, 1, 0], null)).toBe(false)
101
+ })
102
+
103
+ test('getOcclusionShape: soul sand keeps full render-model occlusion despite shorter collision', () => {
104
+ const mcData = MinecraftData(VERSION)
105
+ const soulSandId = mcData.blocksByName.soul_sand!.defaultState
106
+ const downShape = getOcclusionShape(VERSION, soulSandId, [0, -1, 0], blockProvider())
107
+ for (let row = 0; row < 16; row++) {
108
+ expect(downShape[row]).toBe(0xffff)
109
+ }
110
+ })
111
+
76
112
  test('getOcclusionShape: farmland side is shorter than full height', () => {
77
113
  const mcData = MinecraftData(VERSION)
78
114
  const farmlandId = mcData.blocksByName.farmland!.defaultState
@@ -1,7 +1,7 @@
1
1
  //@ts-nocheck
2
2
  import { BasePlaygroundScene } from '../baseScene'
3
3
 
4
- /** Manual check for issue #49: farmland field + cut copper stairs (1.18.2). */
4
+ /** Manual check for issue #49/#73: partial-block culling + cactus on grass (1.18.2). */
5
5
  export default class extends BasePlaygroundScene {
6
6
  override version = '1.18.2'
7
7
  enableCameraOrbitControl = true
@@ -27,5 +27,18 @@ export default class extends BasePlaygroundScene {
27
27
  this.addWorldBlock(20 + x, stairY, 4, 'cut_copper_stairs', stairProps)
28
28
  this.addWorldBlock(20 + x, stairY + 1, 4, 'cut_copper_stairs', stairProps)
29
29
  }
30
+
31
+ const cactusY = 64
32
+ for (let x = 0; x < 4; x++) {
33
+ this.addWorldBlock(30 + x, cactusY - 1, 8, 'grass_block')
34
+ this.addWorldBlock(30 + x, cactusY, 8, 'cactus')
35
+ }
36
+ this.addWorldBlock(35, cactusY - 1, 8, 'grass_block')
37
+ this.addWorldBlock(35, cactusY, 8, 'cactus')
38
+ this.addWorldBlock(35, cactusY + 1, 8, 'cactus')
39
+
40
+ this.addWorldBlock(30, cactusY - 1, 10, 'soul_sand')
41
+ this.addWorldBlock(31, cactusY - 1, 10, 'stone_slab', { type: 'bottom' })
42
+ this.addWorldBlock(32, cactusY - 1, 10, 'stone_slab', { type: 'bottom' })
30
43
  }
31
44
  }
@@ -159,6 +159,11 @@ export class ChunkMeshManager {
159
159
  private readonly _lastCullCamQuat = new THREE.Quaternion()
160
160
  private readonly _cullViewQuat = new THREE.Quaternion()
161
161
  private _cullCamInitialized = false
162
+ /** Visible blend section keys from last cull pass — used by per-quad blend sort. */
163
+ private _visibleBlendKeys: string[] = []
164
+ private readonly _lastBlendSortPos = new THREE.Vector3()
165
+ private _lastBlendSortLayoutVersion = -1
166
+ private _blendSortPosInitialized = false
162
167
  /** One instanced mesh for all shader-cube faces (single draw call). */
163
168
  globalBlockBuffer: GlobalBlockBuffer | null = null
164
169
  globalLegacyBuffer: GlobalLegacyBuffer | null = null
@@ -328,7 +333,8 @@ export class ChunkMeshManager {
328
333
  this.globalLegacyBlendBuffer = new GlobalLegacyBuffer(this.getGlobalLegacyBlendShaderMaterial(), this.scene, {
329
334
  name: 'globalLegacyBlend',
330
335
  initialCapacityQuads: 32_000,
331
- growthIncrementQuads: 32_000
336
+ growthIncrementQuads: 32_000,
337
+ sortBlend: true
332
338
  })
333
339
  this.globalLegacyBlendBuffer.setRenderOrigin(this.renderOrigin)
334
340
  }
@@ -459,6 +465,7 @@ export class ChunkMeshManager {
459
465
  const blendVisible = visible.filter(v => blendBuf?.hasSection(v.key))
460
466
  blendVisible.sort((a, b) => b.distSq - a.distSq)
461
467
  const blendKeys = blendVisible.map(v => v.key)
468
+ this._visibleBlendKeys = blendKeys
462
469
 
463
470
  const cubeVisibleKeys: string[] = []
464
471
  const visibleSlots: Array<{ start: number; count: number }> = []
@@ -532,6 +539,33 @@ export class ChunkMeshManager {
532
539
  this.updatePooledLegacyCullState(cameraWorldX, cameraWorldY, cameraWorldZ)
533
540
  }
534
541
 
542
+ private static readonly BLEND_RESORT_DISTANCE = 1.0
543
+
544
+ /** Per-quad back-to-front reorder within visible blend sections; throttled by camera translation. */
545
+ sortVisibleBlendSections(camX: number, camY: number, camZ: number): void {
546
+ const blendBuf = this.globalLegacyBlendBuffer
547
+ if (!blendBuf || this._visibleBlendKeys.length === 0) return
548
+
549
+ const layoutVersion = blendBuf.getLayoutVersion()
550
+ const layoutChanged = layoutVersion !== this._lastBlendSortLayoutVersion
551
+ if (this._blendSortPosInitialized && !layoutChanged) {
552
+ const dx = camX - this._lastBlendSortPos.x
553
+ const dy = camY - this._lastBlendSortPos.y
554
+ const dz = camZ - this._lastBlendSortPos.z
555
+ if (dx * dx + dy * dy + dz * dz < ChunkMeshManager.BLEND_RESORT_DISTANCE * ChunkMeshManager.BLEND_RESORT_DISTANCE) {
556
+ return
557
+ }
558
+ }
559
+
560
+ for (const key of this._visibleBlendKeys) {
561
+ blendBuf.reorderSectionBlendIndices(key, camX, camY, camZ)
562
+ }
563
+
564
+ this._lastBlendSortPos.set(camX, camY, camZ)
565
+ this._blendSortPosInitialized = true
566
+ this._lastBlendSortLayoutVersion = layoutVersion
567
+ }
568
+
535
569
  private bufferStateKey(): string {
536
570
  const b = this.globalBlockBuffer
537
571
  const o = this.globalLegacyBuffer
@@ -1,11 +1,5 @@
1
1
  //@ts-nocheck
2
- import {
3
- assertDrawSpansWithinLiveRanges,
4
- carveSpansAroundPendingRanges,
5
- FULL_DRAW_VISIBLE_FRACTION,
6
- MAX_OPAQUE_SPANS,
7
- type DirtyRange
8
- } from './globalLegacyBuffer'
2
+ import { carveSpansAroundPendingRanges, FULL_DRAW_VISIBLE_FRACTION, MAX_OPAQUE_SPANS, type DirtyRange } from './globalLegacyBuffer'
9
3
 
10
4
  export { FULL_DRAW_VISIBLE_FRACTION, MAX_OPAQUE_SPANS as MAX_CUBE_SPANS }
11
5
 
@@ -54,10 +48,8 @@ export function buildVisibleCubeSpans(
54
48
  } else {
55
49
  spans = visibleSlots.map(s => ({ start: s.start, count: s.count }))
56
50
  spans.sort((a, b) => a.start - b.start)
57
- const liveDrawRanges = spans.map(s => ({ ...s }))
58
51
  mergeCubeSpans(spans)
59
52
  spans = carveSpansAroundPendingRanges(spans, pendingRanges)
60
- assertDrawSpansWithinLiveRanges(spans, liveDrawRanges, 'globalBlockBuffer')
61
53
  return spans
62
54
  }
63
55
  return carveSpansAroundPendingRanges(spans, pendingRanges)
@@ -711,10 +711,8 @@ export class GlobalBlockBuffer {
711
711
  this.finalizePendingReplace(key)
712
712
  }
713
713
 
714
- console.warn('[globalBlockBuffer] growing faces', this.capacityFaces, '->', '(need', minFaces, ')')
715
714
  let newCap = this.capacityFaces
716
715
  while (newCap < minFaces) newCap += GROWTH_INCREMENT_FACES
717
- console.warn('[globalBlockBuffer] growing faces', this.capacityFaces, '->', newCap)
718
716
 
719
717
  const nw0 = new Uint32Array(newCap)
720
718
  const nw1 = new Uint32Array(newCap)
@@ -32,33 +32,6 @@ export const FULL_DRAW_VISIBLE_FRACTION = 0.75
32
32
  /** Initial multi_draw scratch size; arrays auto-grow — not a draw-call cap. */
33
33
  export const MAX_OPAQUE_SPANS = 64
34
34
 
35
- /** Dev assert: every quad in draw spans must lie in a live section's drawable range. */
36
- export function assertDrawSpansWithinLiveRanges(
37
- spans: ReadonlyArray<{ start: number; count: number }>,
38
- liveRanges: ReadonlyArray<{ start: number; count: number }>,
39
- bufferName: string
40
- ): void {
41
- for (const span of spans) {
42
- for (let q = span.start; q < span.start + span.count; q++) {
43
- let inLive = false
44
- for (const live of liveRanges) {
45
- if (q >= live.start && q < live.start + live.count) {
46
- inLive = true
47
- break
48
- }
49
- }
50
- if (!inLive) {
51
- console.error('[GlobalLegacyBuffer] draw span covers non-live quad', {
52
- buffer: bufferName,
53
- quad: q,
54
- span,
55
- liveRanges
56
- })
57
- }
58
- }
59
- }
60
- }
61
-
62
35
  export type DirtyRange = { start: number; end: number }
63
36
 
64
37
  /** Split draw spans to exclude quad/face ranges still in pendingRanges (not yet on GPU). */
@@ -96,6 +69,8 @@ export type GlobalLegacyBufferOptions = {
96
69
  name?: string
97
70
  initialCapacityQuads?: number
98
71
  growthIncrementQuads?: number
72
+ /** When true, pre-sort blend indices on addSection and support per-quad reorder. */
73
+ sortBlend?: boolean
99
74
  }
100
75
 
101
76
  export type VisibleSectionSpan = { key: string; distSq: number }
@@ -126,6 +101,7 @@ export class GlobalLegacyBuffer {
126
101
  readonly material: THREE.ShaderMaterial
127
102
 
128
103
  private readonly growthIncrementQuads: number
104
+ private readonly blendSort: boolean
129
105
  private capacityQuads: number
130
106
  private positions: Float32Array
131
107
  private colors: Float32Array
@@ -134,10 +110,15 @@ export class GlobalLegacyBuffer {
134
110
  private uvs: Float32Array
135
111
  private aOrigin: Float32Array
136
112
  private indices: Uint32Array
113
+ /** Section-relative world centroid per physical quad (3 floats each). */
114
+ private quadCentroids: Float32Array
115
+ /** Per-quad local index template (6 bytes each, values 0..3). */
116
+ private quadIndexTemplate: Uint8Array
137
117
  private readonly sectionSlots = new Map<string, { start: number; count: number }>()
138
118
  private freeList: Array<{ start: number; count: number }> = []
139
119
  private highWatermark = 0
140
120
  private pendingRanges: Array<{ start: number; end: number }> = []
121
+ private indexPendingRanges: Array<{ start: number; end: number }> = []
141
122
  private readonly _spanScratch: Array<{ start: number; count: number }> = []
142
123
  private renderOrigin: RenderOrigin = { x: 0, y: 0, z: 0 }
143
124
  private layoutVersion = 0
@@ -146,12 +127,20 @@ export class GlobalLegacyBuffer {
146
127
  private uploadEpoch = 0
147
128
  private visibleIndexSpans: LegacyDrawSpan[] = []
148
129
  private readonly _drawScratch: LegacyMultiDrawScratch = createLegacyMultiDrawScratch()
130
+ /** Reusable scratch for applyBlendSort; grown on demand, never allocated per call. */
131
+ private _sortOrder: Uint32Array = new Uint32Array(0)
132
+ private _sortDistSq: Float64Array = new Float64Array(0)
149
133
  private multiDrawCaps: LegacyMultiDrawCaps | null = null
150
134
  private debugOverlay = false
135
+ private _camX = 0
136
+ private _camY = 0
137
+ private _camZ = 0
138
+ private _camInitialized = false
151
139
 
152
140
  constructor(material: THREE.ShaderMaterial, scene: THREE.Object3D, opts?: GlobalLegacyBufferOptions) {
153
141
  this.material = material
154
142
  this.growthIncrementQuads = opts?.growthIncrementQuads ?? DEFAULT_GROWTH_INCREMENT_QUADS
143
+ this.blendSort = opts?.sortBlend ?? false
155
144
  this.capacityQuads = opts?.initialCapacityQuads ?? DEFAULT_INITIAL_CAPACITY_QUADS
156
145
  const maxVerts = this.capacityQuads * VERTS_PER_QUAD
157
146
  this.positions = new Float32Array(maxVerts * FLOATS_PER_VERT)
@@ -161,6 +150,8 @@ export class GlobalLegacyBuffer {
161
150
  this.uvs = new Float32Array(maxVerts * FLOATS_PER_UV_VERT)
162
151
  this.aOrigin = new Float32Array(maxVerts * FLOATS_PER_VERT)
163
152
  this.indices = new Uint32Array(this.capacityQuads * INDICES_PER_QUAD)
153
+ this.quadCentroids = new Float32Array(this.capacityQuads * 3)
154
+ this.quadIndexTemplate = new Uint8Array(this.capacityQuads * INDICES_PER_QUAD)
164
155
 
165
156
  const geometry = new THREE.BufferGeometry()
166
157
  const mkAttr = (arr: Float32Array, itemSize: number, name: string) => {
@@ -305,10 +296,38 @@ export class GlobalLegacyBuffer {
305
296
  this.indices[dstIndexBase + i] = geo.indices[i]! + vertexBase
306
297
  }
307
298
 
299
+ for (let q = 0; q < quadCount; q++) {
300
+ const physQuad = slot.start + q
301
+ const localVertBase = q * VERTS_PER_QUAD
302
+ const posBase = localVertBase * FLOATS_PER_VERT
303
+ let cx = 0
304
+ let cy = 0
305
+ let cz = 0
306
+ for (let v = 0; v < VERTS_PER_QUAD; v++) {
307
+ const p = posBase + v * FLOATS_PER_VERT
308
+ cx += geo.positions[p]!
309
+ cy += geo.positions[p + 1]!
310
+ cz += geo.positions[p + 2]!
311
+ }
312
+ const centBase = physQuad * 3
313
+ this.quadCentroids[centBase] = cx / VERTS_PER_QUAD
314
+ this.quadCentroids[centBase + 1] = cy / VERTS_PER_QUAD
315
+ this.quadCentroids[centBase + 2] = cz / VERTS_PER_QUAD
316
+
317
+ const idxBase = q * INDICES_PER_QUAD
318
+ const tmplBase = physQuad * INDICES_PER_QUAD
319
+ for (let i = 0; i < INDICES_PER_QUAD; i++) {
320
+ this.quadIndexTemplate[tmplBase + i] = geo.indices[idxBase + i]! - localVertBase
321
+ }
322
+ }
323
+
308
324
  this.sectionSlots.set(sectionKey, slot)
309
325
  if (isRemesh && previousSlot) {
310
326
  this.pendingReplace.set(sectionKey, { oldStart: previousSlot.start, oldCount: previousSlot.count })
311
327
  }
328
+ if (this.blendSort && this._camInitialized && quadCount >= 2) {
329
+ this.applyBlendSort(slot.start, quadCount, this._camX, this._camY, this._camZ)
330
+ }
312
331
  this.markDirty(slot.start, slot.start + quadCount - 1)
313
332
  this.syncDefaultDrawGroups()
314
333
  this.layoutVersion++
@@ -435,7 +454,6 @@ export class GlobalLegacyBuffer {
435
454
 
436
455
  if (mode === 'opaque') {
437
456
  let finalQuads: Array<{ start: number; count: number }>
438
- const liveDrawRanges = spans.map(s => ({ ...s }))
439
457
  const usedFullDraw = this.canUseFullDrawShortcut() && visibleQuadCount >= this.highWatermark * FULL_DRAW_VISIBLE_FRACTION
440
458
  if (usedFullDraw) {
441
459
  finalQuads = [{ start: 0, count: this.highWatermark }]
@@ -445,9 +463,6 @@ export class GlobalLegacyBuffer {
445
463
  finalQuads = spans
446
464
  }
447
465
  finalQuads = carveSpansAroundPendingRanges(finalQuads, this.pendingRanges)
448
- if (!usedFullDraw) {
449
- assertDrawSpansWithinLiveRanges(finalQuads, liveDrawRanges, this.mesh.name)
450
- }
451
466
  for (const span of finalQuads) {
452
467
  pushIndexSpan(span.start, span.count)
453
468
  }
@@ -558,6 +573,70 @@ export class GlobalLegacyBuffer {
558
573
  return this.pendingRanges.length > 0
559
574
  }
560
575
 
576
+ hasPendingIndexUploads(): boolean {
577
+ return this.indexPendingRanges.length > 0
578
+ }
579
+
580
+ /**
581
+ * Reorder a section's index buffer back-to-front by quad centroid distance to camera.
582
+ * Does not bump layoutVersion or uploadEpoch (draw spans unchanged).
583
+ */
584
+ reorderSectionBlendIndices(sectionKey: string, camX: number, camY: number, camZ: number): boolean {
585
+ const slot = this.sectionSlots.get(sectionKey)
586
+ if (!slot) return false
587
+ if (this.pendingMove?.key === sectionKey) return false
588
+ if (this.pendingReplace.has(sectionKey)) return false
589
+ if (!this.rangeFullyUploaded(slot.start, slot.start + slot.count - 1)) return false
590
+ if (slot.count < 2) return false
591
+
592
+ this.applyBlendSort(slot.start, slot.count, camX, camY, camZ)
593
+ this.markIndexDirty(slot.start, slot.start + slot.count - 1)
594
+ return true
595
+ }
596
+
597
+ /** Rewrite indices for [slotStart, slotStart+slotCount) back-to-front by quad centroid distance. */
598
+ private applyBlendSort(slotStart: number, slotCount: number, camX: number, camY: number, camZ: number): void {
599
+ const dstFloatBase = slotStart * VERTS_PER_QUAD * FLOATS_PER_VERT
600
+ const sx = this.aOrigin[dstFloatBase]! + this.renderOrigin.x
601
+ const sy = this.aOrigin[dstFloatBase + 1]! + this.renderOrigin.y
602
+ const sz = this.aOrigin[dstFloatBase + 2]! + this.renderOrigin.z
603
+
604
+ if (this._sortOrder.length < slotCount) {
605
+ this._sortOrder = new Uint32Array(slotCount)
606
+ this._sortDistSq = new Float64Array(slotCount)
607
+ }
608
+ const order = this._sortOrder
609
+ const distSq = this._sortDistSq
610
+ for (let p = 0; p < slotCount; p++) {
611
+ order[p] = p
612
+ const physQuad = slotStart + p
613
+ const centBase = physQuad * 3
614
+ const wx = sx + this.quadCentroids[centBase]!
615
+ const wy = sy + this.quadCentroids[centBase + 1]!
616
+ const wz = sz + this.quadCentroids[centBase + 2]!
617
+ const dx = wx - camX
618
+ const dy = wy - camY
619
+ const dz = wz - camZ
620
+ distSq[p] = dx * dx + dy * dy + dz * dz
621
+ }
622
+ const orderView = order.subarray(0, slotCount)
623
+ orderView.sort((a, b) => {
624
+ const d = distSq[b]! - distSq[a]!
625
+ if (d !== 0) return d
626
+ return a - b
627
+ })
628
+
629
+ for (let k = 0; k < slotCount; k++) {
630
+ const src = orderView[k]!
631
+ const srcVertBase = (slotStart + src) * VERTS_PER_QUAD
632
+ const dstIdxBase = (slotStart + k) * INDICES_PER_QUAD
633
+ const tmplBase = (slotStart + src) * INDICES_PER_QUAD
634
+ for (let i = 0; i < INDICES_PER_QUAD; i++) {
635
+ this.indices[dstIdxBase + i] = srcVertBase + this.quadIndexTemplate[tmplBase + i]!
636
+ }
637
+ }
638
+ }
639
+
561
640
  uploadDirtyRange(): void {
562
641
  const r = this.pendingRanges[0]
563
642
  if (!r) return
@@ -613,6 +692,27 @@ export class GlobalLegacyBuffer {
613
692
  this.uploadEpoch++
614
693
  }
615
694
 
695
+ uploadDirtyIndexRange(): void {
696
+ const r = this.indexPendingRanges[0]
697
+ if (!r) return
698
+
699
+ const quadOffset = r.start
700
+ const quadCount = Math.min(r.end - r.start + 1, MAX_UPLOAD_QUADS_PER_FRAME)
701
+ const indexOffset = quadOffset * INDICES_PER_QUAD
702
+ const indexCount = quadCount * INDICES_PER_QUAD
703
+
704
+ const indexAttr = this.mesh.geometry.index as THREE.BufferAttribute
705
+ indexAttr.clearUpdateRanges()
706
+ indexAttr.addUpdateRange(indexOffset, indexCount)
707
+ indexAttr.needsUpdate = true
708
+
709
+ if (quadOffset + quadCount > r.end) {
710
+ this.indexPendingRanges.shift()
711
+ } else {
712
+ r.start = quadOffset + quadCount
713
+ }
714
+ }
715
+
616
716
  setRenderOrigin(renderOrigin: RenderOrigin): void {
617
717
  this.renderOrigin = { ...renderOrigin }
618
718
  }
@@ -637,6 +737,10 @@ export class GlobalLegacyBuffer {
637
737
  }
638
738
 
639
739
  setCameraOrigin(x: number, y: number, z: number): void {
740
+ this._camX = x
741
+ this._camY = y
742
+ this._camZ = z
743
+ this._camInitialized = true
640
744
  const { originDelta, cameraOriginFrac } = computeCameraRelativeUniforms(this.renderOrigin, x, y, z)
641
745
  const u = this.material.uniforms.u_originDelta
642
746
  if (u?.value?.set) u.value.set(originDelta.x, originDelta.y, originDelta.z)
@@ -669,6 +773,7 @@ export class GlobalLegacyBuffer {
669
773
  this.freeList.length = 0
670
774
  this.highWatermark = 0
671
775
  this.pendingRanges.length = 0
776
+ this.indexPendingRanges.length = 0
672
777
  this.pendingMove = null
673
778
  this.pendingReplace.clear()
674
779
  this.uploadEpoch = 0
@@ -688,6 +793,29 @@ export class GlobalLegacyBuffer {
688
793
  this.mergePendingRanges()
689
794
  }
690
795
 
796
+ private markIndexDirty(start: number, end: number): void {
797
+ this.indexPendingRanges.push({ start, end })
798
+ this.indexPendingRanges.sort((a, b) => a.start - b.start)
799
+ this.mergeIndexPendingRanges()
800
+ }
801
+
802
+ private mergeIndexPendingRanges(): void {
803
+ if (this.indexPendingRanges.length < 2) return
804
+ const merged: Array<{ start: number; end: number }> = []
805
+ let cur = this.indexPendingRanges[0]!
806
+ for (let i = 1; i < this.indexPendingRanges.length; i++) {
807
+ const next = this.indexPendingRanges[i]!
808
+ if (next.start <= cur.end + 1) {
809
+ cur = { start: cur.start, end: Math.max(cur.end, next.end) }
810
+ } else {
811
+ merged.push(cur)
812
+ cur = next
813
+ }
814
+ }
815
+ merged.push(cur)
816
+ this.indexPendingRanges = merged
817
+ }
818
+
691
819
  private mergePendingRanges(): void {
692
820
  if (this.pendingRanges.length < 2) return
693
821
  const merged: Array<{ start: number; end: number }> = []
@@ -835,6 +963,14 @@ export class GlobalLegacyBuffer {
835
963
  for (let i = 0; i < indexLen; i++) {
836
964
  this.indices[newIndexBase + i] = this.indices[oldIndexBase + i]! + vertDelta
837
965
  }
966
+
967
+ const oldCentroidBase = oldStart * 3
968
+ const newCentroidBase = newStart * 3
969
+ this.quadCentroids.copyWithin(newCentroidBase, oldCentroidBase, oldCentroidBase + quadCount * 3)
970
+
971
+ const oldTemplateBase = oldStart * INDICES_PER_QUAD
972
+ const newTemplateBase = newStart * INDICES_PER_QUAD
973
+ this.quadIndexTemplate.copyWithin(newTemplateBase, oldTemplateBase, oldTemplateBase + quadCount * INDICES_PER_QUAD)
838
974
  }
839
975
 
840
976
  private rangeFullyUploaded(start: number, end: number): boolean {
@@ -898,6 +1034,8 @@ export class GlobalLegacyBuffer {
898
1034
  const nUv = new Float32Array(newMaxVerts * FLOATS_PER_UV_VERT)
899
1035
  const nOrigin = new Float32Array(newMaxVerts * FLOATS_PER_VERT)
900
1036
  const nIdx = new Uint32Array(newCap * INDICES_PER_QUAD)
1037
+ const nCentroids = new Float32Array(newCap * 3)
1038
+ const nTemplate = new Uint8Array(newCap * INDICES_PER_QUAD)
901
1039
 
902
1040
  nPos.set(this.positions)
903
1041
  nCol.set(this.colors)
@@ -906,6 +1044,8 @@ export class GlobalLegacyBuffer {
906
1044
  nUv.set(this.uvs)
907
1045
  nOrigin.set(this.aOrigin)
908
1046
  nIdx.set(this.indices)
1047
+ nCentroids.set(this.quadCentroids)
1048
+ nTemplate.set(this.quadIndexTemplate)
909
1049
 
910
1050
  this.positions = nPos
911
1051
  this.colors = nCol
@@ -914,6 +1054,8 @@ export class GlobalLegacyBuffer {
914
1054
  this.uvs = nUv
915
1055
  this.aOrigin = nOrigin
916
1056
  this.indices = nIdx
1057
+ this.quadCentroids = nCentroids
1058
+ this.quadIndexTemplate = nTemplate
917
1059
  this.capacityQuads = newCap
918
1060
 
919
1061
  const geometry = this.mesh.geometry
@@ -938,5 +1080,6 @@ export class GlobalLegacyBuffer {
938
1080
  geometry.setIndex(indexAttr)
939
1081
 
940
1082
  this.pendingRanges.length = 0
1083
+ this.indexPendingRanges.length = 0
941
1084
  }
942
1085
  }
@@ -228,7 +228,7 @@ export function createGlobalLegacyBlendMaterial(): THREE.ShaderMaterial {
228
228
  fragmentShader,
229
229
  uniforms: THREE.UniformsUtils.merge([THREE.UniformsLib.fog, legacyUniforms]),
230
230
  transparent: true,
231
- depthWrite: true,
231
+ depthWrite: false,
232
232
  depthTest: true,
233
233
  vertexColors: true,
234
234
  glslVersion: THREE.GLSL3,
@@ -2,6 +2,18 @@
2
2
  import { describe, expect, test } from 'vitest'
3
3
  import { buildVisibleCubeSpans } from '../cubeDrawSpans'
4
4
 
5
+ type DrawSpan = { start: number; count: number }
6
+
7
+ /** Every index in output spans must lie in the union of input section ranges. */
8
+ function expectEveryIndexInUnion(spans: DrawSpan[], sectionRanges: DrawSpan[]): void {
9
+ for (const span of spans) {
10
+ for (let i = span.start; i < span.start + span.count; i++) {
11
+ const inUnion = sectionRanges.some(r => i >= r.start && i < r.start + r.count)
12
+ expect(inUnion, `index ${i} not in union of section ranges`).toBe(true)
13
+ }
14
+ }
15
+ }
16
+
5
17
  describe('buildVisibleCubeSpans', () => {
6
18
  test('contiguous slots merge into one span', () => {
7
19
  const spans = buildVisibleCubeSpans(
@@ -100,4 +112,23 @@ describe('buildVisibleCubeSpans', () => {
100
112
  expect(buildVisibleCubeSpans([], 10)).toEqual([])
101
113
  expect(buildVisibleCubeSpans([{ start: 0, count: 1 }], 0)).toEqual([])
102
114
  })
115
+
116
+ test('merged adjacent sections stay within union of input ranges', () => {
117
+ const sectionRanges = [
118
+ { start: 100, count: 10 },
119
+ { start: 110, count: 10 }
120
+ ]
121
+ const spans = buildVisibleCubeSpans(sectionRanges, 120, false)
122
+ expect(spans).toEqual([{ start: 100, count: 20 }])
123
+ expectEveryIndexInUnion(spans, sectionRanges)
124
+ })
125
+
126
+ test('carved merged spans stay within union of input ranges', () => {
127
+ const sectionRanges = [
128
+ { start: 0, count: 10 },
129
+ { start: 10, count: 10 }
130
+ ]
131
+ const spans = buildVisibleCubeSpans(sectionRanges, 20, false, undefined, [{ start: 5, end: 14 }])
132
+ expectEveryIndexInUnion(spans, sectionRanges)
133
+ })
103
134
  })