minecraft-renderer 0.1.38 → 0.1.40

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.38",
3
+ "version": "0.1.40",
4
4
  "description": "The most Modular Minecraft world renderer with Three.js WebGL backend",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -121,6 +121,8 @@
121
121
  "watch:lib": "node scripts/buildLib.mjs -w",
122
122
  "build:mesher": "node scripts/buildMesherWorker.mjs",
123
123
  "build:wasm": "cd wasm-mesher && ./build.sh web",
124
+ "test:wasm": "cd wasm-mesher && wasm-pack build --target nodejs --out-dir pkg --dev && node build.mjs && node test-chunk.cjs && node test-section-boundary.cjs",
125
+ "test:wasm:boundary": "cd wasm-mesher && wasm-pack build --target nodejs --out-dir pkg --dev && node build.mjs && node test-section-boundary.cjs",
124
126
  "watch:mesher": "pnpm build:mesher -w",
125
127
  "build:threeworker": "node scripts/buildThreeWorker.mjs",
126
128
  "watch:threeworker": "pnpm build:threeworker -w",
@@ -20,6 +20,7 @@ export const defaultWorldRendererConfig = {
20
20
  // Debug settings
21
21
  showChunkBorders: false,
22
22
  enableDebugOverlay: false,
23
+ debugWasmPerf: false,
23
24
  debugModelVariant: undefined as undefined | number[],
24
25
  futuristicReveal: false,
25
26
 
@@ -30,6 +31,14 @@ export const defaultWorldRendererConfig = {
30
31
  _experimentalSmoothChunkLoading: true,
31
32
  _renderByChunks: false,
32
33
  autoLowerRenderDistance: false,
34
+ /** Disable WASM mesher worker-side conversion cache (memory hotfix for
35
+ * iOS Safari and other low-RAM environments). Trades performance for
36
+ * lower per-worker RAM. */
37
+ disableMesherConversionCache: false,
38
+ /** Whether to dedicate the last worker exclusively to block-update
39
+ * remeshing (change worker). When true, initial chunk meshing is
40
+ * distributed only across workers[0 .. n-2]. */
41
+ dedicatedChangeWorker: false,
33
42
 
34
43
  // Rendering engine settings
35
44
  /** Face shading: vanilla Minecraft vs higher-contrast client look */
@@ -9,7 +9,7 @@ import { proxy, subscribe } from 'valtio'
9
9
  import type { ResourcesManagerTransferred } from '../resourcesManager/resourcesManager'
10
10
  import { dynamicMcDataFiles } from './buildSharedConfig.mjs'
11
11
  import { DisplayWorldOptions, GraphicsInitOptions, RendererReactiveState, SoundSystem } from '../graphicsBackend/types'
12
- import { HighestBlockInfo, CustomBlockModels, BlockStateModelInfo, getBlockAssetsCacheKey, MesherConfig, MesherMainEvent, IS_FULL_WORLD_SECTION, SECTION_HEIGHT } from '../mesher/shared'
12
+ import { HighestBlockInfo, CustomBlockModels, BlockStateModelInfo, getBlockAssetsCacheKey, MesherConfig, MesherMainEvent, SECTION_HEIGHT } from '../mesher/shared'
13
13
  import { chunkPos } from './simpleUtils'
14
14
  import { addNewStat, removeAllStats, updatePanesVisibility, updateStatText } from './ui/newStats'
15
15
  import { getPlayerStateUtils } from '../graphicsBackend/playerState'
@@ -70,6 +70,10 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
70
70
  customTexturesDataUrl = undefined as string | undefined
71
71
  workers: any[] = []
72
72
  viewerChunkPosition?: Vec3
73
+ // Last viewer chunk-grid coords for which `onViewerChunkPositionChanged`
74
+ // fired — throttles the hook to chunk-grid changes.
75
+ private lastViewerChunkGridX?: number
76
+ private lastViewerChunkGridZ?: number
73
77
  lastCamUpdate = 0
74
78
  droppedFpsPercentage = 0
75
79
  initialChunkLoadWasStartedIn: number | undefined
@@ -91,6 +95,20 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
91
95
  workersProcessAverageTime = 0
92
96
  workersProcessAverageTimeCount = 0
93
97
  maxWorkersProcessTime = 0
98
+ workersPreAverageTime = 0
99
+ workersWasmAverageTime = 0
100
+ workersPostAverageTime = 0
101
+ workersPhaseSampleCount = 0
102
+ // Pre-stage substage averages (column-mode perf instrumentation).
103
+ workersPreTargetConvertAverageTime = 0
104
+ workersPreNeighborConvertAverageTime = 0
105
+ workersPreNeighborCountAverage = 0
106
+ workersPreTypedArrayBuildAverageTime = 0
107
+ workersPreOtherAverageTime = 0
108
+ // Cumulative cache hit/miss counters (column-mode conversion cache).
109
+ workersPreCacheHitsTotal = 0
110
+ workersPreCacheMissesTotal = 0
111
+ private static readonly PHASE_PERF_LOG_INTERVAL = 64
94
112
  geometryReceiveCount = {} as Record<number, number>
95
113
  allLoadedIn: undefined | number
96
114
  onWorldSwitched = [] as Array<() => void>
@@ -392,18 +410,10 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
392
410
  if (this.loadedChunks[chunkKey]) { // ensure chunk data was added, not a neighbor chunk update
393
411
  let loaded = true
394
412
  const sectionHeight = this.getSectionHeight()
395
- if (IS_FULL_WORLD_SECTION) {
396
- // Only one section per chunk when full world section
397
- const sectionY = this.worldMinYRender
398
- if (!this.finishedSections[`${chunkCoords[0]},${sectionY},${chunkCoords[2]}`]) {
413
+ for (let y = this.worldMinYRender; y < this.worldSizeParams.worldHeight; y += sectionHeight) {
414
+ if (!this.finishedSections[`${chunkCoords[0]},${y},${chunkCoords[2]}`]) {
399
415
  loaded = false
400
- }
401
- } else {
402
- for (let y = this.worldMinYRender; y < this.worldSizeParams.worldHeight; y += sectionHeight) {
403
- if (!this.finishedSections[`${chunkCoords[0]},${y},${chunkCoords[2]}`]) {
404
- loaded = false
405
- break
406
- }
416
+ break
407
417
  }
408
418
  }
409
419
  if (loaded) {
@@ -442,6 +452,46 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
442
452
  this.workersProcessAverageTime = ((this.workersProcessAverageTime * (this.workersProcessAverageTimeCount - 1)) + data.processTime) / this.workersProcessAverageTimeCount
443
453
  this.maxWorkersProcessTime = Math.max(this.maxWorkersProcessTime, data.processTime)
444
454
  }
455
+ if (typeof data.pre === 'number' && typeof data.wasm === 'number' && typeof data.post === 'number'
456
+ && (data.pre > 0 || data.wasm > 0 || data.post > 0)) {
457
+ const n = ++this.workersPhaseSampleCount
458
+ this.workersPreAverageTime = ((this.workersPreAverageTime * (n - 1)) + data.pre) / n
459
+ this.workersWasmAverageTime = ((this.workersWasmAverageTime * (n - 1)) + data.wasm) / n
460
+ this.workersPostAverageTime = ((this.workersPostAverageTime * (n - 1)) + data.post) / n
461
+ // Pre-stage substages — additive schema; treat undefined as 0 so
462
+ // events from older mesher builds don't poison the running mean.
463
+ const ptc = typeof data.preTargetConvert === 'number' ? data.preTargetConvert : 0
464
+ const pnc = typeof data.preNeighborConvert === 'number' ? data.preNeighborConvert : 0
465
+ const pncn = typeof data.preNeighborCount === 'number' ? data.preNeighborCount : 0
466
+ const ptab = typeof data.preTypedArrayBuild === 'number' ? data.preTypedArrayBuild : 0
467
+ const po = typeof data.preOther === 'number' ? data.preOther : 0
468
+ const pch = typeof data.preCacheHits === 'number' ? data.preCacheHits : 0
469
+ const pcm = typeof data.preCacheMisses === 'number' ? data.preCacheMisses : 0
470
+ this.workersPreTargetConvertAverageTime = ((this.workersPreTargetConvertAverageTime * (n - 1)) + ptc) / n
471
+ this.workersPreNeighborConvertAverageTime = ((this.workersPreNeighborConvertAverageTime * (n - 1)) + pnc) / n
472
+ this.workersPreNeighborCountAverage = ((this.workersPreNeighborCountAverage * (n - 1)) + pncn) / n
473
+ this.workersPreTypedArrayBuildAverageTime = ((this.workersPreTypedArrayBuildAverageTime * (n - 1)) + ptab) / n
474
+ this.workersPreOtherAverageTime = ((this.workersPreOtherAverageTime * (n - 1)) + po) / n
475
+ this.workersPreCacheHitsTotal += pch
476
+ this.workersPreCacheMissesTotal += pcm
477
+ if (this.worldRendererConfig.debugWasmPerf && n % WorldRendererCommon.PHASE_PERF_LOG_INTERVAL === 0) {
478
+ const total = this.workersPreAverageTime + this.workersWasmAverageTime + this.workersPostAverageTime
479
+ const prePct = total > 0 ? (this.workersPreAverageTime / total) * 100 : 0
480
+ const wasmPct = total > 0 ? (this.workersWasmAverageTime / total) * 100 : 0
481
+ const postPct = total > 0 ? (this.workersPostAverageTime / total) * 100 : 0
482
+ const preAvg = this.workersPreAverageTime
483
+ const tgtPct = preAvg > 0 ? (this.workersPreTargetConvertAverageTime / preAvg) * 100 : 0
484
+ const nbrPct = preAvg > 0 ? (this.workersPreNeighborConvertAverageTime / preAvg) * 100 : 0
485
+ const tabPct = preAvg > 0 ? (this.workersPreTypedArrayBuildAverageTime / preAvg) * 100 : 0
486
+ const othPct = preAvg > 0 ? (this.workersPreOtherAverageTime / preAvg) * 100 : 0
487
+ const nbrCnt = this.workersPreNeighborCountAverage
488
+ const nbrPerAvg = nbrCnt > 0 ? this.workersPreNeighborConvertAverageTime / nbrCnt : 0
489
+ const cacheTotal = this.workersPreCacheHitsTotal + this.workersPreCacheMissesTotal
490
+ const cacheHitPct = cacheTotal > 0 ? (this.workersPreCacheHitsTotal / cacheTotal) * 100 : 0
491
+ // eslint-disable-next-line no-console
492
+ console.log(`[wasm-mesher perf] n=${n} pre=${this.workersPreAverageTime.toFixed(2)}ms (${prePct.toFixed(1)}%) wasm=${this.workersWasmAverageTime.toFixed(2)}ms (${wasmPct.toFixed(1)}%) post=${this.workersPostAverageTime.toFixed(2)}ms (${postPct.toFixed(1)}%) | pre.targetConvert=${this.workersPreTargetConvertAverageTime.toFixed(2)}ms (${tgtPct.toFixed(1)}%) pre.neighborConvert=${this.workersPreNeighborConvertAverageTime.toFixed(2)}ms (${nbrPct.toFixed(1)}%) [n̄=${nbrCnt.toFixed(2)}, per-nbr=${nbrPerAvg.toFixed(2)}ms] pre.typedArrayBuild=${this.workersPreTypedArrayBuildAverageTime.toFixed(2)}ms (${tabPct.toFixed(1)}%) pre.other=${this.workersPreOtherAverageTime.toFixed(2)}ms (${othPct.toFixed(1)}%) | pre.cache hits=${this.workersPreCacheHitsTotal} misses=${this.workersPreCacheMissesTotal} (${cacheHitPct.toFixed(1)}% hit)`)
493
+ }
494
+ }
445
495
  }
446
496
 
447
497
  if (data.type === 'blockStateModelInfo') {
@@ -503,6 +553,21 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
503
553
  if (!value) continue
504
554
  this.updatePosDataChunk?.(key)
505
555
  }
556
+ const gridX = Math.floor(pos.x / 16)
557
+ const gridZ = Math.floor(pos.z / 16)
558
+ if (gridX !== this.lastViewerChunkGridX || gridZ !== this.lastViewerChunkGridZ) {
559
+ this.lastViewerChunkGridX = gridX
560
+ this.lastViewerChunkGridZ = gridZ
561
+ this.onViewerChunkPositionChanged()
562
+ }
563
+ }
564
+
565
+ /**
566
+ * Fired only when the viewer crosses a chunk-grid boundary.
567
+ * Three subclass overrides this to refresh the near-first reveal gate.
568
+ */
569
+ protected onViewerChunkPositionChanged(): void {
570
+ // overridden by WorldRendererThree
506
571
  }
507
572
 
508
573
  sendWorkers(message: WorkerSend) {
@@ -562,6 +627,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
562
627
  disableBlockEntityTextures: !this.worldRendererConfig.extraBlockRenderers,
563
628
  worldMinY: this.worldMinYRender,
564
629
  worldMaxY: this.worldMinYRender + this.worldSizeParams.worldHeight,
630
+ disableConversionCache: this.worldRendererConfig.disableMesherConversionCache,
565
631
  }
566
632
  }
567
633
 
@@ -594,9 +660,6 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
594
660
  }
595
661
 
596
662
  getSectionHeight() {
597
- if (IS_FULL_WORLD_SECTION) {
598
- return this.worldSizeParams.worldHeight
599
- }
600
663
  return SECTION_HEIGHT
601
664
  }
602
665
 
@@ -636,19 +699,25 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
636
699
  customBlockModels: customBlockModels || undefined
637
700
  })
