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.
@@ -1,10 +1,20 @@
1
1
  //@ts-nocheck
2
2
  import { Vec3 } from 'vec3'
3
3
  import { convertChunkToWasm } from '../wasm-lib/convertChunk'
4
- import { renderWasmOutputToGeometry } from '../wasm-lib/render-from-wasm'
4
+ import { extractColumnHeightmap, splitColumnWasmOutputToSections } from '../wasm-lib/render-from-wasm'
5
5
  import { setBlockStatesData as setMesherData } from './models'
6
- import { defaultMesherConfig, type MesherGeometryOutput, IS_FULL_WORLD_SECTION, SECTION_HEIGHT } from './shared'
6
+ import { defaultMesherConfig, type MesherGeometryOutput, SECTION_HEIGHT } from './shared'
7
7
  import { worldColumnKey, World } from './world'
8
+ import { handleGetHeightmap, EMPTY_COLUMN_HEIGHTMAP_SENTINEL } from './computeHeightmap'
9
+ import { collectBlockEntityMetadata, type SignMeta, type HeadMeta, type BannerMeta } from './blockEntityMetadata'
10
+ import { SectionRequestTracker } from './mesherWasmRequestTracker'
11
+ import {
12
+ CONVERSION_CACHE_LIMIT,
13
+ clearConversionCache,
14
+ getOrConvertColumn,
15
+ invalidateConversion,
16
+ setConversionCacheLimit,
17
+ } from './mesherWasmConversionCache'
8
18
 
9
19
  let wasm: typeof import('../../wasm/wasm_mesher.js') | null = null
10
20
  let wasmInitialized = false
@@ -41,6 +51,10 @@ let config = defaultMesherConfig
41
51
  let version = '1.16.5'
42
52
  let world: World // chunkKey -> chunk data
43
53
  let dirtySections = new Map<string, number>()
54
+ // Kept in sync with `dirtySections` so column mode can filter outgoing
55
+ // geometry/sectionFinished events to only the section keys requested by the
56
+ // main thread, even though a full-column WASM call may generate more data.
57
+ const requestTracker = new SectionRequestTracker()
44
58
  let allDataReady = false
45
59
 
