minecraft-renderer 0.1.37 → 0.1.39

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.
@@ -0,0 +1,163 @@
1
+ //@ts-nocheck
2
+ import { test, expect } from 'vitest'
3
+ import {
4
+ splitColumnWasmOutputToSections,
5
+ renderWasmOutputToGeometry,
6
+ WasmGeometryOutput,
7
+ } from '../../wasm-lib/render-from-wasm'
8
+
9
+ const VERSION = '1.16.5'
10
+ const STONE = 1 // 1.16.5 stone state id
11
+
12
+ // Face mask layout (matches FACE_DIRS in wasm-mesher / render-from-wasm.ts).
13
+ const FACE_UP = 1 << 0
14
+ const FACE_DOWN = 1 << 1
15
+ const FACE_NORTH = 1 << 2
16
+ const FACE_SOUTH = 1 << 3
17
+ const FACE_WEST = 1 << 4
18
+ const FACE_EAST = 1 << 5
19
+ const SIDE_FACES = FACE_NORTH | FACE_SOUTH | FACE_WEST | FACE_EAST
20
+
21
+ // Build a synthetic full-column WASM mesher output by hand. This avoids
22
+ // pulling in `wasm-pack`/the Rust crate from this unit test — the helper
23
+ // under test is pure JS, so an in-memory fixture is sufficient.
24
+ //
25
+ // Scenario: two adjacent stone blocks at world Y=15 and Y=16. Because
26
+ // they are stacked and opaque, Rust mesher would emit:
27
+ // - (x,15,z): bottom face only, top face is suppressed by Y=16
28
+ // - (x,16,z): top face only, bottom face is suppressed by Y=15
29
+ // We hand-craft that output here.
30
+ function makeSeamFixture(): WasmGeometryOutput {
31
+ const blocks: WasmGeometryOutput['blocks'] = []
32
+ for (let z = 0; z < 16; z++) {
33
+ for (let x = 0; x < 16; x++) {
34
+ blocks.push({
35
+ position: [x, 15, z],
36
+ block_state_id: STONE,
37
+ // Y=15 is occluded above by the Y=16 stone — the seam neighbor
38
+ // info is baked in here at the per-block level.
39
+ visible_faces: FACE_DOWN | SIDE_FACES,
40
+ ao_data: [],
41
+ light_data: [],
42
+ })
43
+ blocks.push({
44
+ position: [x, 16, z],
45
+ block_state_id: STONE,
46
+ // Y=16 is occluded below by the Y=15 stone (the seam from the
47
+ // other side).
48
+ visible_faces: FACE_UP | SIDE_FACES,
49
+ ao_data: [],
50
+ light_data: [],
51
+ })
52
+ }
53
+ }
54
+ // Add an isolated block in a third section, so we cover a section
55
+ // that has no seam interaction.
56
+ blocks.push({
57
+ position: [0, 64, 0],
58
+ block_state_id: STONE,
59
+ visible_faces: FACE_UP | FACE_DOWN | SIDE_FACES,
60
+ ao_data: [],
61
+ light_data: [],
62
+ })
63
+ return {
64
+ blocks,
65
+ block_count: blocks.length,
66
+ block_iterations: 0,
67
+ }
68
+ }
69
+
70
+ test('splitColumnWasmOutputToSections: per-section split is equivalent to manual filter+render at the y=15/16 seam', () => {
71
+ const fullColumn = makeSeamFixture()
72
+
73
+ const requested = [
74
+ { x: 0, y: 0, z: 0 }, // contains Y=15 row only
75
+ { x: 0, y: 16, z: 0 }, // contains Y=16 row only
76
+ { x: 0, y: 32, z: 0 }, // empty section
77
+ { x: 0, y: 64, z: 0 }, // contains the isolated block at Y=64
78
+ ]
79
+
80
+ const split = splitColumnWasmOutputToSections(fullColumn, requested, { version: VERSION })
81
+
82
+ expect(split.size).toBe(4)
83
+ for (const r of requested) {
84
+ expect(split.has(`${r.x},${r.y},${r.z}`)).toBe(true)
85
+ }
86
+
87
+ // Reference: do the same split manually (exactly what the helper is
88
+ // supposed to do internally) and confirm the rendered geometry
89
+ // matches byte-for-byte.
90
+ for (const r of requested) {
91
+ const yLo = r.y
92
+ const yHi = r.y + 16
93
+ const sectionBlocks = fullColumn.blocks.filter(b => b.position[1] >= yLo && b.position[1] < yHi)
94
+ const reference = renderWasmOutputToGeometry(
95
+ {
96
+ blocks: sectionBlocks,
97
+ block_count: sectionBlocks.length,
98
+ block_iterations: fullColumn.block_iterations,
99
+ },
100
+ VERSION,
101
+ `${r.x},${r.y},${r.z}`,
102
+ { x: r.x + 8, y: r.y + 8, z: r.z + 8 },
103
+ undefined
104
+ )
105
+ const got = split.get(`${r.x},${r.y},${r.z}`)!.exported
106
+ expect(got.key).toBe(reference.key)
107
+ expect(got.position).toEqual(reference.position)
108
+ expect(got.geometry.positions).toEqual(reference.geometry.positions)
109
+ expect(got.geometry.normals).toEqual(reference.geometry.normals)
110
+ expect(got.geometry.colors).toEqual(reference.geometry.colors)
111
+ expect(got.geometry.uvs).toEqual(reference.geometry.uvs)
112
+ expect(got.geometry.indices).toEqual(reference.geometry.indices)
113
+ }
114
+
115
+ // Empty section returns a real (non-undefined) entry with empty geometry
116
+ // buffers and zero blocksCount.
117
+ const emptyEntry = split.get('0,32,0')!
118
+ expect(emptyEntry).toBeDefined()
119
+ expect(emptyEntry.exported.geometry.positions).toEqual([])
120
+ expect(emptyEntry.exported.geometry.indices).toEqual([])
121
+ expect(emptyEntry.blocksCount).toBe(0)
122
+
123
+ // Seam preservation: the Y=15/16 sections produce non-empty geometry,
124
+ // and crucially the y=15 section does NOT contain the (Y=15 top-face)
125
+ // that would have been emitted if the helper failed to honor the
126
+ // already-baked-in `visible_faces` mask. The cleanest structural
127
+ // assertion is that the seam-sections each contain only the expected
128
+ // number of unique vertical faces. With FACE_UP/FACE_DOWN suppressed
129
+ // at the seam, each block in those sections contributes 1 horizontal
130
+ // face + 4 side faces = 5 quads; both sections also have identical
131
+ // block counts (256 blocks), so their vertex/index counts must match.
132
+ const lower = split.get('0,0,0')!.exported
133
+ const upper = split.get('0,16,0')!.exported
134
+ expect(lower.geometry.positions.length).toBeGreaterThan(0)
135
+ expect(upper.geometry.positions.length).toBeGreaterThan(0)
136
+ expect(lower.geometry.positions.length).toBe(upper.geometry.positions.length)
137
+ expect(lower.geometry.indices.length).toBe(upper.geometry.indices.length)
138
+ // blocksCount must reflect the per-section bucket size, not the column
139
+ // total. Both seam sections contain 256 blocks each.
140
+ expect(split.get('0,0,0')!.blocksCount).toBe(256)
141
+ expect(split.get('0,16,0')!.blocksCount).toBe(256)
142
+ })
143
+
144
+ test('splitColumnWasmOutputToSections: empty requested-keys list returns empty map', () => {
145
+ const fullColumn = makeSeamFixture()
146
+ const out = splitColumnWasmOutputToSections(fullColumn, [], { version: VERSION })
147
+ expect(out.size).toBe(0)
148
+ })
149
+
150
+ test('splitColumnWasmOutputToSections: blocks outside requested sections are dropped', () => {
151
+ const fullColumn = makeSeamFixture()
152
+ // Only request the empty Y=32 section. Y=15/16/64 blocks must NOT
153
+ // leak into it.
154
+ const out = splitColumnWasmOutputToSections(
155
+ fullColumn,
156
+ [{ x: 0, y: 32, z: 0 }],
157
+ { version: VERSION }
158
+ )
159
+ const empty = out.get('0,32,0')!
160
+ expect(empty.exported.geometry.positions).toEqual([])
161
+ expect(empty.exported.geometry.indices).toEqual([])
162
+ expect(empty.blocksCount).toBe(0)
163
+ })
@@ -28,17 +28,60 @@ export interface SectionObject extends THREE.Group {
28
28
  headsContainer?: THREE.Group
29
29
  bannersContainer?: THREE.Group
30
30
  boxHelper?: THREE.BoxHelper
31
+ /**
32
+ * World-space coordinates of the section origin. Cached so that
33
+ * {@link ChunkMeshManager.updateBoxHelper} can position lazily-created
34
+ * border helpers correctly under camera-relative rendering, where
35
+ * `mesh.position` is proxied to (world - sceneOrigin) and cannot be
36
+ * reused directly for objects that are tracked separately.
37
+ */
38
+ worldX?: number
39
+ worldY?: number
40
+ worldZ?: number
31
41
  foutain?: boolean
42
+ /**
43
+ * True while the section is held invisible by the "Batch Chunks Display"
44
+ * (`_renderByChunks`) feature, waiting for the parent chunk to finish meshing
45
+ * before being shown together with the rest of the chunk.
46
+ */
47
+ _waitingForChunkDisplay?: boolean
32
48
  }