638
701
  }
639
- this.workers[0].postMessage({
640
- type: 'getHeightmap',
641
- x,
642
- z,
643
- })
702
+ // WASM mesher pushes heightmaps from `processColumnTick` after each
703
+ // column tick — the main thread no longer requests them on chunk load
704
+ // (would double-compute and starve the WASM hot loop). The JS mesher
705
+ // still needs the explicit request because it has no per-tick column
706
+ // pass.
707
+ if (!this.worldRendererConfig.wasmMesher) {
708
+ this.workers[0].postMessage({
709
+ type: 'getHeightmap',
710
+ x,
711
+ z,
712
+ })
713
+ }
644
714
  this.logWorkerWork(() => `-> chunk ${JSON.stringify({ x, z, chunkLength: chunk.length, customBlockModelsLength: customBlockModels ? Object.keys(customBlockModels).length : 0 })}`)
645
715
  this.mesherLogReader?.chunkReceived(x, z, chunk.length)
646
716
  const sectionHeight = this.getSectionHeight()
647
717
  const CHUNK_SIZE = 16
648
718
 
649
- if (IS_FULL_WORLD_SECTION) {
650
- // Only one section per chunk when full world section
651
- const loc = new Vec3(x, this.worldMinYRender, z)
719
+ for (let y = this.worldMinYRender; y < this.worldSizeParams.worldHeight; y += sectionHeight) {
720
+ const loc = new Vec3(x, y, z)
652
721
  this.setSectionDirty(loc)
653
722
  if (this.neighborChunkUpdates && (!isLightUpdate || this.worldRendererConfig.smoothLighting)) {
654
723
  this.setSectionDirty(loc.offset(-CHUNK_SIZE, 0, 0))
@@ -656,17 +725,6 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
656
725
  this.setSectionDirty(loc.offset(0, 0, -CHUNK_SIZE))
657
726
  this.setSectionDirty(loc.offset(0, 0, CHUNK_SIZE))
658
727
  }
659
- } else {
660
- for (let y = this.worldMinYRender; y < this.worldSizeParams.worldHeight; y += sectionHeight) {
661
- const loc = new Vec3(x, y, z)
662
- this.setSectionDirty(loc)
663
- if (this.neighborChunkUpdates && (!isLightUpdate || this.worldRendererConfig.smoothLighting)) {
664
- this.setSectionDirty(loc.offset(-CHUNK_SIZE, 0, 0))
665
- this.setSectionDirty(loc.offset(CHUNK_SIZE, 0, 0))
666
- this.setSectionDirty(loc.offset(0, 0, -CHUNK_SIZE))
667
- this.setSectionDirty(loc.offset(0, 0, CHUNK_SIZE))
668
- }
669
- }
670
728
  }
671
729
  }
