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.
@@ -0,0 +1,163 @@
1
+ //@ts-nocheck
2
+ import { test, expect } from 'vitest'
3
+ import {
4
+ splitColumnWasmOutputToSections,
5
+ renderWasmOutputToGeometry,
6
+ WasmGeometryOutput,
7
+ } from '../../wasm-lib/render-from-wasm'
8
+
9
+ const VERSION = '1.16.5'
10
+ const STONE = 1 // 1.16.5 stone state id
11
+
12
+ // Face mask layout (matches FACE_DIRS in wasm-mesher / render-from-wasm.ts).
13
+ const FACE_UP = 1 << 0
14
+ const FACE_DOWN = 1 << 1
15
+ const FACE_NORTH = 1 << 2
16
+ const FACE_SOUTH = 1 << 3
17
+ const FACE_WEST = 1 << 4
18
+ const FACE_EAST = 1 << 5
19
+ const SIDE_FACES = FACE_NORTH | FACE_SOUTH | FACE_WEST | FACE_EAST
20
+
21
+ // Build a synthetic full-column WASM mesher output by hand. This avoids
22
+ // pulling in `wasm-pack`/the Rust crate from this unit test — the helper
23
+ // under test is pure JS, so an in-memory fixture is sufficient.
24
+ //
25
+ // Scenario: two adjacent stone blocks at world Y=15 and Y=16. Because
26
+ // they are stacked and opaque, Rust mesher would emit:
27
+ // - (x,15,z): bottom face only, top face is suppressed by Y=16
28
+ // - (x,16,z): top face only, bottom face is suppressed by Y=15
29
+ // We hand-craft that output here.
30
+ function makeSeamFixture(): WasmGeometryOutput {
31
+ const blocks: WasmGeometryOutput['blocks'] = []
32
+ for (let z = 0; z < 16; z++) {
33
+ for (let x = 0; x < 16; x++) {
34
+ blocks.push({
35
+ position: [x, 15, z],
36
+ block_state_id: STONE,
37
+ // Y=15 is occluded above by the Y=16 stone — the seam neighbor
38
+ // info is baked in here at the per-block level.
39
+ visible_faces: FACE_DOWN | SIDE_FACES,
40
+ ao_data: [],
41
+ light_data: [],
42
+ })
43
+ blocks.push({
44
+ position: [x, 16, z],
45
+ block_state_id: STONE,
46
+ // Y=16 is occluded below by the Y=15 stone (the seam from the
47
+ // other side).
48
+ visible_faces: FACE_UP | SIDE_FACES,
49
+ ao_data: [],
50
+ light_data: [],
51
+ })
52
+ }
53
+ }
54
+ // Add an isolated block in a third section, so we cover a section
55
+ // that has no seam interaction.
56
+ blocks.push({
57
+ position: [0, 64, 0],
58
+ block_state_id: STONE,
59
+ visible_faces: FACE_UP | FACE_DOWN | SIDE_FACES,
60
+ ao_data: [],
61
+ light_data: [],
62
+ })
63
+ return {
64
+ blocks,
65
+ block_count: blocks.length,
66
+ block_iterations: 0,
67
+ }
68
+ }
69
+
70
+ test('splitColumnWasmOutputToSections: per-section split is equivalent to manual filter+render at the y=15/16 seam', () => {
71
+ const fullColumn = makeSeamFixture()
72
+
73
+ const requested = [
74
+ { x: 0, y: 0, z: 0 }, // contains Y=15 row only
75
+ { x: 0, y: 16, z: 0 }, // contains Y=16 row only
76
+ { x: 0, y: 32, z: 0 }, // empty section
77
+ { x: 0, y: 64, z: 0 }, // contains the isolated block at Y=64
78
+ ]
79
+
80
+ const split = splitColumnWasmOutputToSections(fullColumn, requested, { version: VERSION })
81
+
82
+ expect(split.size).toBe(4)
83
+ for (const r of requested) {
84
+ expect(split.has(`${r.x},${r.y},${r.z}`)).toBe(true)
85
+ }
86
+
87
+ // Reference: do the same split manually (exactly what the helper is
88
+ // supposed to do internally) and confirm the rendered geometry
89
+ // matches byte-for-byte.
90
+ for (const r of requested) {
91
+ const yLo = r.y
92
+ const yHi = r.y + 16
93
+ const sectionBlocks = fullColumn.blocks.filter(b => b.position[1] >= yLo && b.position[1] < yHi)
94
+ const reference = renderWasmOutputToGeometry(
95
+ {
96
+ blocks: sectionBlocks,
97
+ block_count: sectionBlocks.length,
98
+ block_iterations: fullColumn.block_iterations,
99
+ },
100
+ VERSION,
101
+ `${r.x},${r.y},${r.z}`,
102
+ { x: r.x + 8, y: r.y + 8, z: r.z + 8 },
103
+ undefined
104
+ )
105
+ const got = split.get(`${r.x},${r.y},${r.z}`)!.exported
106
+ expect(got.key).toBe(reference.key)
107
+ expect(got.position).toEqual(reference.position)
108
+ expect(got.geometry.positions).toEqual(reference.geometry.positions)
109
+ expect(got.geometry.normals).toEqual(reference.geometry.normals)
110
+ expect(got.geometry.colors).toEqual(reference.geometry.colors)
111
+ expect(got.geometry.uvs).toEqual(reference.geometry.uvs)
112
+ expect(got.geometry.indices).toEqual(reference.geometry.indices)
113
+ }
114
+
115
+ // Empty section returns a real (non-undefined) entry with empty geometry
116
+ // buffers and zero blocksCount.
117
+ const emptyEntry = split.get('0,32,0')!
118
+ expect(emptyEntry).toBeDefined()
119
+ expect(emptyEntry.exported.geometry.positions).toEqual([])
120
+ expect(emptyEntry.exported.geometry.indices).toEqual([])
121
+ expect(emptyEntry.blocksCount).toBe(0)
122
+
123
+ // Seam preservation: the Y=15/16 sections produce non-empty geometry,
124
+ // and crucially the y=15 section does NOT contain the (Y=15 top-face)
125
+ // that would have been emitted if the helper failed to honor the
126
+ // already-baked-in `visible_faces` mask. The cleanest structural
127
+ // assertion is that the seam-sections each contain only the expected
128
+ // number of unique vertical faces. With FACE_UP/FACE_DOWN suppressed
129
+ // at the seam, each block in those sections contributes 1 horizontal
130
+ // face + 4 side faces = 5 quads; both sections also have identical
131
+ // block counts (256 blocks), so their vertex/index counts must match.
132
+ const lower = split.get('0,0,0')!.exported
133
+ const upper = split.get('0,16,0')!.exported
134
+ expect(lower.geometry.positions.length).toBeGreaterThan(0)
135
+ expect(upper.geometry.positions.length).toBeGreaterThan(0)
136
+ expect(lower.geometry.positions.length).toBe(upper.geometry.positions.length)
137
+ expect(lower.geometry.indices.length).toBe(upper.geometry.indices.length)
138
+ // blocksCount must reflect the per-section bucket size, not the column
139
+ // total. Both seam sections contain 256 blocks each.
140
+ expect(split.get('0,0,0')!.blocksCount).toBe(256)
141
+ expect(split.get('0,16,0')!.blocksCount).toBe(256)
142
+ })
143
+
144
+ test('splitColumnWasmOutputToSections: empty requested-keys list returns empty map', () => {
145
+ const fullColumn = makeSeamFixture()
146
+ const out = splitColumnWasmOutputToSections(fullColumn, [], { version: VERSION })
147
+ expect(out.size).toBe(0)
148
+ })
149
+
150
+ test('splitColumnWasmOutputToSections: blocks outside requested sections are dropped', () => {
151
+ const fullColumn = makeSeamFixture()
152
+ // Only request the empty Y=32 section. Y=15/16/64 blocks must NOT
153
+ // leak into it.
154
+ const out = splitColumnWasmOutputToSections(
155
+ fullColumn,
156
+ [{ x: 0, y: 32, z: 0 }],
157
+ { version: VERSION }
158
+ )
159
+ const empty = out.get('0,32,0')!
160
+ expect(empty.exported.geometry.positions).toEqual([])
161
+ expect(empty.exported.geometry.indices).toEqual([])
162
+ expect(empty.blocksCount).toBe(0)
163
+ })
@@ -57,6 +57,20 @@ export class ChunkMeshManager {
57
57
  * (`x,z`); flushed by `WorldRendererThree.finishChunk(chunkKey)`.
58
58
  */
59
59
  readonly waitingChunksToDisplay: Record<string, string[]> = {}
60
+ /**
61
+ * Chunks whose mesh batch is fully ready but kept invisible by the
62
+ * WASM near-first reveal gate because at least one nearer column is
63
+ * not yet finished. Value = enqueue timestamp (ms), used by the
64
+ * expected-delivery grace window in `isBlockedByNearer`.
65
+ */
66
+ readonly pendingNearReveal = new Map<string, number>()
67
+ private readonly nearRevealTimers = new Map<string, ReturnType<typeof setTimeout>>()
68
+ private readonly nearRevealGraceTimers = new Map<string, ReturnType<typeof setTimeout>>()
69
+ // Force-flush pending reveal after this many ms (last-resort safety).
70
+ private static readonly NEAR_REVEAL_TIMEOUT_MS = 5000
71
+ // Soft window during which the gate also waits for *expected* nearer
72
+ // columns that have not arrived yet (covers far-worker-beats-near-worker).
73
+ private static readonly EXPECTED_NEAR_GRACE_MS = 1500
60
74
  private poolSize!: number
61
75
  private maxPoolSize!: number
62
76
  private minPoolSize!: number
@@ -270,11 +284,14 @@ export class ChunkMeshManager {
270
284
  // chunk appear as a single 16xHx16 tile instead of streaming per-section.
271
285
  // Updates to chunks that are already finished bypass batching to avoid
272
286
  // flickering on block changes / lighting updates.
287
+ // For the WASM column path we force batching ON regardless of the user
288
+ // setting so the near-first reveal gate has sections to hold.
273
289
  const chunkCoords = sectionKey.split(',')
274
290
  const chunkKey = `${chunkCoords[0]},${chunkCoords[2]}`
275
291
  const renderByChunks = !!this.worldRenderer.displayOptions
276
292
  ?.inWorldRenderingConfig?._renderByChunks
277
- if (renderByChunks && !this.worldRenderer.finishedChunks[chunkKey]) {
293
+ const forceBatchForWasm = !!this.worldRenderer.worldRendererConfig?.wasmMesher
294
+ if ((renderByChunks || forceBatchForWasm) && !this.worldRenderer.finishedChunks[chunkKey]) {
278
295
  sectionObject.visible = false
279
296
  sectionObject._waitingForChunkDisplay = true
280
297
  const list = this.waitingChunksToDisplay[chunkKey] ?? (this.waitingChunksToDisplay[chunkKey] = [])
@@ -287,9 +304,34 @@ export class ChunkMeshManager {
287
304
  /**
288
305
  * Reveal all sections of a chunk that were held invisible by the
289
306
  * "Batch Chunks Display" option. Called from `WorldRendererThree.finishChunk`.
307
+ *
308
+ * For the WASM path: if any nearer column is not yet finished, the
309
+ * reveal is deferred (parked in `pendingNearReveal`) and re-checked on
310
+ * the next chunkFinished / player-move / grace-expiry.
290
311
  */
291
312
  finishChunkDisplay (chunkKey: string): void {
292
313
  const sectionKeys = this.waitingChunksToDisplay[chunkKey]
314
+ if (!sectionKeys) {
315
+ // No held sections (empty column / non-batched path) — but the
316
+ // chunk just transitioned to finished, so re-check pending farther.
317
+ this.tryRevealPending()
318
+ return
319
+ }
320
+ if (this.isWasmGateActive() && this.isBlockedByNearer(chunkKey, 0)) {
321
+ this.pendingNearReveal.set(chunkKey, Date.now())
322
+ this.armNearRevealTimer(chunkKey)
323
+ this.armExpectedGraceTimer(chunkKey)
324
+ return
325
+ }
326
+ this.flushChunkDisplay(chunkKey)
327
+ this.tryRevealPending()
328
+ }
329
+
330
+ private flushChunkDisplay (chunkKey: string): void {
331
+ const sectionKeys = this.waitingChunksToDisplay[chunkKey]
332
+ this.pendingNearReveal.delete(chunkKey)
333
+ this.clearNearRevealTimer(chunkKey)
334
+ this.clearExpectedGraceTimer(chunkKey)
293
335
  if (!sectionKeys) return
294
336
  for (const sectionKey of sectionKeys) {
295
337
  const sectionObject = this.sectionObjects[sectionKey]
@@ -300,6 +342,134 @@ export class ChunkMeshManager {
300
342
  delete this.waitingChunksToDisplay[chunkKey]
301
343
  }
302
344
 
345
+ // Re-check every parked entry; each has its own grace window via `ageMs`.
346
+ // Single pass is enough — pending entries are already finished, so flushing
347
+ // one cannot un-block another via this code path (cascading happens via
348
+ // chunkFinished events and per-pending grace timers).
349
+ tryRevealPending (): void {
350
+ if (this.pendingNearReveal.size === 0) return
351
+ const now = Date.now()
352
+ for (const [chunkKey, enqueuedAt] of [...this.pendingNearReveal]) {
353
+ if (!this.isBlockedByNearer(chunkKey, now - enqueuedAt)) {
354
+ this.flushChunkDisplay(chunkKey)
355
+ }
356
+ }
357
+ }
358
+
359
+ // Drop gate state for an unloaded column and re-evaluate any farther
360
+ // chunks that may have been blocked by it.
361
+ onChunkRemovedFromGate (chunkKey: string): void {
362
+ this.pendingNearReveal.delete(chunkKey)
363
+ this.clearNearRevealTimer(chunkKey)
364
+ this.clearExpectedGraceTimer(chunkKey)
365
+ delete this.waitingChunksToDisplay[chunkKey]
366
+ this.tryRevealPending()
367
+ }
368
+
369
+ private isWasmGateActive (): boolean {
370
+ return !!this.worldRenderer.worldRendererConfig?.wasmMesher
371
+ }
372
+
373
+ /**
374
+ * True if some chunk-grid position strictly closer to the viewer than
375
+ * `chunkKey` is not yet `finishedChunks=true`.
376
+ *
377
+ * Two regimes by `ageMs` (time spent in `pendingNearReveal`):
378
+ * - Within `EXPECTED_NEAR_GRACE_MS`: walks every expected position in
379
+ * the view-distance circle; missing-and-not-finished counts as a
380
+ * blocker (catches "far worker beats near worker").
381
+ * - After grace: only actually-loaded-but-not-finished columns block,
382
+ * so a never-arriving column does not freeze the view.
383
+ */
384
+ private isBlockedByNearer (chunkKey: string, ageMs: number): boolean {
385
+ const viewer = this.worldRenderer.viewerChunkPosition
386
+ if (!viewer) return false
387
+ const ownParts = chunkKey.split(',')
388
+ if (ownParts.length !== 2) return false
389
+ const ownX = Number(ownParts[0])
390
+ const ownZ = Number(ownParts[1])
391
+ const playerCx = Math.floor(viewer.x / 16)
392
+ const playerCz = Math.floor(viewer.z / 16)
393
+ const myDx = (ownX >> 4) - playerCx
394
+ const myDz = (ownZ >> 4) - playerCz
395
+ const myDist = myDx * myDx + myDz * myDz
396
+ if (myDist === 0) return false
397
+ const finishedChunks = this.worldRenderer.finishedChunks
398
+ const loadedChunks = this.worldRenderer.loadedChunks
399
+ const viewDist = this.worldRenderer.viewDistance
400
+ const inGrace = ageMs < ChunkMeshManager.EXPECTED_NEAR_GRACE_MS && viewDist > 0
401
+
402
+ if (inGrace) {
403
+ const viewDistSq = viewDist * viewDist
404
+ const limit = Math.min(viewDist, Math.ceil(Math.sqrt(Math.max(0, myDist - 1))))
405
+ for (let dCx = -limit; dCx <= limit; dCx++) {
406
+ for (let dCz = -limit; dCz <= limit; dCz++) {
407
+ const oDistSq = dCx * dCx + dCz * dCz
408
+ if (oDistSq >= myDist || oDistSq > viewDistSq) continue
409
+ const ox = (playerCx + dCx) << 4
410
+ const oz = (playerCz + dCz) << 4
411
+ const otherKey = `${ox},${oz}`
412
+ if (otherKey === chunkKey) continue
413
+ if (!finishedChunks[otherKey]) return true
414
+ }
415
+ }
416
+ return false
417
+ }
418
+
419
+ for (const otherKey in loadedChunks) {
420
+ if (otherKey === chunkKey || finishedChunks[otherKey]) continue
421
+ const parts = otherKey.split(',')
422
+ if (parts.length !== 2) continue
423
+ const odx = (Number(parts[0]) >> 4) - playerCx
424
+ const odz = (Number(parts[1]) >> 4) - playerCz
425
+ if (odx * odx + odz * odz < myDist) return true
426
+ }
427
+ return false
428
+ }
429
+
430
+ private armNearRevealTimer (chunkKey: string): void {
431
+ if (this.nearRevealTimers.has(chunkKey)) return
432
+ const timer = setTimeout(() => {
433
+ this.nearRevealTimers.delete(chunkKey)
434
+ if (!this.pendingNearReveal.has(chunkKey)) return
435
+ console.warn(`[chunk-reveal] safety timeout for ${chunkKey} — a nearer pending column never finished, force-revealing`)
436
+ this.flushChunkDisplay(chunkKey)
437
+ this.tryRevealPending()
438
+ }, ChunkMeshManager.NEAR_REVEAL_TIMEOUT_MS)
439
+ this.nearRevealTimers.set(chunkKey, timer)
440
+ }
441
+
442
+ private clearNearRevealTimer (chunkKey: string): void {
443
+ const timer = this.nearRevealTimers.get(chunkKey)
444
+ if (timer) {
445
+ clearTimeout(timer)
446
+ this.nearRevealTimers.delete(chunkKey)
447
+ }
448
+ }
449
+
450
+ /**
451
+ * Schedule a re-evaluation just after the grace window expires so that
452
+ * "expected but never arrived" positions stop blocking promptly,
453
+ * without waiting for the next chunkFinished / player-move event.
454
+ */
455
+ private armExpectedGraceTimer (chunkKey: string): void {
456
+ if (this.nearRevealGraceTimers.has(chunkKey)) return
457
+ const timer = setTimeout(() => {
458
+ this.nearRevealGraceTimers.delete(chunkKey)
459
+ if (!this.pendingNearReveal.has(chunkKey)) return
460
+ this.tryRevealPending()
461
+ }, ChunkMeshManager.EXPECTED_NEAR_GRACE_MS + 50)
462
+ this.nearRevealGraceTimers.set(chunkKey, timer)
463
+ }
464
+
465
+ private clearExpectedGraceTimer (chunkKey: string): void {
466
+ const timer = this.nearRevealGraceTimers.get(chunkKey)
467
+ if (timer) {
468
+ clearTimeout(timer)
469
+ this.nearRevealGraceTimers.delete(chunkKey)
470
+ }
471
+ }
472
+
303
473
  cleanupSection (sectionKey: string) {
304
474
  // Remove section object from scene
305
475
  const sectionObject = this.sectionObjects[sectionKey]
@@ -597,6 +767,12 @@ export class ChunkMeshManager {
597
767
  this.meshPool.length = 0
598
768
  this.activeSections.clear()
599
769
  this.chunkBoxMaterial.dispose()
770
+ // Drop any pending near-first reveal state and cancel safety timers.
771
+ this.pendingNearReveal.clear()
772
+ for (const timer of this.nearRevealTimers.values()) clearTimeout(timer)
773
+ this.nearRevealTimers.clear()
774
+ for (const timer of this.nearRevealGraceTimers.values()) clearTimeout(timer)
775
+ this.nearRevealGraceTimers.clear()
600
776
  }
601
777
 
602
778
  // Private helper methods
@@ -26,8 +26,15 @@ export class CameraBobbingModule implements RendererModuleController {
26
26
  if (!this.enabled) return
27
27
  const config = this.worldRenderer.displayOptions.inWorldRenderingConfig
28
28
  const { perspective } = this.worldRenderer.playerStateReactive
29
+ // Spectator (gm3) flies through blocks and does not "walk" — view bobbing
30
+ // there only makes the camera feel jittery/unstable. Keep it gated even
31
+ // when the user has `viewBobbing` enabled in settings.
32
+ const shouldBobCamera =
33
+ config.viewBobbing
34
+ && perspective === 'first_person'
35
+ && !this.worldRenderer.playerStateUtils.isSpectator()
29
36
 
30
- if (config.viewBobbing && perspective === 'first_person') {
37
+ if (shouldBobCamera) {
31
38
  if (this.worldRenderer.playerStateReactive.walkDist !== this.lastBobWalkDist) {
32
39
  this.lastBobTickTime = performance.now()
33
40
  this.lastBobWalkDist = this.worldRenderer.playerStateReactive.walkDist
@@ -1424,6 +1424,13 @@ export class WorldRendererThree extends WorldRendererCommon {
1424
1424
  this.chunkMeshManager.releaseSection(key)
1425
1425
  }
1426
1426
  }
1427
+ // Drop near-first reveal state and re-check any farther chunks
1428
+ // that may have been blocked by this column.
1429
+ this.chunkMeshManager.onChunkRemovedFromGate(`${x},${z}`)
1430
+ }
1431
+
1432
+ protected onViewerChunkPositionChanged(): void {
1433
+ this.chunkMeshManager.tryRevealPending()
1427
1434
  }
1428
1435
 
1429
1436
  setSectionDirty(...args: Parameters<WorldRendererCommon['setSectionDirty']>) {
@@ -64,6 +64,7 @@ interface CachedBlockModel {
64
64
  blockProps: Record<string, any>
65
65
  models: any // BlockModelPartsResolved
66
66
  isCube: boolean
67
+ boundingBox: string
67
68
  // Precomputed per-model variant
68
69
  modelVariants: Array<{
69
70
  model: any
@@ -86,10 +87,51 @@ interface WasmBlockFaceData {
86
87
  light_data: number[][]
87
88
  }
88
89
 
89
- interface WasmGeometryOutput {
90
+ export interface WasmGeometryOutput {
90
91
  blocks: WasmBlockFaceData[]
91
92
  block_count: number
92
93
  block_iterations: number
94
+ /**
95
+ * Per-(x,z) max non-invisible block Y for the meshed column, indexed as
96
+ * `z * 16 + x`. Sentinel value `-32768` = no block in that column.
97
+ *
98
+ * Populated by Rust `Mesher::generate_with_world` (see
99
+ * `wasm-mesher/src/mesher.rs`, field `heightmap`). serde_wasm_bindgen
100
+ * serializes `Vec<i16>` as a plain JS `number[]`, which is why the type
101
+ * here is `ArrayLike<number>` rather than `Int16Array` — the runtime
102
+ * adapter `extractColumnHeightmap` handles both shapes.
103
+ *
104
+ * Used at runtime by `mesherWasm.ts` `processColumnTick`: every column
105
+ * tick the WASM heightmap is extracted via `extractColumnHeightmap` and
106
+ * posted to the main thread as a `'heightmap'` message. JS
107
+ * `computeHeightmap` is now only a fallback (length mismatch / missing
108
+ * field) and a safety-net for empty columns at chunk load. Empty-column
109
+ * semantics are aligned: both Rust and JS use `-32768` (see
110
+ * `EMPTY_COLUMN_HEIGHTMAP_SENTINEL`).
111
+ */
112
+ heightmap?: ArrayLike<number> | null
113
+ }
114
+
115
+ /**
116
+ * Extract a 256-entry Int16Array heightmap from a full-column WASM mesher
117
+ * result, indexed as `z * 16 + x` (matching the JS `computeHeightmap`
118
+ * convention). Returns `null` when the WASM output does not carry a
119
+ * heightmap or carries one of unexpected length — in that case the
120
+ * caller MUST fall back to JS `computeHeightmap` rather than guess.
121
+ *
122
+ * This adapter is the single place that converts Rust's `Vec<i16>` heightmap
123
+ * shape into a transferable typed array. Tests exercise this same adapter so
124
+ * future runtime usage and parity assertions cannot drift apart.
125
+ */
126
+ export function extractColumnHeightmap(
127
+ wasmOutput: { heightmap?: ArrayLike<number> | null } | null | undefined
128
+ ): Int16Array | null {
129
+ const raw = wasmOutput?.heightmap
130
+ if (!raw || raw.length !== 256) return null
131
+ if (raw instanceof Int16Array) return new Int16Array(raw)
132
+ const out = new Int16Array(256)
133
+ for (let i = 0; i < 256; i++) out[i] = raw[i]
134
+ return out
93
135
  }
94
136
 
95
137
  /**
@@ -181,6 +223,7 @@ function getCachedBlockModel(
181
223
  models,
182
224
  modelVariants,
183
225
  isCube,
226
+ boundingBox: blockObj.boundingBox,
184
227
  }
185
228
 
186
229
  cache.set(cacheKey, cached)
@@ -697,6 +740,11 @@ export function renderWasmOutputToGeometry(
697
740
  globalShift = vecsub3(globalShift, matmul3(globalMatrix, globalShift))
698
741
  }
699
742
 
743
+ // Mirror JS mesher: doAO = model.ao ?? block.boundingBox !== 'empty'.
744
+ // When false, faces are emitted full-bright without AO/light sampling and without
745
+ // triangle-flip reordering (matches JS `light = 1` and standard winding).
746
+ const doAO = (model as any).ao ?? cachedModel.boundingBox !== 'empty'
747
+
700
748
  for (const element of model.elements ?? []) {
701
749
  let localMatrix = null as any
702
750
  let localShift = null as any
@@ -723,8 +771,12 @@ export function renderWasmOutputToGeometry(
723
771
  Math.round(transformedDir[2]),
724
772
  ]
725
773
  const dirKey = `${transformedDirI[0]},${transformedDirI[1]},${transformedDirI[2]}`
774
+ // faceIdx may be undefined for diagonal-rotated faces (e.g. signs at 45/135/225/315 deg).
775
+ // Such faces are not representable in the 6-axis WASM visible_faces / ao_data / light_data
776
+ // arrays. We still emit them (mirrors JS mesher behavior); cullface and AO/light data
777
+ // lookups are skipped, and the model-lighting fallback below derives AO/light by
778
+ // sampling neighbors via transformedDirI (its rounded form, same as for cardinal axes).
726
779
  const faceIdx = dirKeyToIndex[dirKey]
727
- if (faceIdx === undefined) continue
728
780
 
729
781
  const minx = element.from[0]
730
782
  const miny = element.from[1]
@@ -733,13 +785,13 @@ export function renderWasmOutputToGeometry(
733
785
  const maxy = element.to[1]
734
786
  const maxz = element.to[2]
735
787
 
736
- if (matchingEFace.cullface) {
788
+ if (matchingEFace.cullface && faceIdx !== undefined) {
737
789
  if ((block.visible_faces & (1 << faceIdx)) === 0) {
738
790
  continue
739
791
  }
740
792
  }
741
793
 
742
- const faceDataIndex = wasmFaceToDataIndex[faceIdx]
794
+ const faceDataIndex = faceIdx === undefined ? undefined : wasmFaceToDataIndex[faceIdx]
743
795
  const aoValuesRaw = faceDataIndex === undefined ? undefined : block.ao_data[faceDataIndex]
744
796
  const lightValuesRaw = faceDataIndex === undefined ? undefined : block.light_data[faceDataIndex]
745
797
 
@@ -787,8 +839,13 @@ export function renderWasmOutputToGeometry(
787
839
 
788
840
  let ao = 3
789
841
  let cornerLightResult = 15
842
+ let light: number
790
843
 
791
- if (useModelLighting) {
844
+ if (!doAO) {
845
+ // JS parity: skip AO/light sampling, emit full-bright vertex.
846
+ computedAoValues[cornerIdx] = 3
847
+ light = 1
848
+ } else if (useModelLighting) {
792
849
  const cursor = new Vec3(bx, by, bz)
793
850
 
794
851
  const dx = pos[0] * 2 - 1
@@ -849,9 +906,11 @@ export function renderWasmOutputToGeometry(
849
906
  cornerLightResult = baseLight * 15
850
907
  }
851
908
 
852
- const light = (ao + 1) / 4 * (cornerLightResult / 15)
909
+ if (doAO) {
910
+ light = (ao + 1) / 4 * (cornerLightResult / 15)
911
+ }
853
912
 
854
- colors.push(tint[0] * light, tint[1] * light, tint[2] * light)
913
+ colors.push(tint[0] * light!, tint[1] * light!, tint[2] * light!)
855
914
 
856
915
  const baseu = (pos[3] - 0.5) * uvcs - (pos[4] - 0.5) * uvsn + 0.5
857
916
  const basev = (pos[3] - 0.5) * uvsn + (pos[4] - 0.5) * uvcs + 0.5
@@ -865,7 +924,7 @@ export function renderWasmOutputToGeometry(
865
924
  const aoValues = computedAoValues
866
925
 
867
926
  let tri1: number[], tri2: number[]
868
- if (aoValues[0] + aoValues[3] >= aoValues[1] + aoValues[2]) {
927
+ if (doAO && aoValues[0] + aoValues[3] >= aoValues[1] + aoValues[2]) {
869
928
  tri1 = [baseIndex, baseIndex + 3, baseIndex + 2]
870
929
  tri2 = [baseIndex, baseIndex + 1, baseIndex + 3]
871
930
  } else {
@@ -915,15 +974,101 @@ export function renderWasmOutputToGeometry(
915
974
  },
916
975
  }
917
976
 
918
- console.log(`[WASM] Final geometry summary:`)
919
- console.log(`[WASM] Total vertices: ${positions.length / 3}`)
920
- console.log(`[WASM] Total triangles: ${indices.length / 3}`)
921
- console.log(`[WASM] Positions: [${positions.slice(0, 12).join(',')}...] (first 4 vertices)`)
922
- console.log(`[WASM] Indices: [${indices.slice(0, 12).join(',')}...] (first 2 faces)`)
977
+ log(`[WASM] Final geometry summary:`)
978
+ log(`[WASM] Total vertices: ${positions.length / 3}`)
979
+ log(`[WASM] Total triangles: ${indices.length / 3}`)
980
+ log(`[WASM] Positions: [${positions.slice(0, 12).join(',')}...] (first 4 vertices)`)
981
+ log(`[WASM] Indices: [${indices.slice(0, 12).join(',')}...] (first 2 faces)`)
923
982
 
924
983
  return result
925
984
  }
926
985
 
986
+ /**
987
+ * Split a single full-column WASM mesher result into per-section
988
+ * `ExportedSection` outputs by filtering `wasmResult.blocks` per requested
989
+ * section's Y range and invoking `renderWasmOutputToGeometry` once per
990
+ * section.
991
+ *
992
+ * Why split at the block level (and not after geometry generation):
993
+ * - Liquids (water/lava), signs/heads/banners metadata, AO/light arrays
994
+ * and index numbering are computed inside `renderWasmOutputToGeometry`.
995
+ * Splitting *finished* vertex/index buffers would silently break those.
996
+ * - Filtering blocks by Y range and re-running the post-processor per
997
+ * section keeps the output identical to the existing per-section path.
998
+ *
999
+ * Y=15/16 (and any other inter-section) seam handling:
1000
+ * - The Rust mesher produced `wasmResult` over the full column, so each
1001
+ * block's `visible_faces`, `ao_data` and `light_data` already account
1002
+ * for its true neighbors — including the block above at the section
1003
+ * seam (e.g. a Y=15 top face is correctly suppressed when Y=16 is
1004
+ * opaque, even though Y=16 lives in the next render section).
1005
+ * - This helper therefore does NOT need to widen the per-section block
1006
+ * window: a strict `[sy*sectionHeight, sy*sectionHeight + sectionHeight)`
1007
+ * filter on `block.position[1]` is sufficient. The neighbor information
1008
+ * is already baked into each block's per-face mask/AO/light arrays.
1009
+ *
1010
+ * Empty sections: sections with no blocks in range still get a call into
1011
+ * `renderWasmOutputToGeometry` with an empty `blocks` array, so the
1012
+ * returned `ExportedSection` shape matches what the per-section path
1013
+ * produces for an empty section (empty positions/normals/colors/uvs/
1014
+ * indices arrays).
1015
+ *
1016
+ * Note: this pure helper is not gated internally; callers decide whether
1017
+ * column meshing is enabled.
1018
+ */
1019
+ export function splitColumnWasmOutputToSections(
1020
+ fullColumnOutput: WasmGeometryOutput,
1021
+ requestedSectionKeys: Array<{ x: number, y: number, z: number }>,
1022
+ ctx: { version: string, world?: World, sectionHeight?: number }
1023
+ ): Map<string, { exported: ExportedSection, blocksCount: number }> {
1024
+ const { version, world } = ctx
1025
+ const sectionHeight = ctx.sectionHeight ?? 16
1026
+
1027
+ // Bucket blocks by section Y once, so we don't re-scan the full column
1028
+ // for every requested section. Bucket key = section-relative chunk Y
1029
+ // (i.e. floor(by / sectionHeight)).
1030
+ const blocksByChunkY = new Map<number, WasmBlockFaceData[]>()
1031
+ for (const block of fullColumnOutput.blocks) {
1032
+ const by = block.position[1]
1033
+ const chunkY = Math.floor(by / sectionHeight)
1034
+ let bucket = blocksByChunkY.get(chunkY)
1035
+ if (!bucket) {
1036
+ bucket = []
1037
+ blocksByChunkY.set(chunkY, bucket)
1038
+ }
1039
+ bucket.push(block)
1040
+ }
1041
+
1042
+ const out = new Map<string, { exported: ExportedSection, blocksCount: number }>()
1043
+ for (const { x, y, z } of requestedSectionKeys) {
1044
+ // `y` here is the section's world-Y origin (multiple of sectionHeight),
1045
+ // matching the convention used by `mesherWasm.ts` (section keys are
1046
+ // `${chunkX*16},${sectionY},${chunkZ*16}` with sectionY a world-Y
1047
+ // multiple of 16). Translate to chunk-Y bucket index.
1048
+ const chunkY = Math.floor(y / sectionHeight)
1049
+ const sectionBlocks = blocksByChunkY.get(chunkY) ?? []
1050
+
1051
+ const sectionView: WasmGeometryOutput = {
1052
+ blocks: sectionBlocks,
1053
+ block_count: sectionBlocks.length,
1054
+ block_iterations: fullColumnOutput.block_iterations,
1055
+ }
1056
+
1057
+ const sectionKey = `${x},${y},${z}`
1058
+ const sectionPosition = { x: x + 8, y: y + 8, z: z + 8 }
1059
+ const exported = renderWasmOutputToGeometry(
1060
+ sectionView,
1061
+ version,
1062
+ sectionKey,
1063
+ sectionPosition,
1064
+ world
1065
+ )
1066
+ out.set(sectionKey, { exported, blocksCount: sectionBlocks.length })
1067
+ }
1068
+
1069
+ return out
1070
+ }
1071
+
927
1072
  /**
928
1073
  * Convert WASM output to exported geometry format
929
1074
  */