minecraft-renderer 0.1.106 → 0.1.108

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 (98) hide show
  1. package/dist/lightOwnerWorker.js +9 -0
  2. package/dist/lightOwnerWorker.js.map +7 -0
  3. package/dist/mesher.js +1 -1
  4. package/dist/mesher.js.map +2 -2
  5. package/dist/mesherWasm.js +222 -222
  6. package/dist/minecraft-renderer.js +105 -76
  7. package/dist/minecraft-renderer.js.meta.json +1 -1
  8. package/dist/threeWorker.js +537 -508
  9. package/package.json +1 -1
  10. package/src/bundler/bundlePrepare.test.ts +25 -0
  11. package/src/bundler/bundlePrepare.ts +1 -1
  12. package/src/graphicsBackend/appViewer.ts +15 -0
  13. package/src/graphicsBackend/config.ts +8 -0
  14. package/src/graphicsBackend/rendererDefaultOptions.test.ts +51 -0
  15. package/src/graphicsBackend/rendererDefaultOptions.ts +18 -1
  16. package/src/graphicsBackend/rendererOptionsSync.ts +4 -3
  17. package/src/lib/blockEntityLightRegistry.ts +4 -0
  18. package/src/lib/skyLight.ts +17 -0
  19. package/src/lib/worldrendererCommon.lightOwner.test.ts +916 -0
  20. package/src/lib/worldrendererCommon.removeColumn.test.ts +16 -0
  21. package/src/lib/worldrendererCommon.ts +340 -67
  22. package/src/lighting/cache/rendererLightCache.test.ts +158 -0
  23. package/src/lighting/cache/rendererLightCache.ts +248 -0
  24. package/src/lighting/coordinator/clientLightCoordinator.test.ts +102 -0
  25. package/src/lighting/coordinator/clientLightCoordinator.ts +574 -0
  26. package/src/lighting/debug/clientLightProbe.test.ts +24 -0
  27. package/src/lighting/debug/clientLightProbe.ts +20 -0
  28. package/src/lighting/debug/clientLightTrace.test.ts +271 -0
  29. package/src/lighting/debug/clientLightTrace.ts +335 -0
  30. package/src/lighting/entity/entityLightController.test.ts +216 -0
  31. package/src/lighting/entity/entityLightController.ts +322 -0
  32. package/src/lighting/owner/clientLightOwner.test.ts +467 -0
  33. package/src/lighting/owner/clientLightOwner.ts +523 -0
  34. package/src/lighting/owner/lightOwnerHost.test.ts +78 -0
  35. package/src/lighting/owner/lightOwnerHost.ts +202 -0
  36. package/src/lighting/owner/lightOwnerWorker.ts +154 -0
  37. package/src/lighting/owner/updateLight1171OwnerPath.test.ts +68 -0
  38. package/src/lighting/owner/updateLightToOwnerEvents.ts +49 -0
  39. package/src/lighting/tables/lightTables1171.generated.json +10 -0
  40. package/src/lighting/tables/lightTables1171.test.ts +168 -0
  41. package/src/lighting/tables/lightTables1171.ts +59 -0
  42. package/src/lighting/versioning/clientLightVersions.test.ts +241 -0
  43. package/src/lighting/versioning/clientLightVersions.ts +203 -0
  44. package/src/lighting/versioning/pendingSectionFlush.test.ts +214 -0
  45. package/src/lighting/versioning/pendingSectionFlush.ts +123 -0
  46. package/src/mesher-shared/lightNibblePack.ts +26 -0
  47. package/src/mesher-shared/mcDataWorkerShim.cjs +9 -7
  48. package/src/mesher-shared/modelsGeometryCommon.test.ts +6 -1
  49. package/src/mesher-shared/shared.ts +48 -1
  50. package/src/three/chunkMeshManager.ts +26 -0
  51. package/src/three/entities.ts +97 -8
  52. package/src/three/entity/EntityMesh.ts +1 -0
  53. package/src/three/entity/legWalkAnimation.test.ts +154 -0
  54. package/src/three/entity/legWalkAnimation.ts +193 -0
  55. package/src/three/globalBlockBuffer.ts +94 -2
  56. package/src/three/globalLegacyBuffer.ts +94 -2
  57. package/src/three/graphicsBackendBase.ts +2 -9
  58. package/src/three/modules/clouds.ts +191 -0
  59. package/src/three/modules/index.ts +4 -0
  60. package/src/three/modules/sunMoon.ts +151 -0
  61. package/src/three/skyboxRenderer.ts +15 -8
  62. package/src/three/tests/clouds.test.ts +59 -0
  63. package/src/three/tests/globalBlockBuffer.test.ts +98 -0
  64. package/src/three/tests/globalLegacyBuffer.test.ts +108 -0
  65. package/src/three/tests/waterFog.test.ts +25 -0
  66. package/src/three/waterFog.ts +28 -0
  67. package/src/three/worldRendererThree.ts +84 -39
  68. package/src/wasm-mesher/bridge/render-from-wasm.ts +46 -1
  69. package/src/wasm-mesher/runtime-build/wasm_mesher.d.ts +31 -5
  70. package/src/wasm-mesher/runtime-build/wasm_mesher.js +180 -5
  71. package/src/wasm-mesher/runtime-build/wasm_mesher_bg.wasm +0 -0
  72. package/src/wasm-mesher/runtime-build/wasm_mesher_bg.wasm.d.ts +11 -0
  73. package/src/wasm-mesher/tests/mesherLightDependencies.test.ts +83 -0
  74. package/src/wasm-mesher/tests/mesherWasmColumnTickSelect.test.ts +39 -0
  75. package/src/wasm-mesher/tests/mesherWasmConversionCache.test.ts +30 -1
  76. package/src/wasm-mesher/tests/mesherWasmTickParseCache.test.ts +73 -0
  77. package/src/wasm-mesher/tests/mesherWasmTickSchedule.test.ts +51 -0
  78. package/src/wasm-mesher/tests/mesherWasmTopologyPostCache.test.ts +78 -0
  79. package/src/wasm-mesher/worker/clientLightDarkPublication.probe.test.ts +32 -0
  80. package/src/wasm-mesher/worker/mesherLightDependencies.ts +43 -0
  81. package/src/wasm-mesher/worker/mesherWasm.ownerLightSync.test.ts +198 -0
  82. package/src/wasm-mesher/worker/mesherWasm.ts +506 -96
  83. package/src/wasm-mesher/worker/mesherWasmChunkBuffer.ts +1 -0
  84. package/src/wasm-mesher/worker/mesherWasmColumnTickSelect.ts +47 -0
  85. package/src/wasm-mesher/worker/mesherWasmConversionCache.ts +16 -5
  86. package/src/wasm-mesher/worker/mesherWasmLightDirty.test.ts +17 -1
  87. package/src/wasm-mesher/worker/mesherWasmLightDirty.ts +10 -0
  88. package/src/wasm-mesher/worker/mesherWasmLightMerge.test.ts +208 -0
  89. package/src/wasm-mesher/worker/mesherWasmLightMerge.ts +228 -0
  90. package/src/wasm-mesher/worker/mesherWasmOwnerLight.test.ts +161 -0
  91. package/src/wasm-mesher/worker/mesherWasmOwnerLight.ts +112 -0
  92. package/src/wasm-mesher/worker/mesherWasmTickParseCache.ts +45 -0
  93. package/src/wasm-mesher/worker/mesherWasmTickSchedule.ts +44 -0
  94. package/src/wasm-mesher/worker/mesherWasmTopologyPostCache.ts +63 -0
  95. package/src/worldView/types.ts +3 -0
  96. package/src/worldView/worldView.geometryReload.test.ts +68 -0
  97. package/src/worldView/worldView.spiral.test.ts +1 -1
  98. package/src/worldView/worldView.ts +49 -1
