minecraft-renderer 0.1.40 → 0.1.42

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.
Files changed (57) hide show
  1. package/dist/mesher.js +8 -8
  2. package/dist/mesher.js.map +4 -4
  3. package/dist/mesherWasm.js +93 -93
  4. package/dist/minecraft-renderer.js +57 -57
  5. package/dist/minecraft-renderer.js.meta.json +1 -1
  6. package/dist/threeWorker.js +19 -19
  7. package/package.json +3 -4
  8. package/src/bundler/bundlePrepare.ts +56 -0
  9. package/src/graphicsBackend/appViewer.ts +10 -0
  10. package/src/graphicsBackend/config.ts +1 -1
  11. package/src/graphicsBackend/preloadWorkers.ts +187 -0
  12. package/src/lib/worldrendererCommon.ts +1 -1
  13. package/src/{mesher → mesher-legacy}/mesher.ts +14 -4
  14. package/src/{mesher → mesher-legacy}/test/mesherTester.ts +2 -2
  15. package/src/{mesher → mesher-legacy}/test/run/test-js.ts +1 -1
  16. package/src/{mesher → mesher-legacy}/test/test-perf.ts +1 -1
  17. package/src/{mesher → mesher-legacy}/test/tests.test.ts +1 -1
  18. package/src/{mesher → mesher-shared}/shared.ts +2 -0
  19. package/src/playground/allEntitiesDebug.ts +1 -1
  20. package/src/three/chunkMeshManager.ts +1 -1
  21. package/src/three/entities.ts +19 -6
  22. package/src/three/entity/EntityMesh.ts +123 -140
  23. package/src/three/graphicsBackendBase.ts +13 -0
  24. package/src/three/holdingBlock.ts +1 -1
  25. package/src/three/holdingBlockLegacy.ts +1 -1
  26. package/src/three/modules/sciFiWorldReveal.ts +1 -1
  27. package/src/three/worldRendererThree.ts +2 -2
  28. package/src/wasm-mesher/README.md +90 -0
  29. package/src/{wasm-lib → wasm-mesher/bridge}/convertChunk.ts +2 -2
  30. package/src/{wasm-lib → wasm-mesher/bridge}/render-from-wasm.ts +4 -4
  31. package/src/wasm-mesher/runtime-build/wasm_mesher.d.ts +251 -0
  32. package/src/wasm-mesher/runtime-build/wasm_mesher.js +1061 -0
  33. package/src/wasm-mesher/runtime-build/wasm_mesher_bg.wasm +0 -0
  34. package/src/wasm-mesher/runtime-build/wasm_mesher_bg.wasm.d.ts +26 -0
  35. package/src/{mesher/test → wasm-mesher/tests}/heightmapParity.test.ts +4 -4
  36. package/src/{mesher/test → wasm-mesher/tests}/mesherWasmConversionCache.test.ts +2 -2
  37. package/src/{mesher/test → wasm-mesher/tests}/splitColumnWasmOutput.test.ts +1 -1
  38. package/src/wasm-mesher/worker/mesherWasm.ts +1425 -0
  39. package/src/{mesher → wasm-mesher/worker}/mesherWasmConversionCache.ts +1 -1
  40. package/src/worldView/types.ts +90 -0
  41. package/src/mesher/mesherWasm.ts +0 -696
  42. package/wasm/wasm_mesher.d.ts +0 -46
  43. package/wasm/wasm_mesher.js +0 -443
  44. package/wasm/wasm_mesher_bg.wasm +0 -0
  45. package/wasm/wasm_mesher_bg.wasm.d.ts +0 -9
  46. /package/src/{mesher → mesher-legacy}/test/a.ts +0 -0
  47. /package/src/{mesher → mesher-legacy}/test/playground.ts +0 -0
  48. /package/src/{mesher → mesher-legacy}/test/run/chunk.ts +0 -0
  49. /package/src/{mesher → mesher-legacy}/test/snapshotUtils.ts +0 -0
  50. /package/src/{mesher → mesher-shared}/blockEntityMetadata.ts +0 -0
  51. /package/src/{mesher → mesher-shared}/computeHeightmap.ts +0 -0
  52. /package/src/{mesher → mesher-shared}/models.ts +0 -0
  53. /package/src/{mesher → mesher-shared}/modelsGeometryCommon.ts +0 -0
  54. /package/src/{mesher → mesher-shared}/standaloneRenderer.ts +0 -0
  55. /package/src/{mesher → mesher-shared}/world.ts +0 -0
  56. /package/src/{mesher → mesher-shared}/worldConstants.ts +0 -0
  57. /package/src/{mesher → wasm-mesher/worker}/mesherWasmRequestTracker.ts +0 -0
