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,155 @@
|
|
|
1
|
+
//@ts-nocheck
|
|
2
|
+
// Worker-side cache for `convertChunkToWasm` outputs in column mode.
|
|
3
|
+
//
|
|
4
|
+
// In column-mode meshing, every dirty column triggers a 3x3 conversion
|
|
5
|
+
// (target + up to 8 neighbors). During the initial-load wave each column ends
|
|
6
|
+
// up being converted up to 9 times (once for itself, once per surrounding
|
|
7
|
+
// column that lists it as a neighbor). This cache short-circuits the
|
|
8
|
+
// redundant conversions.
|
|
9
|
+
//
|
|
10
|
+
// Correctness invariants:
|
|
11
|
+
// - Cache value MUST NOT be mutated by consumers. The `ChunkConversionResult`
|
|
12
|
+
// typed arrays are used as `set()` SOURCES (and as wasm INPUTS) in the
|
|
13
|
+
// mesher tick, never as destinations, so the read-only contract holds.
|
|
14
|
+
// - Identity-based key validation: a hit is served only when the stored
|
|
15
|
+
// chunk reference is `===` the live `world.getColumn(x,z)` reference, so a
|
|
16
|
+
// replaced column (via `world.addColumn`) can never accidentally serve
|
|
17
|
+
// stale data even if the explicit invalidation in the message handler is
|
|
18
|
+
// skipped (defense in depth).
|
|
19
|
+
// - In-place mutation of an existing column object (e.g. via
|
|
20
|
+
// `world.setBlockStateId` from a `blockUpdate` message) preserves identity
|
|
21
|
+
// but changes content. The `blockUpdate` handler explicitly invalidates
|
|
22
|
+
// the affected `(chunkX, chunkZ)` to handle this.
|
|
23
|
+
|
|
24
|
+
import type { ChunkConversionResult } from '../wasm-lib/convertChunk'
|
|
25
|
+
|
|
26
|
+
// Hard cap on entries. A 12x12 visible area ≈ 144 columns; 64 keeps the hot
|
|
27
|
+
// ~8x8 window resident. Tunable.
|
|
28
|
+
export const CONVERSION_CACHE_LIMIT = 64
|
|
29
|
+
|
|
30
|
+
// Active limit, mutable via `setConversionCacheLimit`. `0` disables caching
|
|
31
|
+
// entirely (memory hotfix path for low-RAM environments such as iOS Safari).
|
|
32
|
+
let activeLimit = CONVERSION_CACHE_LIMIT
|
|
33
|
+
|
|
34
|
+
interface CacheEntry {
|
|
35
|
+
chunkRef: any
|
|
36
|
+
version: string
|
|
37
|
+
worldMinY: number
|
|
38
|
+
worldMaxY: number
|
|
39
|
+
result: ChunkConversionResult
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const cache = new Map<string, CacheEntry>()
|
|
43
|
+
let hits = 0
|
|
44
|
+
let misses = 0
|
|
45
|
+
|
|
46
|
+
const keyOf = (x: number, z: number) => `${x},${z}`
|
|
47
|
+
|
|
48
|
+
const isDev = () => {
|
|
49
|
+
try {
|
|
50
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
51
|
+
return typeof process === 'undefined' || process.env?.NODE_ENV !== 'production'
|
|
52
|
+
} catch {
|
|
53
|
+
return true
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface GetOrConvertResult {
|
|
58
|
+
result: ChunkConversionResult
|
|
59
|
+
hit: boolean
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function getOrConvertColumn(
|
|
63
|
+
x: number,
|
|
64
|
+
z: number,
|
|
65
|
+
chunkRef: any,
|
|
66
|
+
version: string,
|
|
67
|
+
worldMinY: number,
|
|
68
|
+
worldMaxY: number,
|
|
69
|
+
convert: () => ChunkConversionResult,
|
|
70
|
+
liveChunkRef?: any
|
|
71
|
+
): GetOrConvertResult {
|
|
72
|
+
const k = keyOf(x, z)
|
|
73
|
+
if (activeLimit <= 0) {
|
|
74
|
+
// Cache disabled — bypass entirely. Drop any stale entry that may
|
|
75
|
+
// pre-date the disable call.
|
|
76
|
+
if (cache.size > 0) cache.delete(k)
|
|
77
|
+
misses++
|
|
78
|
+
return { result: convert(), hit: false }
|
|
79
|
+
}
|
|
80
|
+
const e = cache.get(k)
|
|
81
|
+
if (
|
|
82
|
+
e
|
|
83
|
+
&& e.chunkRef === chunkRef
|
|
84
|
+
&& e.version === version
|
|
85
|
+
&& e.worldMinY === worldMinY
|
|
86
|
+
&& e.worldMaxY === worldMaxY
|
|
87
|
+
) {
|
|
88
|
+
// Defense-in-depth: if the live world ref no longer matches the stored
|
|
89
|
+
// ref, the explicit invalidation path was missed somewhere upstream.
|
|
90
|
+
if (liveChunkRef !== undefined && liveChunkRef !== e.chunkRef) {
|
|
91
|
+
if (isDev()) {
|
|
92
|
+
console.warn(`[WASM Mesher] conversion cache identity drift at ${k} — invalidation likely missed`)
|
|
93
|
+
}
|
|
94
|
+
cache.delete(k)
|
|
95
|
+
} else {
|
|
96
|
+
// LRU bump: re-insert to move to most-recent.
|
|
97
|
+
cache.delete(k)
|
|
98
|
+
cache.set(k, e)
|
|
99
|
+
hits++
|
|
100
|
+
return { result: e.result, hit: true }
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const result = convert()
|
|
105
|
+
cache.delete(k)
|
|
106
|
+
cache.set(k, { chunkRef, version, worldMinY, worldMaxY, result })
|
|
107
|
+
while (cache.size > activeLimit) {
|
|
108
|
+
const oldest = cache.keys().next().value
|
|
109
|
+
if (oldest === undefined) break
|
|
110
|
+
cache.delete(oldest)
|
|
111
|
+
}
|
|
112
|
+
misses++
|
|
113
|
+
return { result, hit: false }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function setConversionCacheLimit(n: number): void {
|
|
117
|
+
activeLimit = Math.max(0, n | 0)
|
|
118
|
+
if (activeLimit === 0) {
|
|
119
|
+
cache.clear()
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
while (cache.size > activeLimit) {
|
|
123
|
+
const oldest = cache.keys().next().value
|
|
124
|
+
if (oldest === undefined) break
|
|
125
|
+
cache.delete(oldest)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function getConversionCacheLimit(): number {
|
|
130
|
+
return activeLimit
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function invalidateConversion(x: number, z: number): boolean {
|
|
134
|
+
return cache.delete(keyOf(x, z))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function clearConversionCache(): void {
|
|
138
|
+
cache.clear()
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function getConversionCacheSize(): number {
|
|
142
|
+
return cache.size
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function consumeConversionCacheStats(): { hits: number, misses: number } {
|
|
146
|
+
const r = { hits, misses }
|
|
147
|
+
hits = 0
|
|
148
|
+
misses = 0
|
|
149
|
+
return r
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Test-only helper: peek without bumping LRU or mutating counters.
|
|
153
|
+
export function _peekConversionCache(x: number, z: number): CacheEntry | undefined {
|
|
154
|
+
return cache.get(keyOf(x, z))
|
|
155
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
//@ts-nocheck
|
|
2
|
+
// Tracks requested section keys and their pending dirty counts separately from
|
|
3
|
+
// the section keys that a full-column WASM meshing call can generate.
|
|
4
|
+
//
|
|
5
|
+
// Why this exists:
|
|
6
|
+
// - The legacy per-section path usually generates exactly the requested key.
|
|
7
|
+
// - The column path can mesh a whole chunk column and produce data for more
|
|
8
|
+
// sections than the main thread requested. `WorldRendererCommon` throws on
|
|
9
|
+
// `sectionFinished` for keys it did not register, so the worker must filter
|
|
10
|
+
// outgoing `geometry`/`sectionFinished` events through this tracker.
|
|
11
|
+
// - Each `setSectionDirty(value=true)` is one logical request and must yield
|
|
12
|
+
// exactly one `sectionFinished` event, mirroring the existing per-key
|
|
13
|
+
// counter semantics of `dirtySections`.
|
|
14
|
+
|
|
15
|
+
export class SectionRequestTracker {
|
|
16
|
+
private readonly counts = new Map<string, number>()
|
|
17
|
+
|
|
18
|
+
/** Register one pending request for `key` (called per dirty-section ingest). */
|
|
19
|
+
addRequest (key: string): void {
|
|
20
|
+
this.counts.set(key, (this.counts.get(key) ?? 0) + 1)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** True if at least one request for `key` is still pending. */
|
|
24
|
+
hasPending (key: string): boolean {
|
|
25
|
+
return (this.counts.get(key) ?? 0) > 0
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Pending request count for `key` (0 if none). */
|
|
29
|
+
pendingCount (key: string): number {
|
|
30
|
+
return this.counts.get(key) ?? 0
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Consume one pending request for `key`. Returns true if a request was
|
|
35
|
+
* consumed, false if there was nothing pending. Callers in the column
|
|
36
|
+
* path must treat `false` as a contract violation (the main thread did
|
|
37
|
+
* not request this key).
|
|
38
|
+
*/
|
|
39
|
+
consumeOne (key: string): boolean {
|
|
40
|
+
const c = this.counts.get(key) ?? 0
|
|
41
|
+
if (c <= 0) return false
|
|
42
|
+
if (c === 1) this.counts.delete(key)
|
|
43
|
+
else this.counts.set(key, c - 1)
|
|
44
|
+
return true
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Clear all pending requests (used on worker reset). */
|
|
48
|
+
clear (): void {
|
|
49
|
+
this.counts.clear()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Number of distinct keys with pending requests. */
|
|
53
|
+
size (): number {
|
|
54
|
+
return this.counts.size
|
|
55
|
+
}
|
|
56
|
+
}
|
package/src/mesher/models.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { World, BlockModelPartsResolved, WorldBlock as Block, WorldBlock, worldC
|
|
|
8
8
|
import { BlockElement, buildRotationMatrix, elemFaces, matmul3, matmulmat3, vecadd3, vecsub3 } from './modelsGeometryCommon'
|
|
9
9
|
import { INVISIBLE_BLOCKS } from './worldConstants'
|
|
10
10
|
import { MesherGeometryOutput, HighestBlockInfo } from './shared'
|
|
11
|
+
import { collectBlockEntityMetadata } from './blockEntityMetadata'
|
|
11
12
|
|
|
12
13
|
// Log function disabled by default for zero overhead in production hot loops
|
|
13
14
|
const ENABLE_TS_LOGS = false
|
|
@@ -598,52 +599,7 @@ export function getSectionGeometry(sx: number, sy: number, sz: number, world: Wo
|
|
|
598
599
|
for (cursor.x = sx; cursor.x < sx + 16; cursor.x++) {
|
|
599
600
|
let block = world.getBlock(cursor, blockProvider, attr)!
|
|
600
601
|
if (INVISIBLE_BLOCKS.has(block.name)) continue
|
|
601
|
-
|
|
602
|
-
const key = `${cursor.x},${cursor.y},${cursor.z}`
|
|
603
|
-
const props: any = block.getProperties()
|
|
604
|
-
const facingRotationMap = {
|
|
605
|
-
'north': 2,
|
|
606
|
-
'south': 0,
|
|
607
|
-
'west': 1,
|
|
608
|
-
'east': 3
|
|
609
|
-
}
|
|
610
|
-
const isWall = block.name.endsWith('wall_sign') || block.name.endsWith('wall_hanging_sign')
|
|
611
|
-
const isHanging = block.name.endsWith('hanging_sign')
|
|
612
|
-
attr.signs[key] = {
|
|
613
|
-
isWall,
|
|
614
|
-
isHanging,
|
|
615
|
-
rotation: isWall ? facingRotationMap[props.facing] : +props.rotation
|
|
616
|
-
}
|
|
617
|
-
} else if (block.name === 'player_head' || block.name === 'player_wall_head') {
|
|
618
|
-
const key = `${cursor.x},${cursor.y},${cursor.z}`
|
|
619
|
-
const props: any = block.getProperties()
|
|
620
|
-
const facingRotationMap = {
|
|
621
|
-
'north': 0,
|
|
622
|
-
'south': 2,
|
|
623
|
-
'west': 3,
|
|
624
|
-
'east': 1
|
|
625
|
-
}
|
|
626
|
-
const isWall = block.name === 'player_wall_head'
|
|
627
|
-
attr.heads[key] = {
|
|
628
|
-
isWall,
|
|
629
|
-
rotation: isWall ? facingRotationMap[props.facing] : +props.rotation
|
|
630
|
-
}
|
|
631
|
-
} else if (block.name.includes('_banner') && !world.config.disableBlockEntityTextures) {
|
|
632
|
-
const key = `${cursor.x},${cursor.y},${cursor.z}`
|
|
633
|
-
const props: any = block.getProperties()
|
|
634
|
-
const facingRotationMap = {
|
|
635
|
-
'north': 2,
|
|
636
|
-
'south': 0,
|
|
637
|
-
'west': 1,
|
|
638
|
-
'east': 3
|
|
639
|
-
}
|
|
640
|
-
const isWall = block.name.endsWith('_wall_banner')
|
|
641
|
-
attr.banners[key] = {
|
|
642
|
-
isWall,
|
|
643
|
-
blockName: block.name, // Pass block name for base color extraction
|
|
644
|
-
rotation: isWall ? facingRotationMap[props.facing] : (props.rotation === undefined ? 0 : +props.rotation)
|
|
645
|
-
}
|
|
646
|
-
}
|
|
602
|
+
collectBlockEntityMetadata(block, cursor.x, cursor.y, cursor.z, attr, { disableBlockEntityTextures: world.config.disableBlockEntityTextures })
|
|
647
603
|
const biome = block.biome.name
|
|
648
604
|
|
|
649
605
|
if (world.preflat) { // 10% perf
|
package/src/mesher/shared.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
//@ts-nocheck
|
|
2
2
|
import { BlockType } from '../playground/shared'
|
|
3
3
|
|
|
4
|
-
export const IS_FULL_WORLD_SECTION = false
|
|
5
4
|
export const SECTION_HEIGHT = 16
|
|
6
5
|
|
|
7
6
|
// only here for easier testing
|
|
@@ -18,7 +17,8 @@ export const defaultMesherConfig = {
|
|
|
18
17
|
// textureSize: 1024, // for testing
|
|
19
18
|
debugModelVariant: undefined as undefined | number[],
|
|
20
19
|
clipWorldBelowY: undefined as undefined | number,
|
|
21
|
-
disableBlockEntityTextures: false
|
|
20
|
+
disableBlockEntityTextures: false,
|
|
21
|
+
disableConversionCache: false,
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
export type CustomBlockModels = {
|
|
@@ -65,7 +65,26 @@ export type MesherGeometryOutput = {
|
|
|
65
65
|
|
|
66
66
|
export interface MesherMainEvents {
|
|
67
67
|
geometry: { type: 'geometry'; key: string; geometry: MesherGeometryOutput; workerIndex: number };
|
|
68
|
-
sectionFinished: {
|
|
68
|
+
sectionFinished: {
|
|
69
|
+
type: 'sectionFinished';
|
|
70
|
+
key: string;
|
|
71
|
+
workerIndex: number;
|
|
72
|
+
processTime?: number;
|
|
73
|
+
pre?: number;
|
|
74
|
+
wasm?: number;
|
|
75
|
+
post?: number;
|
|
76
|
+
// Pre-stage substages (added for column-mode perf instrumentation).
|
|
77
|
+
// All times in ms. `preNeighborConvert` is a SUM across neighbors;
|
|
78
|
+
// divide by `preNeighborCount` for per-neighbor average.
|
|
79
|
+
preTargetConvert?: number;
|
|
80
|
+
preNeighborConvert?: number;
|
|
81
|
+
preNeighborCount?: number;
|
|
82
|
+
preTypedArrayBuild?: number;
|
|
83
|
+
preOther?: number;
|
|
84
|
+
// Per-event counts for the column-mode conversion cache.
|
|
85
|
+
preCacheHits?: number;
|
|
86
|
+
preCacheMisses?: number;
|
|
87
|
+
};
|
|
69
88
|
blockStateModelInfo: { type: 'blockStateModelInfo'; info: Record<string, BlockStateModelInfo> };
|
|
70
89
|
heightmap: { type: 'heightmap'; key: string; heightmap: Int16Array };
|
|
71
90
|
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
//@ts-nocheck
|
|
2
|
+
import { test, expect } from 'vitest'
|
|
3
|
+
import Chunks from 'prismarine-chunk'
|
|
4
|
+
import MinecraftData from 'minecraft-data'
|
|
5
|
+
import { Vec3 } from 'vec3'
|
|
6
|
+
import { World } from '../world'
|
|
7
|
+
import { computeHeightmap } from '../computeHeightmap'
|
|
8
|
+
import { INVISIBLE_BLOCKS } from '../worldConstants'
|
|
9
|
+
import { extractColumnHeightmap, WasmGeometryOutput } from '../../wasm-lib/render-from-wasm'
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Heightmap parity test
|
|
13
|
+
//
|
|
14
|
+
// Verifies that the (future) Rust full-column heightmap (as exposed via the
|
|
15
|
+
// `extractColumnHeightmap` adapter) yields the same 256-entry Int16Array as
|
|
16
|
+
// the existing JS source-of-truth `computeHeightmap`, for representative
|
|
17
|
+
// chunk fixtures.
|
|
18
|
+
//
|
|
19
|
+
// Strategy:
|
|
20
|
+
// 1. Build a real `World` with known blocks.
|
|
21
|
+
// 2. Run the real JS `computeHeightmap` to get the reference.
|
|
22
|
+
// 3. Simulate Rust's full-column iteration in JS (mirroring
|
|
23
|
+
// `wasm-mesher/src/mesher.rs::generate_with_world`: scan all
|
|
24
|
+
// `(y, z, x)` over the full Y range, last write per `(x,z)` wins,
|
|
25
|
+
// skipping blocks whose state IDs are in the `invisible_blocks`
|
|
26
|
+
// set — equivalent to `INVISIBLE_BLOCKS` by name) and pack it into
|
|
27
|
+
// a `WasmGeometryOutput.heightmap` field shaped exactly like Rust
|
|
28
|
+
// returns it (`Vec<i16>` => plain `number[]`, length 256, indexed
|
|
29
|
+
// `z*16+x`, sentinel `-32768`).
|
|
30
|
+
// 4. Run the simulated output through the SAME `extractColumnHeightmap`
|
|
31
|
+
// adapter that the runtime uses, and assert element-wise equality
|
|
32
|
+
// with the JS heightmap.
|
|
33
|
+
//
|
|
34
|
+
// If parity ever fails here, Rust heightmap usage in `mesherWasm.ts` MUST stay
|
|
35
|
+
// disabled and `getHeightmap` MUST keep using the JS handler.
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
const VERSION = '1.16.5'
|
|
39
|
+
|
|
40
|
+
type BlockSpec = { x: number, y: number, z: number, name: string }
|
|
41
|
+
|
|
42
|
+
function buildWorld(blocks: BlockSpec[]): { world: World, invisibleStateIds: Set<number> } {
|
|
43
|
+
const mcData = MinecraftData(VERSION)
|
|
44
|
+
const Chunk = Chunks(VERSION) as any
|
|
45
|
+
const chunk = new Chunk(undefined as any)
|
|
46
|
+
|
|
47
|
+
for (const b of blocks) {
|
|
48
|
+
const id = mcData.blocksByName[b.name]?.defaultState
|
|
49
|
+
if (id == null) throw new Error(`Unknown block name in fixture: ${b.name}`)
|
|
50
|
+
chunk.setBlockStateId(new Vec3(b.x, b.y, b.z), id)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const world = new World(VERSION)
|
|
54
|
+
// computeHeightmap requires worldMinY / worldMaxY on world.config; the
|
|
55
|
+
// defaults (0..256) match the 1.16.5 chunk shape we're using here.
|
|
56
|
+
world.addColumn(0, 0, chunk.toJson())
|
|
57
|
+
|
|
58
|
+
// Build the invisible-state-ID set the way `convertChunkToWasm` does at
|
|
59
|
+
// runtime: every state ID of every block whose name is in
|
|
60
|
+
// INVISIBLE_BLOCKS. Used by the Rust simulation below.
|
|
61
|
+
const invisibleStateIds = new Set<number>()
|
|
62
|
+
for (const block of mcData.blocksArray) {
|
|
63
|
+
if (!INVISIBLE_BLOCKS.has(block.name)) continue
|
|
64
|
+
const min = block.minStateId ?? block.defaultState
|
|
65
|
+
const max = block.maxStateId ?? block.defaultState
|
|
66
|
+
for (let id = min; id <= max; id++) invisibleStateIds.add(id)
|
|
67
|
+
}
|
|
68
|
+
return { world, invisibleStateIds }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* JS port of `Mesher::generate_with_world` heightmap pass — bottom-up
|
|
73
|
+
* iteration over the full column with last-write-wins per `(x,z)`.
|
|
74
|
+
* Returns a plain `number[]` matching the on-the-wire shape of Rust's
|
|
75
|
+
* `Vec<i16>` (length 256, indexed `z*16+x`, sentinel `-32768`).
|
|
76
|
+
*/
|
|
77
|
+
function simulateRustColumnHeightmap(
|
|
78
|
+
world: World,
|
|
79
|
+
invisibleStateIds: Set<number>,
|
|
80
|
+
worldMinY: number,
|
|
81
|
+
worldMaxY: number
|
|
82
|
+
): number[] {
|
|
83
|
+
const heightmap = new Array<number>(256).fill(-32768)
|
|
84
|
+
const column = world.getColumn(0, 0)
|
|
85
|
+
if (!column) return heightmap
|
|
86
|
+
|
|
87
|
+
const pos = new Vec3(0, 0, 0)
|
|
88
|
+
for (let y = worldMinY; y < worldMaxY; y++) {
|
|
89
|
+
for (let z = 0; z < 16; z++) {
|
|
90
|
+
for (let x = 0; x < 16; x++) {
|
|
91
|
+
pos.x = x
|
|
92
|
+
pos.y = y
|
|
93
|
+
pos.z = z
|
|
94
|
+
const stateId = column.getBlockStateId(pos)
|
|
95
|
+
if (stateId === 0 || invisibleStateIds.has(stateId)) continue
|
|
96
|
+
heightmap[z * 16 + x] = y
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return heightmap
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function makeWasmOutputWithHeightmap(heightmap: number[]): WasmGeometryOutput {
|
|
104
|
+
return {
|
|
105
|
+
blocks: [],
|
|
106
|
+
block_count: 0,
|
|
107
|
+
block_iterations: 0,
|
|
108
|
+
heightmap,
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function runParity(blocks: BlockSpec[]): { js: Int16Array, rust: Int16Array } {
|
|
113
|
+
const { world, invisibleStateIds } = buildWorld(blocks)
|
|
114
|
+
const js = computeHeightmap(world, 0, 0)
|
|
115
|
+
|
|
116
|
+
const rustShaped = simulateRustColumnHeightmap(
|
|
117
|
+
world,
|
|
118
|
+
invisibleStateIds,
|
|
119
|
+
world.config.worldMinY,
|
|
120
|
+
world.config.worldMaxY
|
|
121
|
+
)
|
|
122
|
+
const wasmOutput = makeWasmOutputWithHeightmap(rustShaped)
|
|
123
|
+
const rust = extractColumnHeightmap(wasmOutput)
|
|
124
|
+
expect(rust).not.toBeNull()
|
|
125
|
+
return { js, rust: rust! }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
test('heightmap parity: flat stone layer at y=5', () => {
|
|
129
|
+
const blocks: BlockSpec[] = []
|
|
130
|
+
for (let z = 0; z < 16; z++) {
|
|
131
|
+
for (let x = 0; x < 16; x++) {
|
|
132
|
+
blocks.push({ x, y: 5, z, name: 'stone' })
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const { js, rust } = runParity(blocks)
|
|
136
|
+
expect(rust.length).toBe(256)
|
|
137
|
+
expect(js.length).toBe(256)
|
|
138
|
+
expect(Array.from(rust)).toEqual(Array.from(js))
|
|
139
|
+
// Sanity: every column should report y=5.
|
|
140
|
+
for (let i = 0; i < 256; i++) expect(js[i]).toBe(5)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('heightmap parity: varied heights, every column populated (Rust == JS)', () => {
|
|
144
|
+
// Fully-populated chunk (every (x,z) has at least one block) so we sidestep
|
|
145
|
+
// the documented empty-column gap captured in the next test.
|
|
146
|
+
const blocks: BlockSpec[] = []
|
|
147
|
+
for (let z = 0; z < 16; z++) {
|
|
148
|
+
for (let x = 0; x < 16; x++) {
|
|
149
|
+
// Default floor.
|
|
150
|
+
blocks.push({ x, y: 5, z, name: 'stone' })
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// Layered overrides exercising mid/high/low surfaces and invisible-skipping.
|
|
154
|
+
blocks.push({ x: 0, y: 64, z: 0, name: 'stone' })
|
|
155
|
+
blocks.push({ x: 1, y: 70, z: 0, name: 'dirt' })
|
|
156
|
+
blocks.push({ x: 2, y: 80, z: 0, name: 'oak_log' }) // top wins over y=64 below
|
|
157
|
+
blocks.push({ x: 2, y: 64, z: 0, name: 'stone' })
|
|
158
|
+
blocks.push({ x: 5, y: 255, z: 5, name: 'stone' }) // high-Y edge (worldMaxY-1)
|
|
159
|
+
blocks.push({ x: 6, y: 0, z: 6, name: 'stone' }) // low-Y edge (worldMinY)
|
|
160
|
+
// Invisible block above a real surface must be skipped on both sides.
|
|
161
|
+
blocks.push({ x: 7, y: 10, z: 7, name: 'stone' })
|
|
162
|
+
blocks.push({ x: 7, y: 11, z: 7, name: 'barrier' })
|
|
163
|
+
blocks.push({ x: 8, y: 12, z: 8, name: 'stone' })
|
|
164
|
+
blocks.push({ x: 8, y: 13, z: 8, name: 'cave_air' })
|
|
165
|
+
|
|
166
|
+
const { js, rust } = runParity(blocks)
|
|
167
|
+
expect(Array.from(rust)).toEqual(Array.from(js))
|
|
168
|
+
|
|
169
|
+
// Spot-check absolute values so a future regression in either side
|
|
170
|
+
// doesn't silently align them on a wrong shared value.
|
|
171
|
+
expect(js[0 * 16 + 0]).toBe(64)
|
|
172
|
+
expect(js[0 * 16 + 1]).toBe(70)
|
|
173
|
+
expect(js[0 * 16 + 2]).toBe(80)
|
|
174
|
+
expect(js[5 * 16 + 5]).toBe(255)
|
|
175
|
+
expect(js[6 * 16 + 6]).toBe(5) // y=0 stone is below the y=5 floor stone
|
|
176
|
+
expect(js[7 * 16 + 7]).toBe(10) // barrier above must be skipped
|
|
177
|
+
expect(js[8 * 16 + 8]).toBe(12) // cave_air above must be skipped
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// Empty-column parity (post-alignment).
|
|
183
|
+
//
|
|
184
|
+
// Historically `computeHeightmap` returned `0` (== worldMinY) for a fully-
|
|
185
|
+
// empty column, because its loop reads at worldMinY, finds air (truthy block,
|
|
186
|
+
// name in INVISIBLE_BLOCKS), exits the `while` because `blockPos.y > worldMinY`
|
|
187
|
+
// becomes false, and fell through to `heightmap[index] = blockPos.y`
|
|
188
|
+
// (== worldMinY). Rust's mesher writes `-32768` for any column with no
|
|
189
|
+
// non-invisible block, so the two encodings disagreed and the WASM
|
|
190
|
+
// `getHeightmap` runtime switch was blocked on that gap.
|
|
191
|
+
//
|
|
192
|
+
// `computeHeightmap` has since been aligned: it now writes
|
|
193
|
+
// `EMPTY_COLUMN_HEIGHTMAP_SENTINEL` (-32768) for empty columns, matching
|
|
194
|
+
// Rust. This test pins that alignment so a regression can never silently
|
|
195
|
+
// re-introduce the divergence.
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
test('heightmap parity: empty columns produce the same sentinel (-32768) in JS and Rust', () => {
|
|
198
|
+
// No blocks at all — fully empty chunk.
|
|
199
|
+
const { js, rust } = runParity([])
|
|
200
|
+
for (let i = 0; i < 256; i++) {
|
|
201
|
+
expect(js[i]).toBe(-32768)
|
|
202
|
+
expect(rust[i]).toBe(-32768)
|
|
203
|
+
}
|
|
204
|
+
expect(Array.from(rust)).toEqual(Array.from(js))
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('extractColumnHeightmap: returns null for missing or wrong-length heightmap (forces JS fallback)', () => {
|
|
208
|
+
expect(extractColumnHeightmap({ heightmap: null })).toBeNull()
|
|
209
|
+
expect(extractColumnHeightmap({})).toBeNull()
|
|
210
|
+
expect(extractColumnHeightmap({ heightmap: [1, 2, 3] })).toBeNull()
|
|
211
|
+
expect(extractColumnHeightmap(undefined)).toBeNull()
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
test('extractColumnHeightmap: accepts both number[] and Int16Array shapes', () => {
|
|
215
|
+
const arr = new Array<number>(256).fill(-32768)
|
|
216
|
+
arr[0] = 42
|
|
217
|
+
const fromArr = extractColumnHeightmap({ heightmap: arr })!
|
|
218
|
+
expect(fromArr).toBeInstanceOf(Int16Array)
|
|
219
|
+
expect(fromArr[0]).toBe(42)
|
|
220
|
+
expect(fromArr[1]).toBe(-32768)
|
|
221
|
+
|
|
222
|
+
const typed = new Int16Array(256)
|
|
223
|
+
typed.fill(-32768)
|
|
224
|
+
typed[5] = 99
|
|
225
|
+
const fromTyped = extractColumnHeightmap({ heightmap: typed })!
|
|
226
|
+
expect(fromTyped).toBeInstanceOf(Int16Array)
|
|
227
|
+
expect(fromTyped[5]).toBe(99)
|
|
228
|
+
// Must be a copy, not aliased — runtime transfers the buffer to the
|
|
229
|
+
// main thread and would otherwise detach the cached one.
|
|
230
|
+
expect(fromTyped).not.toBe(typed)
|
|
231
|
+
})
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
//@ts-nocheck
|
|
2
|
+
import { describe, test, expect, beforeEach, vi } from 'vitest'
|
|
3
|
+
import {
|
|
4
|
+
CONVERSION_CACHE_LIMIT,
|
|
5
|
+
_peekConversionCache,
|
|
6
|
+
clearConversionCache,
|
|
7
|
+
getOrConvertColumn,
|
|
8
|
+
invalidateConversion,
|
|
9
|
+
} from '../mesherWasmConversionCache'
|
|
10
|
+
import type { ChunkConversionResult } from '../../wasm-lib/convertChunk'
|
|
11
|
+
|
|
12
|
+
const makeResult = (tag: number): ChunkConversionResult => ({
|
|
13
|
+
blockStates: new Uint16Array([tag]),
|
|
14
|
+
blockLight: new Uint8Array(0),
|
|
15
|
+
skyLight: new Uint8Array(0),
|
|
16
|
+
biomesArray: new Uint8Array(0),
|
|
17
|
+
invisibleBlocks: new Uint16Array(0),
|
|
18
|
+
transparentBlocks: new Uint16Array(0),
|
|
19
|
+
noAoBlocks: new Uint16Array(0),
|
|
20
|
+
cullIdenticalBlocks: new Uint16Array(0),
|
|
21
|
+
occludingBlocks: new Uint16Array(0),
|
|
22
|
+
blockCount: 0,
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
describe('mesherWasmConversionCache', () => {
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
clearConversionCache()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('miss then hit on same chunk ref returns cached result', () => {
|
|
31
|
+
const ref = { id: 'chunk-a' }
|
|
32
|
+
let calls = 0
|
|
33
|
+
const convert = () => {
|
|
34
|
+
calls++
|
|
35
|
+
return makeResult(1)
|
|
36
|
+
}
|
|
37
|
+
const a = getOrConvertColumn(0, 0, ref, 'v', 0, 256, convert, ref)
|
|
38
|
+
expect(a.hit).toBe(false)
|
|
39
|
+
expect(calls).toBe(1)
|
|
40
|
+
const b = getOrConvertColumn(0, 0, ref, 'v', 0, 256, convert, ref)
|
|
41
|
+
expect(b.hit).toBe(true)
|
|
42
|
+
expect(calls).toBe(1)
|
|
43
|
+
expect(b.result).toBe(a.result)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('miss when chunk reference changes (chunk message replacement)', () => {
|
|
47
|
+
const ref1 = { id: 'r1' }
|
|
48
|
+
const ref2 = { id: 'r2' }
|
|
49
|
+
const r1 = getOrConvertColumn(16, 32, ref1, 'v', 0, 256, () => makeResult(1), ref1)
|
|
50
|
+
const r2 = getOrConvertColumn(16, 32, ref2, 'v', 0, 256, () => makeResult(2), ref2)
|
|
51
|
+
expect(r1.hit).toBe(false)
|
|
52
|
+
expect(r2.hit).toBe(false)
|
|
53
|
+
expect(r2.result).not.toBe(r1.result)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('explicit invalidation forces recompute', () => {
|
|
57
|
+
const ref = { id: 'r' }
|
|
58
|
+
const r1 = getOrConvertColumn(0, 0, ref, 'v', 0, 256, () => makeResult(1), ref)
|
|
59
|
+
expect(r1.hit).toBe(false)
|
|
60
|
+
expect(invalidateConversion(0, 0)).toBe(true)
|
|
61
|
+
const r2 = getOrConvertColumn(0, 0, ref, 'v', 0, 256, () => makeResult(2), ref)
|
|
62
|
+
expect(r2.hit).toBe(false)
|
|
63
|
+
expect(_peekConversionCache(0, 0)?.result).toBe(r2.result)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('invalidating a non-existent key returns false', () => {
|
|
67
|
+
expect(invalidateConversion(999, 999)).toBe(false)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test('LRU evicts oldest beyond CONVERSION_CACHE_LIMIT', () => {
|
|
71
|
+
const refs: any[] = []
|
|
72
|
+
for (let i = 0; i < CONVERSION_CACHE_LIMIT + 5; i++) {
|
|
73
|
+
const ref = { id: i }
|
|
74
|
+
refs.push(ref)
|
|
75
|
+
getOrConvertColumn(i * 16, 0, ref, 'v', 0, 256, () => makeResult(i), ref)
|
|
76
|
+
}
|
|
77
|
+
// The first 5 entries should have been evicted.
|
|
78
|
+
for (let i = 0; i < 5; i++) {
|
|
79
|
+
expect(_peekConversionCache(i * 16, 0)).toBeUndefined()
|
|
80
|
+
}
|
|
81
|
+
// The most-recent entries remain.
|
|
82
|
+
for (let i = 5; i < CONVERSION_CACHE_LIMIT + 5; i++) {
|
|
83
|
+
expect(_peekConversionCache(i * 16, 0)).toBeDefined()
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('hit bumps LRU recency (touched entry survives eviction)', () => {
|
|
88
|
+
const ref0 = { id: 'keep' }
|
|
89
|
+
getOrConvertColumn(0, 0, ref0, 'v', 0, 256, () => makeResult(0), ref0)
|
|
90
|
+
// Fill the cache up to the limit.
|
|
91
|
+
for (let i = 1; i < CONVERSION_CACHE_LIMIT; i++) {
|
|
92
|
+
const ref = { id: i }
|
|
93
|
+
getOrConvertColumn(i * 16, 0, ref, 'v', 0, 256, () => makeResult(i), ref)
|
|
94
|
+
}
|
|
95
|
+
// Touch the oldest (0,0) -> it becomes most-recent.
|
|
96
|
+
const touched = getOrConvertColumn(0, 0, ref0, 'v', 0, 256, () => makeResult(99), ref0)
|
|
97
|
+
expect(touched.hit).toBe(true)
|
|
98
|
+
// Insert one more; the new oldest (16,0) should be evicted, not (0,0).
|
|
99
|
+
const refExtra = { id: 'extra' }
|
|
100
|
+
getOrConvertColumn(9999, 0, refExtra, 'v', 0, 256, () => makeResult(7), refExtra)
|
|
101
|
+
expect(_peekConversionCache(0, 0)).toBeDefined()
|
|
102
|
+
expect(_peekConversionCache(16, 0)).toBeUndefined()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
test('miss when worldMinY/worldMaxY/version metadata changes', () => {
|
|
106
|
+
const ref = { id: 'r' }
|
|
107
|
+
const r1 = getOrConvertColumn(0, 0, ref, 'v1', 0, 256, () => makeResult(1), ref)
|
|
108
|
+
const r2 = getOrConvertColumn(0, 0, ref, 'v2', 0, 256, () => makeResult(2), ref)
|
|
109
|
+
const r3 = getOrConvertColumn(0, 0, ref, 'v2', -64, 256, () => makeResult(3), ref)
|
|
110
|
+
const r4 = getOrConvertColumn(0, 0, ref, 'v2', -64, 320, () => makeResult(4), ref)
|
|
111
|
+
expect(r1.hit).toBe(false)
|
|
112
|
+
expect(r2.hit).toBe(false)
|
|
113
|
+
expect(r3.hit).toBe(false)
|
|
114
|
+
expect(r4.hit).toBe(false)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test('identity drift between stored and live ref triggers warn + miss', () => {
|
|
118
|
+
const stored = { id: 'old' }
|
|
119
|
+
const live = { id: 'new' } // simulates a missed invalidation path
|
|
120
|
+
const r1 = getOrConvertColumn(0, 0, stored, 'v', 0, 256, () => makeResult(1), stored)
|
|
121
|
+
expect(r1.hit).toBe(false)
|
|
122
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
123
|
+
const r2 = getOrConvertColumn(0, 0, stored, 'v', 0, 256, () => makeResult(2), live)
|
|
124
|
+
expect(warn).toHaveBeenCalledTimes(1)
|
|
125
|
+
expect(r2.hit).toBe(false)
|
|
126
|
+
warn.mockRestore()
|
|
127
|
+
})
|
|
128
|
+
})
|
|
@@ -10,8 +10,8 @@ export const getChunk = () => {
|
|
|
10
10
|
const chunkDataBuffer = Buffer.from(chunkData.data)
|
|
11
11
|
|
|
12
12
|
const chunk = new Chunk({ minY: 0, worldHeight: 256, x: 0, z: 0 }) as PCChunk
|
|
13
|
-
|
|
14
|
-
chunk.setBlockStateId(new Vec3(0, 1, 0), 1)
|
|
13
|
+
chunk.load(chunkDataBuffer, bitMap, true, groundUp)
|
|
14
|
+
// chunk.setBlockStateId(new Vec3(0, 1, 0), 1)
|
|
15
15
|
// chunk.setBlockStateId(new Vec3(0, 0, 1), 1)
|
|
16
16
|
|
|
17
17
|
return chunk
|