672
730
 
@@ -674,6 +732,9 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
674
732
  this.loadedChunks[`${x},${z}`] = true
675
733
  this.finishedChunks[`${x},${z}`] = true
676
734
  this.logWorkerWork(`-> markAsLoaded ${JSON.stringify({ x, z })}`)
735
+ // Mirror the main meshing path so the near-first reveal gate can
736
+ // re-evaluate any farther chunks blocked by this column.
737
+ this.renderUpdateEmitter.emit('chunkFinished', `${x},${z}`)
677
738
  this.checkAllFinished()
678
739
  }
679
740
 
@@ -706,15 +767,9 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
706
767
  this.initialChunkLoadWasStartedIn = undefined
707
768
  }
708
769
  const sectionHeight = this.getSectionHeight()
709
- if (IS_FULL_WORLD_SECTION) {
710
- const sectionY = this.worldMinYRender
711
- this.setSectionDirty(new Vec3(x, sectionY, z), false)
712
- delete this.finishedSections[`${x},${sectionY},${z}`]
713
- } else {
714
- for (let y = this.worldSizeParams.minY; y < this.worldSizeParams.worldHeight; y += sectionHeight) {
715
- this.setSectionDirty(new Vec3(x, y, z), false)
716
- delete this.finishedSections[`${x},${y},${z}`]
717
- }
770
+ for (let y = this.worldSizeParams.minY; y < this.worldSizeParams.worldHeight; y += sectionHeight) {
771
+ this.setSectionDirty(new Vec3(x, y, z), false)
772
+ delete this.finishedSections[`${x},${y},${z}`]
718
773
  }
719
774
  this.highestBlocksByChunks.delete(`${x},${z}`)
720
775
  this.reactiveState.world.heightmaps.delete(`${Math.floor(x / 16)},${Math.floor(z / 16)}`)
@@ -885,12 +940,17 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
885
940
  const chunkCornerX = Math.floor(pos.x / CHUNK_SIZE) * CHUNK_SIZE
886
941
  const chunkCornerZ = Math.floor(pos.z / CHUNK_SIZE) * CHUNK_SIZE
887
942
  const chunkKey2 = `${chunkCornerX},${chunkCornerZ}`
888
- const existing = this.heightmapDebounceTimers.get(chunkKey2)
889
- if (existing) clearTimeout(existing)
890
- this.heightmapDebounceTimers.set(chunkKey2, setTimeout(() => {
891
- this.heightmapDebounceTimers.delete(chunkKey2)
892
- this.workers[0]?.postMessage({ type: 'getHeightmap', x: chunkCornerX, z: chunkCornerZ })
893
- }, 100))
943
+ // WASM mesher pushes heightmaps from `processColumnTick`, so the
944
+ // block-update path doesn't need an explicit re-request — the next
945
+ // column tick will repush the recomputed heightmap.
946
+ if (!this.worldRendererConfig.wasmMesher) {
947
+ const existing = this.heightmapDebounceTimers.get(chunkKey2)
948
+ if (existing) clearTimeout(existing)
949
+ this.heightmapDebounceTimers.set(chunkKey2, setTimeout(() => {
950
+ this.heightmapDebounceTimers.delete(chunkKey2)
951
+ this.workers[0]?.postMessage({ type: 'getHeightmap', x: chunkCornerX, z: chunkCornerZ })
952
+ }, 100))
953
+ }
894
954
  }