@@ -0,0 +1,1425 @@
1
+ //@ts-nocheck
2
+ import { Vec3 } from 'vec3'
3
+ import { convertChunkToWasm, getBlockMeta, type ChunkConversionResult } from '../bridge/convertChunk'
4
+ import { extractColumnHeightmap, splitColumnWasmOutputToSections } from '../bridge/render-from-wasm'
5
+ import { setBlockStatesData as setMesherData } from '../../mesher-shared/models'
6
+ import { defaultMesherConfig, type MesherGeometryOutput, SECTION_HEIGHT } from '../../mesher-shared/shared'
7
+ import { worldColumnKey, World } from '../../mesher-shared/world'
8
+ import { handleGetHeightmap, EMPTY_COLUMN_HEIGHTMAP_SENTINEL } from '../../mesher-shared/computeHeightmap'
9
+ import { collectBlockEntityMetadata, type SignMeta, type HeadMeta, type BannerMeta } from '../../mesher-shared/blockEntityMetadata'
10
+ import { SectionRequestTracker } from './mesherWasmRequestTracker'
11
+ import {
12
+ CONVERSION_CACHE_LIMIT,
13
+ clearConversionCache,
14
+ getOrConvertColumn,
15
+ invalidateConversion,
16
+ setConversionCacheLimit,
17
+ } from './mesherWasmConversionCache'
18
+
19
+ let wasm: typeof import('../runtime-build/wasm_mesher.js') | null = null
20
+ let wasmInitialized = false
21
+
22
+ // Pending raw `update_light` packets that arrived before WASM finished
23
+ // loading. Parsed and drained once `initWasm` resolves. Without this queue
24
+ // the very first batch of chunks (~40-50 in our smoke test) lose their
25
+ // real lighting and fall back to fill(15) — which makes shadowed areas
26
+ // (under trees, cliff edges) look brighter than vanilla, and after the
27
+ // renderer interpolates with neighbour chunks that DO have real light,
28
+ // the seams look like "local night".
29
+ const pendingUpdateLightV17: Array<{ rawPacket: Uint8Array, numSections: number }> = []
30
+ // Separate v16 pending queue so 1.16 update_light packets that arrive
31
+ // before WASM is initialised land in the v16 light cache (not v17) on
32
+ // drain — the mesh hot path looks them up per protocol family.
33
+ const pendingUpdateLightV16: Array<{ rawPacket: Uint8Array }> = []
34
+
35
+ function processUpdateLightV17 (rawPacket: Uint8Array, numSections: number): void {
36
+ if (!wasm || !(wasm as any).parseUpdateLightV17) {
37
+ pendingUpdateLightV17.push({ rawPacket, numSections })
38
+ return
39
+ }
40
+ try {
41
+ const parsed: any = (wasm as any).parseUpdateLightV17(rawPacket, numSections)
42
+ const x = (parsed.x as number) * 16
43
+ const z = (parsed.z as number) * 16
44
+ updateLightV17Cache.set(rawCacheKey(x, z), {
45
+ skyLight: parsed.skyLight as Uint8Array,
46
+ blockLight: parsed.blockLight as Uint8Array,
47
+ })
48
+ invalidateConversion(x, z)
49
+ } catch (err) {
50
+ console.warn('[WASM Mesher] parseUpdateLightV17 failed:', err)
51
+ }
52
+ }
53
+
54
+ // 1.16 update_light shares the wire format (and the WASM parser) with
55
+ // 1.17, but we keep a SEPARATE result cache to keep the two protocol
56
+ // families fully isolated — the mesh tick picks v16 vs v17 by which raw
57
+ // chunk cache has the entry, and crossing the streams could mismatch a
58
+ // stale 1.17 column with 1.16 light or vice versa during version switches.
59
+ function processUpdateLightV16 (rawPacket: Uint8Array): void {
60
+ if (!wasm || !(wasm as any).parseUpdateLightV17) {
61
+ pendingUpdateLightV16.push({ rawPacket })
62
+ return
63
+ }
64
+ try {
65
+ const parsed: any = (wasm as any).parseUpdateLightV17(rawPacket, 16)
66
+ const x = (parsed.x as number) * 16
67
+ const z = (parsed.z as number) * 16
68
+ updateLightV16Cache.set(rawCacheKey(x, z), {
69
+ skyLight: parsed.skyLight as Uint8Array,
70
+ blockLight: parsed.blockLight as Uint8Array,
71
+ })
72
+ invalidateConversion(x, z)
73
+ } catch (err) {
74
+ console.warn('[WASM Mesher] parseUpdateLightV17 (v16) failed:', err)
75
+ }
76
+ }
77
+
78
+ async function initWasm() {
79
+ if (wasmInitialized) return
80
+ try {
81
+ wasmInitialized = true
82
+ wasm = await import('../runtime-build/wasm_mesher.js')
83
+ await wasm.default('/wasm_mesher_bg.wasm') as any
84
+
85
+ if (pendingUpdateLightV17.length > 0) {
86
+ console.log('[WASM Mesher] draining', pendingUpdateLightV17.length, 'pending update_light v17 packets')
87
+ const queue = pendingUpdateLightV17.splice(0, pendingUpdateLightV17.length)
88
+ for (const item of queue) processUpdateLightV17(item.rawPacket, item.numSections)
89
+ }
90
+
91
+ if (pendingUpdateLightV16.length > 0) {
92
+ console.log('[WASM Mesher] draining', pendingUpdateLightV16.length, 'pending update_light v16 packets')
93
+ const queue = pendingUpdateLightV16.splice(0, pendingUpdateLightV16.length)
94
+ for (const item of queue) processUpdateLightV16(item.rawPacket)
95
+ }
96
+ } catch (err) {
97
+ console.error('Failed to initialize WASM mesher:', err)
98
+ wasmInitialized = true // Don't try to initialize again
99
+ // Don't throw - allow worker to continue without WASM (will fail on first use)
100
+ }
101
+ }
102
+
103
+ globalThis.structuredClone ??= (value) => JSON.parse(JSON.stringify(value))
104
+
105
+ if (globalThis.module && module.require) {
106
+ // If we are in a node environment, we need to fake some env variables
107
+ const r = module.require
108
+ const { parentPort } = r('worker_threads')
109
+ global.self = parentPort
110
+ global.postMessage = (value, transferList) => { parentPort.postMessage(value, transferList) }
111
+ global.performance = r('perf_hooks').performance
112
+ }
113
+
114
+ let workerIndex = 0
115
+ let config = defaultMesherConfig
116
+ let version = '1.16.5'
117
+ let world: World // chunkKey -> chunk data
118
+ let dirtySections = new Map<string, number>()
119
+ // Kept in sync with `dirtySections` so column mode can filter outgoing
120
+ // geometry/sectionFinished events to only the section keys requested by the
121
+ // main thread, even though a full-column WASM call may generate more data.
122
+ const requestTracker = new SectionRequestTracker()
123
+ let allDataReady = false
124
+
125
+ function sectionKey(x: number, y: number, z: number) {
126
+ return `${x},${y},${z}`
127
+ }
128
+
129
+ const batchMessagesLimit = 100
130
+
131
+ let queuedMessages: any[] = []
132
+ let queueWaiting = false
133
+ const postMessage = (data: any, transferList: any[] = []) => {
134
+ queuedMessages.push({ data, transferList })
135
+ if (queuedMessages.length > batchMessagesLimit) {
136
+ drainQueue(0, batchMessagesLimit)
137
+ }
138
+ if (queueWaiting) return
139
+ queueWaiting = true
140
+ setTimeout(() => {
141
+ queueWaiting = false
142
+ drainQueue(0, queuedMessages.length)
143
+ })
144
+ }
145
+
146
+ function drainQueue(from: number, to: number) {
147
+ const messages = queuedMessages.slice(from, to)
148
+ global.postMessage(messages.map(m => m.data), messages.flatMap(m => m.transferList) as unknown as string)
149
+ queuedMessages = queuedMessages.slice(to)
150
+ }
151
+
152
+ // Single emit point for `sectionFinished`. Consumes one pending request from
153
+ // `requestTracker` and posts via the existing batched `postMessage` queue.
154
+ //
155
+ // Column-mode is the ONLY WASM path now: an emit for a non-requested key is a
156
+ // contract violation (`WorldRendererCommon` would throw on the main thread)
157
+ // and we surface it via `console.warn` so it shows up in dev/CI without
158
+ // killing the worker.
159
+ const emitSectionFinished = (payload: { type: 'sectionFinished', key: string } & Record<string, any>) => {
160
+ const consumed = requestTracker.consumeOne(payload.key)
161
+ if (!consumed) {
162
+ console.warn(`[WASM Mesher] sectionFinished for non-requested key ${payload.key} (column-mode contract violation)`)
163
+ }
164
+ postMessage(payload)
165
+ }
166
+
167
+ let hadDirty = false
168
+ function setSectionDirty(pos: Vec3, value = true) {
169
+ if (hadDirty) return
170
+
171
+ // hadDirty = true
172
+ const x = Math.floor(pos.x / 16) * 16
173
+ const sectionHeight = getSectionHeight()
174
+ const y = Math.floor(pos.y / sectionHeight) * sectionHeight
175
+ const z = Math.floor(pos.z / 16) * 16
176
+ const key = sectionKey(x, y, z)
177
+ if (!value) {
178
+ dirtySections.delete(key)
179
+ // The main thread waits for a sectionFinished response to dirty=false too.
180
+ // Record + consume it so request accounting stays balanced.
181
+ requestTracker.addRequest(key)
182
+ emitSectionFinished({ type: 'sectionFinished', key, workerIndex })
183
+ return
184
+ }
185
+
186
+ // Check if we have the chunk for this section
187
+ const chunk = world?.getColumn(x, z)
188
+ if (chunk?.getSection(pos)) {
189
+ dirtySections.set(key, (dirtySections.get(key) || 0) + 1)
190
+ requestTracker.addRequest(key)
191
+ } else {
192
+ // Missing chunks still owe the main thread a sectionFinished response.
193
+ requestTracker.addRequest(key)
194
+ emitSectionFinished({ type: 'sectionFinished', key, workerIndex })
195
+ }
196
+ }
197
+
198
+ const softCleanup = () => {
199
+ world = new World(world.config.version)
200
+ globalThis.world = world
201
+ }
202
+
203
+ // Stage 3 (issue-15-wasm): cache of raw `map_chunk` packets keyed by
204
+ // `"x,z"`. Populated from the main thread (`setRawMapChunk` message), used
205
+ // to bypass the JS hot loop `convertChunkToWasm` for protocol >= 757
206
+ // (1.18+). Block updates / chunk unload invalidate entries so we fall back
207
+ // to the legacy column-walk path until the next `map_chunk` arrives.
208
+ interface RawMapChunkEntry {
209
+ rawPacket: Uint8Array
210
+ protocol: number
211
+ numSections: number
212
+ }
213
+ const rawMapChunkCache = new Map<string, RawMapChunkEntry>()
214
+ const rawCacheKey = (x: number, z: number) => `${x},${z}`
215
+
216
+ // 1.17 path: pre-extracted section bytes + bit mask (mineflayer already
217
+ // did the cheap top-level packet parsing on the main thread).
218
+ interface ParsedV17Entry {
219
+ protocol: number
220
+ numSections: number
221
+ maxBitsPerBlock: number
222
+ chunkData: Uint8Array
223
+ bitMapLoHi: Uint32Array
224
+ biomes?: Int32Array
225
+ }
226
+ const parsedV17Cache = new Map<string, ParsedV17Entry>()
227
+
228
+ // 1.17 light arrives in a separate `update_light` packet. We parse it via
229
+ // WASM (`parseUpdateLightV17`) and cache per-block arrays keyed by the
230
+ // chunk origin — the next mesh tick of that column merges them in instead
231
+ // of the sky=15/block=0 fallback. May arrive before or after `map_chunk`.
232
+ interface UpdateLightV17Entry {
233
+ skyLight: Uint8Array
234
+ blockLight: Uint8Array
235
+ }
236
+ const updateLightV17Cache = new Map<string, UpdateLightV17Entry>()
237
+
238
+ // 1.16 path: pre-extracted section bytes + bit mask. Bit mask in 1.16
239
+ // is a varint that fits in i32 (only 16 sections), so we accept it as a
240
+ // single number and widen to a [lo,hi]=[bitMap,0] u32 pair when calling
241
+ // the shared `parseChunkSectionsV16V17` parser. Held separately from the
242
+ // v17 cache to keep the two protocol families isolated during version
243
+ // switches; mesh tick picks v16 vs v17 by which cache holds the entry.
244
+ interface ParsedV16Entry {
245
+ protocol: number
246
+ chunkData: Uint8Array
247
+ bitMap: number
248
+ biomes: Int32Array
249
+ }
250
+ const parsedV16Cache = new Map<string, ParsedV16Entry>()
251
+
252
+ // 1.16 sky/block light cache — same shape as the v17 entry, populated by
253
+ // `processUpdateLightV16` (which calls the shared `parseUpdateLightV17`
254
+ // WASM export). Separate map for the same isolation reasons as
255
+ // `parsedV16Cache` above.
256
+ const updateLightV16Cache = new Map<string, UpdateLightV17Entry>()
257
+
258
+ // Mirrors `convertChunkToWasm`'s output (same layout: x + z*16 + y*256,
259
+ // y outer) so it can be dropped straight into `generate_geometry`.
260
+ const convertRawMapChunkToWasm = (
261
+ raw: RawMapChunkEntry,
262
+ version: string
263
+ ): ChunkConversionResult | null => {
264
+ if (!wasm || !(wasm as any).parseMapChunkV18Plus) return null
265
+ // 1.18 introduced the new chunk format; on earlier protocols the packet
266
+ // shape differs and our parser would throw. Fall back to the JS path.
267
+ if (raw.protocol < 757) return null
268
+ // max_bits_per_block / max_bits_per_biome match the parity-tested defaults
269
+ // in `wasm-mesher/src/parser_v18plus.rs` (see Stage 2 fixtures).
270
+ const MAX_BITS_PER_BLOCK = 8
271
+ const MAX_BITS_PER_BIOME = 3
272
+ let parsed: any
273
+ try {
274
+ parsed = (wasm as any).parseMapChunkV18Plus(
275
+ raw.rawPacket,
276
+ raw.numSections,
277
+ MAX_BITS_PER_BLOCK,
278
+ MAX_BITS_PER_BIOME,
279
+ raw.protocol
280
+ )
281
+ } catch (err) {
282
+ console.warn('[WASM Mesher] parseMapChunkV18Plus failed, falling back:', err)
283
+ return null
284
+ }
285
+ const meta = getBlockMeta(version)
286
+ const blockStates: Uint16Array = parsed.blockStates
287
+ let blockCount = 0
288
+ for (let i = 0; i < blockStates.length; i++) {
289
+ if (blockStates[i] !== 0) blockCount++
290
+ }
291
+ return {
292
+ blockStates,
293
+ blockLight: parsed.blockLight,
294
+ skyLight: parsed.skyLight,
295
+ biomesArray: parsed.biomes,
296
+ invisibleBlocks: meta.invisibleBlocks,
297
+ transparentBlocks: meta.transparentBlocks,
298
+ noAoBlocks: meta.noAoBlocks,
299
+ cullIdenticalBlocks: meta.cullIdenticalBlocks,
300
+ occludingBlocks: meta.occludingBlocks,
301
+ blockCount,
302
+ }
303
+ }
304
+
305
+ // 1.17 conversion: WASM now returns blockStates **and** per-block biomes
306
+ // (expanded from the 4×4×4 cell layout). Light comes from the paired
307
+ // `update_light` cache when available; otherwise we fall back to full
308
+ // daylight (sky=15) and no block light so geometry stays visible.
309
+ const convertParsedV17ToWasm = (
310
+ entry: ParsedV17Entry,
311
+ lightEntry: UpdateLightV17Entry | undefined,
312
+ version: string
313
+ ): ChunkConversionResult | null => {
314
+ if (!wasm || !(wasm as any).parseChunkSectionsV16V17) return null
315
+ // Empty `Int32Array` signals "no biomes captured" — WASM falls back to
316
+ // `default_biome` for every block. Plains (id 1) matches the JS path.
317
+ const biomesCells = entry.biomes ?? new Int32Array(0)
318
+ const DEFAULT_BIOME = 1
319
+ let parsed: any
320
+ try {
321
+ parsed = (wasm as any).parseChunkSectionsV16V17(
322
+ entry.chunkData,
323
+ entry.bitMapLoHi,
324
+ entry.numSections,
325
+ entry.maxBitsPerBlock,
326
+ biomesCells,
327
+ DEFAULT_BIOME,
328
+ )
329
+ } catch (err) {
330
+ console.warn('[WASM Mesher] parseChunkSectionsV16V17 failed, falling back:', err)
331
+ return null
332
+ }
333
+ const blockStates: Uint16Array = parsed.blockStates
334
+ const totalBlocks = blockStates.length
335
+ let blockLight: Uint8Array
336
+ let skyLight: Uint8Array
337
+ if (lightEntry && lightEntry.skyLight.length === totalBlocks) {
338
+ skyLight = lightEntry.skyLight
339
+ blockLight = lightEntry.blockLight
340
+ } else {
341
+ blockLight = new Uint8Array(totalBlocks)
342
+ skyLight = new Uint8Array(totalBlocks)
343
+ skyLight.fill(15)
344
+ }
345
+ const biomesArray: Uint8Array = parsed.biomes
346
+ let blockCount = 0
347
+ for (let i = 0; i < totalBlocks; i++) {
348
+ if (blockStates[i] !== 0) blockCount++
349
+ }
350
+ const meta = getBlockMeta(version)
351
+ return {
352
+ blockStates,
353
+ blockLight,
354
+ skyLight,
355
+ biomesArray,
356
+ invisibleBlocks: meta.invisibleBlocks,
357
+ transparentBlocks: meta.transparentBlocks,
358
+ noAoBlocks: meta.noAoBlocks,
359
+ cullIdenticalBlocks: meta.cullIdenticalBlocks,
360
+ occludingBlocks: meta.occludingBlocks,
361
+ blockCount,
362
+ }
363
+ }
364
+
365
+ // 1.16 conversion: shares the WASM parser with 1.17 (chunk-section wire
366
+ // format is identical between 1.16.x and 1.17). The fixed parameters
367
+ // (16 sections, max_bits_per_block=14) match the prismarine-chunk@1.16
368
+ // defaults — anything else means a non-vanilla server we don't support
369
+ // on the fast path, in which case we return null and fall back to the
370
+ // JS column-walk via `convertChunkToWasm`.
371
+ const convertParsedV16ToWasm = (
372
+ entry: ParsedV16Entry,
373
+ lightEntry: UpdateLightV17Entry | undefined,
374
+ version: string
375
+ ): ChunkConversionResult | null => {
376
+ if (!wasm || !(wasm as any).parseChunkSectionsV16V17) return null
377
+ const NUM_SECTIONS = 16
378
+ const MAX_BITS_PER_BLOCK = 15
379
+ const DEFAULT_BIOME = 1
380
+ // 1.16 bit mask is a varint that fits in i32 (only 16 sections used);
381
+ // the WASM parser still expects [lo,hi] u32 pairs (one pair per long),
382
+ // so widen the single number to [bitMap, 0].
383
+ const bitMapLoHi = new Uint32Array([entry.bitMap >>> 0, 0])
384
+ const biomesCells = entry.biomes ?? new Int32Array(0)
385
+ let parsed: any
386
+ try {
387
+ parsed = (wasm as any).parseChunkSectionsV16V17(
388
+ entry.chunkData,
389
+ bitMapLoHi,
390
+ NUM_SECTIONS,
391
+ MAX_BITS_PER_BLOCK,
392
+ biomesCells,
393
+ DEFAULT_BIOME,
394
+ )
395
+ } catch (err) {
396
+ console.warn('[WASM Mesher] parseChunkSectionsV16V17 (v16) failed, falling back:', err)
397
+ return null
398
+ }
399
+ const blockStates: Uint16Array = parsed.blockStates
400
+ const totalBlocks = blockStates.length
401
+ let blockLight: Uint8Array
402
+ let skyLight: Uint8Array
403
+ if (lightEntry && lightEntry.skyLight.length === totalBlocks) {
404
+ skyLight = lightEntry.skyLight
405
+ blockLight = lightEntry.blockLight
406
+ } else {
407
+ blockLight = new Uint8Array(totalBlocks)
408
+ skyLight = new Uint8Array(totalBlocks)
409
+ skyLight.fill(15)
410
+ }
411
+ const biomesArray: Uint8Array = parsed.biomes
412
+ let blockCount = 0
413
+ for (let i = 0; i < totalBlocks; i++) {
414
+ if (blockStates[i] !== 0) blockCount++
415
+ }
416
+ const meta = getBlockMeta(version)
417
+ return {
418
+ blockStates,
419
+ blockLight,
420
+ skyLight,
421
+ biomesArray,
422
+ invisibleBlocks: meta.invisibleBlocks,
423
+ transparentBlocks: meta.transparentBlocks,
424
+ noAoBlocks: meta.noAoBlocks,
425
+ cullIdenticalBlocks: meta.cullIdenticalBlocks,
426
+ occludingBlocks: meta.occludingBlocks,
427
+ blockCount,
428
+ }
429
+ }
430
+
431
+ // ---------------------------------------------------------------------------
432
+ // Fused parse+mesh helpers (single WASM call, no typed arrays in JS heap)
433
+ // ---------------------------------------------------------------------------
434
+
435
+ const MAX_BITS_PER_BLOCK = 8
436
+ const MAX_BITS_PER_BIOME = 3
437
+
438
+ /// Fused single-column mesh for 1.18+ raw map_chunk.
439
+ /// Returns the GeometryOutput directly (or null on failure → fallback).
440
+ const meshColumnFromRawV18Plus = (
441
+ raw: RawMapChunkEntry,
442
+ x: number,
443
+ z: number,
444
+ worldMinY: number,
445
+ worldMaxY: number,
446
+ meta: ReturnType<typeof getBlockMeta>
447
+ ): any | null => {
448
+ if (!wasm || !(wasm as any).generateGeometryFromMapChunkV18Plus) return null
449
+ if (raw.protocol < 757) return null
450
+ const columnHeight = worldMaxY - worldMinY
451
+ try {
452
+ return (wasm as any).generateGeometryFromMapChunkV18Plus(
453
+ raw.rawPacket,
454
+ raw.numSections,
455
+ MAX_BITS_PER_BLOCK,
456
+ MAX_BITS_PER_BIOME,
457
+ raw.protocol,
458
+ x, worldMinY, z, columnHeight,
459
+ worldMinY, worldMaxY,
460
+ worldMinY,
461
+ meta.invisibleBlocks,
462
+ meta.transparentBlocks,
463
+ meta.noAoBlocks,
464
+ meta.cullIdenticalBlocks,
465
+ meta.occludingBlocks,
466
+ config?.enableLighting !== false,
467
+ config?.smoothLighting !== false,
468
+ config?.skyLight || 15
469
+ )
470
+ } catch (err) {
471
+ console.warn('[WASM Mesher] generateGeometryFromMapChunkV18Plus failed, falling back:', err)
472
+ return null
473
+ }
474
+ }
475
+
476
+ /// Fused single-column mesh for 1.16 / 1.17 pre-parsed chunk sections.
477
+ const meshColumnFromParsedV16V17 = (
478
+ chunkData: Uint8Array,
479
+ bitMapLoHi: Uint32Array,
480
+ numSections: number,
481
+ maxBitsPerBlock: number,
482
+ biomesCells: Int32Array | undefined,
483
+ defaultBiome: number,
484
+ skyLight: Uint8Array | null,
485
+ blockLight: Uint8Array | null,
486
+ x: number,
487
+ z: number,
488
+ worldMinY: number,
489
+ worldMaxY: number,
490
+ meta: ReturnType<typeof getBlockMeta>
491
+ ): any | null => {
492
+ if (!wasm || !(wasm as any).generateGeometryFromParsedV16V17) return null
493
+ const columnHeight = worldMaxY - worldMinY
494
+ try {
495
+ return (wasm as any).generateGeometryFromParsedV16V17(
496
+ chunkData,
497
+ bitMapLoHi,
498
+ numSections,
499
+ maxBitsPerBlock,
500
+ biomesCells ?? new Int32Array(0),
501
+ defaultBiome,
502
+ skyLight ?? new Uint8Array(0),
503
+ blockLight ?? new Uint8Array(0),
504
+ x, worldMinY, z, columnHeight,
505
+ worldMinY, worldMaxY,
506
+ worldMinY,
507
+ meta.invisibleBlocks,
508
+ meta.transparentBlocks,
509
+ meta.noAoBlocks,
510
+ meta.cullIdenticalBlocks,
511
+ meta.occludingBlocks,
512
+ config?.enableLighting !== false,
513
+ config?.smoothLighting !== false,
514
+ config?.skyLight || 15
515
+ )
516
+ } catch (err) {
517
+ console.warn('[WASM Mesher] generateGeometryFromParsedV16V17 failed, falling back:', err)
518
+ return null
519
+ }
520
+ }
521
+
522
+ // ---------------------------------------------------------------------------
523
+ // Fused multi-column helpers.
524
+ // Zero-alloc: reuse existing TypedArray buffers from caches, no concat.
525
+ // ---------------------------------------------------------------------------
526
+
527
+ const meshMultiColumnsFromRawV18Plus = (
528
+ chunksToUse: Array<{ x: number, z: number, chunk: any }>,
529
+ x: number,
530
+ z: number,
531
+ worldMinY: number,
532
+ worldMaxY: number,
533
+ meta: ReturnType<typeof getBlockMeta>
534
+ ): any | null => {
535
+ if (!wasm || !(wasm as any).generateGeometryFromMapChunkV18PlusMulti) return null
536
+ const chunkCount = chunksToUse.length
537
+ if (chunkCount === 0) return null
538
+
539
+ const rawPackets: Uint8Array[] = []
540
+ const numSectionsList = new Uint32Array(chunkCount)
541
+ const chunkXs = new Int32Array(chunkCount)
542
+ const chunkZs = new Int32Array(chunkCount)
543
+ let protocol = 0
544
+
545
+ for (let i = 0; i < chunkCount; i++) {
546
+ const raw = rawMapChunkCache.get(rawCacheKey(chunksToUse[i].x, chunksToUse[i].z))
547
+ if (!raw || raw.protocol < 757) return null
548
+ rawPackets.push(raw.rawPacket)
549
+ numSectionsList[i] = raw.numSections
550
+ chunkXs[i] = chunksToUse[i].x
551
+ chunkZs[i] = chunksToUse[i].z
552
+ if (i === 0) protocol = raw.protocol
553
+ }
554
+
555
+ const columnHeight = worldMaxY - worldMinY
556
+ try {
557
+ return (wasm as any).generateGeometryFromMapChunkV18PlusMulti(
558
+ rawPackets,
559
+ numSectionsList,
560
+ MAX_BITS_PER_BLOCK,
561
+ MAX_BITS_PER_BIOME,
562
+ protocol,
563
+ chunkXs,
564
+ chunkZs,
565
+ x, worldMinY, z, columnHeight,
566
+ worldMinY, worldMaxY,
567
+ worldMinY,
568
+ meta.invisibleBlocks,
569
+ meta.transparentBlocks,
570
+ meta.noAoBlocks,
571
+ meta.cullIdenticalBlocks,
572
+ meta.occludingBlocks,
573
+ config?.enableLighting !== false,
574
+ config?.smoothLighting !== false,
575
+ config?.skyLight || 15
576
+ )
577
+ } catch (err) {
578
+ console.warn('[WASM Mesher] generateGeometryFromMapChunkV18PlusMulti failed:', err)
579
+ return null
580
+ }
581
+ }
582
+
583
+ const meshMultiColumnsFromParsedV16V17 = (
584
+ chunksToUse: Array<{ x: number, z: number, chunk: any }>,
585
+ x: number,
586
+ z: number,
587
+ worldMinY: number,
588
+ worldMaxY: number,
589
+ meta: ReturnType<typeof getBlockMeta>
590
+ ): any | null => {
591
+ if (!wasm || !(wasm as any).generateGeometryFromParsedV16V17Multi) return null
592
+ const chunkCount = chunksToUse.length
593
+ if (chunkCount === 0) return null
594
+
595
+ // Determine which cache family owns all columns. Homogeneity is
596
+ // guaranteed by the server protocol version: v16 and v17 caches are
597
+ // never populated simultaneously within a session.
598
+ let family: 'v17' | 'v16' | null = null
599
+ for (let i = 0; i < chunkCount; i++) {
600
+ const key = rawCacheKey(chunksToUse[i].x, chunksToUse[i].z)
601
+ if (parsedV17Cache.has(key)) {
602
+ if (family === 'v16') return null
603
+ family = 'v17'
604
+ } else if (parsedV16Cache.has(key)) {
605
+ if (family === 'v17') return null
606
+ family = 'v16'
607
+ } else {
608
+ return null
609
+ }
610
+ }
611
+ if (!family) return null
612
+
613
+ const chunkDataList: Uint8Array[] = []
614
+ const biomesList: Int32Array[] = []
615
+ const skyLightList: Uint8Array[] = []
616
+ const blockLightList: Uint8Array[] = []
617
+ const numSectionsList = new Uint32Array(chunkCount)
618
+ const chunkXs = new Int32Array(chunkCount)
619
+ const chunkZs = new Int32Array(chunkCount)
620
+ const bitMapLoHi = new Uint32Array(chunkCount * 2)
621
+ let maxBitsPerBlock = 15
622
+
623
+ for (let i = 0; i < chunkCount; i++) {
624
+ const key = rawCacheKey(chunksToUse[i].x, chunksToUse[i].z)
625
+ if (family === 'v17') {
626
+ const entry = parsedV17Cache.get(key)!
627
+ chunkDataList.push(entry.chunkData)
628
+ biomesList.push(entry.biomes ?? new Int32Array(0))
629
+ numSectionsList[i] = entry.numSections
630
+ bitMapLoHi[i * 2] = entry.bitMapLoHi[0]
631
+ bitMapLoHi[i * 2 + 1] = entry.bitMapLoHi[1]
632
+ if (i === 0) maxBitsPerBlock = entry.maxBitsPerBlock
633
+ const light = updateLightV17Cache.get(key)
634
+ skyLightList.push(light?.skyLight ?? new Uint8Array(0))
635
+ blockLightList.push(light?.blockLight ?? new Uint8Array(0))
636
+ } else {
637
+ const entry = parsedV16Cache.get(key)!
638
+ chunkDataList.push(entry.chunkData)
639
+ biomesList.push(entry.biomes ?? new Int32Array(0))
640
+ numSectionsList[i] = 16
641
+ const bm = entry.bitMap >>> 0
642
+ bitMapLoHi[i * 2] = bm
643
+ bitMapLoHi[i * 2 + 1] = 0
644
+ const light = updateLightV16Cache.get(key)
645
+ skyLightList.push(light?.skyLight ?? new Uint8Array(0))
646
+ blockLightList.push(light?.blockLight ?? new Uint8Array(0))
647
+ }
648
+ chunkXs[i] = chunksToUse[i].x
649
+ chunkZs[i] = chunksToUse[i].z
650
+ }
651
+
652
+ const columnHeight = worldMaxY - worldMinY
653
+ try {
654
+ return (wasm as any).generateGeometryFromParsedV16V17Multi(
655
+ chunkDataList,
656
+ bitMapLoHi,
657
+ numSectionsList,
658
+ maxBitsPerBlock,
659
+ biomesList,
660
+ 1,
661
+ skyLightList,
662
+ blockLightList,
663
+ chunkXs,
664
+ chunkZs,
665
+ x, worldMinY, z, columnHeight,
666
+ worldMinY, worldMaxY,
667
+ worldMinY,
668
+ meta.invisibleBlocks,
669
+ meta.transparentBlocks,
670
+ meta.noAoBlocks,
671
+ meta.cullIdenticalBlocks,
672
+ meta.occludingBlocks,
673
+ config?.enableLighting !== false,
674
+ config?.smoothLighting !== false,
675
+ config?.skyLight || 15
676
+ )
677
+ } catch (err) {
678
+ console.warn('[WASM Mesher] generateGeometryFromParsedV16V17Multi failed:', err)
679
+ return null
680
+ }
681
+ }
682
+
683
+ const handleMessage = async (data: any) => {
684
+ const globalVar: any = globalThis
685
+
686
+ if (data.type === 'mcData') {
687
+ globalVar.mcData = data.mcData
688
+ globalVar.loadedData = data.mcData
689
+ }
690
+
691
+ if (data.config) {
692
+ config = { ...config, ...data.config }
693
+ version = config.version || version
694
+ world ??= new World(version)
695
+ world.config = { ...world.config, ...data.config }
696
+ globalThis.world = world
697
+ globalThis.Vec3 = Vec3
698
+ setConversionCacheLimit(config.disableConversionCache ? 0 : CONVERSION_CACHE_LIMIT)
699
+ }
700
+
701
+ switch (data.type) {
702
+ case 'mesherData': {
703
+ setMesherData(data.blockstatesModels, data.blocksAtlas, data.config.outputFormat === 'webgpu')
704
+ ;(globalThis as any).__wasmBlockModelCache = new Map()
705
+ // Conservative: blockstates/version/world config may have changed.
706
+ clearConversionCache()
707
+
708
+ await initWasm()
709
+ allDataReady = true
710
+ workerIndex = data.workerIndex
711
+ break
712
+ }
713
+ case 'dirty': {
714
+ const loc = new Vec3(data.x, data.y, data.z)
715
+ setSectionDirty(loc, data.value)
716
+ break
717
+ }
718
+ case 'chunk': {
719
+ // Invalidate BEFORE replacing the column reference so a stale entry
720
+ // can never outlive the old chunk object.
721
+ invalidateConversion(data.x, data.z)
722
+ if (!world) break
723
+ world.addColumn(data.x, data.z, data.chunk)
724
+ if (data.customBlockModels) {
725
+ const chunkKey = `${data.x},${data.z}`
726
+ world.customBlockModels.set(chunkKey, data.customBlockModels)
727
+ }
728
+ // Safety-net heightmap push for fully empty columns. With WASM
729
+ // mesher as the sole path, the main thread no longer requests
730
+ // `getHeightmap` on chunk load — heightmaps come from
731
+ // `processColumnTick`. But a fully empty column (no sections, or
732
+ // all sections missing) never enters that path because
733
+ // `setSectionDirty` short-circuits when `chunk.getSection(pos)` is
734
+ // falsy, so `processColumnTick` never sees it. Without this push
735
+ // downstream consumers (e.g. `rain.ts`) would have no heightmap
736
+ // entry for such columns. We send a cheap sentinel-filled
737
+ // `Int16Array(256).fill(-32768)` — no JS heightmap scan — only when
738
+ // we detect zero sections; non-empty columns get their real
739
+ // heightmap from the next `processColumnTick`.
740
+ const sectionH = SECTION_HEIGHT
741
+ const minY = config?.worldMinY ?? 0
742
+ const maxY = config?.worldMaxY ?? 256
743
+ const column = world.getColumn(data.x, data.z)
744
+ let hasAnySection = false
745
+ for (let y = minY; y < maxY; y += sectionH) {
746
+ if (column?.getSection?.(new Vec3(0, y, 0))) {
747
+ hasAnySection = true
748
+ break
749
+ }
750
+ }
751
+ if (!hasAnySection) {
752
+ const emptyHeightmap = new Int16Array(256).fill(EMPTY_COLUMN_HEIGHTMAP_SENTINEL)
753
+ postMessage(
754
+ { type: 'heightmap', key: `${data.x >> 4},${data.z >> 4}`, heightmap: emptyHeightmap },
755
+ [emptyHeightmap.buffer]
756
+ )
757
+ }
758
+ break
759
+ }
760
+ case 'unloadChunk': {
761
+ invalidateConversion(data.x, data.z)
762
+ rawMapChunkCache.delete(rawCacheKey(data.x, data.z))
763
+ parsedV17Cache.delete(rawCacheKey(data.x, data.z))
764
+ updateLightV17Cache.delete(rawCacheKey(data.x, data.z))
765
+ parsedV16Cache.delete(rawCacheKey(data.x, data.z))
766
+ updateLightV16Cache.delete(rawCacheKey(data.x, data.z))
767
+ if (!world) break
768
+ world.removeColumn(data.x, data.z)
769
+ world.customBlockModels.delete(`${data.x},${data.z}`)
770
+ if (Object.keys(world.columns).length === 0) softCleanup()
771
+ break
772
+ }
773
+ case 'blockUpdate': {
774
+ const loc = new Vec3(data.pos.x, data.pos.y, data.pos.z).floored()
775
+ if (data.stateId !== undefined && data.stateId !== null) {
776
+ world?.setBlockStateId(loc, data.stateId)
777
+ }
778
+
779
+ const chunkX = Math.floor(loc.x / 16) * 16
780
+ const chunkZ = Math.floor(loc.z / 16) * 16
781
+ // In-place mutation preserves chunk identity; explicit invalidation
782
+ // is required so the next tick recomputes from current block state.
783
+ invalidateConversion(chunkX, chunkZ)
784
+ // Stage 3: the cached raw map_chunk no longer matches the live
785
+ // column after a block update — drop it so the next mesh tick walks
786
+ // the (now-updated) prismarine column instead.
787
+ rawMapChunkCache.delete(rawCacheKey(chunkX, chunkZ))
788
+ parsedV17Cache.delete(rawCacheKey(chunkX, chunkZ))
789
+ parsedV16Cache.delete(rawCacheKey(chunkX, chunkZ))
790
+ const chunkKey = `${chunkX},${chunkZ}`
791
+ if (data.customBlockModels) {
792
+ world?.customBlockModels.set(chunkKey, data.customBlockModels)
793
+ }
794
+ break
795
+ }
796
+ case 'setRawMapChunk': {
797
+ // Stage 3 (issue-15-wasm): main thread captured the raw map_chunk
798
+ // bytes mineflayer received and forwarded them here. We cache by
799
+ // (x,z); the next mesh tick will prefer this raw entry over the JS
800
+ // column-walk path. Invalidate the existing conversion cache entry
801
+ // so the next mesh tick picks the new path even if the column
802
+ // identity is unchanged.
803
+ rawMapChunkCache.set(rawCacheKey(data.x, data.z), {
804
+ rawPacket: data.rawPacket as Uint8Array,
805
+ protocol: data.protocol as number,
806
+ numSections: data.numSections as number,
807
+ })
808
+ invalidateConversion(data.x, data.z)
809
+ break
810
+ }
811
+ case 'setParsedMapChunkV17': {
812
+ // 1.17 path: pre-extracted section bytes + bit mask from mineflayer.
813
+ parsedV17Cache.set(rawCacheKey(data.x, data.z), {
814
+ protocol: data.protocol as number,
815
+ numSections: data.numSections as number,
816
+ maxBitsPerBlock: data.maxBitsPerBlock as number,
817
+ chunkData: data.chunkData as Uint8Array,
818
+ bitMapLoHi: data.bitMapLoHi as Uint32Array,
819
+ biomes: data.biomes as Int32Array | undefined,
820
+ })
821
+ invalidateConversion(data.x, data.z)
822
+ break
823
+ }
824
+ case 'setUpdateLightV17': {
825
+ // 1.17 path: parse the raw `update_light` packet via WASM. The
826
+ // (chunkX, chunkZ) come back inside the result — JS doesn't peek at
827
+ // varints. May arrive before or after the matching map_chunk; either
828
+ // way we cache and invalidate the column conversion so the next tick
829
+ // merges real lighting in.
830
+ // If WASM isn't ready yet, the packet is queued in
831
+ // `pendingUpdateLightV17` and replayed by `initWasm`.
832
+ processUpdateLightV17(data.rawPacket as Uint8Array, data.numSections as number)
833
+ break
834
+ }
835
+ case 'setParsedMapChunkV16': {
836
+ // 1.16 path: pre-extracted section bytes + (single-number) bit mask
837
+ // from mineflayer. Stored separately from v17 to keep the two
838
+ // protocol families isolated during version switches.
839
+ parsedV16Cache.set(rawCacheKey(data.x, data.z), {
840
+ protocol: data.protocol as number,
841
+ chunkData: data.chunkData as Uint8Array,
842
+ bitMap: data.bitMap as number,
843
+ biomes: data.biomes as Int32Array,
844
+ })
845
+ invalidateConversion(data.x, data.z)
846
+ break
847
+ }
848
+ case 'setUpdateLightV16': {
849
+ // 1.16 path: shares the wire format / WASM parser with 1.17 but
850
+ // populates a separate `updateLightV16Cache`. Same pre-WASM queueing
851
+ // semantics as v17.
852
+ processUpdateLightV16(data.rawPacket as Uint8Array)
853
+ break
854
+ }
855
+ case 'reset': {
856
+ world = undefined as any
857
+ dirtySections.clear()
858
+ requestTracker.clear()
859
+ clearConversionCache()
860
+ rawMapChunkCache.clear()
861
+ parsedV17Cache.clear()
862
+ updateLightV17Cache.clear()
863
+ parsedV16Cache.clear()
864
+ updateLightV16Cache.clear()
865
+ globalVar.mcData = null
866
+ globalVar.loadedData = null
867
+ allDataReady = false
868
+ break
869
+ }
870
+ case 'mc-web-ping': {
871
+ const replyWorkerIndex = typeof data.workerIndex === 'number' ? data.workerIndex : workerIndex
872
+ global.postMessage({
873
+ type: 'mc-web-pong',
874
+ workerIndex: replyWorkerIndex,
875
+ t: data.t,
876
+ recvAt: typeof performance !== 'undefined' ? performance.now() : undefined,
877
+ })
878
+ break
879
+ }
880
+ case 'mc-web-ping': {
881
+ const replyWorkerIndex = typeof data.workerIndex === 'number' ? data.workerIndex : workerIndex
882
+ global.postMessage({
883
+ type: 'mc-web-pong',
884
+ workerIndex: replyWorkerIndex,
885
+ t: data.t,
886
+ recvAt: typeof performance !== 'undefined' ? performance.now() : undefined,
887
+ })
888
+ break
889
+ }
890
+ case 'mc-web-ping': {
891
+ const replyWorkerIndex = typeof data.workerIndex === 'number' ? data.workerIndex : workerIndex
892
+ global.postMessage({
893
+ type: 'mc-web-pong',
894
+ workerIndex: replyWorkerIndex,
895
+ t: data.t,
896
+ recvAt: typeof performance !== 'undefined' ? performance.now() : undefined,
897
+ })
898
+ break
899
+ }
900
+ case 'getHeightmap': {
901
+ // Fallback path. With WASM column mesher as the sole path, the main
902
+ // thread should be receiving heightmaps as `'heightmap'` push messages
903
+ // posted by `processColumnTick`. This handler stays as a safety net for
904
+ // cases where the WASM heightmap could not be extracted (length mismatch
905
+ // or missing field) — see the `extractColumnHeightmap` warn below.
906
+ console.warn(`[WASM Mesher] explicit getHeightmap request for ${data.x},${data.z} — push from processColumnTick missed?`)
907
+ if (!world) {
908
+ const emptyHeightmap = new Int16Array(256).fill(EMPTY_COLUMN_HEIGHTMAP_SENTINEL)
909
+ postMessage({ type: 'heightmap', key: `${Math.floor(data.x / 16)},${Math.floor(data.z / 16)}`, heightmap: emptyHeightmap })
910
+ break
911
+ }
912
+ const { key, heightmap } = handleGetHeightmap(world, data.x, data.z)
913
+ postMessage({ type: 'heightmap', key, heightmap }, [heightmap.buffer])
914
+
915
+ break
916
+ }
917
+ // Note: getCustomBlockModel not implemented in WASM version
918
+ // as it requires World class functionality
919
+ }
920
+ }
921
+
922
+ // eslint-disable-next-line no-restricted-globals -- TODO
923
+ self.onmessage = ({ data }) => {
924
+ if (Array.isArray(data)) {
925
+ // eslint-disable-next-line unicorn/no-array-for-each
926
+ data.forEach(handleMessage)
927
+ return
928
+ }
929
+
930
+ handleMessage(data)
931
+ }
932
+
933
+ // Section height is always 16 in column mode (the only WASM path).
934
+ const getSectionHeight = () => SECTION_HEIGHT
935
+
936
+
937
+ // 3x3 X/Z neighbor set for column meshing. Y-agnostic because full-column
938
+ // meshing converts the entire world Y range in one go.
939
+ function collectChunksForColumn(x: number, z: number) {
940
+ const result = [] as Array<{ x: number, z: number, chunk: any }>
941
+ const target = world.getColumn(x, z)
942
+ if (target) result.push({ x, z, chunk: target })
943
+ const offsets = [-16, 0, 16]
944
+ for (const dx of offsets) {
945
+ for (const dz of offsets) {
946
+ if (dx === 0 && dz === 0) continue
947
+ const nx = x + dx
948
+ const nz = z + dz
949
+ const c = world.getColumn(nx, nz)
950
+ if (c) result.push({ x: nx, z: nz, chunk: c })
951
+ }
952
+ }
953
+ return result
954
+ }
955
+
956
+ function makeEmptyColumnGeometry(sx: number, sy: number, sz: number, sectionHeight: number, hadErrors: boolean): MesherGeometryOutput {
957
+ return {
958
+ sectionYNumber: (sy - (config?.worldMinY || 0)) >> 4,
959
+ chunkKey: worldColumnKey(sx, sz),
960
+ sectionStartY: sy,
961
+ sectionEndY: sy + sectionHeight,
962
+ sectionStartX: sx,
963
+ sectionEndX: sx + 16,
964
+ sectionStartZ: sz,
965
+ sectionEndZ: sz + 16,
966
+ sx: sx + 8,
967
+ sy: sy + 8,
968
+ sz: sz + 8,
969
+ positions: new Float32Array(0),
970
+ normals: new Float32Array(0),
971
+ colors: new Float32Array(0),
972
+ uvs: new Float32Array(0),
973
+ indices: new Uint32Array(0),
974
+ indicesCount: 0,
975
+ using32Array: false,
976
+ tiles: {},
977
+ heads: {},
978
+ signs: {},
979
+ banners: {},
980
+ hadErrors,
981
+ blocksCount: 0,
982
+ }
983
+ }
984
+
985
+ // Full-column meshing path — the sole WASM mesh path.
986
+ // It groups dirty section keys by chunk column, runs one WASM call per column
987
+ // over the full Y range, then splits the column output back into per-section
988
+ // geometries. Only requested section keys are emitted back to the main thread.
989
+ function processColumnTick() {
990
+ const worldMinY = config?.worldMinY ?? 0
991
+ const worldMaxY = config?.worldMaxY ?? 256
992
+ const columnHeight = worldMaxY - worldMinY
993
+ const sectionHeight = SECTION_HEIGHT
994
+
995
+ // Group dirty sections by chunk column (`${x},${z}` in world block
996
+ // coords — the same units used by section keys). This guarantees a
997
+ // single WASM call per column per tick even when multiple section keys
998
+ // of the same column are dirty.
999
+ const groups = new Map<string, { x: number, z: number, sections: Array<{ key: string, x: number, y: number, z: number, count: number }> }>()
1000
+ for (const [key, count] of dirtySections) {
1001
+ const [sx, sy, sz] = key.split(',').map(v => parseInt(v, 10))
1002
+ const colKey = `${sx},${sz}`
1003
+ let g = groups.get(colKey)
1004
+ if (!g) {
1005
+ g = { x: sx, z: sz, sections: [] }
1006
+ groups.set(colKey, g)
1007
+ }
1008
+ g.sections.push({ key, x: sx, y: sy, z: sz, count })
1009
+ }
1010
+ dirtySections.clear()
1011
+
1012
+ for (const group of groups.values()) {
1013
+ const { x, z, sections } = group
1014
+ const targetChunk = world.getColumn(x, z)
1015
+
1016
+ let exportedMap: Map<string, { exported: import('../../three/worldGeometryExport').ExportedSection, blocksCount: number }> | null = null
1017
+ let processTime = 0
1018
+ let prePhase = 0
1019
+ let wasmPhase = 0
1020
+ let postPhase = 0
1021
+ let preTargetConvert = 0
1022
+ let preNeighborConvert = 0
1023
+ let preNeighborCount = 0
1024
+ let preTypedArrayBuild = 0
1025
+ let preOther = 0
1026
+ let preCacheHits = 0
1027
+ let preCacheMisses = 0
1028
+ let hadError = false
1029
+ // Outer-scope timestamps so we can finalize `processTime` and
1030
+ // `postPhase` AFTER the per-section emit loop runs (the loop builds
1031
+ // typed arrays, walks block-entity metadata, and calls postMessage —
1032
+ // all of which are part of the worker's real cost and must be
1033
+ // attributed to the column).
1034
+ let columnStart = 0
1035
+ let postStart = 0
1036
+
1037
+ if (targetChunk && wasm) {
1038
+ columnStart = performance.now()
1039
+ const start = columnStart
1040
+ const t0 = start
1041
+ try {
1042
+ const chunksToUse = collectChunksForColumn(x, z)
1043
+ const chunkCount = chunksToUse.length
1044
+
1045
+ let wasmResult: any
1046
+ let t1 = 0
1047
+ let usedFusedPath = false
1048
+
1049
+ const meta = getBlockMeta(version)
1050
+
1051
+ // ------------------------------------------------------------------
1052
+ // Fused fast-path: for single-column meshing (no neighbours), parse
1053
+ // and mesh in ONE WASM call so no typed arrays leave Rust memory.
1054
+ // Falls back to the old two-step path on any failure.
1055
+ // ------------------------------------------------------------------
1056
+ if (chunkCount === 1) {
1057
+ const rawEntry = rawMapChunkCache.get(rawCacheKey(x, z))
1058
+ const v17Entry = parsedV17Cache.get(rawCacheKey(x, z))
1059
+ const v16Entry = parsedV16Cache.get(rawCacheKey(x, z))
1060
+
1061
+ if (rawEntry) {
1062
+ wasmResult = meshColumnFromRawV18Plus(rawEntry, x, z, worldMinY, worldMaxY, meta)
1063
+ } else if (v17Entry) {
1064
+ const v17Light = updateLightV17Cache.get(rawCacheKey(x, z))
1065
+ wasmResult = meshColumnFromParsedV16V17(
1066
+ v17Entry.chunkData, v17Entry.bitMapLoHi, v17Entry.numSections, v17Entry.maxBitsPerBlock,
1067
+ v17Entry.biomes, 1,
1068
+ v17Light?.skyLight ?? null, v17Light?.blockLight ?? null,
1069
+ x, z, worldMinY, worldMaxY, meta
1070
+ )
1071
+ } else if (v16Entry) {
1072
+ const v16Light = updateLightV16Cache.get(rawCacheKey(x, z))
1073
+ const bitMapLoHi = new Uint32Array([v16Entry.bitMap >>> 0, 0])
1074
+ wasmResult = meshColumnFromParsedV16V17(
1075
+ v16Entry.chunkData, bitMapLoHi, 16, 15,
1076
+ v16Entry.biomes, 1,
1077
+ v16Light?.skyLight ?? null, v16Light?.blockLight ?? null,
1078
+ x, z, worldMinY, worldMaxY, meta
1079
+ )
1080
+ }
1081
+
1082
+ if (wasmResult) {
1083
+ usedFusedPath = true
1084
+ t1 = performance.now()
1085
+ wasmPhase = t1 - t0
1086
+ preTargetConvert = wasmPhase
1087
+ }
1088
+ }
1089
+
1090
+ // ------------------------------------------------------------------
1091
+ // Fused multi-column fast-path: parse+mesh all columns in one WASM
1092
+ // call with zero JS typed-array allocation.
1093
+ // Falls back to the old two-step path when the cache is incomplete
1094
+ // or any helper returns null.
1095
+ // ------------------------------------------------------------------
1096
+ if (!wasmResult && chunkCount > 1) {
1097
+ wasmResult = meshMultiColumnsFromRawV18Plus(chunksToUse, x, z, worldMinY, worldMaxY, meta)
1098
+ ?? meshMultiColumnsFromParsedV16V17(chunksToUse, x, z, worldMinY, worldMaxY, meta)
1099
+ if (wasmResult) {
1100
+ usedFusedPath = true
1101
+ t1 = performance.now()
1102
+ wasmPhase = t1 - t0
1103
+ preTargetConvert = wasmPhase
1104
+ }
1105
+ }
1106
+
1107
+ if (!wasmResult) {
1108
+ // --- Old two-step path (multi-column or fused fallback) ---
1109
+ const conversions = chunksToUse.map(({ x: cx, z: cz, chunk }) => {
1110
+ const cs = performance.now()
1111
+ const rawEntry = rawMapChunkCache.get(rawCacheKey(cx, cz))
1112
+ const v17Entry = parsedV17Cache.get(rawCacheKey(cx, cz))
1113
+ const v17Light = updateLightV17Cache.get(rawCacheKey(cx, cz))
1114
+ const v16Entry = parsedV16Cache.get(rawCacheKey(cx, cz))
1115
+ const v16Light = updateLightV16Cache.get(rawCacheKey(cx, cz))
1116
+
1117
+ let conv: ChunkConversionResult | null = null
1118
+ let hit = false
1119
+
1120
+ // WASM fast paths — parse is already fast (2.19× over JS), no
1121
+ // cache needed. Bypass getOrConvertColumn so the conversion
1122
+ // cache only holds JS-fallback results. When a WASM helper
1123
+ // returns null (unsupported protocol, parser error, …) we MUST
1124
+ // fall through to the JS column walk — otherwise the column
1125
+ // would render as empty geometry.
1126
+ if (rawEntry) {
1127
+ conv = convertRawMapChunkToWasm(rawEntry, version)
1128
+ } else if (v17Entry) {
1129
+ conv = convertParsedV17ToWasm(v17Entry, v17Light, version)
1130
+ } else if (v16Entry) {
1131
+ conv = convertParsedV16ToWasm(v16Entry, v16Light, version)
1132
+ }
1133
+
1134
+ if (!conv) {
1135
+ // JS-fallback (column walk) — still cached, since this is the
1136
+ // expensive path the conversion cache was built for.
1137
+ const cached = getOrConvertColumn(
1138
+ cx, cz, chunk, version, worldMinY, worldMaxY,
1139
+ () => convertChunkToWasm(chunk, version, cx, cz, worldMinY, worldMaxY),
1140
+ chunk
1141
+ )
1142
+ conv = cached.result
1143
+ hit = cached.hit
1144
+ }
1145
+
1146
+ const ce = performance.now()
1147
+ if (hit) preCacheHits++
1148
+ else preCacheMisses++
1149
+ if (cx === x && cz === z) {
1150
+ preTargetConvert += ce - cs
1151
+ } else {
1152
+ preNeighborConvert += ce - cs
1153
+ preNeighborCount++
1154
+ }
1155
+ return conv
1156
+ })
1157
+
1158
+ const {
1159
+ invisibleBlocks,
1160
+ transparentBlocks,
1161
+ noAoBlocks,
1162
+ cullIdenticalBlocks,
1163
+ occludingBlocks,
1164
+ } = conversions[0]
1165
+
1166
+ if (chunkCount === 1 || !(wasm as any).generate_geometry_multi) {
1167
+ const { blockStates, blockLight, skyLight, biomesArray } = conversions[0]
1168
+ t1 = performance.now()
1169
+ wasmResult = wasm.generate_geometry(
1170
+ x, worldMinY, z, columnHeight,
1171
+ worldMinY, worldMaxY,
1172
+ worldMinY,
1173
+ blockStates, blockLight, skyLight, biomesArray,
1174
+ invisibleBlocks, transparentBlocks, noAoBlocks, cullIdenticalBlocks, occludingBlocks,
1175
+ config?.enableLighting !== false,
1176
+ config?.smoothLighting !== false,
1177
+ config?.skyLight || 15
1178
+ )
1179
+ } else {
1180
+ const tBuildStart = performance.now()
1181
+ const perChunkLen = conversions[0].blockStates.length
1182
+ const xs = new Int32Array(chunkCount)
1183
+ const zs = new Int32Array(chunkCount)
1184
+ const blockStatesAll = new Uint16Array(perChunkLen * chunkCount)
1185
+ const blockLightAll = new Uint8Array(perChunkLen * chunkCount)
1186
+ const skyLightAll = new Uint8Array(perChunkLen * chunkCount)
1187
+ const biomesAll = new Uint8Array(perChunkLen * chunkCount)
1188
+
1189
+ for (let i = 0; i < chunkCount; i++) {
1190
+ const c = conversions[i]
1191
+ xs[i] = chunksToUse[i].x
1192
+ zs[i] = chunksToUse[i].z
1193
+ blockStatesAll.set(c.blockStates, perChunkLen * i)
1194
+ blockLightAll.set(c.blockLight, perChunkLen * i)
1195
+ skyLightAll.set(c.skyLight, perChunkLen * i)
1196
+ biomesAll.set(c.biomesArray, perChunkLen * i)
1197
+ }
1198
+ preTypedArrayBuild = performance.now() - tBuildStart
1199
+
1200
+ t1 = performance.now()
1201
+ wasmResult = (wasm as any).generate_geometry_multi(
1202
+ x, worldMinY, z, columnHeight,
1203
+ worldMinY, worldMaxY,
1204
+ worldMinY,
1205
+ xs, zs,
1206
+ blockStatesAll, blockLightAll, skyLightAll, biomesAll,
1207
+ invisibleBlocks, transparentBlocks, noAoBlocks, cullIdenticalBlocks, occludingBlocks,
1208
+ config?.enableLighting !== false,
1209
+ config?.smoothLighting !== false,
1210
+ config?.skyLight || 15
1211
+ )
1212
+ }
1213
+ }
1214
+
1215
+ const t2 = performance.now()
1216
+ postStart = t2
1217
+
1218
+ // Split full-column output back into per-section ExportedSection
1219
+ // entries — only for the section keys the main thread actually
1220
+ // requested. Sections in the column that were NOT requested are
1221
+ // intentionally skipped (the request tracker would warn if we
1222
+ // emitted sectionFinished for them).
1223
+ const requestedSectionKeys = sections.map(s => ({ x: s.x, y: s.y, z: s.z }))
1224
+ exportedMap = splitColumnWasmOutputToSections(
1225
+ wasmResult,
1226
+ requestedSectionKeys,
1227
+ { version, world, sectionHeight }
1228
+ )
1229
+
1230
+ // Push heightmap from the WASM column output. With column meshing as
1231
+ // the only WASM path, the main thread does not request heightmaps
1232
+ // explicitly anymore — the worker is the source of truth and pushes
1233
+ // a `'heightmap'` message every column tick. Key shape matches the
1234
+ // legacy `handleGetHeightmap` contract: `${chunkX>>4},${chunkZ>>4}`.
1235
+ const heightmapKey = `${x >> 4},${z >> 4}`
1236
+ const wasmHeightmap = extractColumnHeightmap(wasmResult)
1237
+ if (wasmHeightmap) {
1238
+ postMessage({ type: 'heightmap', key: heightmapKey, heightmap: wasmHeightmap }, [wasmHeightmap.buffer])
1239
+ } else {
1240
+ console.warn(`[WASM Mesher] heightmap extraction returned null for column ${x},${z}, falling back to JS computeHeightmap`)
1241
+ const fallback = handleGetHeightmap(world, x, z)
1242
+ postMessage({ type: 'heightmap', key: fallback.key, heightmap: fallback.heightmap }, [fallback.heightmap.buffer])
1243
+ }
1244
+
1245
+ if (!usedFusedPath) {
1246
+ prePhase = t1 - t0
1247
+ wasmPhase = t2 - t1
1248
+ }
1249
+ preOther = Math.max(0, prePhase - (preTargetConvert + preNeighborConvert + preTypedArrayBuild))
1250
+ // NOTE: `postPhase` and `processTime` are finalized AFTER the
1251
+ // per-section emit loop below — see the `Finalize column phase
1252
+ // numbers` block.
1253
+ } catch (err) {
1254
+ console.error(`[WASM Mesher] Error processing column ${x},${z}:`, err)
1255
+ hadError = true
1256
+ }
1257
+ }
1258
+
1259
+ // Emit geometry + sectionFinished for each requested section. Column-
1260
+ // level perf metrics are attributed to the first sectionFinished of
1261
+ // the first requested section (others get zeros) so totals don't
1262
+ // double-count.
1263
+ //
1264
+ // Coherent chunk appearance: column mode relies on the existing
1265
+ // `_renderByChunks` / `chunkFinished` contract on the main thread.
1266
+ // ChunkMeshManager batches sections per column and reveals them
1267
+ // atomically once `WorldRendererCommon` sees the last
1268
+ // `sectionFinished` for the column. No dedicated `columnFinished`
1269
+ // worker message is needed.
1270
+ // Pass 1: build geometry + postMessage for each requested section.
1271
+ // We collect finished keys here and emit `sectionFinished` only in
1272
+ // Pass 2 below, after `postPhase` / `processTime` have been
1273
+ // finalized — otherwise the totals attached to the first event
1274
+ // would miss the typed-array allocation, block-entity walk, and
1275
+ // postMessage cost of every section in this column.
1276
+ const finished: Array<{ key: string, count: number }> = []
1277
+ for (const s of sections) {
1278
+ const { key, x: sx, y: sy, z: sz, count } = s
1279
+
1280
+ if (exportedMap && !hadError) {
1281
+ const entry = exportedMap.get(key)
1282
+ const exported = entry?.exported
1283
+ const sectionBlocksCount = entry?.blocksCount ?? 0
1284
+ // Block entity metadata still needs a per-section world walk
1285
+ // (signs/heads/banners), matching the legacy per-section path.
1286
+ const signs: Record<string, SignMeta> = {}
1287
+ const heads: Record<string, HeadMeta> = {}
1288
+ const banners: Record<string, BannerMeta> = {}
1289
+ const beTarget = { signs, heads, banners }
1290
+ const beOpts = { disableBlockEntityTextures: world.config.disableBlockEntityTextures }
1291
+ const cursor = new Vec3(0, 0, 0)
1292
+ for (cursor.y = sy; cursor.y < sy + sectionHeight; cursor.y++) {
1293
+ for (cursor.z = sz; cursor.z < sz + 16; cursor.z++) {
1294
+ for (cursor.x = sx; cursor.x < sx + 16; cursor.x++) {
1295
+ const b = world.getBlock(cursor)
1296
+ if (!b) continue
1297
+ collectBlockEntityMetadata(b, cursor.x, cursor.y, cursor.z, beTarget, beOpts)
1298
+ }
1299
+ }
1300
+ }
1301
+
1302
+ let geometry: MesherGeometryOutput
1303
+ let transferable: any[] = []
1304
+ if (exported && exported.geometry.indices.length > 0) {
1305
+ const maxIndex = exported.geometry.indices.length > 0
1306
+ ? Math.max(...exported.geometry.indices)
1307
+ : 0
1308
+ const using32Array = maxIndex > 65535
1309
+ geometry = {
1310
+ sectionYNumber: (sy - (config?.worldMinY || 0)) >> 4,
1311
+ chunkKey: worldColumnKey(sx, sz),
1312
+ sectionStartY: sy,
1313
+ sectionEndY: sy + sectionHeight,
1314
+ sectionStartX: sx,
1315
+ sectionEndX: sx + 16,
1316
+ sectionStartZ: sz,
1317
+ sectionEndZ: sz + 16,
1318
+ sx: sx + 8,
1319
+ sy: sy + 8,
1320
+ sz: sz + 8,
1321
+ positions: new Float32Array(exported.geometry.positions),
1322
+ normals: new Float32Array(exported.geometry.normals),
1323
+ colors: new Float32Array(exported.geometry.colors),
1324
+ uvs: new Float32Array(exported.geometry.uvs),
1325
+ indices: using32Array
1326
+ ? new Uint32Array(exported.geometry.indices)
1327
+ : new Uint16Array(exported.geometry.indices),
1328
+ indicesCount: exported.geometry.indices.length,
1329
+ using32Array,
1330
+ tiles: {},
1331
+ heads,
1332
+ signs,
1333
+ banners,
1334
+ hadErrors: false,
1335
+ // Per-section block bucket size from the column split. The
1336
+ // field is informational (used by `chunkMeshManager` for the
1337
+ // `B:` debug overlay stat) and matches the per-section path's
1338
+ // semantics: number of blocks that contributed faces to this
1339
+ // section's geometry.
1340
+ blocksCount: sectionBlocksCount,
1341
+ }
1342
+ transferable = [
1343
+ geometry.positions?.buffer,
1344
+ geometry.normals?.buffer,
1345
+ geometry.colors?.buffer,
1346
+ geometry.uvs?.buffer,
1347
+ //@ts-ignore
1348
+ geometry.indices?.buffer,
1349
+ ].filter(Boolean)
1350
+ } else {
1351
+ geometry = makeEmptyColumnGeometry(sx, sy, sz, sectionHeight, false)
1352
+ // Still attach block entity metadata so the main thread sees
1353
+ // signs/heads/banners even for empty-mesh sections.
1354
+ geometry.signs = signs
1355
+ geometry.heads = heads
1356
+ geometry.banners = banners
1357
+ }
1358
+ postMessage({ type: 'geometry', key, geometry, workerIndex }, transferable)
1359
+ } else if (hadError) {
1360
+ const errorGeometry = makeEmptyColumnGeometry(sx, sy, sz, sectionHeight, true)
1361
+ postMessage({ type: 'geometry', key, geometry: errorGeometry, workerIndex })
1362
+ }
1363
+ // No targetChunk and no error: skip geometry message (mirrors
1364
+ // legacy behavior for sections whose chunk has been unloaded
1365
+ // mid-tick) but still emit sectionFinished below so the main
1366
+ // thread's sectionsWaiting counter unblocks.
1367
+ finished.push({ key, count })
1368
+ }
1369
+
1370
+ // Finalize column phase numbers — now they include split + per-
1371
+ // section typed-array build + block-entity walk + geometry
1372
+ // postMessage cost.
1373
+ if (columnStart > 0 && !hadError) {
1374
+ const tEnd = performance.now()
1375
+ if (postStart > 0) postPhase = tEnd - postStart
1376
+ processTime = tEnd - columnStart
1377
+ }
1378
+
1379
+ // Pass 2: emit sectionFinished events. Column-level perf metrics
1380
+ // are attributed to the first emitted sectionFinished (others get
1381
+ // zeros) so totals don't double-count.
1382
+ let attributed = false
1383
+ for (const { key, count } of finished) {
1384
+ for (let i = 0; i < count; i++) {
1385
+ emitSectionFinished({
1386
+ type: 'sectionFinished',
1387
+ key,
1388
+ workerIndex,
1389
+ processTime: !attributed ? processTime : 0,
1390
+ pre: !attributed ? prePhase : 0,
1391
+ wasm: !attributed ? wasmPhase : 0,
1392
+ post: !attributed ? postPhase : 0,
1393
+ preTargetConvert: !attributed ? preTargetConvert : 0,
1394
+ preNeighborConvert: !attributed ? preNeighborConvert : 0,
1395
+ preNeighborCount: !attributed ? preNeighborCount : 0,
1396
+ preTypedArrayBuild: !attributed ? preTypedArrayBuild : 0,
1397
+ preOther: !attributed ? preOther : 0,
1398
+ preCacheHits: !attributed ? preCacheHits : 0,
1399
+ preCacheMisses: !attributed ? preCacheMisses : 0,
1400
+ })
1401
+ attributed = true
1402
+ }
1403
+ }
1404
+ }
1405
+ }
1406
+
1407
+ setInterval(async () => {
1408
+ if (!allDataReady) return
1409
+
1410
+ // Ensure WASM is initialized
1411
+ if (!wasmInitialized) {
1412
+ await initWasm()
1413
+ if (!wasmInitialized) return // Still not initialized, skip this cycle
1414
+ }
1415
+
1416
+ if (dirtySections.size === 0) return
1417
+
1418
+ try {
1419
+ processColumnTick()
1420
+ } catch (err) {
1421
+ console.error('[WASM Mesher] processColumnTick failed:', err)
1422
+ // Swallow to avoid breaking the setInterval; individual columns
1423
+ // already have their own try/catch.
1424
+ }
1425
+ }, 50)