33
49
 
34
50
  export class ChunkMeshManager {
35
51
  private readonly meshPool: ChunkMeshPool[] = []
36
52
  private readonly activeSections = new Map<string, ChunkMeshPool>()
37
53
  readonly sectionObjects: Record<string, SectionObject> = {}
54
+ /**
55
+ * Sections kept invisible because the "Batch Chunks Display" option is on
56
+ * and their parent chunk hasn't finished meshing yet. Keyed by chunk key
57
+ * (`x,z`); flushed by `WorldRendererThree.finishChunk(chunkKey)`.
58
+ */
59
+ readonly waitingChunksToDisplay: Record<string, string[]> = {}
60
+ /**
61
+ * Chunks whose mesh batch is fully ready but kept invisible by the
62
+ * WASM near-first reveal gate because at least one nearer column is
63
+ * not yet finished. Value = enqueue timestamp (ms), used by the
64
+ * expected-delivery grace window in `isBlockedByNearer`.
65
+ */
66
+ readonly pendingNearReveal = new Map<string, number>()
67
+ private readonly nearRevealTimers = new Map<string, ReturnType<typeof setTimeout>>()
68
+ private readonly nearRevealGraceTimers = new Map<string, ReturnType<typeof setTimeout>>()
69
+ // Force-flush pending reveal after this many ms (last-resort safety).
70
+ private static readonly NEAR_REVEAL_TIMEOUT_MS = 5000
71
+ // Soft window during which the gate also waits for *expected* nearer
72
+ // columns that have not arrived yet (covers far-worker-beats-near-worker).
73
+ private static readonly EXPECTED_NEAR_GRACE_MS = 1500
38
74
  private poolSize!: number
39
75
  private maxPoolSize!: number
40
76
  private minPoolSize!: number
41
77
  private readonly signHeadsRenderer: SignHeadsRenderer
78
+ /**
79
+ * Shared transparent material used as the basis for the wireframe chunk
80
+ * border `BoxHelper` created lazily in {@link updateBoxHelper}. Kept on the
81
+ * manager so the BoxHelper machinery doesn't allocate a new material per
82
+ * section.
83
+ */
84
+ private readonly chunkBoxMaterial = new THREE.MeshBasicMaterial({ color: 0x00_00_00, transparent: true, opacity: 0 })
42
85
 
43
86
  // Performance tracking
44
87
  private hits = 0
@@ -156,6 +199,17 @@ export class ChunkMeshManager {
156
199
  // Store metadata
157
200
  sectionObject.tilesCount = geometryData.positions.length / 3 / 4
158
201
  sectionObject.blocksCount = geometryData.blocksCount
202
+ sectionObject.worldX = geometryData.sx
203
+ sectionObject.worldY = geometryData.sy
204
+ sectionObject.worldZ = geometryData.sz
205
+ // Stamp the section key so modules (e.g. sciFiWorldReveal) can resolve
206
+ // mesh -> section without falling back to sceneOrigin world-position math.
207
+ ;(sectionObject as any).sectionKey = sectionKey
208
+ // Tag the group so `WorldRendererThree.getThirdPersonCamera` raycast can
209
+ // still find chunk meshes — the old `WorldBlockGeometry` set this name
210
+ // unconditionally; the pooling port lost that and only the border-helper
211
+ // path used to restore it.
212
+ sectionObject.name = 'chunk'
159
213
 
160
214
  try {
161
215
  // Add signs container
@@ -218,13 +272,220 @@ export class ChunkMeshManager {
218
272
  this.scene.add(sectionObject)
219
273
  sectionObject.matrixAutoUpdate = false
220
274
 
275
+ // Create chunk border helper eagerly when the option is on so freshly
276
+ // streamed sections immediately get the F3+G yellow wireframe instead of
277
+ // appearing only on the next toggle.
278
+ if (this.worldRenderer.displayOptions?.inWorldRenderingConfig?.showChunkBorders) {
279
+ this.updateBoxHelper(sectionKey, true)
280
+ }
281
+
282
+ // Honor "Batch Chunks Display" (`_renderByChunks`): keep this section's
283
+ // mesh hidden until the whole chunk has finished meshing, so users see a
284
+ // chunk appear as a single 16xHx16 tile instead of streaming per-section.
285
+ // Updates to chunks that are already finished bypass batching to avoid
286
+ // flickering on block changes / lighting updates.
287
+ // For the WASM column path we force batching ON regardless of the user
288
+ // setting so the near-first reveal gate has sections to hold.
289
+ const chunkCoords = sectionKey.split(',')
290
+ const chunkKey = `${chunkCoords[0]},${chunkCoords[2]}`
291
+ const renderByChunks = !!this.worldRenderer.displayOptions
292
+ ?.inWorldRenderingConfig?._renderByChunks
293
+ const forceBatchForWasm = !!this.worldRenderer.worldRendererConfig?.wasmMesher
294
+ if ((renderByChunks || forceBatchForWasm) && !this.worldRenderer.finishedChunks[chunkKey]) {
295
+ sectionObject.visible = false
296
+ sectionObject._waitingForChunkDisplay = true
297
+ const list = this.waitingChunksToDisplay[chunkKey] ?? (this.waitingChunksToDisplay[chunkKey] = [])
298
+ if (!list.includes(sectionKey)) list.push(sectionKey)
299
+ }
300
+
221
301
  return sectionObject
222
302
  }
223
303
 
304
+ /**
305
+ * Reveal all sections of a chunk that were held invisible by the
306
+ * "Batch Chunks Display" option. Called from `WorldRendererThree.finishChunk`.
307
+ *
308
+ * For the WASM path: if any nearer column is not yet finished, the
309
+ * reveal is deferred (parked in `pendingNearReveal`) and re-checked on
310
+ * the next chunkFinished / player-move / grace-expiry.
311
+ */
312
+ finishChunkDisplay (chunkKey: string): void {
313
+ const sectionKeys = this.waitingChunksToDisplay[chunkKey]
314
+ if (!sectionKeys) {
315
+ // No held sections (empty column / non-batched path) — but the
316
+ // chunk just transitioned to finished, so re-check pending farther.
317
+ this.tryRevealPending()
318
+ return
319
+ }
320
+ if (this.isWasmGateActive() && this.isBlockedByNearer(chunkKey, 0)) {
321
+ this.pendingNearReveal.set(chunkKey, Date.now())
322
+ this.armNearRevealTimer(chunkKey)
323
+ this.armExpectedGraceTimer(chunkKey)
324
+ return
325
+ }
326
+ this.flushChunkDisplay(chunkKey)
327
+ this.tryRevealPending()
328
+ }
329
+
330
+ private flushChunkDisplay (chunkKey: string): void {
331
+ const sectionKeys = this.waitingChunksToDisplay[chunkKey]
332
+ this.pendingNearReveal.delete(chunkKey)
333
+ this.clearNearRevealTimer(chunkKey)
334
+ this.clearExpectedGraceTimer(chunkKey)
335
+ if (!sectionKeys) return
336
+ for (const sectionKey of sectionKeys) {
337
+ const sectionObject = this.sectionObjects[sectionKey]
338
+ if (!sectionObject) continue
339
+ sectionObject._waitingForChunkDisplay = false
340
+ sectionObject.visible = true
341
+ }
342
+ delete this.waitingChunksToDisplay[chunkKey]
343
+ }
344
+
345
+ // Re-check every parked entry; each has its own grace window via `ageMs`.
346
+ // Single pass is enough — pending entries are already finished, so flushing
347
+ // one cannot un-block another via this code path (cascading happens via
348
+ // chunkFinished events and per-pending grace timers).
349
+ tryRevealPending (): void {
350
+ if (this.pendingNearReveal.size === 0) return
351
+ const now = Date.now()
352
+ for (const [chunkKey, enqueuedAt] of [...this.pendingNearReveal]) {
353
+ if (!this.isBlockedByNearer(chunkKey, now - enqueuedAt)) {
354
+ this.flushChunkDisplay(chunkKey)
355
+ }
356
+ }
357
+ }
358
+
359
+ // Drop gate state for an unloaded column and re-evaluate any farther
360
+ // chunks that may have been blocked by it.
361
+ onChunkRemovedFromGate (chunkKey: string): void {
362
+ this.pendingNearReveal.delete(chunkKey)
363
+ this.clearNearRevealTimer(chunkKey)
364
+ this.clearExpectedGraceTimer(chunkKey)
365
+ delete this.waitingChunksToDisplay[chunkKey]
366
+ this.tryRevealPending()
367
+ }
368
+
369
+ private isWasmGateActive (): boolean {
370
+ return !!this.worldRenderer.worldRendererConfig?.wasmMesher
371
+ }
372
+
373
+ /**
374
+ * True if some chunk-grid position strictly closer to the viewer than
375
+ * `chunkKey` is not yet `finishedChunks=true`.
376
+ *
377
+ * Two regimes by `ageMs` (time spent in `pendingNearReveal`):
378
+ * - Within `EXPECTED_NEAR_GRACE_MS`: walks every expected position in
379
+ * the view-distance circle; missing-and-not-finished counts as a
380
+ * blocker (catches "far worker beats near worker").
381
+ * - After grace: only actually-loaded-but-not-finished columns block,
382
+ * so a never-arriving column does not freeze the view.
383
+ */
384
+ private isBlockedByNearer (chunkKey: string, ageMs: number): boolean {
385
+ const viewer = this.worldRenderer.viewerChunkPosition
386
+ if (!viewer) return false
387
+ const ownParts = chunkKey.split(',')
388
+ if (ownParts.length !== 2) return false
389
+ const ownX = Number(ownParts[0])
390
+ const ownZ = Number(ownParts[1])
391
+ const playerCx = Math.floor(viewer.x / 16)
392
+ const playerCz = Math.floor(viewer.z / 16)
393
+ const myDx = (ownX >> 4) - playerCx
394
+ const myDz = (ownZ >> 4) - playerCz
395
+ const myDist = myDx * myDx + myDz * myDz
396
+ if (myDist === 0) return false
397
+ const finishedChunks = this.worldRenderer.finishedChunks
398
+ const loadedChunks = this.worldRenderer.loadedChunks
399
+ const viewDist = this.worldRenderer.viewDistance
400
+ const inGrace = ageMs < ChunkMeshManager.EXPECTED_NEAR_GRACE_MS && viewDist > 0
401
+
402
+ if (inGrace) {
403
+ const viewDistSq = viewDist * viewDist
404
+ const limit = Math.min(viewDist, Math.ceil(Math.sqrt(Math.max(0, myDist - 1))))
405
+ for (let dCx = -limit; dCx <= limit; dCx++) {
406
+ for (let dCz = -limit; dCz <= limit; dCz++) {
407
+ const oDistSq = dCx * dCx + dCz * dCz
408
+ if (oDistSq >= myDist || oDistSq > viewDistSq) continue
409
+ const ox = (playerCx + dCx) << 4
410
+ const oz = (playerCz + dCz) << 4
411
+ const otherKey = `${ox},${oz}`
412
+ if (otherKey === chunkKey) continue
413
+ if (!finishedChunks[otherKey]) return true
414
+ }
415
+ }
416
+ return false
417
+ }
418
+
419
+ for (const otherKey in loadedChunks) {
420
+ if (otherKey === chunkKey || finishedChunks[otherKey]) continue
421
+ const parts = otherKey.split(',')
422
+ if (parts.length !== 2) continue
423
+ const odx = (Number(parts[0]) >> 4) - playerCx
424
+ const odz = (Number(parts[1]) >> 4) - playerCz
425
+ if (odx * odx + odz * odz < myDist) return true
426
+ }
427
+ return false
428
+ }
429
+
430
+ private armNearRevealTimer (chunkKey: string): void {
431
+ if (this.nearRevealTimers.has(chunkKey)) return
432
+ const timer = setTimeout(() => {
433
+ this.nearRevealTimers.delete(chunkKey)
434
+ if (!this.pendingNearReveal.has(chunkKey)) return
435
+ console.warn(`[chunk-reveal] safety timeout for ${chunkKey} — a nearer pending column never finished, force-revealing`)
436
+ this.flushChunkDisplay(chunkKey)
437
+ this.tryRevealPending()
438
+ }, ChunkMeshManager.NEAR_REVEAL_TIMEOUT_MS)
439
+ this.nearRevealTimers.set(chunkKey, timer)
440
+ }
441
+
442
+ private clearNearRevealTimer (chunkKey: string): void {
443
+ const timer = this.nearRevealTimers.get(chunkKey)
444
+ if (timer) {
445
+ clearTimeout(timer)
446
+ this.nearRevealTimers.delete(chunkKey)
447
+ }
448
+ }
449
+
450
+ /**
451
+ * Schedule a re-evaluation just after the grace window expires so that
452
+ * "expected but never arrived" positions stop blocking promptly,
453
+ * without waiting for the next chunkFinished / player-move event.
454
+ */
455
+ private armExpectedGraceTimer (chunkKey: string): void {
456
+ if (this.nearRevealGraceTimers.has(chunkKey)) return
457
+ const timer = setTimeout(() => {
458
+ this.nearRevealGraceTimers.delete(chunkKey)
459
+ if (!this.pendingNearReveal.has(chunkKey)) return
460
+ this.tryRevealPending()
461
+ }, ChunkMeshManager.EXPECTED_NEAR_GRACE_MS + 50)
462
+ this.nearRevealGraceTimers.set(chunkKey, timer)
463
+ }
464
+
465
+ private clearExpectedGraceTimer (chunkKey: string): void {
466
+ const timer = this.nearRevealGraceTimers.get(chunkKey)
467
+ if (timer) {
468
+ clearTimeout(timer)
469
+ this.nearRevealGraceTimers.delete(chunkKey)
470
+ }
471
+ }
472
+
224
473
  cleanupSection (sectionKey: string) {
225
474
  // Remove section object from scene
226
475
  const sectionObject = this.sectionObjects[sectionKey]
227
476
  if (sectionObject) {
477
+ // Drop from any pending "batch display" queue so we don't try to flip
478
+ // visibility on a stale (released) object later.
479
+ if (sectionObject._waitingForChunkDisplay) {
480
+ const chunkCoords = sectionKey.split(',')
481
+ const chunkKey = `${chunkCoords[0]},${chunkCoords[2]}`
482
+ const list = this.waitingChunksToDisplay[chunkKey]
483
+ if (list) {
484
+ const idx = list.indexOf(sectionKey)
485
+ if (idx !== -1) list.splice(idx, 1)
486
+ if (list.length === 0) delete this.waitingChunksToDisplay[chunkKey]
487
+ }
488
+ }
228
489
  // Cleanup banner textures before disposing
229
490
  if (sectionObject.bannersContainer) {
230
491
  sectionObject.bannersContainer.traverse((child) => {
@@ -243,6 +504,21 @@ export class ChunkMeshManager {
243
504
  }
244
505
  this.worldRenderer.sceneOrigin.removeAndUntrackAll(sectionObject)
245
506
  this.scene.remove(sectionObject)
507
+ // boxHelper lives directly on the scene (so it stays world-anchored
508
+ // under camera-relative rendering), so it must be cleaned up explicitly
509
+ // — `removeAndUntrackAll` above only walks `sectionObject` descendants.
510
+ if (sectionObject.boxHelper) {
511
+ this.worldRenderer.sceneOrigin.removeAndUntrack(sectionObject.boxHelper)
512
+ this.scene.remove(sectionObject.boxHelper)
513
+ sectionObject.boxHelper.geometry.dispose()
514
+ const helperMat = sectionObject.boxHelper.material as THREE.Material | THREE.Material[]
515
+ if (Array.isArray(helperMat)) {
516
+ for (const m of helperMat) m.dispose()
517
+ } else {
518
+ helperMat.dispose()
519
+ }
520
+ sectionObject.boxHelper = undefined
521
+ }
246
522
  delete this.sectionObjects[sectionKey]
247
523
  }
248
524
  }
@@ -285,19 +561,28 @@ export class ChunkMeshManager {
285
561
  /**
286
562
  * Update box helper for a section
287
563
  */
288
- updateBoxHelper (sectionKey: string, showChunkBorders: boolean, chunkBoxMaterial: THREE.Material) {
564
+ updateBoxHelper (sectionKey: string, showChunkBorders: boolean, chunkBoxMaterial: THREE.Material = this.chunkBoxMaterial) {
289
565
  const sectionObject = this.sectionObjects[sectionKey]
290
566
  if (!sectionObject?.mesh) return
291
567
 
292
568
  if (showChunkBorders) {
293
569
  if (!sectionObject.boxHelper) {
294
- // mesh with static dimensions: 16x16x16
570
+ // Build a 16x16x16 reference mesh in world coordinates so BoxHelper's
571
+ // `setFromObject` produces the correct geometry. The reference mesh is
572
+ // not added to the scene; only the resulting BoxHelper is.
295
573
  const staticChunkMesh = new THREE.Mesh(new THREE.BoxGeometry(16, 16, 16), chunkBoxMaterial)
296
- staticChunkMesh.position.copy(sectionObject.mesh.position)
297
574
  const boxHelper = new THREE.BoxHelper(staticChunkMesh, 0xff_ff_00)
298
575
  boxHelper.name = 'helper'
299
- sectionObject.add(boxHelper)
300
- sectionObject.name = 'chunk'
576
+ // Add directly to the scene and track it through sceneOrigin so that
577
+ // camera-relative rendering (floating origin) keeps the helper pinned
578
+ // to its world coordinates instead of following the camera.
579
+ const sx = sectionObject.worldX ?? 0
580
+ const sy = sectionObject.worldY ?? 0
581
+ const sz = sectionObject.worldZ ?? 0
582
+ this.worldRenderer.sceneOrigin.track(boxHelper, { updateMatrix: true })
583
+ boxHelper.position.set(sx, sy, sz)
584
+ boxHelper.updateMatrix()
585
+ this.scene.add(boxHelper)
301
586
  sectionObject.boxHelper = boxHelper
302
587
  }
303
588
  sectionObject.boxHelper.visible = true
@@ -306,6 +591,28 @@ export class ChunkMeshManager {
306
591
  }
307
592
  }
308
593
 
594
+ /**
595
+ * Create / toggle chunk border helpers for every active section. Used by
596
+ * `WorldRendererThree.updateShowChunksBorder` so the F3+G hotkey works
597
+ * after the move from `WorldBlockGeometry` (which created the helpers
598
+ * eagerly per section) to the pooled `ChunkMeshManager`.
599
+ */
600
+ updateAllBoxHelpers (showChunkBorders: boolean) {
601
+ for (const sectionKey of Object.keys(this.sectionObjects)) {
602
+ this.updateBoxHelper(sectionKey, showChunkBorders)
603
+ }
604
+ }
605
+
606
+ /**
607
+ * Forward to {@link SignHeadsRenderer.cleanChunkTextures} so callers in
608
+ * `WorldRendererThree` (which historically owned the sign-texture cache)
609
+ * can invalidate cached sign textures when a section is marked dirty,
610
+ * without reaching into the manager's private members.
611
+ */
612
+ cleanSignChunkTextures (x: number, z: number) {
613
+ this.signHeadsRenderer.cleanChunkTextures(x, z)
614
+ }
615
+
309
616
  /**
310
617
  * Get mesh for section if it exists
311
618
  */
@@ -459,6 +766,13 @@ export class ChunkMeshManager {
459
766
 
460
767
  this.meshPool.length = 0
461
768
  this.activeSections.clear()
769
+ this.chunkBoxMaterial.dispose()
770
+ // Drop any pending near-first reveal state and cancel safety timers.
771
+ this.pendingNearReveal.clear()
772
+ for (const timer of this.nearRevealTimers.values()) clearTimeout(timer)
773
+ this.nearRevealTimers.clear()
774
+ for (const timer of this.nearRevealGraceTimers.values()) clearTimeout(timer)
775
+ this.nearRevealGraceTimers.clear()
462
776
  }
463
777
 
464
778
  // Private helper methods
@@ -667,6 +981,12 @@ export class ChunkMeshManager {
667
981
  updateSectionsVisibility (): void {
668
982
  const cameraPos = this.worldRenderer.cameraSectionPos
669
983
  for (const [sectionKey, sectionObject] of Object.entries(this.sectionObjects)) {
984
+ // Don't override "Batch Chunks Display" hiding — those sections must
985
+ // stay invisible until their chunk finishes meshing.
986
+ if (sectionObject._waitingForChunkDisplay) {
987
+ sectionObject.visible = false
988
+ continue
989
+ }
670
990
  if (!this.performanceOverrideDistance) {
671
991
  sectionObject.visible = true
672
992
  continue
@@ -805,4 +1125,21 @@ class SignHeadsRenderer {
805
1125
  textures[texturekey] = tex
806
1126
  return tex
807
1127
  }
1128
+
1129
+ /**
1130
+ * Dispose all cached sign textures for the chunk containing world coords
1131
+ * (x, z). Called from `WorldRendererThree.cleanChunkTextures` so that
1132
+ * re-meshes triggered by `setSectionDirty` (e.g. a player edits a sign)
1133
+ * pick up fresh block-entity NBT instead of returning the stale cached
1134
+ * texture from {@link SignHeadsRenderer.getSignTexture}.
1135
+ */
1136
+ cleanChunkTextures (x: number, z: number) {
1137
+ const key = `${Math.floor(x / 16)},${Math.floor(z / 16)}`
1138
+ const textures = this.chunkTextures.get(key)
1139
+ if (!textures) return
1140
+ for (const k of Object.keys(textures)) {
1141
+ textures[k].dispose()
1142
+ delete textures[k]
1143
+ }
1144
+ }
808
1145
  }
@@ -26,8 +26,15 @@ export class CameraBobbingModule implements RendererModuleController {
26
26
  if (!this.enabled) return
27
27
  const config = this.worldRenderer.displayOptions.inWorldRenderingConfig
28
28
  const { perspective } = this.worldRenderer.playerStateReactive
29
+ // Spectator (gm3) flies through blocks and does not "walk" — view bobbing
30
+ // there only makes the camera feel jittery/unstable. Keep it gated even
31
+ // when the user has `viewBobbing` enabled in settings.
32
+ const shouldBobCamera =
33
+ config.viewBobbing
34
+ && perspective === 'first_person'
35
+ && !this.worldRenderer.playerStateUtils.isSpectator()
29
36
 
30
- if (config.viewBobbing && perspective === 'first_person') {
37
+ if (shouldBobCamera) {
31
38
  if (this.worldRenderer.playerStateReactive.walkDist !== this.lastBobWalkDist) {
32
39
  this.lastBobTickTime = performance.now()
33
40
  this.lastBobWalkDist = this.worldRenderer.playerStateReactive.walkDist