895
955
  this.logWorkerWork(`-> blockUpdate ${JSON.stringify({ pos, stateId, customBlockModels })}`)
896
956
  this.setSectionDirty(pos, true, true)
@@ -937,14 +997,43 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
937
997
  getWorkerNumber(pos: Vec3, updateAction = false) {
938
998
  const CHUNK_SIZE = 16
939
999
  const sectionHeight = this.getSectionHeight()
1000
+ const dedicated = this.worldRendererConfig.dedicatedChangeWorker
1001
+
1002
+ if (dedicated && this.workers.length > 1) {
1003
+ // WASM column meshing must keep all vertical sections of a chunk
1004
+ // column on one worker — skip dedicated change worker to avoid
1005
+ // concurrent column meshing across different workers.
1006
+ if (this.worldRendererConfig.wasmMesher) {
1007
+ return mod(Math.floor(pos.x / CHUNK_SIZE) + Math.floor(pos.z / CHUNK_SIZE), this.workers.length)
1008
+ }
1009
+ if (updateAction) {
1010
+ const key = `${Math.floor(pos.x / CHUNK_SIZE) * CHUNK_SIZE},${Math.floor(pos.y / sectionHeight) * sectionHeight},${Math.floor(pos.z / CHUNK_SIZE) * CHUNK_SIZE}`
1011
+ const busy = this.sectionsWaiting.get(key) && !this.finishedSections[key]
1012
+ if (busy) {
1013
+ // Section is already being meshed by a general worker — route
1014
+ // the update to the same worker to avoid concurrent meshing.
1015
+ const generalWorkers = this.workers.length - 1
1016
+ return mod(Math.floor(pos.x / CHUNK_SIZE) + Math.floor(pos.y / sectionHeight) + Math.floor(pos.z / CHUNK_SIZE), generalWorkers)
1017
+ }
1018
+ return this.workers.length - 1
1019
+ }
1020
+ const generalWorkers = this.workers.length - 1
1021
+ return mod(Math.floor(pos.x / CHUNK_SIZE) + Math.floor(pos.y / sectionHeight) + Math.floor(pos.z / CHUNK_SIZE), generalWorkers)
1022
+ }
1023
+
1024
+ if (this.worldRendererConfig.wasmMesher) {
1025
+ // WASM column meshing must keep all vertical sections of a chunk column
1026
+ // on one worker. Hash by x/z only and bypass the change-worker shortcut
1027
+ // so block edits cannot remesh the same column concurrently on worker 0.
1028
+ return mod(Math.floor(pos.x / CHUNK_SIZE) + Math.floor(pos.z / CHUNK_SIZE), this.workers.length)
1029
+ }
940
1030
  if (updateAction) {
941
1031
  const key = `${Math.floor(pos.x / CHUNK_SIZE) * CHUNK_SIZE},${Math.floor(pos.y / sectionHeight) * sectionHeight},${Math.floor(pos.z / CHUNK_SIZE) * CHUNK_SIZE}`
942
1032
  const cantUseChangeWorker = this.sectionsWaiting.get(key) && !this.finishedSections[key]
943
1033
  if (!cantUseChangeWorker) return 0
944
1034
  }
945
1035
 
946
- const hash = mod(Math.floor(pos.x / CHUNK_SIZE) + Math.floor(pos.y / sectionHeight) + Math.floor(pos.z / CHUNK_SIZE), this.workers.length)
947
- return hash + 0
1036
+ return mod(Math.floor(pos.x / CHUNK_SIZE) + Math.floor(pos.y / sectionHeight) + Math.floor(pos.z / CHUNK_SIZE), this.workers.length)
948
1037
  }