46
60
  function sectionKey(x: number, y: number, z: number) {
@@ -70,6 +84,21 @@ function drainQueue(from: number, to: number) {
70
84
  queuedMessages = queuedMessages.slice(to)
71
85
  }
72
86
 
87
+ // Single emit point for `sectionFinished`. Consumes one pending request from
88
+ // `requestTracker` and posts via the existing batched `postMessage` queue.
89
+ //
90
+ // Column-mode is the ONLY WASM path now: an emit for a non-requested key is a
91
+ // contract violation (`WorldRendererCommon` would throw on the main thread)
92
+ // and we surface it via `console.warn` so it shows up in dev/CI without
93
+ // killing the worker.
94
+ const emitSectionFinished = (payload: { type: 'sectionFinished', key: string } & Record<string, any>) => {
95
+ const consumed = requestTracker.consumeOne(payload.key)
96
+ if (!consumed) {
97
+ console.warn(`[WASM Mesher] sectionFinished for non-requested key ${payload.key} (column-mode contract violation)`)
98
+ }
99
+ postMessage(payload)
100
+ }
101
+
73
102
  let hadDirty = false
74
103
  function setSectionDirty(pos: Vec3, value = true) {
75
104
  if (hadDirty) return
@@ -82,7 +111,10 @@ function setSectionDirty(pos: Vec3, value = true) {
82
111
  const key = sectionKey(x, y, z)
83
112
  if (!value) {
84
113
  dirtySections.delete(key)
85
- postMessage({ type: 'sectionFinished', key, workerIndex })
114
+ // The main thread waits for a sectionFinished response to dirty=false too.
115
+ // Record + consume it so request accounting stays balanced.
116
+ requestTracker.addRequest(key)
117
+ emitSectionFinished({ type: 'sectionFinished', key, workerIndex })
86
118
  return
87
119
  }
88
120
 
@@ -90,8 +122,11 @@ function setSectionDirty(pos: Vec3, value = true) {
90
122
  const chunk = world?.getColumn(x, z)
91
123
  if (chunk?.getSection(pos)) {
92
124
  dirtySections.set(key, (dirtySections.get(key) || 0) + 1)
125
+ requestTracker.addRequest(key)
93
126
  } else {
94
- postMessage({ type: 'sectionFinished', key, workerIndex })
127
+ // Missing chunks still owe the main thread a sectionFinished response.
128
+ requestTracker.addRequest(key)
129
+ emitSectionFinished({ type: 'sectionFinished', key, workerIndex })
95
130
  }
96
131
  }
97
132
 
@@ -115,12 +150,15 @@ const handleMessage = async (data: any) => {
115
150
  world.config = { ...world.config, ...data.config }
116
151
  globalThis.world = world
117
152
  globalThis.Vec3 = Vec3
153
+ setConversionCacheLimit(config.disableConversionCache ? 0 : CONVERSION_CACHE_LIMIT)
118
154
  }
119
155
 
120
156
  switch (data.type) {
121
157
  case 'mesherData': {
122
158
  setMesherData(data.blockstatesModels, data.blocksAtlas, data.config.outputFormat === 'webgpu')
123
159
  ;(globalThis as any).__wasmBlockModelCache = new Map()
160
+ // Conservative: blockstates/version/world config may have changed.
161
+ clearConversionCache()
124
162
 
125
163
  await initWasm()
126
164
  allDataReady = true
@@ -133,14 +171,50 @@ const handleMessage = async (data: any) => {
133
171
  break
134
172
  }
135
173
  case 'chunk': {
174
+ // Invalidate BEFORE replacing the column reference so a stale entry
175
+ // can never outlive the old chunk object.
176
+ invalidateConversion(data.x, data.z)
177
+ if (!world) break
136
178
  world.addColumn(data.x, data.z, data.chunk)
137
179
  if (data.customBlockModels) {
138
180
  const chunkKey = `${data.x},${data.z}`
139
181
  world.customBlockModels.set(chunkKey, data.customBlockModels)
140
182
  }
183
+ // Safety-net heightmap push for fully empty columns. With WASM
184
+ // mesher as the sole path, the main thread no longer requests
185
+ // `getHeightmap` on chunk load — heightmaps come from
186
+ // `processColumnTick`. But a fully empty column (no sections, or
187
+ // all sections missing) never enters that path because
188
+ // `setSectionDirty` short-circuits when `chunk.getSection(pos)` is
189
+ // falsy, so `processColumnTick` never sees it. Without this push
190
+ // downstream consumers (e.g. `rain.ts`) would have no heightmap
191
+ // entry for such columns. We send a cheap sentinel-filled
192
+ // `Int16Array(256).fill(-32768)` — no JS heightmap scan — only when
193
+ // we detect zero sections; non-empty columns get their real
194
+ // heightmap from the next `processColumnTick`.
195
+ const sectionH = SECTION_HEIGHT
196
+ const minY = config?.worldMinY ?? 0
197
+ const maxY = config?.worldMaxY ?? 256
198
+ const column = world.getColumn(data.x, data.z)
199
+ let hasAnySection = false
200
+ for (let y = minY; y < maxY; y += sectionH) {
201
+ if (column?.getSection?.(new Vec3(0, y, 0))) {
202
+ hasAnySection = true
203
+ break
204
+ }
205
+ }
206
+ if (!hasAnySection) {
207
+ const emptyHeightmap = new Int16Array(256).fill(EMPTY_COLUMN_HEIGHTMAP_SENTINEL)
208
+ postMessage(
209
+ { type: 'heightmap', key: `${data.x >> 4},${data.z >> 4}`, heightmap: emptyHeightmap },
210
+ [emptyHeightmap.buffer]
211
+ )
212
+ }
141
213
  break
142
214
  }
143
215
  case 'unloadChunk': {
216
+ invalidateConversion(data.x, data.z)
217
+ if (!world) break
144
218
  world.removeColumn(data.x, data.z)
145
219
  world.customBlockModels.delete(`${data.x},${data.z}`)
146
220
  if (Object.keys(world.columns).length === 0) softCleanup()
@@ -152,7 +226,12 @@ const handleMessage = async (data: any) => {
152
226
  world?.setBlockStateId(loc, data.stateId)
153
227
  }
154
228
 
155
- const chunkKey = `${Math.floor(loc.x / 16) * 16},${Math.floor(loc.z / 16) * 16}`
229
+ const chunkX = Math.floor(loc.x / 16) * 16
230
+ const chunkZ = Math.floor(loc.z / 16) * 16
231
+ // In-place mutation preserves chunk identity; explicit invalidation
232
+ // is required so the next tick recomputes from current block state.
233
+ invalidateConversion(chunkX, chunkZ)
234
+ const chunkKey = `${chunkX},${chunkZ}`
156
235
  if (data.customBlockModels) {
157
236
  world?.customBlockModels.set(chunkKey, data.customBlockModels)
158
237
  }
@@ -161,13 +240,32 @@ const handleMessage = async (data: any) => {
161
240
  case 'reset': {
162
241
  world = undefined as any
163
242
  dirtySections.clear()
243
+ requestTracker.clear()
244
+ clearConversionCache()
164
245
  globalVar.mcData = null
165
246
  globalVar.loadedData = null
166
247
  allDataReady = false
167
248
  break
168
249
  }
169
- // Note: getCustomBlockModel and getHeightmap not implemented in WASM version
170
- // as they require World class functionality
250
+ case 'getHeightmap': {
251
+ // Fallback path. With WASM column mesher as the sole path, the main
252
+ // thread should be receiving heightmaps as `'heightmap'` push messages
253
+ // posted by `processColumnTick`. This handler stays as a safety net for
254
+ // cases where the WASM heightmap could not be extracted (length mismatch
255
+ // or missing field) — see the `extractColumnHeightmap` warn below.
256
+ console.warn(`[WASM Mesher] explicit getHeightmap request for ${data.x},${data.z} — push from processColumnTick missed?`)
257
+ if (!world) {
258
+ const emptyHeightmap = new Int16Array(256).fill(EMPTY_COLUMN_HEIGHTMAP_SENTINEL)
259
+ postMessage({ type: 'heightmap', key: `${Math.floor(data.x / 16)},${Math.floor(data.z / 16)}`, heightmap: emptyHeightmap })
260
+ break
261
+ }
262
+ const { key, heightmap } = handleGetHeightmap(world, data.x, data.z)
263
+ postMessage({ type: 'heightmap', key, heightmap }, [heightmap.buffer])
264
+
265
+ break
266
+ }
267
+ // Note: getCustomBlockModel not implemented in WASM version
268
+ // as it requires World class functionality
171
269
  }
172
270
  }
173
271
 
@@ -182,18 +280,16 @@ self.onmessage = ({ data }) => {
182
280
  handleMessage(data)
183
281
  }
184
282
 
185
- // Calculate section height based on IS_FULL_WORLD_SECTION
186
- const getSectionHeight = () => {
187
- if (IS_FULL_WORLD_SECTION && config) {
188
- return (config.worldMaxY || 256) - (config.worldMinY || 0)
189
- }
190
- return SECTION_HEIGHT
191
- }
283
+ // Section height is always 16 in column mode (the only WASM path).
284
+ const getSectionHeight = () => SECTION_HEIGHT
192
285
 
193
286
 
194
- function collectChunksForSection(x: number, y: number, z: number) {
287
+ // 3x3 X/Z neighbor set for column meshing. Y-agnostic because full-column
288
+ // meshing converts the entire world Y range in one go.
289
+ function collectChunksForColumn(x: number, z: number) {
195
290
  const result = [] as Array<{ x: number, z: number, chunk: any }>
196
- result.push({ x, z, chunk: world.getColumn(x, z) })
291
+ const target = world.getColumn(x, z)
292
+ if (target) result.push({ x, z, chunk: target })
197
293
  const offsets = [-16, 0, 16]
198
294
  for (const dx of offsets) {
199
295
  for (const dz of offsets) {
@@ -204,53 +300,129 @@ function collectChunksForSection(x: number, y: number, z: number) {
204
300
  if (c) result.push({ x: nx, z: nz, chunk: c })
205
301
  }
206
302
  }
207
- return result.filter(r => r.chunk)
303
+ return result
208
304
  }
209
305
 
210
- setInterval(async () => {
211
- if (!allDataReady) return
212
-
213
- // Ensure WASM is initialized
214
- if (!wasmInitialized) {
215
- await initWasm()
216
- if (!wasmInitialized) return // Still not initialized, skip this cycle
306
+ function makeEmptyColumnGeometry(sx: number, sy: number, sz: number, sectionHeight: number, hadErrors: boolean): MesherGeometryOutput {
307
+ return {
308
+ sectionYNumber: (sy - (config?.worldMinY || 0)) >> 4,
309
+ chunkKey: worldColumnKey(sx, sz),
310
+ sectionStartY: sy,
311
+ sectionEndY: sy + sectionHeight,
312
+ sectionStartX: sx,
313
+ sectionEndX: sx + 16,
314
+ sectionStartZ: sz,
315
+ sectionEndZ: sz + 16,
316
+ sx: sx + 8,
317
+ sy: sy + 8,
318
+ sz: sz + 8,
319
+ positions: new Float32Array(0),
320
+ normals: new Float32Array(0),
321
+ colors: new Float32Array(0),
322
+ uvs: new Float32Array(0),
323
+ indices: new Uint32Array(0),
324
+ indicesCount: 0,
325
+ using32Array: false,
326
+ tiles: {},
327
+ heads: {},
328
+ signs: {},
329
+ banners: {},
330
+ hadErrors,
331
+ blocksCount: 0,
217
332
  }
333
+ }
218
334
 
219
- if (dirtySections.size === 0) return
220
-
221
- const sectionHeight = getSectionHeight()
335
+ // Full-column meshing path — the sole WASM mesh path.
336
+ // It groups dirty section keys by chunk column, runs one WASM call per column
337
+ // over the full Y range, then splits the column output back into per-section
338
+ // geometries. Only requested section keys are emitted back to the main thread.
339
+ function processColumnTick() {
340
+ const worldMinY = config?.worldMinY ?? 0
341
+ const worldMaxY = config?.worldMaxY ?? 256
342
+ const columnHeight = worldMaxY - worldMinY
343
+ const sectionHeight = SECTION_HEIGHT
344
+
345
+ // Group dirty sections by chunk column (`${x},${z}` in world block
346
+ // coords — the same units used by section keys). This guarantees a
347
+ // single WASM call per column per tick even when multiple section keys
348
+ // of the same column are dirty.
349
+ const groups = new Map<string, { x: number, z: number, sections: Array<{ key: string, x: number, y: number, z: number, count: number }> }>()
350
+ for (const [key, count] of dirtySections) {
351
+ const [sx, sy, sz] = key.split(',').map(v => parseInt(v, 10))
352
+ const colKey = `${sx},${sz}`
353
+ let g = groups.get(colKey)
354
+ if (!g) {
355
+ g = { x: sx, z: sz, sections: [] }
356
+ groups.set(colKey, g)
357
+ }
358
+ g.sections.push({ key, x: sx, y: sy, z: sz, count })
359
+ }
360
+ dirtySections.clear()
222
361
 
223
- for (const key of dirtySections.keys()) {
224
- // for (const key of [] as string[]) {
225
- const [x, y, z] = key.split(',').map(v => parseInt(v, 10))
226
- const chunk = world.getColumn(x, z)
362
+ for (const group of groups.values()) {
363
+ const { x, z, sections } = group
364
+ const targetChunk = world.getColumn(x, z)
227
365
 
366
+ let exportedMap: Map<string, { exported: import('../three/worldGeometryExport').ExportedSection, blocksCount: number }> | null = null
228
367
  let processTime = 0
229
- if (chunk?.getSection(new Vec3(x, y, z)) && wasm) {
230
- const start = performance.now()
231
-
368
+ let prePhase = 0
369
+ let wasmPhase = 0
370
+ let postPhase = 0
371
+ let preTargetConvert = 0
372
+ let preNeighborConvert = 0
373
+ let preNeighborCount = 0
374
+ let preTypedArrayBuild = 0
375
+ let preOther = 0
376
+ let preCacheHits = 0
377
+ let preCacheMisses = 0
378
+ let hadError = false
379
+ // Outer-scope timestamps so we can finalize `processTime` and
380
+ // `postPhase` AFTER the per-section emit loop runs (the loop builds
381
+ // typed arrays, walks block-entity metadata, and calls postMessage —
382
+ // all of which are part of the worker's real cost and must be
383
+ // attributed to the column).
384
+ let columnStart = 0
385
+ let postStart = 0
386
+
387
+ if (targetChunk && wasm) {
388
+ columnStart = performance.now()
389
+ const start = columnStart
390
+ const t0 = start
232
391
  try {
233
- // Convert chunk to WASM format (always recompute since section is dirty)
234
- // If IS_FULL_WORLD_SECTION is false, only convert the specific section
235
- const worldMinY = config?.worldMinY || 0
236
- const worldMaxY = config?.worldMaxY || 256
237
- const sectionY = IS_FULL_WORLD_SECTION ? undefined : y
238
- const convertSectionHeight = IS_FULL_WORLD_SECTION ? undefined : sectionHeight
239
-
240
- // Run WASM mesher for this section
241
- const chunksToUse = collectChunksForSection(x, y, z)
392
+ const chunksToUse = collectChunksForColumn(x, z)
242
393
  const chunkCount = chunksToUse.length
243
394
 
244
- const conversions = chunksToUse.map(({ x: cx, z: cz, chunk }) => convertChunkToWasm(
245
- chunk,
246
- version,
247
- cx,
248
- cz,
249
- worldMinY,
250
- worldMaxY,
251
- sectionY,
252
- convertSectionHeight
253
- ))
395
+ const conversions = chunksToUse.map(({ x: cx, z: cz, chunk }) => {
396
+ const cs = performance.now()
397
+ const { result: conv, hit } = getOrConvertColumn(
398
+ cx,
399
+ cz,
400
+ chunk,
401
+ version,
402
+ worldMinY,
403
+ worldMaxY,
404
+ () => convertChunkToWasm(
405
+ chunk,
406
+ version,
407
+ cx,
408
+ cz,
409
+ worldMinY,
410
+ worldMaxY
411
+ // No sectionY/sectionHeight => full column conversion.
412
+ ),
413
+ chunk
414
+ )
415
+ const ce = performance.now()
416
+ if (hit) preCacheHits++
417
+ else preCacheMisses++
418
+ if (cx === x && cz === z) {
419
+ preTargetConvert += ce - cs
420
+ } else {
421
+ preNeighborConvert += ce - cs
422
+ preNeighborCount++
423
+ }
424
+ return conv
425
+ })
254
426
 
255
427
  const {
256
428
  invisibleBlocks,
@@ -260,12 +432,18 @@ setInterval(async () => {
260
432
  occludingBlocks,
261
433
  } = conversions[0]
262
434
 
263
- let wasmResult
435
+ let wasmResult: any
436
+ let t1: number
264
437
  if (chunkCount === 1 || !(wasm as any).generate_geometry_multi) {
438
+ // Single-chunk path: no discrete typed-array build/copy step
439
+ // (the per-chunk arrays from convertChunkToWasm are passed
440
+ // straight through). preTypedArrayBuild stays 0.
265
441
  const { blockStates, blockLight, skyLight, biomesArray } = conversions[0]
442
+ t1 = performance.now()
266
443
  wasmResult = wasm.generate_geometry(
267
- x, y, z, sectionHeight,
444
+ x, worldMinY, z, columnHeight,
268
445
  worldMinY, worldMaxY,
446
+ worldMinY,
269
447
  blockStates, blockLight, skyLight, biomesArray,
270
448
  invisibleBlocks, transparentBlocks, noAoBlocks, cullIdenticalBlocks, occludingBlocks,
271
449
  config?.enableLighting !== false,
@@ -273,6 +451,7 @@ setInterval(async () => {
273
451
  config?.skyLight || 15
274
452
  )
275
453
  } else {
454
+ const tBuildStart = performance.now()
276
455
  const perChunkLen = conversions[0].blockStates.length
277
456
  const xs = new Int32Array(chunkCount)
278
457
  const zs = new Int32Array(chunkCount)
@@ -290,10 +469,13 @@ setInterval(async () => {
290
469
  skyLightAll.set(c.skyLight, perChunkLen * i)
291
470
  biomesAll.set(c.biomesArray, perChunkLen * i)
292
471
  }
472
+ preTypedArrayBuild = performance.now() - tBuildStart
293
473
 
474
+ t1 = performance.now()
294
475
  wasmResult = (wasm as any).generate_geometry_multi(
295
- x, y, z, sectionHeight,
476
+ x, worldMinY, z, columnHeight,
296
477
  worldMinY, worldMaxY,
478
+ worldMinY,
297
479
  xs, zs,
298
480
  blockStatesAll, blockLightAll, skyLightAll, biomesAll,
299
481
  invisibleBlocks, transparentBlocks, noAoBlocks, cullIdenticalBlocks, occludingBlocks,
@@ -303,102 +485,212 @@ setInterval(async () => {
303
485
  )
304
486
  }
305
487
 
488
+ const t2 = performance.now()
489
+ postStart = t2
306
490
 
307
- // Convert WASM output to MesherGeometryOutput format
308
- const sectionKeyStr = worldColumnKey(x, z)
309
- const exportedSection = renderWasmOutputToGeometry(
491
+ // Split full-column output back into per-section ExportedSection
492
+ // entries only for the section keys the main thread actually
493
+ // requested. Sections in the column that were NOT requested are
494
+ // intentionally skipped (the request tracker would warn if we
495
+ // emitted sectionFinished for them).
496
+ const requestedSectionKeys = sections.map(s => ({ x: s.x, y: s.y, z: s.z }))
497
+ exportedMap = splitColumnWasmOutputToSections(
310
498
  wasmResult,
311
- version,
312
- sectionKeyStr,
313
- { x: x + 8, y: y + 8, z: z + 8 },
314
- world
499
+ requestedSectionKeys,
500
+ { version, world, sectionHeight }
315
501
  )
316
502
 
317
- // Convert to MesherGeometryOutput format
318
- // Determine if we need Uint32Array based on max index
319
- const maxIndex = Math.max(...exportedSection.geometry.indices)
320
- const using32Array = maxIndex > 65535
321
-
322
- // console.log('exportedSection.geometry', exportedSection.geometry)
323
- const geometry: MesherGeometryOutput = {
324
- sectionYNumber: (y - (config?.worldMinY || 0)) >> 4,
325
- chunkKey: sectionKeyStr,
326
- sectionStartY: y,
327
- sectionEndY: y + sectionHeight,
328
- sectionStartX: x,
329
- sectionEndX: x + 16,
330
- sectionStartZ: z,
331
- sectionEndZ: z + 16,
332
- sx: x + 8,
333
- sy: y + 8,
334
- sz: z + 8,
335
- positions: new Float32Array(exportedSection.geometry.positions),
336
- normals: new Float32Array(exportedSection.geometry.normals),
337
- colors: new Float32Array(exportedSection.geometry.colors),
338
- uvs: new Float32Array(exportedSection.geometry.uvs),
339
- indices: using32Array
340
- ? new Uint32Array(exportedSection.geometry.indices)
341
- : new Uint16Array(exportedSection.geometry.indices),
342
- indicesCount: exportedSection.geometry.indices.length,
343
- using32Array,
344
- tiles: {},
345
- heads: {},
346
- signs: {},
347
- banners: {},
348
- hadErrors: false,
349
- blocksCount: wasmResult.block_count,
503
+ // Push heightmap from the WASM column output. With column meshing as
504
+ // the only WASM path, the main thread does not request heightmaps
505
+ // explicitly anymore — the worker is the source of truth and pushes
506
+ // a `'heightmap'` message every column tick. Key shape matches the
507
+ // legacy `handleGetHeightmap` contract: `${chunkX>>4},${chunkZ>>4}`.
508
+ const heightmapKey = `${x >> 4},${z >> 4}`
509
+ const wasmHeightmap = extractColumnHeightmap(wasmResult)
510
+ if (wasmHeightmap) {
511
+ postMessage({ type: 'heightmap', key: heightmapKey, heightmap: wasmHeightmap }, [wasmHeightmap.buffer])
512
+ } else {
513
+ console.warn(`[WASM Mesher] heightmap extraction returned null for column ${x},${z}, falling back to JS computeHeightmap`)
514
+ const fallback = handleGetHeightmap(world, x, z)
515
+ postMessage({ type: 'heightmap', key: fallback.key, heightmap: fallback.heightmap }, [fallback.heightmap.buffer])
350
516
  }
351
517
 
352
- const transferable = [
353
- geometry.positions?.buffer,
354
- geometry.normals?.buffer,
355
- geometry.colors?.buffer,
356
- geometry.uvs?.buffer,
357
- //@ts-ignore
358
- geometry.indices?.buffer,
359
- ].filter(Boolean)
360
-
361
- postMessage({ type: 'geometry', key, geometry, workerIndex }, transferable)
362
- processTime = performance.now() - start
518
+ prePhase = t1 - t0
519
+ wasmPhase = t2 - t1
520
+ preOther = Math.max(0, prePhase - (preTargetConvert + preNeighborConvert + preTypedArrayBuild))
521
+ // NOTE: `postPhase` and `processTime` are finalized AFTER the
522
+ // per-section emit loop below — see the `Finalize column phase
523
+ // numbers` block.
363
524
  } catch (err) {
364
- console.error(`[WASM Mesher] Error processing section ${key}:`, err)
365
- // Send error geometry
366
- const errorGeometry: MesherGeometryOutput = {
367
- sectionYNumber: (y - (config?.worldMinY || 0)) >> 4,
368
- chunkKey: worldColumnKey(x, z),
369
- sectionStartY: y,
370
- sectionEndY: y + sectionHeight,
371
- sectionStartX: x,
372
- sectionEndX: x + 16,
373
- sectionStartZ: z,
374
- sectionEndZ: z + 16,
375
- sx: x + 8,
376
- sy: y + 8,
377
- sz: z + 8,
378
- positions: new Float32Array(0),
379
- normals: new Float32Array(0),
380
- colors: new Float32Array(0),
381
- uvs: new Float32Array(0),
382
- indices: new Uint32Array(0),
383
- indicesCount: 0,
384
- using32Array: false,
385
- tiles: {},
386
- heads: {},
387
- signs: {},
388
- banners: {},
389
- hadErrors: true,
390
- blocksCount: 0,
525
+ console.error(`[WASM Mesher] Error processing column ${x},${z}:`, err)
526
+ hadError = true
527
+ }
528
+ }
529
+
530
+ // Emit geometry + sectionFinished for each requested section. Column-
531
+ // level perf metrics are attributed to the first sectionFinished of
532
+ // the first requested section (others get zeros) so totals don't
533
+ // double-count.
534
+ //
535
+ // Coherent chunk appearance: column mode relies on the existing
536
+ // `_renderByChunks` / `chunkFinished` contract on the main thread.
537
+ // ChunkMeshManager batches sections per column and reveals them
538
+ // atomically once `WorldRendererCommon` sees the last
539
+ // `sectionFinished` for the column. No dedicated `columnFinished`
540
+ // worker message is needed.
541
+ // Pass 1: build geometry + postMessage for each requested section.
542
+ // We collect finished keys here and emit `sectionFinished` only in
543
+ // Pass 2 below, after `postPhase` / `processTime` have been
544
+ // finalized — otherwise the totals attached to the first event
545
+ // would miss the typed-array allocation, block-entity walk, and
546
+ // postMessage cost of every section in this column.
547
+ const finished: Array<{ key: string, count: number }> = []
548
+ for (const s of sections) {
549
+ const { key, x: sx, y: sy, z: sz, count } = s
550
+
551
+ if (exportedMap && !hadError) {
552
+ const entry = exportedMap.get(key)
553
+ const exported = entry?.exported
554
+ const sectionBlocksCount = entry?.blocksCount ?? 0
555
+ // Block entity metadata still needs a per-section world walk
556
+ // (signs/heads/banners), matching the legacy per-section path.
557
+ const signs: Record<string, SignMeta> = {}
558
+ const heads: Record<string, HeadMeta> = {}
559
+ const banners: Record<string, BannerMeta> = {}
560
+ const beTarget = { signs, heads, banners }
561
+ const beOpts = { disableBlockEntityTextures: world.config.disableBlockEntityTextures }
562
+ const cursor = new Vec3(0, 0, 0)
563
+ for (cursor.y = sy; cursor.y < sy + sectionHeight; cursor.y++) {
564
+ for (cursor.z = sz; cursor.z < sz + 16; cursor.z++) {
565
+ for (cursor.x = sx; cursor.x < sx + 16; cursor.x++) {
566
+ const b = world.getBlock(cursor)
567
+ if (!b) continue
568
+ collectBlockEntityMetadata(b, cursor.x, cursor.y, cursor.z, beTarget, beOpts)
569
+ }
570
+ }
391
571
  }
572
+
573
+ let geometry: MesherGeometryOutput
574
+ let transferable: any[] = []
575
+ if (exported && exported.geometry.indices.length > 0) {
576
+ const maxIndex = exported.geometry.indices.length > 0
577
+ ? Math.max(...exported.geometry.indices)
578
+ : 0
579
+ const using32Array = maxIndex > 65535
580
+ geometry = {
581
+ sectionYNumber: (sy - (config?.worldMinY || 0)) >> 4,
582
+ chunkKey: worldColumnKey(sx, sz),
583
+ sectionStartY: sy,
584
+ sectionEndY: sy + sectionHeight,
585
+ sectionStartX: sx,
586
+ sectionEndX: sx + 16,
587
+ sectionStartZ: sz,
588
+ sectionEndZ: sz + 16,
589
+ sx: sx + 8,
590
+ sy: sy + 8,
591
+ sz: sz + 8,
592
+ positions: new Float32Array(exported.geometry.positions),
593
+ normals: new Float32Array(exported.geometry.normals),
594
+ colors: new Float32Array(exported.geometry.colors),
595
+ uvs: new Float32Array(exported.geometry.uvs),
596
+ indices: using32Array
597
+ ? new Uint32Array(exported.geometry.indices)
598
+ : new Uint16Array(exported.geometry.indices),
599
+ indicesCount: exported.geometry.indices.length,
600
+ using32Array,
601
+ tiles: {},
602
+ heads,
603
+ signs,
604
+ banners,
605
+ hadErrors: false,
606
+ // Per-section block bucket size from the column split. The
607
+ // field is informational (used by `chunkMeshManager` for the
608
+ // `B:` debug overlay stat) and matches the per-section path's
609
+ // semantics: number of blocks that contributed faces to this
610
+ // section's geometry.
611
+ blocksCount: sectionBlocksCount,
612
+ }
613
+ transferable = [
614
+ geometry.positions?.buffer,
615
+ geometry.normals?.buffer,
616
+ geometry.colors?.buffer,
617
+ geometry.uvs?.buffer,
618
+ //@ts-ignore
619
+ geometry.indices?.buffer,
620
+ ].filter(Boolean)
621
+ } else {
622
+ geometry = makeEmptyColumnGeometry(sx, sy, sz, sectionHeight, false)
623
+ // Still attach block entity metadata so the main thread sees
624
+ // signs/heads/banners even for empty-mesh sections.
625
+ geometry.signs = signs
626
+ geometry.heads = heads
627
+ geometry.banners = banners
628
+ }
629
+ postMessage({ type: 'geometry', key, geometry, workerIndex }, transferable)
630
+ } else if (hadError) {
631
+ const errorGeometry = makeEmptyColumnGeometry(sx, sy, sz, sectionHeight, true)
392
632
  postMessage({ type: 'geometry', key, geometry: errorGeometry, workerIndex })
393
633
  }
634
+ // No targetChunk and no error: skip geometry message (mirrors
635
+ // legacy behavior for sections whose chunk has been unloaded
636
+ // mid-tick) but still emit sectionFinished below so the main
637
+ // thread's sectionsWaiting counter unblocks.
638
+ finished.push({ key, count })
394
639
  }
395
640
 
396
- const dirtyTimes = dirtySections.get(key)
397
- if (!dirtyTimes) throw new Error('dirtySections.get(key) is falsy')
398
- for (let i = 0; i < dirtyTimes; i++) {
399
- postMessage({ type: 'sectionFinished', key, workerIndex, processTime })
400
- processTime = 0
641
+ // Finalize column phase numbers — now they include split + per-
642
+ // section typed-array build + block-entity walk + geometry
643
+ // postMessage cost.
644
+ if (columnStart > 0 && !hadError) {
645
+ const tEnd = performance.now()
646
+ if (postStart > 0) postPhase = tEnd - postStart
647
+ processTime = tEnd - columnStart
401
648
  }
402
- dirtySections.delete(key)
649
+
650
+ // Pass 2: emit sectionFinished events. Column-level perf metrics
651
+ // are attributed to the first emitted sectionFinished (others get
652
+ // zeros) so totals don't double-count.
653
+ let attributed = false
654
+ for (const { key, count } of finished) {
655
+ for (let i = 0; i < count; i++) {
656
+ emitSectionFinished({
657
+ type: 'sectionFinished',
658
+ key,
659
+ workerIndex,
660
+ processTime: !attributed ? processTime : 0,
661
+ pre: !attributed ? prePhase : 0,
662
+ wasm: !attributed ? wasmPhase : 0,
663
+ post: !attributed ? postPhase : 0,
664
+ preTargetConvert: !attributed ? preTargetConvert : 0,
665
+ preNeighborConvert: !attributed ? preNeighborConvert : 0,
666
+ preNeighborCount: !attributed ? preNeighborCount : 0,
667
+ preTypedArrayBuild: !attributed ? preTypedArrayBuild : 0,
668
+ preOther: !attributed ? preOther : 0,
669
+ preCacheHits: !attributed ? preCacheHits : 0,
670
+ preCacheMisses: !attributed ? preCacheMisses : 0,
671
+ })
672
+ attributed = true
673
+ }
674
+ }
675
+ }
676
+ }
677
+
678
+ setInterval(async () => {
679
+ if (!allDataReady) return
680
+
681
+ // Ensure WASM is initialized
682
+ if (!wasmInitialized) {
683
+ await initWasm()
684
+ if (!wasmInitialized) return // Still not initialized, skip this cycle
685
+ }
686
+
687
+ if (dirtySections.size === 0) return
688
+
689
+ try {
690
+ processColumnTick()
691
+ } catch (err) {
692
+ console.error('[WASM Mesher] processColumnTick failed:', err)
693
+ // Swallow to avoid breaking the setInterval; individual columns
694
+ // already have their own try/catch.
403
695
  }
404
696
  }, 50)