minecraft-renderer 0.1.91 → 0.1.92

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.
@@ -35,6 +35,7 @@ import { disposeObject } from './threeJsUtils'
35
35
  import { getBannerTexture, createBannerMesh, releaseBannerTexture } from './bannerRenderer'
36
36
  import { getSignTexture, releaseSignTexture, disposeAllSignTextures } from './signTextureCache'
37
37
  import { BlockEntityLightRegistry } from '../lib/blockEntityLightRegistry'
38
+ import { SectionOcclusionCull, hsvToRgb } from './occlusion/sectionOcclusionCull'
38
39
 
39
40
  export interface ChunkMeshPool {
40
41
  mesh: THREE.Mesh
@@ -72,6 +73,8 @@ export interface SectionObject extends THREE.Group {
72
73
  worldX?: number
73
74
  worldY?: number
74
75
  worldZ?: number
76
+ /** Packed VisibilitySet from mesher (section occlusion graph). */
77
+ visibilitySet?: number
75
78
  foutain?: boolean
76
79
  /**
77
80
  * True while the section is held invisible by the "Batch Chunks Display"
@@ -170,6 +173,9 @@ export class ChunkMeshManager {
170
173
  globalLegacyBlendBuffer: GlobalLegacyBuffer | null = null
171
174
  /** Tight world AABBs for shader-cube frustum culling (section centers). */
172
175
  private readonly shaderSectionRaycastBoxes = new Map<string, ShaderSectionRaycastEntry>()
176
+ private readonly sectionOcclusionCull = new SectionOcclusionCull()
177
+ private _lastOcclusionVisibleKeys = ''
178
+ private _lastSmartCullEnabled: boolean | undefined
173
179
 
174
180
  // Performance tracking
175
181
  private hits = 0
@@ -399,7 +405,13 @@ export class ChunkMeshManager {
399
405
  }
400
406
  }
401
407
 
402
- private updatePooledLegacyCullState(cameraWorldX: number, cameraWorldY: number, cameraWorldZ: number): void {
408
+ private updatePooledLegacyCullState(
409
+ cameraWorldX: number,
410
+ cameraWorldY: number,
411
+ cameraWorldZ: number,
412
+ smartCull: boolean,
413
+ occlusionVisible: ReadonlySet<string>
414
+ ): void {
403
415
  for (const poolEntry of this.activeSections.values()) {
404
416
  const sectionKey = poolEntry.sectionKey
405
417
  if (!sectionKey) continue
@@ -419,16 +431,44 @@ export class ChunkMeshManager {
419
431
  this._legacyCullBoxMin,
420
432
  this._legacyCullBoxMax
421
433
  )
434
+ if (smartCull && !occlusionVisible.has(sectionKey)) {
435
+ poolEntry.mesh.visible = false
436
+ }
422
437
  }
423
438
  }
424
439
 
440
+ isSectionOcclusionVisible(sectionKey: string): boolean {
441
+ return this.sectionOcclusionCull.isSectionVisible(sectionKey)
442
+ }
443
+
444
+ /** Invalidate occlusion graph and mark cull dirty when smart-cull enablement changes. */
445
+ notifySmartCullChanged(smartCull: boolean): void {
446
+ if (this._lastSmartCullEnabled === smartCull) return
447
+ this._lastSmartCullEnabled = smartCull
448
+ this.sectionOcclusionCull.invalidate()
449
+ this.markCullDirty()
450
+ }
451
+
425
452
  /**
426
453
  * Shared section visibility + span groups for global legacy and cube buffers.
427
454
  */
428
- updateSectionCullAndSort(camera: THREE.Camera, cameraWorldX: number, cameraWorldY: number, cameraWorldZ: number): void {
455
+ updateSectionCullAndSort(camera: THREE.Camera, cameraWorldX: number, cameraWorldY: number, cameraWorldZ: number, smartCull: boolean): void {
429
456
  this._legacyCullProjScreen.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse)
430
457
  this._legacyCullFrustum.setFromProjectionMatrix(this._legacyCullProjScreen)
431
458
 
459
+ const sectionHeight = this.worldRenderer.getSectionHeight?.() ?? 16
460
+ const worldSizeParams = this.worldRenderer.worldSizeParams ?? { minY: 0, worldHeight: 256 }
461
+ const occlusionVisible = this.sectionOcclusionCull.update({
462
+ smartCull,
463
+ cameraWorldX,
464
+ cameraWorldY,
465
+ cameraWorldZ,
466
+ viewDistance: this.worldRenderer.viewDistance,
467
+ sectionHeight,
468
+ worldMinY: worldSizeParams.minY,
469
+ worldMaxY: worldSizeParams.minY + worldSizeParams.worldHeight
470
+ })
471
+
432
472
  const visible = this._visibleSectionSpans
433
473
  visible.length = 0
434
474
 
@@ -452,6 +492,14 @@ export class ChunkMeshManager {
452
492
  }
453
493
  }
454
494
 
495
+ if (smartCull) {
496
+ for (let i = visible.length - 1; i >= 0; i--) {
497
+ if (!occlusionVisible.has(visible[i]!.key)) {
498
+ visible.splice(i, 1)
499
+ }
500
+ }
501
+ }
502
+
455
503
  const opaqueBuf = this.globalLegacyBuffer
456
504
  const blendBuf = this.globalLegacyBlendBuffer
457
505
  const gb = this.globalBlockBuffer
@@ -493,6 +541,9 @@ export class ChunkMeshManager {
493
541
  if (!inFrustum) {
494
542
  return
495
543
  }
544
+ if (smartCull && !occlusionVisible.has(key)) {
545
+ return
546
+ }
496
547
  cubeVisibleKeys.push(key)
497
548
  const drawStart = gb.getSectionDrawStart(key)
498
549
  const drawCount = gb.getSectionDrawCount(key)
@@ -503,10 +554,13 @@ export class ChunkMeshManager {
503
554
  }
504
555
  cubeVisibleKeys.sort()
505
556
 
557
+ const occlusionFp = smartCull ? [...occlusionVisible].sort().join(',') : 'off'
506
558
  const fingerprint = [
507
559
  opaqueKeys.join(','),
508
560
  blendKeys.join(','),
509
561
  cubeVisibleKeys.join(','),
562
+ occlusionFp,
563
+ smartCull ? '1' : '0',
510
564
  opaqueBuf?.getLayoutVersion() ?? 0,
511
565
  blendBuf?.getLayoutVersion() ?? 0,
512
566
  gb?.getLayoutVersion() ?? 0,
@@ -516,10 +570,12 @@ export class ChunkMeshManager {
516
570
  ].join('|')
517
571
 
518
572
  if (fingerprint === this._lastCullFingerprint && !this.hasPendingBufferWork()) {
519
- this.updatePooledLegacyCullState(cameraWorldX, cameraWorldY, cameraWorldZ)
573
+ this.updatePooledLegacyCullState(cameraWorldX, cameraWorldY, cameraWorldZ, smartCull, occlusionVisible)
574
+ this._lastOcclusionVisibleKeys = occlusionFp
520
575
  return
521
576
  }
522
577
  this._lastCullFingerprint = fingerprint
578
+ this._lastOcclusionVisibleKeys = occlusionFp
523
579
 
524
580
  opaqueBuf?.updateDrawSpans(visible, 'opaque')
525
581
  blendBuf?.updateDrawSpans(visible, 'sortedBlend')
@@ -536,7 +592,7 @@ export class ChunkMeshManager {
536
592
  }
537
593
 
538
594
  this.lastBufferStateKey = this.bufferStateKey()
539
- this.updatePooledLegacyCullState(cameraWorldX, cameraWorldY, cameraWorldZ)
595
+ this.updatePooledLegacyCullState(cameraWorldX, cameraWorldY, cameraWorldZ, smartCull, occlusionVisible)
540
596
  }
541
597
 
542
598
  private static readonly BLEND_RESORT_DISTANCE = 1.0
@@ -999,6 +1055,8 @@ export class ChunkMeshManager {
999
1055
  sectionObject.worldX = geometryData.sx
1000
1056
  sectionObject.worldY = geometryData.sy
1001
1057
  sectionObject.worldZ = geometryData.sz
1058
+ sectionObject.visibilitySet = geometryData.visibilitySet
1059
+ this.sectionOcclusionCull.registerSection(sectionKey, geometryData.visibilitySet, geometryData.sx, geometryData.sy, geometryData.sz)
1002
1060
  // Stamp the section key so modules (e.g. sciFiWorldReveal) can resolve
1003
1061
  // mesh -> section without falling back to sceneOrigin world-position math.
1004
1062
  ;(sectionObject as any).sectionKey = sectionKey
@@ -1313,6 +1371,7 @@ export class ChunkMeshManager {
1313
1371
  this.globalLegacyBlendBuffer?.removeSection(sectionKey)
1314
1372
  this.maybeUnregisterLegacyCullSection(sectionKey)
1315
1373
  this.unregisterShaderSectionRaycastBox(sectionKey)
1374
+ this.sectionOcclusionCull.unregisterSection(sectionKey)
1316
1375
  }
1317
1376
  this.markCullDirty()
1318
1377
  delete sectionObject.deferredLegacyOpaque
@@ -1450,6 +1509,38 @@ export class ChunkMeshManager {
1450
1509
  }
1451
1510
  }
1452
1511
 
1512
+ /**
1513
+ * Debug overlay: tint section borders by occlusion BFS step (Java ChunkCullingDebugRenderer paths mode).
1514
+ */
1515
+ updateCaveCullingDebug(enabled: boolean, smartCull: boolean): void {
1516
+ if (!enabled) {
1517
+ for (const sectionKey of Object.keys(this.sectionObjects)) {
1518
+ const sectionObject = this.sectionObjects[sectionKey]
1519
+ if (sectionObject?.boxHelper && !this.worldRenderer.displayOptions?.inWorldRenderingConfig?.showChunkBorders) {
1520
+ sectionObject.boxHelper.visible = false
1521
+ }
1522
+ }
1523
+ return
1524
+ }
1525
+
1526
+ for (const [sectionKey, sectionObject] of Object.entries(this.sectionObjects)) {
1527
+ if (!sectionObject) continue
1528
+ if (!sectionObject.boxHelper) {
1529
+ this.updateBoxHelper(sectionKey, true)
1530
+ }
1531
+ const helper = sectionObject.boxHelper
1532
+ if (!helper) continue
1533
+ helper.visible = true
1534
+ const color = !smartCull
1535
+ ? 0x00_ff_00
1536
+ : this.sectionOcclusionCull.isSectionVisible(sectionKey)
1537
+ ? hsvToRgb(this.sectionOcclusionCull.getStep(sectionKey) ?? 0)
1538
+ : 0xff_00_00
1539
+ const mat = helper.material as THREE.LineBasicMaterial
1540
+ mat.color.setHex(color)
1541
+ }
1542
+ }
1543
+
1453
1544
  /**
1454
1545
  * Get mesh for section if it exists
1455
1546
  */
@@ -1516,6 +1607,14 @@ export class ChunkMeshManager {
1516
1607
  }
1517
1608
  }
1518
1609
 
1610
+ getDrawnStats(): { cubeFaces: number; legacyOpaqueQuads: number; legacyBlendQuads: number } {
1611
+ return {
1612
+ cubeFaces: this.globalBlockBuffer?.getVisibleFaceCount() ?? 0,
1613
+ legacyOpaqueQuads: this.globalLegacyBuffer?.getVisibleQuadCount() ?? 0,
1614
+ legacyBlendQuads: this.globalLegacyBlendBuffer?.getVisibleQuadCount() ?? 0
1615
+ }
1616
+ }
1617
+
1519
1618
  getStats() {
1520
1619
  const freeCount = this.meshPool.filter(entry => !entry.inUse).length
1521
1620
  const hitRate = this.hits + this.misses > 0 ? ((this.hits / (this.hits + this.misses)) * 100).toFixed(1) : '0'
@@ -457,7 +457,10 @@ export class Entities {
457
457
  const dz = entity.position.z - botPos.z
458
458
  const distanceSquared = dx * dx + dy * dy + dz * dz
459
459
 
460
- entity.visible = !!(distanceSquared < VISIBLE_DISTANCE || this.worldRenderer.shouldObjectVisible(entity))
460
+ const sectionKey = this.worldRenderer.entitySectionKey(entity.position.x, entity.position.y, entity.position.z)
461
+ const occlusionVisible = this.worldRenderer.isSectionOcclusionVisible(sectionKey)
462
+
463
+ entity.visible = !!((distanceSquared < VISIBLE_DISTANCE || this.worldRenderer.shouldObjectVisible(entity)) && occlusionVisible)
461
464
 
462
465
  this.maybeRenderPlayerSkin(entityIdRaw)
463
466
  }
@@ -147,6 +147,12 @@ export class GlobalBlockBuffer {
147
147
  return this.visibleSpans
148
148
  }
149
149
 
150
+ getVisibleFaceCount(): number {
151
+ let faces = 0
152
+ for (const s of this.visibleSpans) faces += s.count
153
+ return faces
154
+ }
155
+
150
156
  forEachSectionSlot(cb: (key: string, slot: { start: number; count: number }) => void): void {
151
157
  for (const [key, slot] of this.sectionSlots) {
152
158
  cb(key, slot)
@@ -216,6 +216,12 @@ export class GlobalLegacyBuffer {
216
216
  return this.visibleIndexSpans
217
217
  }
218
218
 
219
+ getVisibleQuadCount(): number {
220
+ let indices = 0
221
+ for (const s of this.visibleIndexSpans) indices += s.indexCount
222
+ return indices / INDICES_PER_QUAD
223
+ }
224
+
219
225
  private syncDefaultDrawGroups(): void {
220
226
  const geometry = this.mesh.geometry
221
227
  geometry.clearGroups()
@@ -0,0 +1,90 @@
1
+ //@ts-nocheck
2
+ import { SectionOcclusionGraph, type OcclusionSectionRecord, type OcclusionUpdateParams } from './sectionOcclusionGraph'
3
+ import { VISIBILITY_SET_ALL_TRUE } from '../../mesher-shared/visibilitySet'
4
+
5
+ export type { OcclusionSectionRecord, OcclusionUpdateParams }
6
+
7
+ export class SectionOcclusionCull {
8
+ private readonly graph = new SectionOcclusionGraph()
9
+ private readonly registered = new Set<string>()
10
+ private lastVisible = new Set<string>()
11
+
12
+ registerSection(key: string, visibilitySet: number | undefined, worldX: number, worldY: number, worldZ: number): void {
13
+ this.registered.add(key)
14
+ this.graph.registerSection(key, {
15
+ visibilitySet: visibilitySet ?? VISIBILITY_SET_ALL_TRUE,
16
+ worldX,
17
+ worldY,
18
+ worldZ
19
+ })
20
+ }
21
+
22
+ unregisterSection(key: string): void {
23
+ this.registered.delete(key)
24
+ this.graph.unregisterSection(key)
25
+ }
26
+
27
+ invalidate(): void {
28
+ this.graph.invalidate()
29
+ }
30
+
31
+ update(params: OcclusionUpdateParams): Set<string> {
32
+ this.lastVisible = this.graph.update(params)
33
+ return this.lastVisible
34
+ }
35
+
36
+ isSectionVisible(key: string): boolean {
37
+ if (!this.registered.has(key)) return true
38
+ return this.lastVisible.has(key)
39
+ }
40
+
41
+ hasRegisteredSection(key: string): boolean {
42
+ return this.registered.has(key)
43
+ }
44
+
45
+ getVisibleKeys(): ReadonlySet<string> {
46
+ return this.lastVisible
47
+ }
48
+
49
+ getStep(key: string): number | undefined {
50
+ return this.graph.getStep(key)
51
+ }
52
+
53
+ getGraph(): SectionOcclusionGraph {
54
+ return this.graph
55
+ }
56
+ }
57
+
58
+ export function hsvToRgb(step: number): number {
59
+ const hue = (step % 50) / 50
60
+ const h = hue * 6
61
+ const c = 0.9
62
+ const x = c * (1 - Math.abs((h % 2) - 1))
63
+ let r = 0
64
+ let g = 0
65
+ let b = 0
66
+ if (h < 1) {
67
+ r = c
68
+ g = x
69
+ } else if (h < 2) {
70
+ r = x
71
+ g = c
72
+ } else if (h < 3) {
73
+ g = c
74
+ b = x
75
+ } else if (h < 4) {
76
+ g = x
77
+ b = c
78
+ } else if (h < 5) {
79
+ r = x
80
+ b = c
81
+ } else {
82
+ r = c
83
+ b = x
84
+ }
85
+ const m = 0.1
86
+ const ri = Math.round((r + m) * 255)
87
+ const gi = Math.round((g + m) * 255)
88
+ const bi = Math.round((b + m) * 255)
89
+ return (ri << 16) | (gi << 8) | bi
90
+ }
@@ -0,0 +1,272 @@
1
+ //@ts-nocheck
2
+ /**
3
+ * Sync port of Minecraft SectionOcclusionGraph BFS (v1 — no Octree/async/advanced ray-march).
4
+ * @see extracted_minecraft_data/client/net/minecraft/client/renderer/SectionOcclusionGraph.java
5
+ */
6
+
7
+ import { Vec3 } from 'vec3'
8
+ import { Direction, DIRECTIONS, VISIBILITY_SET_ALL_TRUE, oppositeDirection, visibilityBetweenPacked } from '../../mesher-shared/visibilitySet'
9
+
10
+ export type OcclusionSectionRecord = {
11
+ visibilitySet: number
12
+ worldX: number
13
+ worldY: number
14
+ worldZ: number
15
+ }
16
+
17
+ export type OcclusionUpdateParams = {
18
+ smartCull: boolean
19
+ cameraWorldX: number
20
+ cameraWorldY: number
21
+ cameraWorldZ: number
22
+ viewDistance: number
23
+ sectionHeight: number
24
+ worldMinY: number
25
+ worldMaxY: number
26
+ }
27
+
28
+ export class OcclusionNode {
29
+ readonly sectionKey: string
30
+ private sourceDirections = 0
31
+ directions = 0
32
+ readonly step: number
33
+
34
+ constructor(sectionKey: string, sourceDir: Direction | null, step: number) {
35
+ this.sectionKey = sectionKey
36
+ this.step = step
37
+ if (sourceDir != null) {
38
+ this.addSourceDirection(sourceDir)
39
+ }
40
+ }
41
+
42
+ setDirections(fromParent: number, exitDir: Direction): void {
43
+ this.directions = (fromParent | this.directions | (1 << exitDir)) >>> 0
44
+ }
45
+
46
+ hasDirection(dir: Direction): boolean {
47
+ return (this.directions & (1 << dir)) !== 0
48
+ }
49
+
50
+ addSourceDirection(dir: Direction): void {
51
+ this.sourceDirections = (this.sourceDirections | (1 << dir)) >>> 0
52
+ }
53
+
54
+ hasSourceDirection(index: number): boolean {
55
+ return (this.sourceDirections & (1 << index)) !== 0
56
+ }
57
+
58
+ hasSourceDirections(): boolean {
59
+ return this.sourceDirections !== 0
60
+ }
61
+ }
62
+
63
+ function sectionKeyFromWorld(worldX: number, worldY: number, worldZ: number): string {
64
+ return `${worldX},${worldY},${worldZ}`
65
+ }
66
+
67
+ function parseSectionKey(key: string): { x: number; y: number; z: number } {
68
+ const [x, y, z] = key.split(',').map(Number)
69
+ return { x: x!, y: y!, z: z! }
70
+ }
71
+
72
+ function getNeighborKey(key: string, dir: Direction, sectionHeight: number): string {
73
+ const { x, y, z } = parseSectionKey(key)
74
+ switch (dir) {
75
+ case Direction.DOWN:
76
+ return sectionKeyFromWorld(x, y - sectionHeight, z)
77
+ case Direction.UP:
78
+ return sectionKeyFromWorld(x, y + sectionHeight, z)
79
+ case Direction.NORTH:
80
+ return sectionKeyFromWorld(x, y, z - 16)
81
+ case Direction.SOUTH:
82
+ return sectionKeyFromWorld(x, y, z + 16)
83
+ case Direction.WEST:
84
+ return sectionKeyFromWorld(x - 16, y, z)
85
+ case Direction.EAST:
86
+ return sectionKeyFromWorld(x + 16, y, z)
87
+ default:
88
+ return key
89
+ }
90
+ }
91
+
92
+ function isInViewDistance(cameraKey: string, sectionKey: string, viewDistance: number, sectionHeight: number): boolean {
93
+ const cam = parseSectionKey(cameraKey)
94
+ const sec = parseSectionKey(sectionKey)
95
+ const camChunkX = Math.floor(cam.x / 16)
96
+ const camChunkZ = Math.floor(cam.z / 16)
97
+ const secChunkX = Math.floor(sec.x / 16)
98
+ const secChunkZ = Math.floor(sec.z / 16)
99
+ if (Math.abs(secChunkX - camChunkX) > viewDistance || Math.abs(secChunkZ - camChunkZ) > viewDistance) {
100
+ return false
101
+ }
102
+ const camSecY = Math.floor(cam.y / sectionHeight)
103
+ const secSecY = Math.floor(sec.y / sectionHeight)
104
+ return Math.abs(secSecY - camSecY) <= viewDistance
105
+ }
106
+
107
+ export class SectionOcclusionGraph {
108
+ private readonly sections = new Map<string, OcclusionSectionRecord>()
109
+ private readonly nodeByKey = new Map<string, OcclusionNode>()
110
+ private visibleKeys = new Set<string>()
111
+ private stepByKey = new Map<string, number>()
112
+ private needsFullUpdate = true
113
+ private lastCameraKey = ''
114
+
115
+ registerSection(key: string, record: OcclusionSectionRecord): void {
116
+ // v1: every register/unregister triggers a synchronous full BFS on next update (no partial graph).
117
+ this.sections.set(key, record)
118
+ this.needsFullUpdate = true
119
+ }
120
+
121
+ unregisterSection(key: string): void {
122
+ if (this.sections.delete(key)) {
123
+ this.needsFullUpdate = true
124
+ }
125
+ this.nodeByKey.delete(key)
126
+ this.visibleKeys.delete(key)
127
+ this.stepByKey.delete(key)
128
+ }
129
+
130
+ invalidate(): void {
131
+ this.needsFullUpdate = true
132
+ }
133
+
134
+ getVisibleKeys(): ReadonlySet<string> {
135
+ return this.visibleKeys
136
+ }
137
+
138
+ getStep(key: string): number | undefined {
139
+ return this.stepByKey.get(key)
140
+ }
141
+
142
+ isVisible(key: string): boolean {
143
+ return this.visibleKeys.has(key)
144
+ }
145
+
146
+ update(params: OcclusionUpdateParams): Set<string> {
147
+ const cameraKey = sectionKeyFromWorld(
148
+ Math.floor(params.cameraWorldX / 16) * 16,
149
+ Math.floor(params.cameraWorldY / params.sectionHeight) * params.sectionHeight,
150
+ Math.floor(params.cameraWorldZ / 16) * 16
151
+ )
152
+
153
+ if (cameraKey !== this.lastCameraKey) {
154
+ this.needsFullUpdate = true
155
+ this.lastCameraKey = cameraKey
156
+ }
157
+
158
+ if (!params.smartCull) {
159
+ this.visibleKeys = new Set(this.sections.keys())
160
+ this.stepByKey.clear()
161
+ for (const key of this.visibleKeys) {
162
+ this.stepByKey.set(key, 0)
163
+ }
164
+ return this.visibleKeys
165
+ }
166
+
167
+ if (this.needsFullUpdate) {
168
+ this.runFullUpdate(cameraKey, params)
169
+ this.needsFullUpdate = false
170
+ }
171
+
172
+ return this.visibleKeys
173
+ }
174
+
175
+ private runFullUpdate(cameraKey: string, params: OcclusionUpdateParams): void {
176
+ this.nodeByKey.clear()
177
+ this.visibleKeys = new Set()
178
+ this.stepByKey.clear()
179
+
180
+ const queue: OcclusionNode[] = []
181
+ this.initializeQueueForFullUpdate(cameraKey, queue, params)
182
+
183
+ while (queue.length > 0) {
184
+ const node = queue.shift()!
185
+ const sectionKey = node.sectionKey
186
+ if (!this.sections.has(sectionKey)) continue
187
+
188
+ this.visibleKeys.add(sectionKey)
189
+ this.stepByKey.set(sectionKey, node.step)
190
+
191
+ const visibilitySet = this.sections.get(sectionKey)?.visibilitySet ?? VISIBILITY_SET_ALL_TRUE
192
+
193
+ for (const exitDir of DIRECTIONS) {
194
+ const neighborSectionKey = getNeighborKey(sectionKey, exitDir, params.sectionHeight)
195
+ if (!this.sections.has(neighborSectionKey)) continue
196
+ if (!isInViewDistance(cameraKey, neighborSectionKey, params.viewDistance, params.sectionHeight)) continue
197
+
198
+ if (node.hasDirection(oppositeDirection(exitDir))) continue
199
+
200
+ if (node.hasSourceDirections()) {
201
+ let canSee = false
202
+ for (let i = 0; i < DIRECTIONS.length; i++) {
203
+ if (node.hasSourceDirection(i) && visibilityBetweenPacked(visibilitySet, oppositeDirection(DIRECTIONS[i]!), exitDir)) {
204
+ canSee = true
205
+ break
206
+ }
207
+ }
208
+ if (!canSee) continue
209
+ }
210
+
211
+ const existing = this.nodeByKey.get(neighborSectionKey)
212
+ if (existing) {
213
+ existing.addSourceDirection(exitDir)
214
+ } else {
215
+ const next = new OcclusionNode(neighborSectionKey, exitDir, node.step + 1)
216
+ next.setDirections(node.directions, exitDir)
217
+ this.nodeByKey.set(neighborSectionKey, next)
218
+ queue.push(next)
219
+ }
220
+ }
221
+ }
222
+ }
223
+
224
+ /** @see SectionOcclusionGraph.initializeQueueForFullUpdate */
225
+ private initializeQueueForFullUpdate(cameraKey: string, queue: OcclusionNode[], params: OcclusionUpdateParams): void {
226
+ if (this.sections.has(cameraKey)) {
227
+ const node = new OcclusionNode(cameraKey, null, 0)
228
+ this.nodeByKey.set(cameraKey, node)
229
+ queue.push(node)
230
+ return
231
+ }
232
+
233
+ const cam = parseSectionKey(cameraKey)
234
+ const camSecY = Math.floor(cam.y / params.sectionHeight)
235
+ const minSecY = Math.floor(params.worldMinY / params.sectionHeight)
236
+ const maxSecY = Math.floor((params.worldMaxY - 1) / params.sectionHeight)
237
+ const belowMin = camSecY < minSecY
238
+ const surfaceSecY = belowMin ? minSecY : maxSecY
239
+ const surfaceY = surfaceSecY * params.sectionHeight
240
+
241
+ const camChunkX = Math.floor(cam.x / 16)
242
+ const camChunkZ = Math.floor(cam.z / 16)
243
+ const seeds: Array<{ node: OcclusionNode; distSq: number }> = []
244
+
245
+ for (let dx = -params.viewDistance; dx <= params.viewDistance; dx++) {
246
+ for (let dz = -params.viewDistance; dz <= params.viewDistance; dz++) {
247
+ const key = sectionKeyFromWorld((camChunkX + dx) * 16, surfaceY, (camChunkZ + dz) * 16)
248
+ if (!this.sections.has(key)) continue
249
+ if (!isInViewDistance(cameraKey, key, params.viewDistance, params.sectionHeight)) continue
250
+
251
+ const entryDir = belowMin ? Direction.UP : Direction.DOWN
252
+ const node = new OcclusionNode(key, entryDir, 0)
253
+ node.setDirections(0, entryDir)
254
+ if (dx > 0) node.setDirections(node.directions, Direction.EAST)
255
+ else if (dx < 0) node.setDirections(node.directions, Direction.WEST)
256
+ if (dz > 0) node.setDirections(node.directions, Direction.SOUTH)
257
+ else if (dz < 0) node.setDirections(node.directions, Direction.NORTH)
258
+
259
+ const center = new Vec3(cam.x + 8, cam.y + 8, cam.z + 8)
260
+ const sec = parseSectionKey(key)
261
+ const distSq = center.distanceTo(new Vec3(sec.x + 8, sec.y + 8, sec.z + 8))
262
+ seeds.push({ node, distSq })
263
+ }
264
+ }
265
+
266
+ seeds.sort((a, b) => a.distSq - b.distSq)
267
+ for (const seed of seeds) {
268
+ this.nodeByKey.set(seed.node.sectionKey, seed.node)
269
+ queue.push(seed.node)
270
+ }
271
+ }
272
+ }
@@ -243,13 +243,13 @@ test('ChunkMeshManager: hidden section excluded from draw spans', () => {
243
243
  camera.lookAt(8, 8, 8)
244
244
  camera.updateMatrixWorld()
245
245
 
246
- manager.updateSectionCullAndSort(camera, 8, 8, 20)
246
+ manager.updateSectionCullAndSort(camera, 8, 8, 20, false)
247
247
  expect(manager.globalLegacyBlendBuffer?.getVisibleIndexSpans().length).toBe(0)
248
248
 
249
249
  section.visible = true
250
250
  const blendBuf = manager.globalLegacyBlendBuffer!
251
251
  while (blendBuf.hasPendingUploads()) blendBuf.uploadDirtyRange()
252
- manager.updateSectionCullAndSort(camera, 8, 8, 20)
252
+ manager.updateSectionCullAndSort(camera, 8, 8, 20, false)
253
253
  expect(manager.globalLegacyBlendBuffer?.getVisibleIndexSpans().length).toBeGreaterThan(0)
254
254
 
255
255
  manager.cleanupSection(key)
@@ -323,15 +323,15 @@ test('ChunkMeshManager: hidden cube section excluded from draw spans', () => {
323
323
  drainCubeUploads(manager)
324
324
 
325
325
  const camera = makeCullCamera()
326
- manager.updateSectionCullAndSort(camera, 8, 8, 20)
326
+ manager.updateSectionCullAndSort(camera, 8, 8, 20, false)
327
327
  expect(manager.globalBlockBuffer?.getVisibleSpans().length).toBeGreaterThan(0)
328
328
 
329
329
  section.visible = false
330
- manager.updateSectionCullAndSort(camera, 8, 8, 20)
330
+ manager.updateSectionCullAndSort(camera, 8, 8, 20, false)
331
331
  expect(manager.globalBlockBuffer?.getVisibleSpans().length).toBe(0)
332
332
 
333
333
  section.visible = true
334
- manager.updateSectionCullAndSort(camera, 8, 8, 20)
334
+ manager.updateSectionCullAndSort(camera, 8, 8, 20, false)
335
335
  expect(manager.globalBlockBuffer?.getVisibleSpans().length).toBeGreaterThan(0)
336
336
 
337
337
  manager.cleanupSection(key)
@@ -346,7 +346,7 @@ test('ChunkMeshManager: finishChunkDisplay reveals cube spans and marks cull dir
346
346
  expect(manager.globalBlockBuffer?.hasSection(key)).toBe(true)
347
347
 
348
348
  const camera = makeCullCamera()
349
- manager.updateSectionCullAndSort(camera, 8, 8, 20)
349
+ manager.updateSectionCullAndSort(camera, 8, 8, 20, false)
350
350
  expect(manager.globalBlockBuffer?.getVisibleSpans().length).toBe(0)
351
351
 
352
352
  const markCullDirtySpy = vi.spyOn(manager, 'markCullDirty')
@@ -355,7 +355,7 @@ test('ChunkMeshManager: finishChunkDisplay reveals cube spans and marks cull dir
355
355
  expect(markCullDirtySpy).toHaveBeenCalled()
356
356
 
357
357
  drainCubeUploads(manager)
358
- manager.updateSectionCullAndSort(camera, 8, 8, 20)
358
+ manager.updateSectionCullAndSort(camera, 8, 8, 20, false)
359
359
  expect(manager.globalBlockBuffer?.getVisibleSpans().length).toBeGreaterThan(0)
360
360
 
361
361
  markCullDirtySpy.mockRestore()