minecraft-renderer 0.1.84 → 0.1.85

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.
@@ -10,6 +10,16 @@ import { collectBlockEntityMetadata, type SignMeta, type HeadMeta, type BannerMe
10
10
  import { SectionRequestTracker } from './mesherWasmRequestTracker'
11
11
  import { sectionYsForLightColumnDirty } from './mesherWasmLightDirty'
12
12
  import { CONVERSION_CACHE_LIMIT, clearConversionCache, getOrConvertColumn, invalidateConversion, setConversionCacheLimit } from './mesherWasmConversionCache'
13
+ import { PendingChunkBuffer } from './mesherWasmChunkBuffer'
14
+ import {
15
+ PendingNeighborHealTracker,
16
+ SIDE_NEIGHBOR_OFFSETS,
17
+ collectChunksForColumnUnion,
18
+ columnDataAvailable,
19
+ countParsedCache3x3,
20
+ countWorldColumns3x3,
21
+ type PacketCaches
22
+ } from './mesherWasmNeighborhood'
13
23
 
14
24
  let wasm: typeof import('../runtime-build/wasm_mesher.js') | null = null
15
25
  let wasmInitialized = false
@@ -304,6 +314,77 @@ interface ParsedV16Entry {
304
314
  }
305
315
  const parsedV16Cache = new Map<string, ParsedV16Entry>()
306
316
 
317
+ const pendingChunks = new PendingChunkBuffer()
318
+ const pendingNeighborHeal = new PendingNeighborHealTracker()
319
+ let blindMeshWarnCount = 0
320
+ const BLIND_MESH_WARN_LIMIT = 5
321
+
322
+ const packetCaches = (): PacketCaches => ({
323
+ raw: rawMapChunkCache,
324
+ v17: parsedV17Cache,
325
+ v16: parsedV16Cache
326
+ })
327
+
328
+ function notifyColumnsAwaitingNeighbor(neighborX: number, neighborZ: number) {
329
+ for (const col of pendingNeighborHeal.takeColumnsAwaitingNeighbor(neighborX, neighborZ)) {
330
+ postMessage({ type: 'neighborDataArrived', x: col.x, z: col.z, workerIndex })
331
+ }
332
+ }
333
+
334
+ function onColumnDataArrived(x: number, z: number) {
335
+ notifyColumnsAwaitingNeighbor(x, z)
336
+ }
337
+
338
+ function warnGenuinelyBlindMesh(columnX: number, columnZ: number, missingSides: number) {
339
+ if (missingSides <= 0) return
340
+ blindMeshWarnCount++
341
+ if (blindMeshWarnCount <= BLIND_MESH_WARN_LIMIT) {
342
+ console.warn(`[mesher] column ${columnX},${columnZ} meshed with ${missingSides}/4 side neighbors missing from both pipelines — genuinely blind mesh`)
343
+ } else if (blindMeshWarnCount === BLIND_MESH_WARN_LIMIT + 1) {
344
+ console.warn(`[mesher] genuinely blind mesh warnings suppressed after ${BLIND_MESH_WARN_LIMIT} occurrences`)
345
+ }
346
+ }
347
+
348
+ function processChunkMessage(data: { x: number; z: number; chunk: any; customBlockModels?: any }) {
349
+ world.addColumn(data.x, data.z, data.chunk)
350
+ // --- Light sync (folded in from main): apply any light that arrived before
351
+ // this column's data. Doing it here (not inline in the handler) means
352
+ // chunks replayed from pendingChunks via drainPendingChunks() also get
353
+ // their buffered light applied.
354
+ syncV17LightToColumn(data.x, data.z)
355
+ const lightKey = rawCacheKey(data.x, data.z)
356
+ if (pendingLightDirtyColumns.delete(lightKey) || updateLightV17Cache.has(lightKey)) {
357
+ dirtyColumnSectionsForLightUpdate(data.x, data.z)
358
+ }
359
+ if (data.customBlockModels) {
360
+ const chunkKey = `${data.x},${data.z}`
361
+ world.customBlockModels.set(chunkKey, data.customBlockModels)
362
+ }
363
+ const sectionH = SECTION_HEIGHT
364
+ const minY = config?.worldMinY ?? 0
365
+ const maxY = config?.worldMaxY ?? 256
366
+ const column = world.getColumn(data.x, data.z)
367
+ let hasAnySection = false
368
+ for (let y = minY; y < maxY; y += sectionH) {
369
+ if (column?.getSection?.(new Vec3(0, y, 0))) {
370
+ hasAnySection = true
371
+ break
372
+ }
373
+ }
374
+ if (!hasAnySection) {
375
+ const emptyHeightmap = new Int16Array(256).fill(EMPTY_COLUMN_HEIGHTMAP_SENTINEL)
376
+ postMessage({ type: 'heightmap', key: `${data.x >> 4},${data.z >> 4}`, heightmap: emptyHeightmap }, [emptyHeightmap.buffer])
377
+ }
378
+ }
379
+
380
+ function drainPendingChunks() {
381
+ if (!world) return
382
+ pendingChunks.drain(msg => {
383
+ processChunkMessage(msg)
384
+ onColumnDataArrived(msg.x, msg.z)
385
+ })
386
+ }
387
+
307
388
  // 1.16 sky/block light cache — same shape as the v17 entry, populated by
308
389
  // `processUpdateLightV16` (which calls the shared `parseUpdateLightV17`
309
390
  // WASM export). Separate map for the same isolation reasons as
@@ -748,6 +829,7 @@ const handleMessage = async (data: any) => {
748
829
  await initWasm()
749
830
  allDataReady = true
750
831
  workerIndex = data.workerIndex
832
+ drainPendingChunks()
751
833
  break
752
834
  }
753
835
  case 'dirty': {
@@ -759,44 +841,17 @@ const handleMessage = async (data: any) => {
759
841
  // Invalidate BEFORE replacing the column reference so a stale entry
760
842
  // can never outlive the old chunk object.
761
843
  invalidateConversion(data.x, data.z)
762
- if (!world) break
763
- world.addColumn(data.x, data.z, data.chunk)
764
- syncV17LightToColumn(data.x, data.z)
765
- const lightKey = rawCacheKey(data.x, data.z)
766
- if (pendingLightDirtyColumns.delete(lightKey) || updateLightV17Cache.has(lightKey)) {
767
- dirtyColumnSectionsForLightUpdate(data.x, data.z)
768
- }
769
- if (data.customBlockModels) {
770
- const chunkKey = `${data.x},${data.z}`
771
- world.customBlockModels.set(chunkKey, data.customBlockModels)
772
- }
773
- // Safety-net heightmap push for fully empty columns. With WASM
774
- // mesher as the sole path, the main thread no longer requests
775
- // `getHeightmap` on chunk load — heightmaps come from
776
- // `processColumnTick`. But a fully empty column (no sections, or
777
- // all sections missing) never enters that path because
778
- // `setSectionDirty` short-circuits when `chunk.getSection(pos)` is
779
- // falsy, so `processColumnTick` never sees it. Without this push
780
- // downstream consumers (e.g. `rain.ts`) would have no heightmap
781
- // entry for such columns. We send a cheap sentinel-filled
782
- // `Int16Array(256).fill(-32768)` — no JS heightmap scan — only when
783
- // we detect zero sections; non-empty columns get their real
784
- // heightmap from the next `processColumnTick`.
785
- const sectionH = SECTION_HEIGHT
786
- const minY = config?.worldMinY ?? 0
787
- const maxY = config?.worldMaxY ?? 256
788
- const column = world.getColumn(data.x, data.z)
789
- let hasAnySection = false
790
- for (let y = minY; y < maxY; y += sectionH) {
791
- if (column?.getSection?.(new Vec3(0, y, 0))) {
792
- hasAnySection = true
793
- break
794
- }
795
- }
796
- if (!hasAnySection) {
797
- const emptyHeightmap = new Int16Array(256).fill(EMPTY_COLUMN_HEIGHTMAP_SENTINEL)
798
- postMessage({ type: 'heightmap', key: `${data.x >> 4},${data.z >> 4}`, heightmap: emptyHeightmap }, [emptyHeightmap.buffer])
844
+ if (!world) {
845
+ pendingChunks.enqueue({
846
+ x: data.x,
847
+ z: data.z,
848
+ chunk: data.chunk,
849
+ customBlockModels: data.customBlockModels
850
+ })
851
+ break
799
852
  }
853
+ processChunkMessage(data)
854
+ onColumnDataArrived(data.x, data.z)
800
855
  break
801
856
  }
802
857
  case 'unloadChunk': {
@@ -810,6 +865,7 @@ const handleMessage = async (data: any) => {
810
865
  if (!world) break
811
866
  world.removeColumn(data.x, data.z)
812
867
  world.customBlockModels.delete(`${data.x},${data.z}`)
868
+ pendingNeighborHeal.clearColumn(data.x, data.z)
813
869
  requestTracker.clearColumn(data.x, data.z)
814
870
  for (const key of [...dirtySections.keys()]) {
815
871
  const [sx, , sz] = key.split(',').map(Number)
@@ -856,6 +912,7 @@ const handleMessage = async (data: any) => {
856
912
  numSections: data.numSections as number
857
913
  })
858
914
  invalidateConversion(data.x, data.z)
915
+ onColumnDataArrived(data.x, data.z)
859
916
  break
860
917
  }
861
918
  case 'setParsedMapChunkV17': {
@@ -869,6 +926,7 @@ const handleMessage = async (data: any) => {
869
926
  biomes: data.biomes as Int32Array | undefined
870
927
  })
871
928
  invalidateConversion(data.x, data.z)
929
+ onColumnDataArrived(data.x, data.z)
872
930
  break
873
931
  }
874
932
  case 'setUpdateLightV17': {
@@ -893,6 +951,7 @@ const handleMessage = async (data: any) => {
893
951
  biomes: data.biomes as Int32Array
894
952
  })
895
953
  invalidateConversion(data.x, data.z)
954
+ onColumnDataArrived(data.x, data.z)
896
955
  break
897
956
  }
898
957
  case 'setUpdateLightV16': {
@@ -913,6 +972,9 @@ const handleMessage = async (data: any) => {
913
972
  pendingLightDirtyColumns.clear()
914
973
  parsedV16Cache.clear()
915
974
  updateLightV16Cache.clear()
975
+ pendingChunks.clear()
976
+ pendingNeighborHeal.clear()
977
+ blindMeshWarnCount = 0
916
978
  globalVar.mcData = null
917
979
  globalVar.loadedData = null
918
980
  allDataReady = false
@@ -984,23 +1046,8 @@ self.onmessage = ({ data }) => {
984
1046
  // Section height is always 16 in column mode (the only WASM path).
985
1047
  const getSectionHeight = () => SECTION_HEIGHT
986
1048
 
987
- // 3x3 X/Z neighbor set for column meshing. Y-agnostic because full-column
988
- // meshing converts the entire world Y range in one go.
989
1049
  function collectChunksForColumn(x: number, z: number) {
990
- const result = [] as Array<{ x: number; z: number; chunk: any }>
991
- const target = world.getColumn(x, z)
992
- if (target) result.push({ x, z, chunk: target })
993
- const offsets = [-16, 0, 16]
994
- for (const dx of offsets) {
995
- for (const dz of offsets) {
996
- if (dx === 0 && dz === 0) continue
997
- const nx = x + dx
998
- const nz = z + dz
999
- const c = world.getColumn(nx, nz)
1000
- if (c) result.push({ x: nx, z: nz, chunk: c })
1001
- }
1002
- }
1003
- return result
1050
+ return collectChunksForColumnUnion(x, z, (nx, nz) => world.getColumn(nx, nz), packetCaches())
1004
1051
  }
1005
1052
 
1006
1053
  function makeEmptyColumnGeometry(sx: number, sy: number, sz: number, sectionHeight: number, hadErrors: boolean): MesherGeometryOutput {
@@ -1079,6 +1126,9 @@ function processColumnTick() {
1079
1126
  let preCacheMisses = 0
1080
1127
  let hadError = false
1081
1128
  let columnMeshPath = 'none'
1129
+ let chunkCount = 0
1130
+ let worldColumns3x3 = 0
1131
+ let parsedCache3x3 = 0
1082
1132
  // Outer-scope timestamps so we can finalize `processTime` and
1083
1133
  // `postPhase` AFTER the per-section emit loop runs (the loop builds
1084
1134
  // typed arrays, walks block-entity metadata, and calls postMessage —
@@ -1093,7 +1143,21 @@ function processColumnTick() {
1093
1143
  const t0 = start
1094
1144
  try {
1095
1145
  const chunksToUse = collectChunksForColumn(x, z)
1096
- const chunkCount = chunksToUse.length
1146
+ chunkCount = chunksToUse.length
1147
+ const caches = packetCaches()
1148
+ worldColumns3x3 = countWorldColumns3x3(x, z, (nx, nz) => world.getColumn(nx, nz))
1149
+ parsedCache3x3 = countParsedCache3x3(x, z, caches)
1150
+
1151
+ let missingSideCount = 0
1152
+ for (const [dx, dz] of SIDE_NEIGHBOR_OFFSETS) {
1153
+ const nx = x + dx
1154
+ const nz = z + dz
1155
+ if (!columnDataAvailable(nx, nz, (cx, cz) => world.getColumn(cx, cz), caches)) {
1156
+ pendingNeighborHeal.recordMissingSide(x, z, nx, nz)
1157
+ missingSideCount++
1158
+ }
1159
+ }
1160
+ warnGenuinelyBlindMesh(x, z, missingSideCount)
1097
1161
 
1098
1162
  let wasmResult: any
1099
1163
  let t1 = 0
@@ -1183,7 +1247,9 @@ function processColumnTick() {
1183
1247
 
1184
1248
  if (!wasmResult) {
1185
1249
  // --- Old two-step path (multi-column or fused fallback) ---
1186
- const conversions = chunksToUse.map(({ x: cx, z: cz, chunk }) => {
1250
+ const conversions: ChunkConversionResult[] = []
1251
+ const meshChunks: typeof chunksToUse = []
1252
+ for (const { x: cx, z: cz, chunk } of chunksToUse) {
1187
1253
  const cs = performance.now()
1188
1254
  const rawEntry = rawMapChunkCache.get(rawCacheKey(cx, cz))
1189
1255
  const v17Entry = parsedV17Cache.get(rawCacheKey(cx, cz))
@@ -1211,6 +1277,9 @@ function processColumnTick() {
1211
1277
  if (!conv) {
1212
1278
  // JS-fallback (column walk) — still cached, since this is the
1213
1279
  // expensive path the conversion cache was built for.
1280
+ // Cache-only neighbors (pipeline B without world.columns) are
1281
+ // skipped — fused multi should have handled them already.
1282
+ if (!chunk) continue
1214
1283
  const cached = getOrConvertColumn(
1215
1284
  cx,
1216
1285
  cz,
@@ -1234,12 +1303,18 @@ function processColumnTick() {
1234
1303
  preNeighborConvert += ce - cs
1235
1304
  preNeighborCount++
1236
1305
  }
1237
- return conv
1238
- })
1306
+ conversions.push(conv)
1307
+ meshChunks.push({ x: cx, z: cz, chunk })
1308
+ }
1309
+
1310
+ const twoStepChunkCount = conversions.length
1311
+ if (twoStepChunkCount === 0) {
1312
+ throw new Error(`[WASM Mesher] no convertible columns for ${x},${z}`)
1313
+ }
1239
1314
 
1240
1315
  const { invisibleBlocks, transparentBlocks, noAoBlocks, cullIdenticalBlocks, occludingBlocks } = conversions[0]
1241
1316
 
1242
- if (chunkCount === 1 || !(wasm as any).generate_geometry_multi) {
1317
+ if (twoStepChunkCount === 1 || !(wasm as any).generate_geometry_multi) {
1243
1318
  const { blockStates, blockLight, skyLight, biomesArray } = conversions[0]
1244
1319
  t1 = performance.now()
1245
1320
  wasmResult = wasm.generate_geometry(
@@ -1267,17 +1342,17 @@ function processColumnTick() {
1267
1342
  } else {
1268
1343
  const tBuildStart = performance.now()
1269
1344
  const perChunkLen = conversions[0].blockStates.length
1270
- const xs = new Int32Array(chunkCount)
1271
- const zs = new Int32Array(chunkCount)
1272
- const blockStatesAll = new Uint16Array(perChunkLen * chunkCount)
1273
- const blockLightAll = new Uint8Array(perChunkLen * chunkCount)
1274
- const skyLightAll = new Uint8Array(perChunkLen * chunkCount)
1275
- const biomesAll = new Uint8Array(perChunkLen * chunkCount)
1276
-
1277
- for (let i = 0; i < chunkCount; i++) {
1345
+ const xs = new Int32Array(twoStepChunkCount)
1346
+ const zs = new Int32Array(twoStepChunkCount)
1347
+ const blockStatesAll = new Uint16Array(perChunkLen * twoStepChunkCount)
1348
+ const blockLightAll = new Uint8Array(perChunkLen * twoStepChunkCount)
1349
+ const skyLightAll = new Uint8Array(perChunkLen * twoStepChunkCount)
1350
+ const biomesAll = new Uint8Array(perChunkLen * twoStepChunkCount)
1351
+
1352
+ for (let i = 0; i < twoStepChunkCount; i++) {
1278
1353
  const c = conversions[i]
1279
- xs[i] = chunksToUse[i].x
1280
- zs[i] = chunksToUse[i].z
1354
+ xs[i] = meshChunks[i].x
1355
+ zs[i] = meshChunks[i].z
1281
1356
  blockStatesAll.set(c.blockStates, perChunkLen * i)
1282
1357
  blockLightAll.set(c.blockLight, perChunkLen * i)
1283
1358
  skyLightAll.set(c.skyLight, perChunkLen * i)
@@ -1545,7 +1620,11 @@ function processColumnTick() {
1545
1620
  preTypedArrayBuild: !attributed ? preTypedArrayBuild : 0,
1546
1621
  preOther: !attributed ? preOther : 0,
1547
1622
  preCacheHits: !attributed ? preCacheHits : 0,
1548
- preCacheMisses: !attributed ? preCacheMisses : 0
1623
+ preCacheMisses: !attributed ? preCacheMisses : 0,
1624
+ chunkCount: !attributed ? chunkCount : 0,
1625
+ worldColumns3x3: !attributed ? worldColumns3x3 : 0,
1626
+ parsedCache3x3: !attributed ? parsedCache3x3 : 0,
1627
+ columnMeshPath: !attributed ? columnMeshPath : 'none'
1549
1628
  })
1550
1629
  attributed = true
1551
1630
  }
@@ -0,0 +1,28 @@
1
+ //@ts-nocheck
2
+ export type PendingChunkMessage = {
3
+ x: number
4
+ z: number
5
+ chunk: any
6
+ customBlockModels?: any
7
+ }
8
+
9
+ export class PendingChunkBuffer {
10
+ private pending: PendingChunkMessage[] = []
11
+
12
+ enqueue(msg: PendingChunkMessage) {
13
+ this.pending.push(msg)
14
+ }
15
+
16
+ drain(apply: (msg: PendingChunkMessage) => void) {
17
+ const batch = this.pending.splice(0)
18
+ for (const msg of batch) apply(msg)
19
+ }
20
+
21
+ get size() {
22
+ return this.pending.length
23
+ }
24
+
25
+ clear() {
26
+ this.pending.length = 0
27
+ }
28
+ }
@@ -0,0 +1,125 @@
1
+ //@ts-nocheck
2
+ export type ColumnChunkEntry = { x: number; z: number; chunk: any | null }
3
+
4
+ export interface PacketCacheSet {
5
+ has: (key: string) => boolean
6
+ }
7
+
8
+ export interface PacketCaches {
9
+ raw: PacketCacheSet
10
+ v17: PacketCacheSet
11
+ v16: PacketCacheSet
12
+ }
13
+
14
+ export function columnCacheKey(x: number, z: number) {
15
+ return `${x},${z}`
16
+ }
17
+
18
+ export function columnHasPacketCache(x: number, z: number, caches: PacketCaches): boolean {
19
+ const k = columnCacheKey(x, z)
20
+ return caches.raw.has(k) || caches.v17.has(k) || caches.v16.has(k)
21
+ }
22
+
23
+ export function columnDataAvailable(x: number, z: number, getColumn: (x: number, z: number) => any | null | undefined, caches: PacketCaches): boolean {
24
+ return !!getColumn(x, z) || columnHasPacketCache(x, z, caches)
25
+ }
26
+
27
+ export function countWorldColumns3x3(x: number, z: number, getColumn: (x: number, z: number) => any | null | undefined): number {
28
+ let count = 0
29
+ for (const dx of [-16, 0, 16]) {
30
+ for (const dz of [-16, 0, 16]) {
31
+ if (getColumn(x + dx, z + dz)) count++
32
+ }
33
+ }
34
+ return count
35
+ }
36
+
37
+ export function countParsedCache3x3(x: number, z: number, caches: PacketCaches): number {
38
+ let count = 0
39
+ for (const dx of [-16, 0, 16]) {
40
+ for (const dz of [-16, 0, 16]) {
41
+ if (columnHasPacketCache(x + dx, z + dz, caches)) count++
42
+ }
43
+ }
44
+ return count
45
+ }
46
+
47
+ export function collectChunksForColumnUnion(
48
+ x: number,
49
+ z: number,
50
+ getColumn: (x: number, z: number) => any | null | undefined,
51
+ caches: PacketCaches
52
+ ): ColumnChunkEntry[] {
53
+ const result: ColumnChunkEntry[] = []
54
+ const seen = new Set<string>()
55
+
56
+ const add = (nx: number, nz: number) => {
57
+ const key = columnCacheKey(nx, nz)
58
+ if (seen.has(key)) return
59
+ seen.add(key)
60
+ const chunk = getColumn(nx, nz) ?? null
61
+ if (chunk || columnHasPacketCache(nx, nz, caches)) {
62
+ result.push({ x: nx, z: nz, chunk })
63
+ }
64
+ }
65
+
66
+ add(x, z)
67
+ for (const dx of [-16, 0, 16]) {
68
+ for (const dz of [-16, 0, 16]) {
69
+ if (dx === 0 && dz === 0) continue
70
+ add(x + dx, z + dz)
71
+ }
72
+ }
73
+ return result
74
+ }
75
+
76
+ export const SIDE_NEIGHBOR_OFFSETS = [
77
+ [-16, 0],
78
+ [16, 0],
79
+ [0, -16],
80
+ [0, 16]
81
+ ] as const
82
+
83
+ export class PendingNeighborHealTracker {
84
+ // Neighbor column key -> columns that meshed without this neighbor.
85
+ private awaiting = new Map<string, Set<string>>()
86
+
87
+ private colKey(x: number, z: number) {
88
+ return columnCacheKey(x, z)
89
+ }
90
+
91
+ recordMissingSide(columnX: number, columnZ: number, missingNeighborX: number, missingNeighborZ: number) {
92
+ const neighborKey = this.colKey(missingNeighborX, missingNeighborZ)
93
+ const columnKey = this.colKey(columnX, columnZ)
94
+ let set = this.awaiting.get(neighborKey)
95
+ if (!set) {
96
+ set = new Set()
97
+ this.awaiting.set(neighborKey, set)
98
+ }
99
+ set.add(columnKey)
100
+ }
101
+
102
+ takeColumnsAwaitingNeighbor(neighborX: number, neighborZ: number): Array<{ x: number; z: number }> {
103
+ const key = this.colKey(neighborX, neighborZ)
104
+ const waiting = this.awaiting.get(key)
105
+ if (!waiting || waiting.size === 0) return []
106
+ this.awaiting.delete(key)
107
+ return [...waiting].map(k => {
108
+ const [sx, sz] = k.split(',').map(Number)
109
+ return { x: sx, z: sz }
110
+ })
111
+ }
112
+
113
+ clearColumn(x: number, z: number) {
114
+ const columnKey = this.colKey(x, z)
115
+ for (const [neighborKey, set] of this.awaiting) {
116
+ set.delete(columnKey)
117
+ if (set.size === 0) this.awaiting.delete(neighborKey)
118
+ }
119
+ this.awaiting.delete(columnKey)
120
+ }
121
+
122
+ clear() {
123
+ this.awaiting.clear()
124
+ }
125
+ }