minecraft-renderer 0.1.37 → 0.1.39
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 +57 -57
- package/dist/minecraft-renderer.js.meta.json +1 -1
- package/dist/threeWorker.js +239 -239
- package/package.json +3 -1
- package/src/graphicsBackend/config.ts +6 -1
- package/src/lib/worldrendererCommon.ts +119 -53
- 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 +342 -5
- package/src/three/modules/cameraBobbing.ts +8 -1
- package/src/three/worldRendererThree.ts +65 -33
- 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
|
+
})
|
|
@@ -28,17 +28,60 @@ export interface SectionObject extends THREE.Group {
|
|
|
28
28
|
headsContainer?: THREE.Group
|
|
29
29
|
bannersContainer?: THREE.Group
|
|
30
30
|
boxHelper?: THREE.BoxHelper
|
|
31
|
+
/**
|
|
32
|
+
* World-space coordinates of the section origin. Cached so that
|
|
33
|
+
* {@link ChunkMeshManager.updateBoxHelper} can position lazily-created
|
|
34
|
+
* border helpers correctly under camera-relative rendering, where
|
|
35
|
+
* `mesh.position` is proxied to (world - sceneOrigin) and cannot be
|
|
36
|
+
* reused directly for objects that are tracked separately.
|
|
37
|
+
*/
|
|
38
|
+
worldX?: number
|
|
39
|
+
worldY?: number
|
|
40
|
+
worldZ?: number
|
|
31
41
|
foutain?: boolean
|
|
42
|
+
/**
|
|
43
|
+
* True while the section is held invisible by the "Batch Chunks Display"
|
|
44
|
+
* (`_renderByChunks`) feature, waiting for the parent chunk to finish meshing
|
|
45
|
+
* before being shown together with the rest of the chunk.
|
|
46
|
+
*/
|
|
47
|
+
_waitingForChunkDisplay?: boolean
|
|
32
48
|
}
|
|
33
49
|
|
|
34
50
|
export class ChunkMeshManager {
|
|
35
51
|
private readonly meshPool: ChunkMeshPool[] = []
|
|
36
52
|
private readonly activeSections = new Map<string, ChunkMeshPool>()
|
|
37
53
|
readonly sectionObjects: Record<string, SectionObject> = {}
|
|
54
|
+
/**
|
|
55
|
+
* Sections kept invisible because the "Batch Chunks Display" option is on
|
|
56
|
+
* and their parent chunk hasn't finished meshing yet. Keyed by chunk key
|
|
57
|
+
* (`x,z`); flushed by `WorldRendererThree.finishChunk(chunkKey)`.
|
|
58
|
+
*/
|
|
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
|
|
38
74
|
private poolSize!: number
|
|
39
75
|
private maxPoolSize!: number
|
|
40
76
|
private minPoolSize!: number
|
|
41
77
|
private readonly signHeadsRenderer: SignHeadsRenderer
|
|
78
|
+
/**
|
|
79
|
+
* Shared transparent material used as the basis for the wireframe chunk
|
|
80
|
+
* border `BoxHelper` created lazily in {@link updateBoxHelper}. Kept on the
|
|
81
|
+
* manager so the BoxHelper machinery doesn't allocate a new material per
|
|
82
|
+
* section.
|
|
83
|
+
*/
|
|
84
|
+
private readonly chunkBoxMaterial = new THREE.MeshBasicMaterial({ color: 0x00_00_00, transparent: true, opacity: 0 })
|
|
42
85
|
|
|
43
86
|
// Performance tracking
|
|
44
87
|
private hits = 0
|
|
@@ -156,6 +199,17 @@ export class ChunkMeshManager {
|
|
|
156
199
|
// Store metadata
|
|
157
200
|
sectionObject.tilesCount = geometryData.positions.length / 3 / 4
|
|
158
201
|
sectionObject.blocksCount = geometryData.blocksCount
|
|
202
|
+
sectionObject.worldX = geometryData.sx
|
|
203
|
+
sectionObject.worldY = geometryData.sy
|
|
204
|
+
sectionObject.worldZ = geometryData.sz
|
|
205
|
+
// Stamp the section key so modules (e.g. sciFiWorldReveal) can resolve
|
|
206
|
+
// mesh -> section without falling back to sceneOrigin world-position math.
|
|
207
|
+
;(sectionObject as any).sectionKey = sectionKey
|
|
208
|
+
// Tag the group so `WorldRendererThree.getThirdPersonCamera` raycast can
|
|
209
|
+
// still find chunk meshes — the old `WorldBlockGeometry` set this name
|
|
210
|
+
// unconditionally; the pooling port lost that and only the border-helper
|
|
211
|
+
// path used to restore it.
|
|
212
|
+
sectionObject.name = 'chunk'
|
|
159
213
|
|
|
160
214
|
try {
|
|
161
215
|
// Add signs container
|
|
@@ -218,13 +272,220 @@ export class ChunkMeshManager {
|
|
|
218
272
|
this.scene.add(sectionObject)
|
|
219
273
|
sectionObject.matrixAutoUpdate = false
|
|
220
274
|
|
|
275
|
+
// Create chunk border helper eagerly when the option is on so freshly
|
|
276
|
+
// streamed sections immediately get the F3+G yellow wireframe instead of
|
|
277
|
+
// appearing only on the next toggle.
|
|
278
|
+
if (this.worldRenderer.displayOptions?.inWorldRenderingConfig?.showChunkBorders) {
|
|
279
|
+
this.updateBoxHelper(sectionKey, true)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Honor "Batch Chunks Display" (`_renderByChunks`): keep this section's
|
|
283
|
+
// mesh hidden until the whole chunk has finished meshing, so users see a
|
|
284
|
+
// chunk appear as a single 16xHx16 tile instead of streaming per-section.
|
|
285
|
+
// Updates to chunks that are already finished bypass batching to avoid
|
|
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.
|
|
289
|
+
const chunkCoords = sectionKey.split(',')
|
|
290
|
+
const chunkKey = `${chunkCoords[0]},${chunkCoords[2]}`
|
|
291
|
+
const renderByChunks = !!this.worldRenderer.displayOptions
|
|
292
|
+
?.inWorldRenderingConfig?._renderByChunks
|
|
293
|
+
const forceBatchForWasm = !!this.worldRenderer.worldRendererConfig?.wasmMesher
|
|
294
|
+
if ((renderByChunks || forceBatchForWasm) && !this.worldRenderer.finishedChunks[chunkKey]) {
|
|
295
|
+
sectionObject.visible = false
|
|
296
|
+
sectionObject._waitingForChunkDisplay = true
|
|
297
|
+
const list = this.waitingChunksToDisplay[chunkKey] ?? (this.waitingChunksToDisplay[chunkKey] = [])
|
|
298
|
+
if (!list.includes(sectionKey)) list.push(sectionKey)
|
|
299
|
+
}
|
|
300
|
+
|
|
221
301
|
return sectionObject
|
|
222
302
|
}
|
|
223
303
|
|
|
304
|
+
/**
|
|
305
|
+
* Reveal all sections of a chunk that were held invisible by the
|
|
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.
|
|
311
|
+
*/
|
|
312
|
+
finishChunkDisplay (chunkKey: string): void {
|
|
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)
|
|
335
|
+
if (!sectionKeys) return
|
|
336
|
+
for (const sectionKey of sectionKeys) {
|
|
337
|
+
const sectionObject = this.sectionObjects[sectionKey]
|
|
338
|
+
if (!sectionObject) continue
|
|
339
|
+
sectionObject._waitingForChunkDisplay = false
|
|
340
|
+
sectionObject.visible = true
|
|
341
|
+
}
|
|
342
|
+
delete this.waitingChunksToDisplay[chunkKey]
|
|
343
|
+
}
|
|
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
|
+
|
|
224
473
|
cleanupSection (sectionKey: string) {
|
|
225
474
|
// Remove section object from scene
|
|
226
475
|
const sectionObject = this.sectionObjects[sectionKey]
|
|
227
476
|
if (sectionObject) {
|
|
477
|
+
// Drop from any pending "batch display" queue so we don't try to flip
|
|
478
|
+
// visibility on a stale (released) object later.
|
|
479
|
+
if (sectionObject._waitingForChunkDisplay) {
|
|
480
|
+
const chunkCoords = sectionKey.split(',')
|
|
481
|
+
const chunkKey = `${chunkCoords[0]},${chunkCoords[2]}`
|
|
482
|
+
const list = this.waitingChunksToDisplay[chunkKey]
|
|
483
|
+
if (list) {
|
|
484
|
+
const idx = list.indexOf(sectionKey)
|
|
485
|
+
if (idx !== -1) list.splice(idx, 1)
|
|
486
|
+
if (list.length === 0) delete this.waitingChunksToDisplay[chunkKey]
|
|
487
|
+
}
|
|
488
|
+
}
|
|
228
489
|
// Cleanup banner textures before disposing
|
|
229
490
|
if (sectionObject.bannersContainer) {
|
|
230
491
|
sectionObject.bannersContainer.traverse((child) => {
|
|
@@ -243,6 +504,21 @@ export class ChunkMeshManager {
|
|
|
243
504
|
}
|
|
244
505
|
this.worldRenderer.sceneOrigin.removeAndUntrackAll(sectionObject)
|
|
245
506
|
this.scene.remove(sectionObject)
|
|
507
|
+
// boxHelper lives directly on the scene (so it stays world-anchored
|
|
508
|
+
// under camera-relative rendering), so it must be cleaned up explicitly
|
|
509
|
+
// — `removeAndUntrackAll` above only walks `sectionObject` descendants.
|
|
510
|
+
if (sectionObject.boxHelper) {
|
|
511
|
+
this.worldRenderer.sceneOrigin.removeAndUntrack(sectionObject.boxHelper)
|
|
512
|
+
this.scene.remove(sectionObject.boxHelper)
|
|
513
|
+
sectionObject.boxHelper.geometry.dispose()
|
|
514
|
+
const helperMat = sectionObject.boxHelper.material as THREE.Material | THREE.Material[]
|
|
515
|
+
if (Array.isArray(helperMat)) {
|
|
516
|
+
for (const m of helperMat) m.dispose()
|
|
517
|
+
} else {
|
|
518
|
+
helperMat.dispose()
|
|
519
|
+
}
|
|
520
|
+
sectionObject.boxHelper = undefined
|
|
521
|
+
}
|
|
246
522
|
delete this.sectionObjects[sectionKey]
|
|
247
523
|
}
|
|
248
524
|
}
|
|
@@ -285,19 +561,28 @@ export class ChunkMeshManager {
|
|
|
285
561
|
/**
|
|
286
562
|
* Update box helper for a section
|
|
287
563
|
*/
|
|
288
|
-
updateBoxHelper (sectionKey: string, showChunkBorders: boolean, chunkBoxMaterial: THREE.Material) {
|
|
564
|
+
updateBoxHelper (sectionKey: string, showChunkBorders: boolean, chunkBoxMaterial: THREE.Material = this.chunkBoxMaterial) {
|
|
289
565
|
const sectionObject = this.sectionObjects[sectionKey]
|
|
290
566
|
if (!sectionObject?.mesh) return
|
|
291
567
|
|
|
292
568
|
if (showChunkBorders) {
|
|
293
569
|
if (!sectionObject.boxHelper) {
|
|
294
|
-
// mesh
|
|
570
|
+
// Build a 16x16x16 reference mesh in world coordinates so BoxHelper's
|
|
571
|
+
// `setFromObject` produces the correct geometry. The reference mesh is
|
|
572
|
+
// not added to the scene; only the resulting BoxHelper is.
|
|
295
573
|
const staticChunkMesh = new THREE.Mesh(new THREE.BoxGeometry(16, 16, 16), chunkBoxMaterial)
|
|
296
|
-
staticChunkMesh.position.copy(sectionObject.mesh.position)
|
|
297
574
|
const boxHelper = new THREE.BoxHelper(staticChunkMesh, 0xff_ff_00)
|
|
298
575
|
boxHelper.name = 'helper'
|
|
299
|
-
|
|
300
|
-
|
|
576
|
+
// Add directly to the scene and track it through sceneOrigin so that
|
|
577
|
+
// camera-relative rendering (floating origin) keeps the helper pinned
|
|
578
|
+
// to its world coordinates instead of following the camera.
|
|
579
|
+
const sx = sectionObject.worldX ?? 0
|
|
580
|
+
const sy = sectionObject.worldY ?? 0
|
|
581
|
+
const sz = sectionObject.worldZ ?? 0
|
|
582
|
+
this.worldRenderer.sceneOrigin.track(boxHelper, { updateMatrix: true })
|
|
583
|
+
boxHelper.position.set(sx, sy, sz)
|
|
584
|
+
boxHelper.updateMatrix()
|
|
585
|
+
this.scene.add(boxHelper)
|
|
301
586
|
sectionObject.boxHelper = boxHelper
|
|
302
587
|
}
|
|
303
588
|
sectionObject.boxHelper.visible = true
|
|
@@ -306,6 +591,28 @@ export class ChunkMeshManager {
|
|
|
306
591
|
}
|
|
307
592
|
}
|
|
308
593
|
|
|
594
|
+
/**
|
|
595
|
+
* Create / toggle chunk border helpers for every active section. Used by
|
|
596
|
+
* `WorldRendererThree.updateShowChunksBorder` so the F3+G hotkey works
|
|
597
|
+
* after the move from `WorldBlockGeometry` (which created the helpers
|
|
598
|
+
* eagerly per section) to the pooled `ChunkMeshManager`.
|
|
599
|
+
*/
|
|
600
|
+
updateAllBoxHelpers (showChunkBorders: boolean) {
|
|
601
|
+
for (const sectionKey of Object.keys(this.sectionObjects)) {
|
|
602
|
+
this.updateBoxHelper(sectionKey, showChunkBorders)
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Forward to {@link SignHeadsRenderer.cleanChunkTextures} so callers in
|
|
608
|
+
* `WorldRendererThree` (which historically owned the sign-texture cache)
|
|
609
|
+
* can invalidate cached sign textures when a section is marked dirty,
|
|
610
|
+
* without reaching into the manager's private members.
|
|
611
|
+
*/
|
|
612
|
+
cleanSignChunkTextures (x: number, z: number) {
|
|
613
|
+
this.signHeadsRenderer.cleanChunkTextures(x, z)
|
|
614
|
+
}
|
|
615
|
+
|
|
309
616
|
/**
|
|
310
617
|
* Get mesh for section if it exists
|
|
311
618
|
*/
|
|
@@ -459,6 +766,13 @@ export class ChunkMeshManager {
|
|
|
459
766
|
|
|
460
767
|
this.meshPool.length = 0
|
|
461
768
|
this.activeSections.clear()
|
|
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()
|
|
462
776
|
}
|
|
463
777
|
|
|
464
778
|
// Private helper methods
|
|
@@ -667,6 +981,12 @@ export class ChunkMeshManager {
|
|
|
667
981
|
updateSectionsVisibility (): void {
|
|
668
982
|
const cameraPos = this.worldRenderer.cameraSectionPos
|
|
669
983
|
for (const [sectionKey, sectionObject] of Object.entries(this.sectionObjects)) {
|
|
984
|
+
// Don't override "Batch Chunks Display" hiding — those sections must
|
|
985
|
+
// stay invisible until their chunk finishes meshing.
|
|
986
|
+
if (sectionObject._waitingForChunkDisplay) {
|
|
987
|
+
sectionObject.visible = false
|
|
988
|
+
continue
|
|
989
|
+
}
|
|
670
990
|
if (!this.performanceOverrideDistance) {
|
|
671
991
|
sectionObject.visible = true
|
|
672
992
|
continue
|
|
@@ -805,4 +1125,21 @@ class SignHeadsRenderer {
|
|
|
805
1125
|
textures[texturekey] = tex
|
|
806
1126
|
return tex
|
|
807
1127
|
}
|
|
1128
|
+
|
|
1129
|
+
/**
|
|
1130
|
+
* Dispose all cached sign textures for the chunk containing world coords
|
|
1131
|
+
* (x, z). Called from `WorldRendererThree.cleanChunkTextures` so that
|
|
1132
|
+
* re-meshes triggered by `setSectionDirty` (e.g. a player edits a sign)
|
|
1133
|
+
* pick up fresh block-entity NBT instead of returning the stale cached
|
|
1134
|
+
* texture from {@link SignHeadsRenderer.getSignTexture}.
|
|
1135
|
+
*/
|
|
1136
|
+
cleanChunkTextures (x: number, z: number) {
|
|
1137
|
+
const key = `${Math.floor(x / 16)},${Math.floor(z / 16)}`
|
|
1138
|
+
const textures = this.chunkTextures.get(key)
|
|
1139
|
+
if (!textures) return
|
|
1140
|
+
for (const k of Object.keys(textures)) {
|
|
1141
|
+
textures[k].dispose()
|
|
1142
|
+
delete textures[k]
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
808
1145
|
}
|
|
@@ -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
|