minecraft-renderer 0.1.37 → 0.1.38

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.37",
3
+ "version": "0.1.38",
4
4
  "description": "The most Modular Minecraft world renderer with Three.js WebGL backend",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -19,7 +19,7 @@ export const defaultWorldRendererConfig = {
19
19
 
20
20
  // Debug settings
21
21
  showChunkBorders: false,
22
- enableDebugOverlay: true,
22
+ enableDebugOverlay: false,
23
23
  debugModelVariant: undefined as undefined | number[],
24
24
  futuristicReveal: false,
25
25
 
@@ -28,17 +28,46 @@ 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[]> = {}
38
60
  private poolSize!: number
39
61
  private maxPoolSize!: number
40
62
  private minPoolSize!: number
41
63
  private readonly signHeadsRenderer: SignHeadsRenderer
64
+ /**
65
+ * Shared transparent material used as the basis for the wireframe chunk
66
+ * border `BoxHelper` created lazily in {@link updateBoxHelper}. Kept on the
67
+ * manager so the BoxHelper machinery doesn't allocate a new material per
68
+ * section.
69
+ */
70
+ private readonly chunkBoxMaterial = new THREE.MeshBasicMaterial({ color: 0x00_00_00, transparent: true, opacity: 0 })
42
71
 
43
72
  // Performance tracking
44
73
  private hits = 0
@@ -156,6 +185,17 @@ export class ChunkMeshManager {
156
185
  // Store metadata
157
186
  sectionObject.tilesCount = geometryData.positions.length / 3 / 4
158
187
  sectionObject.blocksCount = geometryData.blocksCount
188
+ sectionObject.worldX = geometryData.sx
189
+ sectionObject.worldY = geometryData.sy
190
+ sectionObject.worldZ = geometryData.sz
191
+ // Stamp the section key so modules (e.g. sciFiWorldReveal) can resolve
192
+ // mesh -> section without falling back to sceneOrigin world-position math.
193
+ ;(sectionObject as any).sectionKey = sectionKey
194
+ // Tag the group so `WorldRendererThree.getThirdPersonCamera` raycast can
195
+ // still find chunk meshes — the old `WorldBlockGeometry` set this name
196
+ // unconditionally; the pooling port lost that and only the border-helper
197
+ // path used to restore it.
198
+ sectionObject.name = 'chunk'
159
199
 
160
200
  try {
161
201
  // Add signs container
@@ -218,13 +258,64 @@ export class ChunkMeshManager {
218
258
  this.scene.add(sectionObject)
219
259
  sectionObject.matrixAutoUpdate = false
220
260
 
261
+ // Create chunk border helper eagerly when the option is on so freshly
262
+ // streamed sections immediately get the F3+G yellow wireframe instead of
263
+ // appearing only on the next toggle.
264
+ if (this.worldRenderer.displayOptions?.inWorldRenderingConfig?.showChunkBorders) {
265
+ this.updateBoxHelper(sectionKey, true)
266
+ }
267
+
268
+ // Honor "Batch Chunks Display" (`_renderByChunks`): keep this section's
269
+ // mesh hidden until the whole chunk has finished meshing, so users see a
270
+ // chunk appear as a single 16xHx16 tile instead of streaming per-section.
271
+ // Updates to chunks that are already finished bypass batching to avoid
272
+ // flickering on block changes / lighting updates.
273
+ const chunkCoords = sectionKey.split(',')
274
+ const chunkKey = `${chunkCoords[0]},${chunkCoords[2]}`
275
+ const renderByChunks = !!this.worldRenderer.displayOptions
276
+ ?.inWorldRenderingConfig?._renderByChunks
277
+ if (renderByChunks && !this.worldRenderer.finishedChunks[chunkKey]) {
278
+ sectionObject.visible = false
279
+ sectionObject._waitingForChunkDisplay = true
280
+ const list = this.waitingChunksToDisplay[chunkKey] ?? (this.waitingChunksToDisplay[chunkKey] = [])
281
+ if (!list.includes(sectionKey)) list.push(sectionKey)
282
+ }
283
+
221
284
  return sectionObject
222
285
  }
223
286
 
287
+ /**
288
+ * Reveal all sections of a chunk that were held invisible by the
289
+ * "Batch Chunks Display" option. Called from `WorldRendererThree.finishChunk`.
290
+ */
291
+ finishChunkDisplay (chunkKey: string): void {
292
+ const sectionKeys = this.waitingChunksToDisplay[chunkKey]
293
+ if (!sectionKeys) return
294
+ for (const sectionKey of sectionKeys) {
295
+ const sectionObject = this.sectionObjects[sectionKey]
296
+ if (!sectionObject) continue
297
+ sectionObject._waitingForChunkDisplay = false
298
+ sectionObject.visible = true
299
+ }
300
+ delete this.waitingChunksToDisplay[chunkKey]
301
+ }
302
+
224
303
  cleanupSection (sectionKey: string) {
225
304
  // Remove section object from scene
226
305
  const sectionObject = this.sectionObjects[sectionKey]
227
306
  if (sectionObject) {
307
+ // Drop from any pending "batch display" queue so we don't try to flip
308
+ // visibility on a stale (released) object later.
309
+ if (sectionObject._waitingForChunkDisplay) {
310
+ const chunkCoords = sectionKey.split(',')
311
+ const chunkKey = `${chunkCoords[0]},${chunkCoords[2]}`
312
+ const list = this.waitingChunksToDisplay[chunkKey]
313
+ if (list) {
314
+ const idx = list.indexOf(sectionKey)
315
+ if (idx !== -1) list.splice(idx, 1)
316
+ if (list.length === 0) delete this.waitingChunksToDisplay[chunkKey]
317
+ }
318
+ }
228
319
  // Cleanup banner textures before disposing
229
320
  if (sectionObject.bannersContainer) {
230
321
  sectionObject.bannersContainer.traverse((child) => {
@@ -243,6 +334,21 @@ export class ChunkMeshManager {
243
334
  }
244
335
  this.worldRenderer.sceneOrigin.removeAndUntrackAll(sectionObject)
245
336
  this.scene.remove(sectionObject)
337
+ // boxHelper lives directly on the scene (so it stays world-anchored
338
+ // under camera-relative rendering), so it must be cleaned up explicitly
339
+ // — `removeAndUntrackAll` above only walks `sectionObject` descendants.
340
+ if (sectionObject.boxHelper) {
341
+ this.worldRenderer.sceneOrigin.removeAndUntrack(sectionObject.boxHelper)
342
+ this.scene.remove(sectionObject.boxHelper)
343
+ sectionObject.boxHelper.geometry.dispose()
344
+ const helperMat = sectionObject.boxHelper.material as THREE.Material | THREE.Material[]
345
+ if (Array.isArray(helperMat)) {
346
+ for (const m of helperMat) m.dispose()
347
+ } else {
348
+ helperMat.dispose()
349
+ }
350
+ sectionObject.boxHelper = undefined
351
+ }
246
352
  delete this.sectionObjects[sectionKey]
247
353
  }
248
354
  }
@@ -285,19 +391,28 @@ export class ChunkMeshManager {
285
391
  /**
286
392
  * Update box helper for a section
287
393
  */
288
- updateBoxHelper (sectionKey: string, showChunkBorders: boolean, chunkBoxMaterial: THREE.Material) {
394
+ updateBoxHelper (sectionKey: string, showChunkBorders: boolean, chunkBoxMaterial: THREE.Material = this.chunkBoxMaterial) {
289
395
  const sectionObject = this.sectionObjects[sectionKey]
290
396
  if (!sectionObject?.mesh) return
291
397
 
292
398
  if (showChunkBorders) {
293
399
  if (!sectionObject.boxHelper) {
294
- // mesh with static dimensions: 16x16x16
400
+ // Build a 16x16x16 reference mesh in world coordinates so BoxHelper's
401
+ // `setFromObject` produces the correct geometry. The reference mesh is
402
+ // not added to the scene; only the resulting BoxHelper is.
295
403
  const staticChunkMesh = new THREE.Mesh(new THREE.BoxGeometry(16, 16, 16), chunkBoxMaterial)
296
- staticChunkMesh.position.copy(sectionObject.mesh.position)
297
404
  const boxHelper = new THREE.BoxHelper(staticChunkMesh, 0xff_ff_00)
298
405
  boxHelper.name = 'helper'
299
- sectionObject.add(boxHelper)
300
- sectionObject.name = 'chunk'
406
+ // Add directly to the scene and track it through sceneOrigin so that
407
+ // camera-relative rendering (floating origin) keeps the helper pinned
408
+ // to its world coordinates instead of following the camera.
409
+ const sx = sectionObject.worldX ?? 0
410
+ const sy = sectionObject.worldY ?? 0
411
+ const sz = sectionObject.worldZ ?? 0
412
+ this.worldRenderer.sceneOrigin.track(boxHelper, { updateMatrix: true })
413
+ boxHelper.position.set(sx, sy, sz)
414
+ boxHelper.updateMatrix()
415
+ this.scene.add(boxHelper)
301
416
  sectionObject.boxHelper = boxHelper
302
417
  }
303
418
  sectionObject.boxHelper.visible = true
@@ -306,6 +421,28 @@ export class ChunkMeshManager {
306
421
  }
307
422
  }
308
423
 
424
+ /**
425
+ * Create / toggle chunk border helpers for every active section. Used by
426
+ * `WorldRendererThree.updateShowChunksBorder` so the F3+G hotkey works
427
+ * after the move from `WorldBlockGeometry` (which created the helpers
428
+ * eagerly per section) to the pooled `ChunkMeshManager`.
429
+ */
430
+ updateAllBoxHelpers (showChunkBorders: boolean) {
431
+ for (const sectionKey of Object.keys(this.sectionObjects)) {
432
+ this.updateBoxHelper(sectionKey, showChunkBorders)
433
+ }
434
+ }
435
+
436
+ /**
437
+ * Forward to {@link SignHeadsRenderer.cleanChunkTextures} so callers in
438
+ * `WorldRendererThree` (which historically owned the sign-texture cache)
439
+ * can invalidate cached sign textures when a section is marked dirty,
440
+ * without reaching into the manager's private members.
441
+ */
442
+ cleanSignChunkTextures (x: number, z: number) {
443
+ this.signHeadsRenderer.cleanChunkTextures(x, z)
444
+ }
445
+
309
446
  /**
310
447
  * Get mesh for section if it exists
311
448
  */
@@ -459,6 +596,7 @@ export class ChunkMeshManager {
459
596
 
460
597
  this.meshPool.length = 0
461
598
  this.activeSections.clear()
599
+ this.chunkBoxMaterial.dispose()
462
600
  }
463
601
 
464
602
  // Private helper methods
@@ -667,6 +805,12 @@ export class ChunkMeshManager {
667
805
  updateSectionsVisibility (): void {
668
806
  const cameraPos = this.worldRenderer.cameraSectionPos
669
807
  for (const [sectionKey, sectionObject] of Object.entries(this.sectionObjects)) {
808
+ // Don't override "Batch Chunks Display" hiding — those sections must
809
+ // stay invisible until their chunk finishes meshing.
810
+ if (sectionObject._waitingForChunkDisplay) {
811
+ sectionObject.visible = false
812
+ continue
813
+ }
670
814
  if (!this.performanceOverrideDistance) {
671
815
  sectionObject.visible = true
672
816
  continue
@@ -805,4 +949,21 @@ class SignHeadsRenderer {
805
949
  textures[texturekey] = tex
806
950
  return tex
807
951
  }
952
+
953
+ /**
954
+ * Dispose all cached sign textures for the chunk containing world coords
955
+ * (x, z). Called from `WorldRendererThree.cleanChunkTextures` so that
956
+ * re-meshes triggered by `setSectionDirty` (e.g. a player edits a sign)
957
+ * pick up fresh block-entity NBT instead of returning the stale cached
958
+ * texture from {@link SignHeadsRenderer.getSignTexture}.
959
+ */
960
+ cleanChunkTextures (x: number, z: number) {
961
+ const key = `${Math.floor(x / 16)},${Math.floor(z / 16)}`
962
+ const textures = this.chunkTextures.get(key)
963
+ if (!textures) return
964
+ for (const k of Object.keys(textures)) {
965
+ textures[k].dispose()
966
+ delete textures[k]
967
+ }
968
+ }
808
969
  }
@@ -67,7 +67,7 @@ export class WorldRendererThree extends WorldRendererCommon {
67
67
  cameraContainer!: THREE.Object3D
68
68
  media: ThreeJsMedia
69
69
  get waitingChunksToDisplay() {
70
- return {} as { [chunkKey: string]: string[] }
70
+ return this.chunkMeshManager.waitingChunksToDisplay
71
71
  }
72
72
  waypoints: WaypointsRenderer
73
73
  cinimaticScript: CinimaticScriptRunner
@@ -82,7 +82,14 @@ export class WorldRendererThree extends WorldRendererCommon {
82
82
  camera!: THREE.PerspectiveCamera
83
83
  renderTimeAvg = 0
84
84
  private pendingSectionUpdates = new Map<string, { geometry: MesherGeometryOutput, key: string, type: string }>()
85
- private pendingSectionBufferStartTime: number | null = null
85
+ /**
86
+ * Per-section buffering timestamps for `applyPendingSectionUpdates`.
87
+ * Each section gets its own deadline so a continuous stream of updates
88
+ * (e.g. server-side block changes from explosions, pistons, fluid ticks)
89
+ * does not flush freshly added sections together with stale ones via a
90
+ * single global timer.
91
+ */
92
+ private pendingSectionBufferStartTimes = new Map<string, number>()
86
93
  private static readonly MAX_SECTION_UPDATE_BUFFER_MS = 500
87
94
  // Memory usage tracking (in bytes)
88
95
  get estimatedMemoryUsage() {
@@ -747,42 +754,57 @@ export class WorldRendererThree extends WorldRendererCommon {
747
754
  }
748
755
 
749
756
  finishChunk(chunkKey: string) {
750
- // Existing sections are buffered and flushed from applyPendingSectionUpdates().
757
+ // Reveal all sections of this chunk that were held invisible by the
758
+ // "Batch Chunks Display" (`_renderByChunks`) option. No-op when the
759
+ // option is off — `waitingChunksToDisplay` is empty in that case.
760
+ this.chunkMeshManager.finishChunkDisplay(chunkKey)
751
761
  }
752
762
 
753
763
  private applyPendingSectionUpdates() {
754
764
  if (this.pendingSectionUpdates.size === 0) return
755
765
 
756
766
  const now = performance.now()
757
- const sinceFirst = now - (this.pendingSectionBufferStartTime ?? now)
767
+ const sectionHeight = this.getSectionHeight()
768
+ const ready: string[] = []
769
+
770
+ for (const key of this.pendingSectionUpdates.keys()) {
771
+ const startedAt = this.pendingSectionBufferStartTimes.get(key) ?? now
772
+ const sinceFirst = now - startedAt
758
773
 
759
- if (sinceFirst < WorldRendererThree.MAX_SECTION_UPDATE_BUFFER_MS) {
760
- const sectionHeight = this.getSectionHeight()
761
- for (const key of this.pendingSectionUpdates.keys()) {
774
+ if (sinceFirst < WorldRendererThree.MAX_SECTION_UPDATE_BUFFER_MS) {
775
+ // Still within this section's grace window — wait if any neighbor is
776
+ // currently being re-meshed so we don't briefly expose a hole between
777
+ // the just-updated section and a stale neighbor (sky-flicker bug).
762
778
  const [sx, sy, sz] = key.split(',').map(Number)
763
779
  const neighborKeys = [
764
780
  `${sx - 16},${sy},${sz}`, `${sx + 16},${sy},${sz}`,
765
781
  `${sx},${sy - sectionHeight},${sz}`, `${sx},${sy + sectionHeight},${sz}`,
766
782
  `${sx},${sy},${sz - 16}`, `${sx},${sy},${sz + 16}`,
767
783
  ]
768
-
784
+ let neighborBusy = false
769
785
  for (const neighborKey of neighborKeys) {
770
786
  if (
771
787
  this.sectionsWaiting.has(neighborKey) &&
772
788
  !this.pendingSectionUpdates.has(neighborKey) &&
773
789
  this.sectionObjects[neighborKey]
774
790
  ) {
775
- return
791
+ neighborBusy = true
792
+ break
776
793
  }
777
794
  }
795
+ if (neighborBusy) continue
778
796
  }
797
+
798
+ ready.push(key)
779
799
  }
780
800
 
781
- const updates = [...this.pendingSectionUpdates.values()]
782
- this.pendingSectionUpdates.clear()
783
- this.pendingSectionBufferStartTime = null
801
+ if (ready.length === 0) return
802
+
803
+ for (const key of ready) {
804
+ const update = this.pendingSectionUpdates.get(key)!
805
+ this.pendingSectionUpdates.delete(key)
806
+ this.pendingSectionBufferStartTimes.delete(key)
784
807
 
785
- for (const update of updates) {
786
808
  const chunkCoords = update.key.split(',')
787
809
  const chunkKey = `${chunkCoords[0]},${chunkCoords[2]}`
788
810
 
@@ -805,12 +827,9 @@ export class WorldRendererThree extends WorldRendererCommon {
805
827
  for (const key of [...this.pendingSectionUpdates.keys()]) {
806
828
  if (key.startsWith(`${x},`) && key.endsWith(`,${z}`)) {
807
829
  this.pendingSectionUpdates.delete(key)
830
+ this.pendingSectionBufferStartTimes.delete(key)
808
831
  }
809
832
  }
810
-
811
- if (this.pendingSectionUpdates.size === 0) {
812
- this.pendingSectionBufferStartTime = null
813
- }
814
833
  }
815
834
 
816
835
  handleWorkerMessage(data: { geometry: MesherGeometryOutput, key, type }): void {
@@ -819,15 +838,17 @@ export class WorldRendererThree extends WorldRendererCommon {
819
838
  const chunkKey = `${chunkCoords[0]},${chunkCoords[2]}`
820
839
  if (!this.loadedChunks[chunkKey] || !this.active) {
821
840
  this.pendingSectionUpdates.delete(data.key)
822
- if (this.pendingSectionUpdates.size === 0) {
823
- this.pendingSectionBufferStartTime = null
824
- }
841
+ this.pendingSectionBufferStartTimes.delete(data.key)
825
842
  return
826
843
  }
827
844
 
828
845
  if (this.sectionObjects[data.key]) {
829
846
  this.pendingSectionUpdates.set(data.key, data)
830
- this.pendingSectionBufferStartTime ??= performance.now()
847
+ // Per-section deadline: only set if we don't already have one, so
848
+ // repeated updates to the same section don't postpone its flush.
849
+ if (!this.pendingSectionBufferStartTimes.has(data.key)) {
850
+ this.pendingSectionBufferStartTimes.set(data.key, performance.now())
851
+ }
831
852
  return
832
853
  }
833
854
 
@@ -1137,6 +1158,7 @@ export class WorldRendererThree extends WorldRendererCommon {
1137
1158
  chunksRenderDistanceOverride !== undefined
1138
1159
  ) {
1139
1160
  for (const [key, object] of Object.entries(this.sectionObjects)) {
1161
+ if (object._waitingForChunkDisplay) continue
1140
1162
  const [x, y, z] = key.split(',').map(Number)
1141
1163
  const isVisible =
1142
1164
  // eslint-disable-next-line no-constant-binary-expression, sonarjs/no-redundant-boolean
@@ -1149,9 +1171,11 @@ export class WorldRendererThree extends WorldRendererCommon {
1149
1171
  object.visible = isVisible
1150
1172
  }
1151
1173
  } else {
1152
- for (const object of Object.values(this.sectionObjects)) {
1153
- object.visible = true
1154
- }
1174
+ // No debug visibility override active — defer to the manager so the
1175
+ // performance-based override distance (set by `recordRenderTime` /
1176
+ // `autoLowerRenderDistance`) is honored, instead of force-showing every
1177
+ // section every frame and clobbering it.
1178
+ this.chunkMeshManager.updateSectionsVisibility()
1155
1179
  }
1156
1180
  }
1157
1181
 
@@ -1322,20 +1346,16 @@ export class WorldRendererThree extends WorldRendererCommon {
1322
1346
  }
1323
1347
 
1324
1348
  updateShowChunksBorder(value: boolean) {
1325
- for (const object of Object.values(this.sectionObjects)) {
1326
- for (const child of object.children) {
1327
- if (child.name === 'helper') {
1328
- child.visible = value
1329
- }
1330
- }
1331
- }
1349
+ // Lazily create helpers on the first toggle (they are not created upfront
1350
+ // for sections streamed in while the option was off).
1351
+ this.chunkMeshManager.updateAllBoxHelpers(value)
1332
1352
  }
1333
1353
 
1334
1354
  resetWorld() {
1335
1355
  super.resetWorld()
1336
1356
 
1337
1357
  this.pendingSectionUpdates.clear()
1338
- this.pendingSectionBufferStartTime = null
1358
+ this.pendingSectionBufferStartTimes.clear()
1339
1359
  this.chunkMeshManager.dispose()
1340
1360
  this.chunkMeshManager = new ChunkMeshManager(this, this.scene, this.material, this.worldSizeParams.worldHeight, this.viewDistance)
1341
1361
 
@@ -1366,6 +1386,11 @@ export class WorldRendererThree extends WorldRendererCommon {
1366
1386
  textures[key].dispose()
1367
1387
  delete textures[key]
1368
1388
  }
1389
+ // Sign / head textures moved to ChunkMeshManager.signHeadsRenderer in PR
1390
+ // #16; without invalidating that cache here, sign edits (and any other
1391
+ // block-entity NBT change picked up via setSectionDirty) would re-render
1392
+ // with the stale cached canvas until a full world reset.
1393
+ this.chunkMeshManager.cleanSignChunkTextures(x, z)
1369
1394
  }
1370
1395
 
1371
1396
  readdChunks() {
@@ -1422,7 +1447,7 @@ export class WorldRendererThree extends WorldRendererCommon {
1422
1447
 
1423
1448
  destroy(): void {
1424
1449
  this.pendingSectionUpdates.clear()
1425
- this.pendingSectionBufferStartTime = null
1450
+ this.pendingSectionBufferStartTimes.clear()
1426
1451
  this.chunkMeshManager.dispose()
1427
1452
  this.disposeModules()
1428
1453
  this.fireworksLegacy.destroy()