@@ -1,5 +1,6 @@
1
1
  //@ts-nocheck
2
2
  import { Vec3 } from 'vec3'
3
+ import { setClientLightProbePositions } from '../../lighting/debug/clientLightProbe'
3
4
  import { convertChunkToWasm, getBlockMeta, getGlobalPaletteBits, type ChunkConversionResult } from '../bridge/convertChunk'
4
5
  import { extractColumnHeightmap, splitColumnWasmOutputToSections } from '../bridge/render-from-wasm'
5
6
  import { createGeometryIndexArray } from '../bridge/geometryIndices'
@@ -12,8 +13,30 @@ import { worldColumnKey, World } from '../../mesher-shared/world'
12
13
  import { handleGetHeightmap, EMPTY_COLUMN_HEIGHTMAP_SENTINEL } from '../../mesher-shared/computeHeightmap'
13
14
  import { collectBlockEntityMetadata, type SignMeta, type HeadMeta, type BannerMeta } from '../../mesher-shared/blockEntityMetadata'
14
15
  import { SectionRequestTracker } from './mesherWasmRequestTracker'
15
- import { sectionYsForLightColumnDirty } from './mesherWasmLightDirty'
16
- import { CONVERSION_CACHE_LIMIT, clearConversionCache, getOrConvertColumn, invalidateConversion, setConversionCacheLimit } from './mesherWasmConversionCache'
16
+ import { dropRawMapChunkOnLightOnlyReload, sectionYsForLightColumnDirty } from './mesherWasmLightDirty'
17
+ import { applyOwnerPublicationToColumnCaches, applyRawLightPacketToCaches, ownerDeltaSectionWorldYs, revertDisplayToIncoming } from './mesherWasmOwnerLight'
18
+ import { INITIAL_TOPOLOGY_REVISION, snapshotMeshVersions, validateOwnerPublicationMessage } from '../../lighting/versioning/clientLightVersions'
19
+ import { provenMeshLightVersion } from './mesherLightDependencies'
20
+ import { BULK_MESH_INTERVAL_MS, createMeshTickScheduler } from './mesherWasmTickSchedule'
21
+ import {
22
+ displayLightColumn,
23
+ overlayDisplayLight,
24
+ isLightSectionPresent,
25
+ parsedUpdateLightFromWasm,
26
+ worldSectionMaskBit,
27
+ type UpdateLightColumnCache
28
+ } from './mesherWasmLightMerge'
29
+ import {
30
+ CONVERSION_CACHE_LIMIT,
31
+ clearConversionCache,
32
+ getOrConvertColumn,
33
+ invalidateConversion,
34
+ patchConversionBlock,
35
+ setConversionCacheLimit
36
+ } from './mesherWasmConversionCache'
37
+ import { beginTickWasmParseCache, endTickWasmParseCache, getOrConvertTickWasmParse } from './mesherWasmTickParseCache'
38
+ import { clearTopologyPostCache, clearTopologyPostCacheColumn, getOrComputeTopologyPost } from './mesherWasmTopologyPostCache'
39
+ import { selectColumnGroupsForTick } from './mesherWasmColumnTickSelect'
17
40
  import { PendingChunkBuffer } from './mesherWasmChunkBuffer'
