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.
- package/dist/mesher.js +20 -20
- package/dist/mesher.js.map +4 -4
- package/dist/mesherWasm.js +72 -75
- package/dist/minecraft-renderer.js +54 -54
- package/dist/minecraft-renderer.js.meta.json +1 -1
- package/dist/threeWorker.js +354 -354
- package/package.json +3 -1
- package/src/graphicsBackend/config.ts +9 -0
- package/src/lib/worldrendererCommon.ts +144 -54
- package/src/mesher/blockEntityMetadata.ts +70 -0
- package/src/mesher/computeHeightmap.ts +66 -0
- package/src/mesher/mesher.ts +13 -20
- package/src/mesher/mesherWasm.ts +432 -140
- package/src/mesher/mesherWasmConversionCache.ts +155 -0
- package/src/mesher/mesherWasmRequestTracker.ts +56 -0
- package/src/mesher/models.ts +2 -46
- package/src/mesher/shared.ts +22 -3
- package/src/mesher/test/heightmapParity.test.ts +231 -0
- package/src/mesher/test/mesherWasmConversionCache.test.ts +128 -0
- package/src/mesher/test/run/chunk.ts +2 -2
- package/src/mesher/test/splitColumnWasmOutput.test.ts +163 -0
- package/src/three/chunkMeshManager.ts +177 -1
- package/src/three/modules/cameraBobbing.ts +8 -1
- package/src/three/worldRendererThree.ts +7 -0
- package/src/wasm-lib/render-from-wasm.ts +158 -13
- package/wasm/wasm_mesher.d.ts +4 -4
- package/wasm/wasm_mesher.js +23 -66
- package/wasm/wasm_mesher_bg.wasm +0 -0
- package/wasm/wasm_mesher_bg.wasm.d.ts +9 -0
|
@@ -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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
909
|
+
if (doAO) {
|
|
910
|
+
light = (ao + 1) / 4 * (cornerLightResult / 15)
|
|
911
|
+
}
|
|
853
912
|
|
|
854
|
-
colors.push(tint[0] * 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
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
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
|
*/
|