949
1038
 
950
1039
  async debugGetWorkerCustomBlockModel(pos: Vec3) {
@@ -1032,7 +1121,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
1032
1121
  // Dispatch sections to workers based on position
1033
1122
  // This guarantees uniformity accross workers and that a given section
1034
1123
  // is always dispatched to the same worker
1035
- const hash = this.getWorkerNumber(pos, useChangeWorker && this.mesherLogger.active)
1124
+ const hash = this.getWorkerNumber(pos, useChangeWorker && (this.mesherLogger.active || this.worldRendererConfig.dedicatedChangeWorker))
1036
1125
  this.sectionsWaiting.set(key, (this.sectionsWaiting.get(key) ?? 0) + 1)
1037
1126
  if (this.forceCallFromMesherReplayer) {
1038
1127
  this.workers[hash].postMessage({
@@ -1064,8 +1153,9 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
1064
1153
  // group messages and send as one
1065
1154
  for (const workerIndex in this.toWorkerMessagesQueue) {
1066
1155
  const worker = this.workers[Number(workerIndex)]
1067
- worker.postMessage(this.toWorkerMessagesQueue[workerIndex])
1068
- for (const message of this.toWorkerMessagesQueue[workerIndex]) {
1156
+ const messages = this.toWorkerMessagesQueue[workerIndex]
1157
+ worker.postMessage(messages)
1158
+ for (const message of messages) {
1069
1159
  this.logWorkerWork(`-> ${workerIndex} dispatchMessages ${message.type} ${JSON.stringify({ x: message.x, y: message.y, z: message.z, value: message.value })}`)
1070
1160
  }
1071
1161
  }
@@ -0,0 +1,70 @@
1
+ //@ts-nocheck
2
+ export interface SignMeta { isWall: boolean; isHanging: boolean; rotation: number }
3
+ export interface HeadMeta { isWall: boolean; rotation: number }
4
+ export interface BannerMeta { isWall: boolean; blockName: string; rotation: number }
5
+
6
+ export interface BlockEntityMetadataTarget {
7
+ signs: Record<string, SignMeta>
8
+ heads: Record<string, HeadMeta>
9
+ banners: Record<string, BannerMeta>
10
+ }
11
+
12
+ export interface BlockEntityMetadataOptions {
13
+ disableBlockEntityTextures?: boolean
14
+ }
15
+
16
+ type BlockLike = { name: string; getProperties(): any }
17
+
18
+ export function collectBlockEntityMetadata(
19
+ block: BlockLike,
20
+ x: number, y: number, z: number,
21
+ target: BlockEntityMetadataTarget,
22
+ options: BlockEntityMetadataOptions
23
+ ): void {
24
+ if ((block.name.includes('_sign') || block.name === 'sign') && !options.disableBlockEntityTextures) {
25
+ const key = `${x},${y},${z}`
26
+ const props: any = block.getProperties()
27
+ const facingRotationMap = {
28
+ 'north': 2,
29
+ 'south': 0,
30
+ 'west': 1,
31
+ 'east': 3
32
+ }
33
+ const isWall = block.name.endsWith('wall_sign') || block.name.endsWith('wall_hanging_sign')
34
+ const isHanging = block.name.endsWith('hanging_sign')
35
+ target.signs[key] = {
36
+ isWall,
37
+ isHanging,
38
+ rotation: isWall ? facingRotationMap[props.facing] : +props.rotation
39
+ }
40
+ } else if (block.name === 'player_head' || block.name === 'player_wall_head') {
41
+ const key = `${x},${y},${z}`
42
+ const props: any = block.getProperties()
43
+ const facingRotationMap = {
44
+ 'north': 0,
45
+ 'south': 2,
46
+ 'west': 3,
47
+ 'east': 1
48
+ }
49
+ const isWall = block.name === 'player_wall_head'
50
+ target.heads[key] = {
51
+ isWall,
52
+ rotation: isWall ? facingRotationMap[props.facing] : +props.rotation
53
+ }
54
+ } else if (block.name.includes('_banner') && !options.disableBlockEntityTextures) {
55
+ const key = `${x},${y},${z}`
56
+ const props: any = block.getProperties()
57
+ const facingRotationMap = {
58
+ 'north': 2,
59
+ 'south': 0,
60
+ 'west': 1,
61
+ 'east': 3
62
+ }
63
+ const isWall = block.name.endsWith('_wall_banner')
64
+ target.banners[key] = {
65
+ isWall,
66
+ blockName: block.name, // Pass block name for base color extraction
67
+ rotation: isWall ? facingRotationMap[props.facing] : (props.rotation === undefined ? 0 : +props.rotation)
68
+ }
69
+ }
70
+ }
@@ -0,0 +1,66 @@
1
+ //@ts-nocheck
2
+ import { Vec3 } from 'vec3'
3
+ import { World } from './world'
4
+ import { INVISIBLE_BLOCKS } from './worldConstants'
5
+
6
+ /**
7
+ * Sentinel value written to the heightmap for any column that contains no
8
+ * non-INVISIBLE block in `[worldMinY, worldMaxY]`. Matches the value that
9
+ * Rust's `wasm-mesher` writes for empty columns in its `Vec<i16>` heightmap,
10
+ * so JS and Rust heightmaps are element-wise comparable.
11
+ *
12
+ * Downstream consumers (e.g. `src/three/modules/rain.ts`) MUST treat this
13
+ * value as "no surface" rather than as a real Y coordinate.
14
+ */
15
+ export const EMPTY_COLUMN_HEIGHTMAP_SENTINEL = -32768
16
+
17
+ /**
18
+ * Compute the surface heightmap for one 16x16 chunk column.
19
+ *
20
+ * Returns a 256-entry Int16Array indexed as `z * 16 + x`, where each entry is
21
+ * the world-Y of the highest non-INVISIBLE block in that column, or
22
+ * `EMPTY_COLUMN_HEIGHTMAP_SENTINEL` (-32768) if no such block exists.
23
+ *
24
+ * Shared by the JS-mode mesher (`mesher.ts`) and WASM-mode mesher
25
+ * (`mesherWasm.ts`) `getHeightmap` handlers to guarantee element-wise parity.
26
+ */
27
+ export function computeHeightmap(world: World, chunkX: number, chunkZ: number): Int16Array {
28
+ const heightmap = new Int16Array(256)
29
+
30
+ const blockPos = new Vec3(0, 0, 0)
31
+ for (let z = 0; z < 16; z++) {
32
+ for (let x = 0; x < 16; x++) {
33
+ blockPos.x = x + chunkX
34
+ blockPos.z = z + chunkZ
35
+ blockPos.y = world.config.worldMaxY
36
+ let block = world.getBlock(blockPos)
37
+ while (block && INVISIBLE_BLOCKS.has(block.name) && blockPos.y > world.config.worldMinY) {
38
+ blockPos.y -= 1
39
+ block = world.getBlock(blockPos)
40
+ }
41
+ const index = z * 16 + x
42
+ // Loop exits either when we found a visible (non-INVISIBLE) block, or
43
+ // when we hit worldMinY with the column still entirely invisible/empty.
44
+ // Only the former is a real surface; the latter is the empty-column
45
+ // case and must use the sentinel to match Rust's encoding.
46
+ heightmap[index] = block && !INVISIBLE_BLOCKS.has(block.name)
47
+ ? blockPos.y
48
+ : EMPTY_COLUMN_HEIGHTMAP_SENTINEL
49
+ }
50
+ }
51
+ return heightmap
52
+ }
53
+
54
+ /**
55
+ * Shared `getHeightmap` worker-handler logic.
56
+ *
57
+ * Both `mesher.ts` and `mesherWasm.ts` route their `case 'getHeightmap'` here so
58
+ * the post-message payload (key + heightmap) is computed in exactly one place.
59
+ * Test fixtures (see `wasm-mesher/test-section-boundary.ts`) invoke this helper
60
+ * directly to exercise the real handler path end-to-end.
61
+ */
62
+ export function handleGetHeightmap(world: World, x: number, z: number): { key: string, heightmap: Int16Array } {
63
+ const heightmap = computeHeightmap(world, x, z)
64
+ const key = `${Math.floor(x / 16)},${Math.floor(z / 16)}`
65
+ return { key, heightmap }
66
+ }
@@ -3,7 +3,7 @@ import { Vec3 } from 'vec3'
3
3
  import { World } from './world'
4
4
  import { getSectionGeometry, setBlockStatesData as setMesherData } from './models'
5
5
  import { BlockStateModelInfo } from './shared'
6
- import { INVISIBLE_BLOCKS } from './worldConstants'
6
+ import { handleGetHeightmap, EMPTY_COLUMN_HEIGHTMAP_SENTINEL } from './computeHeightmap'
7
7
 
8
8
  globalThis.structuredClone ??= (value) => JSON.parse(JSON.stringify(value))
9
9
 
@@ -109,6 +109,7 @@ const handleMessage = data => {
109
109
  break
110
110
  }
111
111
  case 'chunk': {
112
+ if (!world) break
112
113
  world.addColumn(data.x, data.z, data.chunk)
113
114
  if (data.customBlockModels) {
114
115
  const chunkKey = `${data.x},${data.z}`
@@ -117,6 +118,7 @@ const handleMessage = data => {
117
118
  break
118
119
  }
119
120
  case 'unloadChunk': {
121
+ if (!world) break
120
122
  world.removeColumn(data.x, data.z)
121
123
  world.customBlockModels.delete(`${data.x},${data.z}`)
122
124
  if (Object.keys(world.columns).length === 0) softCleanup()
@@ -146,6 +148,10 @@ const handleMessage = data => {
146
148
  break
147
149
  }
148
150
  case 'getCustomBlockModel': {
151
+ if (!world) {
152
+ global.postMessage({ type: 'customBlockModel', chunkKey: '', customBlockModel: undefined })
153
+ break
154
+ }
149
155
  const pos = new Vec3(data.pos.x, data.pos.y, data.pos.z)
150
156
  const chunkKey = `${Math.floor(pos.x / 16) * 16},${Math.floor(pos.z / 16) * 16}`
151
157
  const customBlockModel = world.customBlockModels.get(chunkKey)?.[`${pos.x},${pos.y},${pos.z}`]
@@ -153,26 +159,13 @@ const handleMessage = data => {
153
159
  break
154
160
  }
155
161
  case 'getHeightmap': {
156
- const heightmap = new Int16Array(256)
157
-
158
- const blockPos = new Vec3(0, 0, 0)
159
- for (let z = 0; z < 16; z++) {
160
- for (let x = 0; x < 16; x++) {
161
- const blockX = x + data.x
162
- const blockZ = z + data.z
163
- blockPos.x = blockX
164
- blockPos.z = blockZ
165
- blockPos.y = world.config.worldMaxY
166
- let block = world.getBlock(blockPos)
167
- while (block && INVISIBLE_BLOCKS.has(block.name) && blockPos.y > world.config.worldMinY) {
168
- blockPos.y -= 1
169
- block = world.getBlock(blockPos)
170
- }
171
- const index = z * 16 + x
172
- heightmap[index] = block ? blockPos.y : -32768
173
- }
162
+ if (!world) {
163
+ const emptyHeightmap = new Int16Array(256).fill(EMPTY_COLUMN_HEIGHTMAP_SENTINEL)
164
+ postMessage({ type: 'heightmap', key: `${Math.floor(data.x / 16)},${Math.floor(data.z / 16)}`, heightmap: emptyHeightmap })
165
+ break
174
166
  }
175
- postMessage({ type: 'heightmap', key: `${Math.floor(data.x / 16)},${Math.floor(data.z / 16)}`, heightmap })
167
+ const { key, heightmap } = handleGetHeightmap(world, data.x, data.z)
168
+ postMessage({ type: 'heightmap', key, heightmap })
176
169
 
177
170
  break
178
171
  }