18
41
  import {
19
42
  PendingNeighborHealTracker,
@@ -24,6 +47,14 @@ import {
24
47
  countWorldColumns3x3,
25
48
  type PacketCaches
26
49
  } from './mesherWasmNeighborhood'
50
+ import {
51
+ CLIENT_LIGHT_TRACE_MESSAGE,
52
+ armClientLightTrace,
53
+ enableClientLightTrace,
54
+ isClientLightTraceArmed,
55
+ isClientLightTraceEnabled,
56
+ postClientLightTrace
57
+ } from '../../lighting/debug/clientLightTrace'
27
58
 
28
59
  let wasm: typeof import('../runtime-build/wasm_mesher.js') | null = null
29
60
  let wasmInitialized = false
@@ -51,11 +82,16 @@ function processUpdateLightV17(rawPacket: Uint8Array, numSections: number): void
51
82
  const parsed: any = (wasm as any).parseUpdateLightV17(rawPacket, numSections)
52
83
  const x = (parsed.x as number) * 16
53
84
  const z = (parsed.z as number) * 16
54
- const skyLight = parsed.skyLight as Uint8Array
55
- updateLightV17Cache.set(rawCacheKey(x, z), {
56
- skyLight,
57
- blockLight: parsed.blockLight as Uint8Array
85
+ const key = rawCacheKey(x, z)
86
+ const result = applyRawLightPacketToCaches({
87
+ ownerOwnsColumn: ownerOwnedLightColumns.has(key),
88
+ incoming: incomingUpdateLightV17Cache.get(key),
89
+ display: updateLightV17Cache.get(key),
90
+ parsed: parsedUpdateLightFromWasm(parsed, numSections)
58
91
  })
92
+ incomingUpdateLightV17Cache.set(key, result.incoming)
93
+ if (!result.dirtyDisplay || !result.display) return
94
+ updateLightV17Cache.set(key, result.display)
59
95
  invalidateConversion(x, z)
60
96
  dirtyColumnSectionsForLightUpdate(x, z)
61
97
  const hadColumn = !!world?.getColumn(x, z)
@@ -80,10 +116,16 @@ function processUpdateLightV16(rawPacket: Uint8Array): void {
80
116
  const parsed: any = (wasm as any).parseUpdateLightV17(rawPacket, 16)
81
117
  const x = (parsed.x as number) * 16
82
118
  const z = (parsed.z as number) * 16
83
- updateLightV16Cache.set(rawCacheKey(x, z), {
84
- skyLight: parsed.skyLight as Uint8Array,
85
- blockLight: parsed.blockLight as Uint8Array
119
+ const key = rawCacheKey(x, z)
120
+ const result = applyRawLightPacketToCaches({
121
+ ownerOwnsColumn: ownerOwnedLightColumns.has(key),
122
+ incoming: incomingUpdateLightV16Cache.get(key),
123
+ display: updateLightV16Cache.get(key),
124
+ parsed: parsedUpdateLightFromWasm(parsed, 16)
86
125
  })
126
+ incomingUpdateLightV16Cache.set(key, result.incoming)
127
+ if (!result.dirtyDisplay || !result.display) return
128
+ updateLightV16Cache.set(key, result.display)
87
129
  invalidateConversion(x, z)
88
130
  dirtyColumnSectionsForLightUpdate(x, z)
89
131
  } catch (err) {
@@ -135,6 +177,23 @@ let config = defaultMesherConfig
135
177
  let version = '1.16.5'
136
178
  let world: World // chunkKey -> chunk data
137
179
  let dirtySections = new Map<string, number>()
180
+ const urgentSections = new Set<string>()
181
+ const dirtyLightMeta = new Map<string, { lightPublicationVersion?: number; worldGeneration?: number }>()
182
+ const dirtyTraceMeta = new Map<string, { clientLightRequestId?: number; clientLightEditSeq?: number; clientLightSessionEpoch?: number }>()
183
+ const dirtyVersionMeta = new Map<
184
+ string,
185
+ {
186
+ sessionEpoch?: number
187
+ columnIncarnation?: number
188
+ requestId?: number
189
+ topologyRevision?: number
190
+ neighborTopologyRevisions?: Array<{ key: string; topologyRevision: number }>
191
+ }
192
+ >()
193
+ const appliedOwnerLightVersion = new Map<string, number>()
194
+ const columnIncarnation = new Map<string, number>()
195
+ let ownerSessionEpoch = 0
196
+ let rejectOwnerPublications = false
138
197
  // Kept in sync with `dirtySections` so column mode can filter outgoing
139
198
  // geometry/sectionFinished events to only the section keys requested by the
140
199
  // main thread, even though a full-column WASM call may generate more data.
@@ -162,8 +221,19 @@ const postMessage = (data: any, transferList: any[] = []) => {
162
221
  })
163
222
  }
164
223
 
224
+ const postTrace = (message: { type: typeof CLIENT_LIGHT_TRACE_MESSAGE; events?: unknown; event?: unknown }) => {
225
+ global.postMessage(message)
226
+ }
227
+
165
228
  function drainQueue(from: number, to: number) {
166
229
  const messages = queuedMessages.slice(from, to)
230
+ const remaining = queuedMessages.length - to
231
+ if (isClientLightTraceEnabled() && isClientLightTraceArmed()) {
232
+ postClientLightTrace(postTrace, {
233
+ phase: 'mesherFlush',
234
+ queueDepth: remaining
235
+ })
236
+ }
167
237
  global.postMessage(
168
238
  messages.map(m => m.data),
169
239
  messages.flatMap(m => m.transferList) as unknown as string
@@ -260,25 +330,71 @@ const parsedV17Cache = new Map<string, ParsedV17Entry>()
260
330
  // WASM (`parseUpdateLightV17`) and cache per-block arrays keyed by the
261
331
  // chunk origin — the next mesh tick of that column merges them in instead
262
332
  // of the sky=15/block=0 fallback. May arrive before or after `map_chunk`.
263
- interface UpdateLightV17Entry {
264
- skyLight: Uint8Array
265
- blockLight: Uint8Array
333
+ const updateLightV17Cache = new Map<string, UpdateLightColumnCache>()
334
+ const incomingUpdateLightV17Cache = new Map<string, UpdateLightColumnCache>()
335
+ const ownerOwnedLightColumns = new Set<string>()
336
+
337
+ function takeGeometryVersionMeta(key: string, sx: number, sz: number, usedColumns: ReadonlySet<string>) {
338
+ const lightMeta = dirtyLightMeta.get(key)
339
+ dirtyLightMeta.delete(key)
340
+ const traceMeta = dirtyTraceMeta.get(key)
341
+ dirtyTraceMeta.delete(key)
342
+ const job = dirtyVersionMeta.get(key)
343
+ dirtyVersionMeta.delete(key)
344
+ const columnKey = rawCacheKey(sx, sz)
345
+ const owned = ownerOwnedLightColumns.has(columnKey)
346
+ const lightPublicationVersion = provenMeshLightVersion({
347
+ sx,
348
+ sz,
349
+ usedColumns,
350
+ appliedVersionByColumn: appliedOwnerLightVersion
351
+ })
352
+ if (!job && !owned && !lightMeta && lightPublicationVersion === 0) return { ...traceMeta }
353
+ const appliedLightMeta = { ...(lightMeta ?? {}) }
354
+ delete appliedLightMeta.lightPublicationVersion
355
+ return {
356
+ ...appliedLightMeta,
357
+ ...traceMeta,
358
+ ...snapshotMeshVersions({
359
+ sessionEpoch: job?.sessionEpoch ?? ownerSessionEpoch,
360
+ columnIncarnation: columnIncarnation.get(columnKey) ?? job?.columnIncarnation ?? 1,
361
+ requestId: job?.requestId ?? 0,
362
+ topologyRevision: job?.topologyRevision ?? INITIAL_TOPOLOGY_REVISION,
363
+ lightPublicationVersion,
364
+ worldGeneration: lightMeta?.worldGeneration ?? 0,
365
+ neighborTopologyRevisions: job?.neighborTopologyRevisions ?? [],
366
+ meshMode: owned ? 'owner' : 'legacyBootstrap'
367
+ })
368
+ }
369
+ }
370
+
371
+ function displayCachedLight(entry: UpdateLightColumnCache | undefined): { skyLight: Uint8Array; blockLight: Uint8Array } | undefined {
372
+ if (!entry) return undefined
373
+ return {
374
+ skyLight: displayLightColumn(entry, 'sky', config?.skyLight ?? 15),
375
+ blockLight: displayLightColumn(entry, 'block', 0)
376
+ }
266
377
  }
267
- const updateLightV17Cache = new Map<string, UpdateLightV17Entry>()
268
378
  /** Columns that received `update_light` before the worker column existed. */
269
379
  const pendingLightDirtyColumns = new Set<string>()
270
380
  const _syncLightPos = new Vec3(0, 0, 0)
271
381
 
272
- function resolveUpdateLightV17Entry(x: number, z: number, chunk: any | undefined, worldMinY: number, worldMaxY: number): UpdateLightV17Entry | undefined {
273
- const cached = updateLightV17Cache.get(rawCacheKey(x, z))
274
- if (cached?.blockLight?.length) return cached
275
- if (!chunk) return cached
382
+ function resolveUpdateLightV17Entry(
383
+ x: number,
384
+ z: number,
385
+ chunk: any | undefined,
386
+ worldMinY: number,
387
+ worldMaxY: number
388
+ ): { skyLight: Uint8Array; blockLight: Uint8Array } | undefined {
389
+ const displayed = displayCachedLight(updateLightV17Cache.get(rawCacheKey(x, z)))
390
+ if (displayed) return displayed
391
+ if (!chunk) return undefined
276
392
  const { result } = getOrConvertColumn(x, z, chunk, version, worldMinY, worldMaxY, () => convertChunkToWasm(chunk, version, x, z, worldMinY, worldMaxY), chunk)
277
393
  return { blockLight: result.blockLight, skyLight: result.skyLight }
278
394
  }
279
395
 
280
396
  /** Write cached 1.17 `update_light` arrays into the worker prismarine column. */
281
- function syncV17LightToColumn(x: number, z: number): boolean {
397
+ function syncV17LightToColumn(x: number, z: number, onlySectionWorldYs?: number[]): boolean {
282
398
  if (!world) return false
283
399
  const col = world.getColumn(x, z)
284
400
  const entry = updateLightV17Cache.get(rawCacheKey(x, z))
@@ -287,16 +403,37 @@ function syncV17LightToColumn(x: number, z: number): boolean {
287
403
  const CHUNK_SIZE = 16
288
404
  const minY = config?.worldMinY ?? 0
289
405
  const maxY = config?.worldMaxY ?? 256
290
- const { blockLight, skyLight } = entry
406
+ const { blockLight, skyLight, blockPresent, skyPresent } = entry
407
+ const onlyYs = onlySectionWorldYs && onlySectionWorldYs.length ? new Set(onlySectionWorldYs) : null
291
408
 
292
409
  for (let y = minY; y < maxY; y++) {
410
+ const sectionOriginY = Math.floor((y - minY) / CHUNK_SIZE) * CHUNK_SIZE + minY
411
+ if (onlyYs && !onlyYs.has(sectionOriginY)) continue
412
+ const sectionIndex = Math.floor((y - minY) / CHUNK_SIZE)
413
+ const skyAuthoritative = isLightSectionPresent(skyPresent, worldSectionMaskBit(sectionIndex))
414
+ const blockAuthoritative = isLightSectionPresent(blockPresent, worldSectionMaskBit(sectionIndex))
415
+ if (!skyAuthoritative && !blockAuthoritative) continue
416
+ // Prismarine skips zero writes into null sections. Allocate through its
417
+ // setters so masks remain correct; the normal loop restores the true value.
418
+ const lightSectionIndex = sectionIndex + 1
419
+ const lightSections = col as typeof col & {
420
+ blockLightSections?: Array<unknown | null>
421
+ skyLightSections?: Array<unknown | null>
422
+ }
423
+ _syncLightPos.set(0, y, 0)
424
+ if (blockAuthoritative && lightSections.blockLightSections?.[lightSectionIndex] === null) {
425
+ col.setBlockLight(_syncLightPos, 1)
426
+ }
427
+ if (skyAuthoritative && lightSections.skyLightSections?.[lightSectionIndex] === null) {
428
+ col.setSkyLight(_syncLightPos, 1)
429
+ }
293
430
  for (let lz = 0; lz < CHUNK_SIZE; lz++) {
294
431
  for (let lx = 0; lx < CHUNK_SIZE; lx++) {
295
432
  const idx = lx + lz * CHUNK_SIZE + (y - minY) * CHUNK_SIZE * CHUNK_SIZE
296
- if (idx >= blockLight.length) continue
433
+ if (idx >= blockLight.length && idx >= skyLight.length) continue
297
434
  _syncLightPos.set(lx, y, lz)
298
- col.setBlockLight(_syncLightPos, blockLight[idx]!)
299
- col.setSkyLight(_syncLightPos, skyLight[idx]!)
435
+ if (blockAuthoritative && idx < blockLight.length) col.setBlockLight(_syncLightPos, blockLight[idx]!)
436
+ if (skyAuthoritative && idx < skyLight.length) col.setSkyLight(_syncLightPos, skyLight[idx]!)
300
437
  }
301
438
  }
302
439
  }
@@ -384,6 +521,7 @@ function processChunkMessage(data: { x: number; z: number; chunk: any; customBlo
384
521
  function drainPendingChunks() {
385
522
  if (!world) return
386
523
  pendingChunks.drain(msg => {
524
+ dropRawMapChunkOnLightOnlyReload(msg.isLightUpdate, rawMapChunkCache, rawCacheKey(msg.x, msg.z))
387
525
  processChunkMessage(msg)
388
526
  onColumnDataArrived(msg.x, msg.z)
389
527
  })
@@ -393,7 +531,8 @@ function drainPendingChunks() {
393
531
  // `processUpdateLightV16` (which calls the shared `parseUpdateLightV17`
394
532
  // WASM export). Separate map for the same isolation reasons as
395
533
  // `parsedV16Cache` above.
396
- const updateLightV16Cache = new Map<string, UpdateLightV17Entry>()
534
+ const updateLightV16Cache = new Map<string, UpdateLightColumnCache>()
535
+ const incomingUpdateLightV16Cache = new Map<string, UpdateLightColumnCache>()
397
536
 
398
537
  // Mirrors `convertChunkToWasm`'s output (same layout: x + z*16 + y*256,
399
538
  // y outer) so it can be dropped straight into `generate_geometry`.
@@ -434,7 +573,7 @@ const convertRawMapChunkToWasm = (raw: RawMapChunkEntry, version: string): Chunk
434
573
  // (expanded from the 4×4×4 cell layout). Light comes from the paired
435
574
  // `update_light` cache when available; otherwise we fall back to full
436
575
  // daylight (sky=15) and no block light so geometry stays visible.
437
- const convertParsedV17ToWasm = (entry: ParsedV17Entry, lightEntry: UpdateLightV17Entry | undefined, version: string): ChunkConversionResult | null => {
576
+ const convertParsedV17ToWasm = (entry: ParsedV17Entry, lightEntry: UpdateLightColumnCache | undefined, version: string): ChunkConversionResult | null => {
438
577
  if (!wasm || !(wasm as any).parseChunkSectionsV16V17) return null
439
578
  // Empty `Int32Array` signals "no biomes captured" — WASM falls back to
440
579
  // `default_biome` for every block. Plains (id 1) matches the JS path.
@@ -451,9 +590,9 @@ const convertParsedV17ToWasm = (entry: ParsedV17Entry, lightEntry: UpdateLightV1
451
590
  const totalBlocks = blockStates.length
452
591
  let blockLight: Uint8Array
453
592
  let skyLight: Uint8Array
454
- if (lightEntry && lightEntry.skyLight.length === totalBlocks) {
455
- skyLight = lightEntry.skyLight
456
- blockLight = lightEntry.blockLight
593
+ if (lightEntry) {
594
+ skyLight = displayLightColumn(lightEntry, 'sky', config?.skyLight ?? 15)
595
+ blockLight = displayLightColumn(lightEntry, 'block', 0)
457
596
  } else {
458
597
  blockLight = new Uint8Array(totalBlocks)
459
598
  skyLight = new Uint8Array(totalBlocks)
@@ -485,7 +624,7 @@ const convertParsedV17ToWasm = (entry: ParsedV17Entry, lightEntry: UpdateLightV1
485
624
  // defaults — anything else means a non-vanilla server we don't support
486
625
  // on the fast path, in which case we return null and fall back to the
487
626
  // JS column-walk via `convertChunkToWasm`.
488
- const convertParsedV16ToWasm = (entry: ParsedV16Entry, lightEntry: UpdateLightV17Entry | undefined, version: string): ChunkConversionResult | null => {
627
+ const convertParsedV16ToWasm = (entry: ParsedV16Entry, lightEntry: UpdateLightColumnCache | undefined, version: string): ChunkConversionResult | null => {
489
628
  if (!wasm || !(wasm as any).parseChunkSectionsV16V17) return null
490
629
  const NUM_SECTIONS = 16
491
630
  const MAX_BITS_PER_BLOCK = 15
@@ -506,9 +645,9 @@ const convertParsedV16ToWasm = (entry: ParsedV16Entry, lightEntry: UpdateLightV1
506
645
  const totalBlocks = blockStates.length
507
646
  let blockLight: Uint8Array
508
647
  let skyLight: Uint8Array
509
- if (lightEntry && lightEntry.skyLight.length === totalBlocks) {
510
- skyLight = lightEntry.skyLight
511
- blockLight = lightEntry.blockLight
648
+ if (lightEntry) {
649
+ skyLight = displayLightColumn(lightEntry, 'sky', config?.skyLight ?? 15)
650
+ blockLight = displayLightColumn(lightEntry, 'block', 0)
512
651
  } else {
513
652
  blockLight = new Uint8Array(totalBlocks)
514
653
  skyLight = new Uint8Array(totalBlocks)
@@ -758,7 +897,7 @@ const meshMultiColumnsFromParsedV16V17 = (
758
897
  const bm = entry.bitMap >>> 0
759
898
  bitMapLoHi[i * 2] = bm
760
899
  bitMapLoHi[i * 2 + 1] = 0
761
- const light = updateLightV16Cache.get(key)
900
+ const light = displayCachedLight(updateLightV16Cache.get(key))
762
901
  skyLightList.push(light?.skyLight ?? new Uint8Array(0))
763
902
  blockLightList.push(light?.blockLight ?? new Uint8Array(0))
764
903
  }
@@ -820,6 +959,10 @@ const handleMessage = async (data: any) => {
820
959
  }
821
960
 
822
961
  switch (data.type) {
962
+ case 'clientLightProbe': {
963
+ setClientLightProbePositions(data.points)
964
+ break
965
+ }
823
966
  case 'mesherData': {
824
967
  setMesherData(data.blockstatesModels, data.blocksAtlas, data.config.outputFormat === 'webgpu')
825
968
  ;(globalThis as any).__wasmBlockModelCache = new Map()
@@ -835,18 +978,78 @@ const handleMessage = async (data: any) => {
835
978
  case 'dirty': {
836
979
  const loc = new Vec3(data.x, data.y, data.z)
837
980
  setSectionDirty(loc, data.value)
981
+ {
982
+ const sectionHeight = getSectionHeight()
983
+ const key = sectionKey(Math.floor(loc.x / 16) * 16, Math.floor(loc.y / sectionHeight) * sectionHeight, Math.floor(loc.z / 16) * 16)
984
+ if (data.urgent) {
985
+ urgentSections.add(key)
986
+ meshTickScheduler.kick()
987
+ } else if (!data.value) urgentSections.delete(key)
988
+ }
989
+ if (typeof data.lightPublicationVersion === 'number') {
990
+ const sectionHeight = getSectionHeight()
991
+ const key = sectionKey(Math.floor(loc.x / 16) * 16, Math.floor(loc.y / sectionHeight) * sectionHeight, Math.floor(loc.z / 16) * 16)
992
+ dirtyLightMeta.set(key, {
993
+ lightPublicationVersion: data.lightPublicationVersion,
994
+ worldGeneration: data.worldGeneration
995
+ })
996
+ }
997
+ {
998
+ const sectionHeight = getSectionHeight()
999
+ const key = sectionKey(Math.floor(loc.x / 16) * 16, Math.floor(loc.y / sectionHeight) * sectionHeight, Math.floor(loc.z / 16) * 16)
1000
+ if (
1001
+ typeof data.sessionEpoch === 'number' ||
1002
+ typeof data.columnIncarnation === 'number' ||
1003
+ typeof data.requestId === 'number' ||
1004
+ typeof data.topologyRevision === 'number'
1005
+ ) {
1006
+ dirtyVersionMeta.set(key, {
1007
+ sessionEpoch: data.sessionEpoch,
1008
+ columnIncarnation: data.columnIncarnation,
1009
+ requestId: data.requestId,
1010
+ topologyRevision: data.topologyRevision,
1011
+ neighborTopologyRevisions: data.neighborTopologyRevisions
1012
+ })
1013
+ }
1014
+ if (typeof data.clientLightRequestId === 'number' || typeof data.clientLightEditSeq === 'number') {
1015
+ dirtyTraceMeta.set(key, {
1016
+ clientLightRequestId: data.clientLightRequestId,
1017
+ clientLightEditSeq: data.clientLightEditSeq,
1018
+ clientLightSessionEpoch: data.clientLightSessionEpoch
1019
+ })
1020
+ if (isClientLightTraceEnabled() && isClientLightTraceArmed()) {
1021
+ postClientLightTrace(postTrace, {
1022
+ phase: 'mesherEnqueue',
1023
+ sectionKey: key,
1024
+ requestId: data.clientLightRequestId,
1025
+ editSeq: data.clientLightEditSeq,
1026
+ sessionEpoch: data.clientLightSessionEpoch,
1027
+ lightVersion: data.lightPublicationVersion,
1028
+ worldGeneration: data.worldGeneration,
1029
+ workerIndex
1030
+ })
1031
+ }
1032
+ }
1033
+ }
1034
+ break
1035
+ }
1036
+ case 'clientLightTraceConfig': {
1037
+ enableClientLightTrace(Boolean(data.enabled))
1038
+ armClientLightTrace(Boolean(data.armed))
838
1039
  break
839
1040
  }
840
1041
  case 'chunk': {
841
1042
  // Invalidate BEFORE replacing the column reference so a stale entry
842
1043
  // can never outlive the old chunk object.
843
1044
  invalidateConversion(data.x, data.z)
1045
+ dropRawMapChunkOnLightOnlyReload(data.isLightUpdate, rawMapChunkCache, rawCacheKey(data.x, data.z))
844
1046
  if (!world) {
845
1047
  pendingChunks.enqueue({
846
1048
  x: data.x,
847
1049
  z: data.z,
848
1050
  chunk: data.chunk,
849
- customBlockModels: data.customBlockModels
1051
+ customBlockModels: data.customBlockModels,
1052
+ isLightUpdate: data.isLightUpdate
850
1053
  })
851
1054
  break
852
1055
  }
@@ -856,21 +1059,32 @@ const handleMessage = async (data: any) => {
856
1059
  }
857
1060
  case 'unloadChunk': {
858
1061
  invalidateConversion(data.x, data.z)
859
- rawMapChunkCache.delete(rawCacheKey(data.x, data.z))
860
- parsedV17Cache.delete(rawCacheKey(data.x, data.z))
861
- updateLightV17Cache.delete(rawCacheKey(data.x, data.z))
862
- parsedV16Cache.delete(rawCacheKey(data.x, data.z))
863
- updateLightV16Cache.delete(rawCacheKey(data.x, data.z))
864
- pendingLightDirtyColumns.delete(rawCacheKey(data.x, data.z))
1062
+ const unloadKey = rawCacheKey(data.x, data.z)
1063
+ rawMapChunkCache.delete(unloadKey)
1064
+ parsedV17Cache.delete(unloadKey)
1065
+ updateLightV17Cache.delete(unloadKey)
1066
+ incomingUpdateLightV17Cache.delete(unloadKey)
1067
+ parsedV16Cache.delete(unloadKey)
1068
+ updateLightV16Cache.delete(unloadKey)
1069
+ incomingUpdateLightV16Cache.delete(unloadKey)
1070
+ ownerOwnedLightColumns.delete(unloadKey)
1071
+ appliedOwnerLightVersion.delete(unloadKey)
1072
+ columnIncarnation.set(unloadKey, (columnIncarnation.get(unloadKey) ?? 1) + 1)
1073
+ pendingLightDirtyColumns.delete(unloadKey)
865
1074
  if (!world) break
866
1075
  world.removeColumn(data.x, data.z)
867
1076
  world.customBlockModels.delete(`${data.x},${data.z}`)
868
1077
  pendingNeighborHeal.clearColumn(data.x, data.z)
869
1078
  requestTracker.clearColumn(data.x, data.z)
1079
+ clearTopologyPostCacheColumn(data.x, data.z)
870
1080
  for (const key of [...dirtySections.keys()]) {
871
1081
  const [sx, , sz] = key.split(',').map(Number)
872
1082
  if (sx === data.x && sz === data.z) {
873
1083
  dirtySections.delete(key)
1084
+ dirtyLightMeta.delete(key)
1085
+ dirtyTraceMeta.delete(key)
1086
+ dirtyVersionMeta.delete(key)
1087
+ urgentSections.delete(key)
874
1088
  }
875
1089
  }
876
1090
  if (Object.keys(world.columns).length === 0) softCleanup()
@@ -884,9 +1098,10 @@ const handleMessage = async (data: any) => {
884
1098
 
885
1099
  const chunkX = Math.floor(loc.x / 16) * 16
886
1100
  const chunkZ = Math.floor(loc.z / 16) * 16
887
- // In-place mutation preserves chunk identity; explicit invalidation
888
- // is required so the next tick recomputes from current block state.
889
- invalidateConversion(chunkX, chunkZ)
1101
+ const liveColumn = world?.getColumn(chunkX, chunkZ)
1102
+ if (data.stateId == null || !patchConversionBlock(chunkX, chunkZ, liveColumn, loc.x, loc.y, loc.z, data.stateId)) {
1103
+ invalidateConversion(chunkX, chunkZ)
1104
+ }
890
1105
  // Stage 3: the cached raw map_chunk no longer matches the live
891
1106
  // column after a block update — drop it so the next mesh tick walks
892
1107
  // the (now-updated) prismarine column instead.
@@ -961,17 +1176,110 @@ const handleMessage = async (data: any) => {
961
1176
  processUpdateLightV16(data.rawPacket as Uint8Array)
962
1177
  break
963
1178
  }
1179
+ case 'setOwnerAcceptFence': {
1180
+ if (typeof data.sessionEpoch === 'number') ownerSessionEpoch = data.sessionEpoch
1181
+ rejectOwnerPublications = data.rejectOwnerPublications === true
1182
+ break
1183
+ }
1184
+ case 'revertOwnerLightToIncoming': {
1185
+ if (typeof data.sessionEpoch === 'number') ownerSessionEpoch = data.sessionEpoch
1186
+ rejectOwnerPublications = true
1187
+ for (const key of [...ownerOwnedLightColumns]) {
1188
+ const [cx, cz] = key.split(',').map(Number)
1189
+ const v16In = incomingUpdateLightV16Cache.get(key)
1190
+ const v17In = incomingUpdateLightV17Cache.get(key)
1191
+ const v16 = updateLightV16Cache.get(key)
1192
+ const v17 = updateLightV17Cache.get(key)
1193
+ if (v16 && !v17) {
1194
+ const reverted = revertDisplayToIncoming({ incoming: v16In, display: v16 })
1195
+ if (reverted.display) updateLightV16Cache.set(key, reverted.display)
1196
+ } else {
1197
+ const reverted = revertDisplayToIncoming({ incoming: v17In, display: v17 })
1198
+ if (reverted.display) {
1199
+ updateLightV17Cache.set(key, reverted.display)
1200
+ // The legacy mesh path reads the JS column, so it must leave owner light too.
1201
+ syncV17LightToColumn(cx, cz)
1202
+ }
1203
+ }
1204
+ ownerOwnedLightColumns.delete(key)
1205
+ appliedOwnerLightVersion.delete(key)
1206
+ }
1207
+ break
1208
+ }
1209
+ case 'applyOwnerLightPublication': {
1210
+ if (rejectOwnerPublications) break
1211
+ if (typeof data.sessionEpoch === 'number' && ownerSessionEpoch !== 0 && data.sessionEpoch !== ownerSessionEpoch) break
1212
+ const checked = validateOwnerPublicationMessage(data)
1213
+ if (!checked.ok) break
1214
+ const sections = (data.sections ?? []) as Array<{ sx: number; sy: number; sz: number; blockLight: Uint8Array; skyLight?: Uint8Array }>
1215
+ const worldMinY = config?.worldMinY ?? 0
1216
+ const worldMaxY = config?.worldMaxY ?? 256
1217
+ const numSections = Math.max(1, Math.floor((worldMaxY - worldMinY) / 16))
1218
+ const columns = new Set<string>()
1219
+ for (const section of sections) columns.add(`${section.sx},${section.sz}`)
1220
+ const publicationVersion = typeof data.publicationVersion === 'number' ? data.publicationVersion : 0
1221
+ for (const col of columns) {
1222
+ const [cx, cz] = col.split(',').map(Number)
1223
+ const key = rawCacheKey(cx, cz)
1224
+ const v16 = updateLightV16Cache.get(key)
1225
+ const v17 = updateLightV17Cache.get(key)
1226
+ if (v16 && !v17) {
1227
+ const applied = applyOwnerPublicationToColumnCaches({
1228
+ incoming: incomingUpdateLightV16Cache.get(key),
1229
+ display: v16,
1230
+ sections,
1231
+ worldMinY,
1232
+ columnWorldX: cx,
1233
+ columnWorldZ: cz,
1234
+ numSections: v16.numSections
1235
+ })
1236
+ if (applied.incoming) incomingUpdateLightV16Cache.set(key, applied.incoming)
1237
+ updateLightV16Cache.set(key, applied.display)
1238
+ } else {
1239
+ const applied = applyOwnerPublicationToColumnCaches({
1240
+ incoming: incomingUpdateLightV17Cache.get(key),
1241
+ display: v17,
1242
+ sections,
1243
+ worldMinY,
1244
+ columnWorldX: cx,
1245
+ columnWorldZ: cz,
1246
+ numSections: v17?.numSections ?? numSections
1247
+ })
1248
+ if (applied.incoming) incomingUpdateLightV17Cache.set(key, applied.incoming)
1249
+ updateLightV17Cache.set(key, applied.display)
1250
+ // Light packets stop reaching this worker once the owner is ready, and the
1251
+ // legacy mesh path reads the JS column rather than the display cache.
1252
+ syncV17LightToColumn(cx, cz, ownerDeltaSectionWorldYs(sections, cx, cz))
1253
+ }
1254
+ ownerOwnedLightColumns.add(key)
1255
+ appliedOwnerLightVersion.set(key, publicationVersion)
1256
+ if (typeof data.sessionEpoch === 'number') ownerSessionEpoch = data.sessionEpoch
1257
+ }
1258
+ break
1259
+ }
964
1260
  case 'reset': {
965
1261
  world = undefined as any
966
1262
  dirtySections.clear()
1263
+ dirtyLightMeta.clear()
1264
+ dirtyTraceMeta.clear()
1265
+ dirtyVersionMeta.clear()
1266
+ urgentSections.clear()
1267
+ appliedOwnerLightVersion.clear()
1268
+ columnIncarnation.clear()
1269
+ ownerSessionEpoch = 0
1270
+ rejectOwnerPublications = false
967
1271
  requestTracker.clear()
968
1272
  clearConversionCache()
1273
+ clearTopologyPostCache()
969
1274
  rawMapChunkCache.clear()
970
1275
  parsedV17Cache.clear()
971
1276
  updateLightV17Cache.clear()
1277
+ incomingUpdateLightV17Cache.clear()
1278
+ ownerOwnedLightColumns.clear()
972
1279
  pendingLightDirtyColumns.clear()
973
1280
  parsedV16Cache.clear()
974
1281
  updateLightV16Cache.clear()
1282
+ incomingUpdateLightV16Cache.clear()
975
1283
  pendingChunks.clear()
976
1284
  pendingNeighborHeal.clear()
977
1285
  blindMeshWarnCount = 0
@@ -1086,7 +1394,38 @@ function makeEmptyColumnGeometry(sx: number, sy: number, sz: number, sectionHeig
1086
1394
  // It groups dirty section keys by chunk column, runs one WASM call per column
1087
1395
  // over the full Y range, then splits the column output back into per-section
1088
1396
  // geometries. Only requested section keys are emitted back to the main thread.
1089
- function processColumnTick() {
1397
+ async function processColumnTick() {
1398
+ beginTickWasmParseCache()
1399
+ try {
1400
+ await processColumnTickInner()
1401
+ } finally {
1402
+ endTickWasmParseCache()
1403
+ }
1404
+ }
1405
+
1406
+ function dirtyHasUrgent() {
1407
+ for (const key of dirtySections.keys()) {
1408
+ if (urgentSections.has(key)) return true
1409
+ }
1410
+ return false
1411
+ }
1412
+
1413
+ function requeueColumnGroups(pending: Array<{ sections: Array<{ key: string; count: number }> }>, urgentKeys: Set<string>) {
1414
+ for (const group of pending) {
1415
+ for (const section of group.sections) {
1416
+ dirtySections.set(section.key, (dirtySections.get(section.key) ?? 0) + section.count)
1417
+ if (urgentKeys.has(section.key)) urgentSections.add(section.key)
1418
+ }
1419
+ }
1420
+ }
1421
+
1422
+ function yieldToIncomingMessages() {
1423
+ return new Promise<void>(resolve => {
1424
+ setTimeout(resolve, 0)
1425
+ })
1426
+ }
1427
+
1428
+ async function processColumnTickInner() {
1090
1429
  const worldMinY = config?.worldMinY ?? 0
1091
1430
  const worldMaxY = config?.worldMaxY ?? 256
1092
1431
  const columnHeight = worldMaxY - worldMinY
@@ -1096,20 +1435,25 @@ function processColumnTick() {
1096
1435
  // coords — the same units used by section keys). This guarantees a
1097
1436
  // single WASM call per column per tick even when multiple section keys
1098
1437
  // of the same column are dirty.
1099
- const groups = new Map<string, { x: number; z: number; sections: Array<{ key: string; x: number; y: number; z: number; count: number }> }>()
1100
- for (const [key, count] of dirtySections) {
1101
- const [sx, sy, sz] = key.split(',').map(v => parseInt(v, 10))
1102
- const colKey = `${sx},${sz}`
1103
- let g = groups.get(colKey)
1104
- if (!g) {
1105
- g = { x: sx, z: sz, sections: [] }
1106
- groups.set(colKey, g)
1438
+ const { selected: groups, remaining } = selectColumnGroupsForTick(dirtySections, urgentSections)
1439
+ dirtySections.clear()
1440
+ for (const [key, count] of remaining) dirtySections.set(key, count)
1441
+ const selectedUrgent = new Set<string>()
1442
+ for (const group of groups) {
1443
+ for (const section of group.sections) {
1444
+ if (urgentSections.delete(section.key)) selectedUrgent.add(section.key)
1107
1445
  }
1108
- g.sections.push({ key, x: sx, y: sy, z: sz, count })
1109
1446
  }
1110
- dirtySections.clear()
1447
+ if (isClientLightTraceEnabled() && isClientLightTraceArmed()) {
1448
+ postClientLightTrace(postTrace, {
1449
+ phase: 'mesherStart',
1450
+ queueDepth: groups.length,
1451
+ workerIndex
1452
+ })
1453
+ }
1111
1454
 
1112
- for (const group of groups.values()) {
1455
+ for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) {
1456
+ const group = groups[groupIndex]!
1113
1457
  const { x, z, sections } = group
1114
1458
  const targetChunk = world.getColumn(x, z)
1115
1459
 
@@ -1125,6 +1469,8 @@ function processColumnTick() {
1125
1469
  let preOther = 0
1126
1470
  let preCacheHits = 0
1127
1471
  let preCacheMisses = 0
1472
+ let preWasmParseHits = 0
1473
+ let preWasmParseMisses = 0
1128
1474
  let hadError = false
1129
1475
  let columnMeshPath = 'none'
1130
1476
  let chunkCount = 0
@@ -1137,6 +1483,7 @@ function processColumnTick() {
1137
1483
  // attributed to the column).
1138
1484
  let columnStart = 0
1139
1485
  let postStart = 0
1486
+ let usedLightColumns = new Set<string>()
1140
1487
 
1141
1488
  if (targetChunk && wasm) {
1142
1489
  columnStart = performance.now()
@@ -1144,6 +1491,7 @@ function processColumnTick() {
1144
1491
  const t0 = start
1145
1492
  try {
1146
1493
  const chunksToUse = collectChunksForColumn(x, z)
1494
+ usedLightColumns = new Set(chunksToUse.map(entry => rawCacheKey(entry.x, entry.z)))
1147
1495
  chunkCount = chunksToUse.length
1148
1496
  const caches = packetCaches()
1149
1497
  worldColumns3x3 = countWorldColumns3x3(x, z, (nx, nz) => world.getColumn(nx, nz))
@@ -1199,7 +1547,7 @@ function processColumnTick() {
1199
1547
  )
1200
1548
  if (wasmResult) columnMeshPath = 'v17_fused'
1201
1549
  } else if (v16Entry) {
1202
- const v16Light = updateLightV16Cache.get(rawCacheKey(x, z))
1550
+ const v16Light = displayCachedLight(updateLightV16Cache.get(rawCacheKey(x, z)))
1203
1551
  const bitMapLoHi = new Uint32Array([v16Entry.bitMap >>> 0, 0])
1204
1552
  wasmResult = meshColumnFromParsedV16V17(
1205
1553
  v16Entry.chunkData,
@@ -1254,25 +1602,53 @@ function processColumnTick() {
1254
1602
  const cs = performance.now()
1255
1603
  const rawEntry = rawMapChunkCache.get(rawCacheKey(cx, cz))
1256
1604
  const v17Entry = parsedV17Cache.get(rawCacheKey(cx, cz))
1257
- const v17Light = resolveUpdateLightV17Entry(cx, cz, chunk, worldMinY, worldMaxY)
1605
+ const v17Light = updateLightV17Cache.get(rawCacheKey(cx, cz))
1258
1606
  const v16Entry = parsedV16Cache.get(rawCacheKey(cx, cz))
1259
1607
  const v16Light = updateLightV16Cache.get(rawCacheKey(cx, cz))
1260
1608
 
1261
1609
  let conv: ChunkConversionResult | null = null
1262
1610
  let hit = false
1263
1611
 
1264
- // WASM fast paths — parse is already fast (2.19× over JS), no
1265
- // cache needed. Bypass getOrConvertColumn so the conversion
1266
- // cache only holds JS-fallback results. When a WASM helper
1267
- // returns null (unsupported protocol, parser error, …) we MUST
1268
- // fall through to the JS column walk — otherwise the column
1269
- // would render as empty geometry.
1270
- if (rawEntry) {
1271
- conv = convertRawMapChunkToWasm(rawEntry, version)
1612
+ // WASM fast paths — parse is already fast (2.19× over JS). Cache
1613
+ // for the rest of this tick so 9 dirty columns share neighbour
1614
+ // parses. Bypass getOrConvertColumn so the conversion cache only
1615
+ // holds JS-fallback results. When a WASM helper returns null
1616
+ // (unsupported protocol, parser error, …) we MUST fall through
1617
+ // to the JS column walk — otherwise the column would render as
1618
+ // empty geometry.
1619
+ const tickKey = rawCacheKey(cx, cz)
1620
+ if (chunk) {
1621
+ const cached = getOrConvertColumn(
1622
+ cx,
1623
+ cz,
1624
+ chunk,
1625
+ version,
1626
+ worldMinY,
1627
+ worldMaxY,
1628
+ () =>
1629
+ (rawEntry && convertRawMapChunkToWasm(rawEntry, version)) ||
1630
+ (v17Entry && convertParsedV17ToWasm(v17Entry, undefined, version)) ||
1631
+ (v16Entry && convertParsedV16ToWasm(v16Entry, undefined, version)) ||
1632
+ convertChunkToWasm(chunk, version, cx, cz, worldMinY, worldMaxY),
1633
+ chunk
1634
+ )
1635
+ conv = cached.result
1636
+ hit = cached.hit
1637
+ } else if (rawEntry) {
1638
+ const cached = getOrConvertTickWasmParse(tickKey, () => convertRawMapChunkToWasm(rawEntry, version))
1639
+ conv = cached.result
1640
+ if (cached.hit) preWasmParseHits++
1641
+ else preWasmParseMisses++
1272
1642
  } else if (v17Entry) {
1273
- conv = convertParsedV17ToWasm(v17Entry, v17Light, version)
1643
+ const cached = getOrConvertTickWasmParse(tickKey, () => convertParsedV17ToWasm(v17Entry, v17Light, version))
1644
+ conv = cached.result
1645
+ if (cached.hit) preWasmParseHits++
1646
+ else preWasmParseMisses++
1274
1647
  } else if (v16Entry) {
1275
- conv = convertParsedV16ToWasm(v16Entry, v16Light, version)
1648
+ const cached = getOrConvertTickWasmParse(tickKey, () => convertParsedV16ToWasm(v16Entry, v16Light, version))
1649
+ conv = cached.result
1650
+ if (cached.hit) preWasmParseHits++
1651
+ else preWasmParseMisses++
1276
1652
  }
1277
1653
 
1278
1654
  if (!conv) {
@@ -1304,9 +1680,15 @@ function processColumnTick() {
1304
1680
  preNeighborConvert += ce - cs
1305
1681
  preNeighborCount++
1306
1682
  }
1683
+ const display = v17Light ?? v16Light
1684
+ if (display) {
1685
+ overlayDisplayLight(display, 'block', conv.blockLight, 0)
1686
+ overlayDisplayLight(display, 'sky', conv.skyLight, config?.skyLight ?? 15)
1687
+ }
1307
1688
  conversions.push(conv)
1308
1689
  meshChunks.push({ x: cx, z: cz, chunk })
1309
1690
  }
1691
+ usedLightColumns = new Set(meshChunks.map(entry => rawCacheKey(entry.x, entry.z)))
1310
1692
 
1311
1693
  const twoStepChunkCount = conversions.length
1312
1694
  if (twoStepChunkCount === 0) {
@@ -1461,27 +1843,39 @@ function processColumnTick() {
1461
1843
  const sectionBlocksCount = entry?.blocksCount ?? 0
1462
1844
  // Block entity metadata still needs a per-section world walk
1463
1845
  // (signs/heads/banners), matching the legacy per-section path.
1464
- const signs: Record<string, SignMeta> = {}
1465
- const heads: Record<string, HeadMeta> = {}
1466
- const banners: Record<string, BannerMeta> = {}
1467
- const beTarget = { signs, heads, banners }
1468
- const beOpts = { disableBlockEntityTextures: world.config.disableBlockEntityTextures }
1469
- const { occludingLookup } = getBlockMeta(version)
1470
- const visGraph = new VisGraph()
1471
- const cursor = new Vec3(0, 0, 0)
1472
- for (cursor.y = sy; cursor.y < sy + sectionHeight; cursor.y++) {
1473
- for (cursor.z = sz; cursor.z < sz + 16; cursor.z++) {
1474
- for (cursor.x = sx; cursor.x < sx + 16; cursor.x++) {
1475
- const b = world.getBlock(cursor)
1476
- if (!b) continue
1477
- if (occludingLookup[b.stateId]) {
1478
- visGraph.setOpaque(cursor.x - sx, cursor.y - sy, cursor.z - sz)
1846
+ const topologyRevision = dirtyVersionMeta.get(key)?.topologyRevision
1847
+ const post = getOrComputeTopologyPost(key, topologyRevision, () => {
1848
+ const signs: Record<string, SignMeta> = {}
1849
+ const heads: Record<string, HeadMeta> = {}
1850
+ const banners: Record<string, BannerMeta> = {}
1851
+ const beTarget = { signs, heads, banners }
1852
+ const beOpts = { disableBlockEntityTextures: world.config.disableBlockEntityTextures }
1853
+ const { occludingLookup } = getBlockMeta(version)
1854
+ const visGraph = new VisGraph()
1855
+ const cursor = new Vec3(0, 0, 0)
1856
+ for (cursor.y = sy; cursor.y < sy + sectionHeight; cursor.y++) {
1857
+ for (cursor.z = sz; cursor.z < sz + 16; cursor.z++) {
1858
+ for (cursor.x = sx; cursor.x < sx + 16; cursor.x++) {
1859
+ const b = world.getBlock(cursor)
1860
+ if (!b) continue
1861
+ if (occludingLookup[b.stateId]) {
1862
+ visGraph.setOpaque(cursor.x - sx, cursor.y - sy, cursor.z - sz)
1863
+ }
1864
+ collectBlockEntityMetadata(b, cursor.x, cursor.y, cursor.z, beTarget, beOpts, world)
1479
1865
  }
1480
- collectBlockEntityMetadata(b, cursor.x, cursor.y, cursor.z, beTarget, beOpts, world)
1481
1866
  }
1482
1867
  }
1483
- }
1484
- const sectionVisibilitySet = packVisibilitySet(visGraph.resolve())
1868
+ return {
1869
+ visibilitySet: packVisibilitySet(visGraph.resolve()),
1870
+ signs,
1871
+ heads,
1872
+ banners
1873
+ }
1874
+ })
1875
+ const signs = post.payload.signs as Record<string, SignMeta>
1876
+ const heads = post.payload.heads as Record<string, HeadMeta>
1877
+ const banners = post.payload.banners as Record<string, BannerMeta>
1878
+ const sectionVisibilitySet = post.payload.visibilitySet as ReturnType<typeof packVisibilitySet>
1485
1879
 
1486
1880
  let geometry: MesherGeometryOutput
1487
1881
  let transferable: any[] = []
@@ -1587,10 +1981,12 @@ function processColumnTick() {
1587
1981
  geometry.heads = heads
1588
1982
  geometry.banners = banners
1589
1983
  }
1590
- postMessage({ type: 'geometry', key, geometry, workerIndex }, transferable)
1984
+ const versionMeta = takeGeometryVersionMeta(key, sx, sz, usedLightColumns)
1985
+ postMessage({ type: 'geometry', key, geometry, workerIndex, ...versionMeta }, transferable)
1591
1986
  } else if (hadError) {
1592
1987
  const errorGeometry = makeEmptyColumnGeometry(sx, sy, sz, sectionHeight, true)
1593
- postMessage({ type: 'geometry', key, geometry: errorGeometry, workerIndex })
1988
+ const versionMeta = takeGeometryVersionMeta(key, sx, sz, usedLightColumns)
1989
+ postMessage({ type: 'geometry', key, geometry: errorGeometry, workerIndex, ...versionMeta })
1594
1990
  }
1595
1991
  // No targetChunk and no error: skip geometry message (mirrors
1596
1992
  // legacy behavior for sections whose chunk has been unloaded
@@ -1629,6 +2025,8 @@ function processColumnTick() {
1629
2025
  preOther: !attributed ? preOther : 0,
1630
2026
  preCacheHits: !attributed ? preCacheHits : 0,
1631
2027
  preCacheMisses: !attributed ? preCacheMisses : 0,
2028
+ preWasmParseHits: !attributed ? preWasmParseHits : 0,
2029
+ preWasmParseMisses: !attributed ? preWasmParseMisses : 0,
1632
2030
  chunkCount: !attributed ? chunkCount : 0,
1633
2031
  worldColumns3x3: !attributed ? worldColumns3x3 : 0,
1634
2032
  parsedCache3x3: !attributed ? parsedCache3x3 : 0,
@@ -1637,25 +2035,37 @@ function processColumnTick() {
1637
2035
  attributed = true
1638
2036
  }
1639
2037
  }
2038
+ if (groupIndex < groups.length - 1) {
2039
+ await yieldToIncomingMessages()
2040
+ if (dirtyHasUrgent()) {
2041
+ requeueColumnGroups(groups.slice(groupIndex + 1), selectedUrgent)
2042
+ break
2043
+ }
2044
+ }
2045
+ }
2046
+ if (isClientLightTraceEnabled() && isClientLightTraceArmed()) {
2047
+ postClientLightTrace(postTrace, {
2048
+ phase: 'mesherEnd',
2049
+ queueDepth: 0,
2050
+ workerIndex
2051
+ })
1640
2052
  }
1641
2053
  }
1642
2054
 
1643
- setInterval(async () => {
2055
+ const meshTickScheduler = createMeshTickScheduler(async () => {
1644
2056
  if (!allDataReady) return
1645
-
1646
- // Ensure WASM is initialized
1647
2057
  if (!wasmInitialized) {
1648
2058
  await initWasm()
1649
- if (!wasmInitialized) return // Still not initialized, skip this cycle
2059
+ if (!wasmInitialized) return
1650
2060
  }
1651
-
1652
2061
  if (dirtySections.size === 0) return
1653
-
1654
2062
  try {
1655
- processColumnTick()
2063
+ await processColumnTick()
1656
2064
  } catch (err) {
1657
2065
  console.error('[WASM Mesher] processColumnTick failed:', err)
1658
- // Swallow to avoid breaking the setInterval; individual columns
1659
- // already have their own try/catch.
1660
2066
  }
1661
- }, 50)
2067
+ })
2068
+
2069
+ setInterval(() => {
2070
+ meshTickScheduler.kick()
2071
+ }, BULK_MESH_INTERVAL_MS)