minecraft-renderer 0.1.88 → 0.1.90
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 +3 -3
- package/dist/mesherWasm.js +168 -168
- package/dist/minecraft-renderer.js +58 -58
- package/dist/minecraft-renderer.js.meta.json +1 -1
- package/dist/threeWorker.js +394 -394
- package/package.json +1 -1
- package/src/mesher-shared/faceOcclusion.ts +56 -12
- package/src/mesher-shared/tests/faceOcclusion.test.ts +36 -0
- package/src/playground/scenes/partialBlockCulling.ts +14 -1
- package/src/three/cubeDrawSpans.ts +1 -9
- package/src/three/globalBlockBuffer.ts +0 -2
- package/src/three/globalLegacyBuffer.ts +0 -31
- package/src/three/tests/cubeDrawSpans.test.ts +31 -0
- package/src/three/tests/globalLegacyBuffer.test.ts +15 -0
- package/src/wasm-mesher/bridge/render-from-wasm.ts +6 -2
- package/src/wasm-mesher/tests/cullingRegression.test.ts +158 -0
package/package.json
CHANGED
|
@@ -253,19 +253,21 @@ function getBlockFromStateId(version: string, stateId: number) {
|
|
|
253
253
|
return Block.fromStateId(stateId, 1)
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
-
function
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
return
|
|
256
|
+
function isFullCubeRenderModel(models: BlockModelPartsResolved | undefined): boolean {
|
|
257
|
+
try {
|
|
258
|
+
if (!models?.length || models.length !== 1) return false
|
|
259
|
+
return models[0]!.every(v =>
|
|
260
|
+
(v.elements ?? []).every(e => {
|
|
261
|
+
return e.from[0] === 0 && e.from[1] === 0 && e.from[2] === 0 && e.to[0] === 16 && e.to[1] === 16 && e.to[2] === 16
|
|
262
|
+
})
|
|
263
|
+
)
|
|
264
|
+
} catch {
|
|
265
|
+
return false
|
|
266
266
|
}
|
|
267
|
+
}
|
|
267
268
|
|
|
268
|
-
|
|
269
|
+
function buildShapesFromRenderModel(models: BlockModelPartsResolved | undefined): Uint16Array[] {
|
|
270
|
+
const shapes = DIR_KEYS.map(() => emptyShape())
|
|
269
271
|
|
|
270
272
|
for (const modelVars of models ?? []) {
|
|
271
273
|
const model = modelVars[0]
|
|
@@ -289,10 +291,52 @@ function getAllShapesForState(version: string, stateId: number, blockProvider: W
|
|
|
289
291
|
}
|
|
290
292
|
}
|
|
291
293
|
|
|
292
|
-
shapeCache.set(cacheKey, shapes)
|
|
293
294
|
return shapes
|
|
294
295
|
}
|
|
295
296
|
|
|
297
|
+
function buildShapesFromCollisionBoxes(boxes: number[][]): Uint16Array[] {
|
|
298
|
+
const shapes = DIR_KEYS.map(() => emptyShape())
|
|
299
|
+
|
|
300
|
+
for (const box of boxes) {
|
|
301
|
+
const x0 = snapBlockUnit(box[0]! * 16)
|
|
302
|
+
const y0 = snapBlockUnit(box[1]! * 16)
|
|
303
|
+
const z0 = snapBlockUnit(box[2]! * 16)
|
|
304
|
+
const x1 = snapBlockUnit(box[3]! * 16)
|
|
305
|
+
const y1 = snapBlockUnit(box[4]! * 16)
|
|
306
|
+
const z1 = snapBlockUnit(box[5]! * 16)
|
|
307
|
+
|
|
308
|
+
if (y1 === 16) orRectIntoShape(x0, z0, x1, z1, shapes[dirIndex([0, 1, 0])]!)
|
|
309
|
+
if (y0 === 0) orRectIntoShape(x0, z0, x1, z1, shapes[dirIndex([0, -1, 0])]!)
|
|
310
|
+
if (x1 === 16) orRectIntoShape(z0, y0, z1, y1, shapes[dirIndex([1, 0, 0])]!)
|
|
311
|
+
if (x0 === 0) orRectIntoShape(z0, y0, z1, y1, shapes[dirIndex([-1, 0, 0])]!)
|
|
312
|
+
if (z1 === 16) orRectIntoShape(x0, y0, x1, y1, shapes[dirIndex([0, 0, 1])]!)
|
|
313
|
+
if (z0 === 0) orRectIntoShape(x0, y0, x1, y1, shapes[dirIndex([0, 0, -1])]!)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return shapes
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function getAllShapesForState(version: string, stateId: number, blockProvider: WorldBlockProvider): Uint16Array[] {
|
|
320
|
+
const cacheKey = `${version}:${stateId}`
|
|
321
|
+
const cached = shapeCache.get(cacheKey)
|
|
322
|
+
if (cached) return cached
|
|
323
|
+
|
|
324
|
+
const shapes = DIR_KEYS.map(() => emptyShape())
|
|
325
|
+
const blockObj = getBlockFromStateId(version, stateId)
|
|
326
|
+
if (!blockObj || !blockRendersSolid(blockObj)) {
|
|
327
|
+
shapeCache.set(cacheKey, shapes)
|
|
328
|
+
return shapes
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const models = blockProvider.getAllResolvedModels0_1({ name: blockObj.name, properties: blockObj.getProperties() }, false) as BlockModelPartsResolved
|
|
332
|
+
const boxes = blockObj.shapes as number[][] | undefined
|
|
333
|
+
|
|
334
|
+
const resolvedShapes = isFullCubeRenderModel(models) ? buildShapesFromRenderModel(models) : boxes?.length ? buildShapesFromCollisionBoxes(boxes) : shapes
|
|
335
|
+
|
|
336
|
+
shapeCache.set(cacheKey, resolvedShapes)
|
|
337
|
+
return resolvedShapes
|
|
338
|
+
}
|
|
339
|
+
|
|
296
340
|
export function getOcclusionShape(version: string, stateId: number, worldDir: CardinalDir, blockProvider: WorldBlockProvider): Uint16Array {
|
|
297
341
|
return getAllShapesForState(version, stateId, blockProvider)[dirIndex(worldDir)]!
|
|
298
342
|
}
|
|
@@ -73,6 +73,42 @@ test('getOcclusionShape: full cube covers entire plane', () => {
|
|
|
73
73
|
}
|
|
74
74
|
})
|
|
75
75
|
|
|
76
|
+
test('getOcclusionShape: cactus down occlusion is inset, not full footprint', () => {
|
|
77
|
+
const mcData = MinecraftData(VERSION)
|
|
78
|
+
const cactusId = mcData.blocksByName.cactus!.defaultState
|
|
79
|
+
const downShape = getOcclusionShape(VERSION, cactusId, [0, -1, 0], blockProvider())
|
|
80
|
+
expect(downShape[0]! & 1).toBe(0)
|
|
81
|
+
expect(downShape[0]! & (1 << 15)).toBe(0)
|
|
82
|
+
let covered = 0
|
|
83
|
+
for (let row = 0; row < 16; row++) {
|
|
84
|
+
for (let col = 0; col < 16; col++) {
|
|
85
|
+
if (downShape[row]! & (1 << col)) covered++
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
expect(covered).toBe(14 * 14)
|
|
89
|
+
expect(getOcclusionShape(VERSION, cactusId, [0, 1, 0], blockProvider())[0]).toBe(0)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('faceIsCulled: grass top not culled by cactus above', () => {
|
|
93
|
+
const mcData = MinecraftData(VERSION)
|
|
94
|
+
const grassId = mcData.blocksByName.grass_block!.defaultState
|
|
95
|
+
const cactusId = mcData.blocksByName.cactus!.defaultState
|
|
96
|
+
const provider = blockProvider()
|
|
97
|
+
const blockObj = PrismarineBlockLoader(VERSION).fromStateId(grassId, 1)
|
|
98
|
+
const models = provider.getAllResolvedModels0_1({ name: blockObj.name, properties: blockObj.getProperties() }, false)
|
|
99
|
+
const element = models[0]![0]!.elements![0]!
|
|
100
|
+
expect(faceIsCulled(VERSION, element, 'up', cactusId, { stateId: grassId, name: 'grass_block' }, provider, [0, 1, 0], null)).toBe(false)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('getOcclusionShape: soul sand keeps full render-model occlusion despite shorter collision', () => {
|
|
104
|
+
const mcData = MinecraftData(VERSION)
|
|
105
|
+
const soulSandId = mcData.blocksByName.soul_sand!.defaultState
|
|
106
|
+
const downShape = getOcclusionShape(VERSION, soulSandId, [0, -1, 0], blockProvider())
|
|
107
|
+
for (let row = 0; row < 16; row++) {
|
|
108
|
+
expect(downShape[row]).toBe(0xffff)
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
|
|
76
112
|
test('getOcclusionShape: farmland side is shorter than full height', () => {
|
|
77
113
|
const mcData = MinecraftData(VERSION)
|
|
78
114
|
const farmlandId = mcData.blocksByName.farmland!.defaultState
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
//@ts-nocheck
|
|
2
2
|
import { BasePlaygroundScene } from '../baseScene'
|
|
3
3
|
|
|
4
|
-
/** Manual check for issue #49:
|
|
4
|
+
/** Manual check for issue #49/#73: partial-block culling + cactus on grass (1.18.2). */
|
|
5
5
|
export default class extends BasePlaygroundScene {
|
|
6
6
|
override version = '1.18.2'
|
|
7
7
|
enableCameraOrbitControl = true
|
|
@@ -27,5 +27,18 @@ export default class extends BasePlaygroundScene {
|
|
|
27
27
|
this.addWorldBlock(20 + x, stairY, 4, 'cut_copper_stairs', stairProps)
|
|
28
28
|
this.addWorldBlock(20 + x, stairY + 1, 4, 'cut_copper_stairs', stairProps)
|
|
29
29
|
}
|
|
30
|
+
|
|
31
|
+
const cactusY = 64
|
|
32
|
+
for (let x = 0; x < 4; x++) {
|
|
33
|
+
this.addWorldBlock(30 + x, cactusY - 1, 8, 'grass_block')
|
|
34
|
+
this.addWorldBlock(30 + x, cactusY, 8, 'cactus')
|
|
35
|
+
}
|
|
36
|
+
this.addWorldBlock(35, cactusY - 1, 8, 'grass_block')
|
|
37
|
+
this.addWorldBlock(35, cactusY, 8, 'cactus')
|
|
38
|
+
this.addWorldBlock(35, cactusY + 1, 8, 'cactus')
|
|
39
|
+
|
|
40
|
+
this.addWorldBlock(30, cactusY - 1, 10, 'soul_sand')
|
|
41
|
+
this.addWorldBlock(31, cactusY - 1, 10, 'stone_slab', { type: 'bottom' })
|
|
42
|
+
this.addWorldBlock(32, cactusY - 1, 10, 'stone_slab', { type: 'bottom' })
|
|
30
43
|
}
|
|
31
44
|
}
|
|
@@ -1,11 +1,5 @@
|
|
|
1
1
|
//@ts-nocheck
|
|
2
|
-
import {
|
|
3
|
-
assertDrawSpansWithinLiveRanges,
|
|
4
|
-
carveSpansAroundPendingRanges,
|
|
5
|
-
FULL_DRAW_VISIBLE_FRACTION,
|
|
6
|
-
MAX_OPAQUE_SPANS,
|
|
7
|
-
type DirtyRange
|
|
8
|
-
} from './globalLegacyBuffer'
|
|
2
|
+
import { carveSpansAroundPendingRanges, FULL_DRAW_VISIBLE_FRACTION, MAX_OPAQUE_SPANS, type DirtyRange } from './globalLegacyBuffer'
|
|
9
3
|
|
|
10
4
|
export { FULL_DRAW_VISIBLE_FRACTION, MAX_OPAQUE_SPANS as MAX_CUBE_SPANS }
|
|
11
5
|
|
|
@@ -54,10 +48,8 @@ export function buildVisibleCubeSpans(
|
|
|
54
48
|
} else {
|
|
55
49
|
spans = visibleSlots.map(s => ({ start: s.start, count: s.count }))
|
|
56
50
|
spans.sort((a, b) => a.start - b.start)
|
|
57
|
-
const liveDrawRanges = spans.map(s => ({ ...s }))
|
|
58
51
|
mergeCubeSpans(spans)
|
|
59
52
|
spans = carveSpansAroundPendingRanges(spans, pendingRanges)
|
|
60
|
-
assertDrawSpansWithinLiveRanges(spans, liveDrawRanges, 'globalBlockBuffer')
|
|
61
53
|
return spans
|
|
62
54
|
}
|
|
63
55
|
return carveSpansAroundPendingRanges(spans, pendingRanges)
|
|
@@ -711,10 +711,8 @@ export class GlobalBlockBuffer {
|
|
|
711
711
|
this.finalizePendingReplace(key)
|
|
712
712
|
}
|
|
713
713
|
|
|
714
|
-
console.warn('[globalBlockBuffer] growing faces', this.capacityFaces, '->', '(need', minFaces, ')')
|
|
715
714
|
let newCap = this.capacityFaces
|
|
716
715
|
while (newCap < minFaces) newCap += GROWTH_INCREMENT_FACES
|
|
717
|
-
console.warn('[globalBlockBuffer] growing faces', this.capacityFaces, '->', newCap)
|
|
718
716
|
|
|
719
717
|
const nw0 = new Uint32Array(newCap)
|
|
720
718
|
const nw1 = new Uint32Array(newCap)
|
|
@@ -32,33 +32,6 @@ export const FULL_DRAW_VISIBLE_FRACTION = 0.75
|
|
|
32
32
|
/** Initial multi_draw scratch size; arrays auto-grow — not a draw-call cap. */
|
|
33
33
|
export const MAX_OPAQUE_SPANS = 64
|
|
34
34
|
|
|
35
|
-
/** Dev assert: every quad in draw spans must lie in a live section's drawable range. */
|
|
36
|
-
export function assertDrawSpansWithinLiveRanges(
|
|
37
|
-
spans: ReadonlyArray<{ start: number; count: number }>,
|
|
38
|
-
liveRanges: ReadonlyArray<{ start: number; count: number }>,
|
|
39
|
-
bufferName: string
|
|
40
|
-
): void {
|
|
41
|
-
for (const span of spans) {
|
|
42
|
-
for (let q = span.start; q < span.start + span.count; q++) {
|
|
43
|
-
let inLive = false
|
|
44
|
-
for (const live of liveRanges) {
|
|
45
|
-
if (q >= live.start && q < live.start + live.count) {
|
|
46
|
-
inLive = true
|
|
47
|
-
break
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
if (!inLive) {
|
|
51
|
-
console.error('[GlobalLegacyBuffer] draw span covers non-live quad', {
|
|
52
|
-
buffer: bufferName,
|
|
53
|
-
quad: q,
|
|
54
|
-
span,
|
|
55
|
-
liveRanges
|
|
56
|
-
})
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
|
|
62
35
|
export type DirtyRange = { start: number; end: number }
|
|
63
36
|
|
|
64
37
|
/** Split draw spans to exclude quad/face ranges still in pendingRanges (not yet on GPU). */
|
|
@@ -481,7 +454,6 @@ export class GlobalLegacyBuffer {
|
|
|
481
454
|
|
|
482
455
|
if (mode === 'opaque') {
|
|
483
456
|
let finalQuads: Array<{ start: number; count: number }>
|
|
484
|
-
const liveDrawRanges = spans.map(s => ({ ...s }))
|
|
485
457
|
const usedFullDraw = this.canUseFullDrawShortcut() && visibleQuadCount >= this.highWatermark * FULL_DRAW_VISIBLE_FRACTION
|
|
486
458
|
if (usedFullDraw) {
|
|
487
459
|
finalQuads = [{ start: 0, count: this.highWatermark }]
|
|
@@ -491,9 +463,6 @@ export class GlobalLegacyBuffer {
|
|
|
491
463
|
finalQuads = spans
|
|
492
464
|
}
|
|
493
465
|
finalQuads = carveSpansAroundPendingRanges(finalQuads, this.pendingRanges)
|
|
494
|
-
if (!usedFullDraw) {
|
|
495
|
-
assertDrawSpansWithinLiveRanges(finalQuads, liveDrawRanges, this.mesh.name)
|
|
496
|
-
}
|
|
497
466
|
for (const span of finalQuads) {
|
|
498
467
|
pushIndexSpan(span.start, span.count)
|
|
499
468
|
}
|
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
import { describe, expect, test } from 'vitest'
|
|
3
3
|
import { buildVisibleCubeSpans } from '../cubeDrawSpans'
|
|
4
4
|
|
|
5
|
+
type DrawSpan = { start: number; count: number }
|
|
6
|
+
|
|
7
|
+
/** Every index in output spans must lie in the union of input section ranges. */
|
|
8
|
+
function expectEveryIndexInUnion(spans: DrawSpan[], sectionRanges: DrawSpan[]): void {
|
|
9
|
+
for (const span of spans) {
|
|
10
|
+
for (let i = span.start; i < span.start + span.count; i++) {
|
|
11
|
+
const inUnion = sectionRanges.some(r => i >= r.start && i < r.start + r.count)
|
|
12
|
+
expect(inUnion, `index ${i} not in union of section ranges`).toBe(true)
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
5
17
|
describe('buildVisibleCubeSpans', () => {
|
|
6
18
|
test('contiguous slots merge into one span', () => {
|
|
7
19
|
const spans = buildVisibleCubeSpans(
|
|
@@ -100,4 +112,23 @@ describe('buildVisibleCubeSpans', () => {
|
|
|
100
112
|
expect(buildVisibleCubeSpans([], 10)).toEqual([])
|
|
101
113
|
expect(buildVisibleCubeSpans([{ start: 0, count: 1 }], 0)).toEqual([])
|
|
102
114
|
})
|
|
115
|
+
|
|
116
|
+
test('merged adjacent sections stay within union of input ranges', () => {
|
|
117
|
+
const sectionRanges = [
|
|
118
|
+
{ start: 100, count: 10 },
|
|
119
|
+
{ start: 110, count: 10 }
|
|
120
|
+
]
|
|
121
|
+
const spans = buildVisibleCubeSpans(sectionRanges, 120, false)
|
|
122
|
+
expect(spans).toEqual([{ start: 100, count: 20 }])
|
|
123
|
+
expectEveryIndexInUnion(spans, sectionRanges)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
test('carved merged spans stay within union of input ranges', () => {
|
|
127
|
+
const sectionRanges = [
|
|
128
|
+
{ start: 0, count: 10 },
|
|
129
|
+
{ start: 10, count: 10 }
|
|
130
|
+
]
|
|
131
|
+
const spans = buildVisibleCubeSpans(sectionRanges, 20, false, undefined, [{ start: 5, end: 14 }])
|
|
132
|
+
expectEveryIndexInUnion(spans, sectionRanges)
|
|
133
|
+
})
|
|
103
134
|
})
|
|
@@ -701,6 +701,21 @@ test('GlobalLegacyBuffer: carveSpansAroundPendingRanges splits merged span', ()
|
|
|
701
701
|
])
|
|
702
702
|
})
|
|
703
703
|
|
|
704
|
+
test('GlobalLegacyBuffer: carved merged adjacent sections stay within union of input ranges', () => {
|
|
705
|
+
const sectionRanges = [
|
|
706
|
+
{ start: 100, count: 10 },
|
|
707
|
+
{ start: 110, count: 10 }
|
|
708
|
+
]
|
|
709
|
+
const merged = [{ start: 100, count: 20 }]
|
|
710
|
+
const carved = carveSpansAroundPendingRanges(merged, [{ start: 105, end: 114 }])
|
|
711
|
+
for (const span of carved) {
|
|
712
|
+
for (let q = span.start; q < span.start + span.count; q++) {
|
|
713
|
+
const inUnion = sectionRanges.some(r => q >= r.start && q < r.start + r.count)
|
|
714
|
+
expect(inUnion, `quad ${q} not in union of section ranges`).toBe(true)
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
})
|
|
718
|
+
|
|
704
719
|
test('GlobalLegacyBuffer: uploadEpoch increments when dirty range drains', () => {
|
|
705
720
|
const scene = new THREE.Scene()
|
|
706
721
|
const mat = createGlobalLegacyBlockMaterial()
|
|
@@ -642,7 +642,7 @@ export function renderWasmOutputToGeometry(
|
|
|
642
642
|
const doAO = (model as { ao?: boolean }).ao ?? cachedModel.boundingBox !== 'empty'
|
|
643
643
|
|
|
644
644
|
let forceCullMask = 0
|
|
645
|
-
if (world) {
|
|
645
|
+
if (world && cachedModel.isCube) {
|
|
646
646
|
const shaderCubeFaceNameToIndex: Record<string, number> = {
|
|
647
647
|
up: 0,
|
|
648
648
|
down: 1,
|
|
@@ -787,7 +787,11 @@ export function renderWasmOutputToGeometry(
|
|
|
787
787
|
const maxy = element.to[1]
|
|
788
788
|
const maxz = element.to[2]
|
|
789
789
|
|
|
790
|
-
|
|
790
|
+
// `visible_faces` is a cell-boundary occlusion mask, so it may only cull faces
|
|
791
|
+
// that vanilla considers cullable at all — i.e. faces that declare `cullface`.
|
|
792
|
+
// Inset faces without it (a stair riser) are always drawn. Removing this guard
|
|
793
|
+
// is what broke issue #81 in 7ce1ebb.
|
|
794
|
+
if (matchingEFace.cullface && faceIdx !== undefined && (block.visible_faces & (1 << faceIdx)) === 0) {
|
|
791
795
|
continue
|
|
792
796
|
}
|
|
793
797
|
|
|
@@ -14,6 +14,7 @@ import { setBlockStatesData, getSectionGeometry } from '../../mesher-shared/mode
|
|
|
14
14
|
import { resetFaceOcclusionCache } from '../../mesher-shared/faceOcclusion'
|
|
15
15
|
import { convertChunkToWasm } from '../bridge/convertChunk'
|
|
16
16
|
import { renderWasmOutputToGeometry } from '../bridge/render-from-wasm'
|
|
17
|
+
import type { ExportedSection } from '../../mesher-shared/exportedGeometryTypes'
|
|
17
18
|
|
|
18
19
|
const VERSION = '1.18.2'
|
|
19
20
|
const SECTION_Y = 0
|
|
@@ -130,6 +131,81 @@ function assertMesherParity(world: World, expectedQuads: number) {
|
|
|
130
131
|
expect(wasmShader).toBe(expectedQuads)
|
|
131
132
|
}
|
|
132
133
|
|
|
134
|
+
function renderWasmSection(world: World, shaderCubes = false): ExportedSection {
|
|
135
|
+
const column = world.getColumn(0, 0)!
|
|
136
|
+
const conversion = convertChunkToWasm(column, VERSION, 0, 0, SECTION_Y, SECTION_Y + SECTION_HEIGHT, SECTION_Y, SECTION_HEIGHT)
|
|
137
|
+
const wasmResult = wasmModule.generate_geometry(
|
|
138
|
+
0,
|
|
139
|
+
SECTION_Y,
|
|
140
|
+
0,
|
|
141
|
+
SECTION_HEIGHT,
|
|
142
|
+
SECTION_Y,
|
|
143
|
+
SECTION_Y + SECTION_HEIGHT,
|
|
144
|
+
SECTION_Y,
|
|
145
|
+
conversion.blockStates,
|
|
146
|
+
conversion.blockLight,
|
|
147
|
+
conversion.skyLight,
|
|
148
|
+
conversion.biomesArray,
|
|
149
|
+
conversion.invisibleBlocks,
|
|
150
|
+
conversion.transparentBlocks,
|
|
151
|
+
conversion.noAoBlocks,
|
|
152
|
+
conversion.cullIdenticalBlocks,
|
|
153
|
+
conversion.occludingBlocks,
|
|
154
|
+
true,
|
|
155
|
+
false,
|
|
156
|
+
15
|
|
157
|
+
)
|
|
158
|
+
return renderWasmOutputToGeometry(wasmResult, VERSION, '0,0,0', { x: 8, y: 8, z: 8 }, world, {
|
|
159
|
+
sectionHeight: SECTION_HEIGHT,
|
|
160
|
+
shaderCubes
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Inset stair riser in cell (bx, by, bz): normal along expectedNx, all four verts on the cell mid-x-plane and within the cell AABB. */
|
|
165
|
+
function hasInsetHorizontalRiser(section: ExportedSection, bx: number, by: number, bz: number, expectedNx: 1 | -1): boolean {
|
|
166
|
+
const pos = section.geometry.positions
|
|
167
|
+
const norm = section.geometry.normals
|
|
168
|
+
const idx = section.geometry.indices
|
|
169
|
+
const eps = 1e-3
|
|
170
|
+
const minX = (bx & 15) - 8
|
|
171
|
+
const maxX = minX + 1
|
|
172
|
+
const minY = (by & 15) - 8
|
|
173
|
+
const maxY = minY + 1
|
|
174
|
+
const minZ = (bz & 15) - 8
|
|
175
|
+
const maxZ = minZ + 1
|
|
176
|
+
const riserX = minX + 0.5
|
|
177
|
+
|
|
178
|
+
for (let i = 0; i < idx.length; i += 6) {
|
|
179
|
+
const vertIndices = [...new Set([idx[i], idx[i + 1], idx[i + 2], idx[i + 3], idx[i + 4], idx[i + 5]])]
|
|
180
|
+
if (vertIndices.length !== 4) continue
|
|
181
|
+
|
|
182
|
+
const nx = norm[vertIndices[0]! * 3]!
|
|
183
|
+
const ny = norm[vertIndices[0]! * 3 + 1]!
|
|
184
|
+
const nz = norm[vertIndices[0]! * 3 + 2]!
|
|
185
|
+
if (Math.abs(ny) > eps || Math.abs(nz) > eps) continue
|
|
186
|
+
if (Math.abs(nx - expectedNx) > eps) continue
|
|
187
|
+
|
|
188
|
+
const onRiserPlane = vertIndices.every(vi => Math.abs(pos[vi * 3]! - riserX) < eps)
|
|
189
|
+
if (!onRiserPlane) continue
|
|
190
|
+
|
|
191
|
+
const inCell = vertIndices.every(vi => {
|
|
192
|
+
const px = pos[vi * 3]!
|
|
193
|
+
const py = pos[vi * 3 + 1]!
|
|
194
|
+
const pz = pos[vi * 3 + 2]!
|
|
195
|
+
return px >= minX - eps && px <= maxX + eps && py >= minY - eps && py <= maxY + eps && pz >= minZ - eps && pz <= maxZ + eps
|
|
196
|
+
})
|
|
197
|
+
if (inCell) return true
|
|
198
|
+
}
|
|
199
|
+
return false
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function stairWithStoneNeighbor(facing: 'east' | 'west' | 'south' | 'north', stone: { x: number; y: number; z: number }): BlockSpec[] {
|
|
203
|
+
return [
|
|
204
|
+
{ x: 0, y: 0, z: 0, name: 'cut_copper_stairs', props: { facing, half: 'bottom', shape: 'straight', waterlogged: false } },
|
|
205
|
+
{ x: stone.x, y: stone.y, z: stone.z, name: 'stone' }
|
|
206
|
+
]
|
|
207
|
+
}
|
|
208
|
+
|
|
133
209
|
function farmlandFieldBlocks(): BlockSpec[] {
|
|
134
210
|
const blocks: BlockSpec[] = []
|
|
135
211
|
for (let z = 0; z < 16; z++) {
|
|
@@ -279,6 +355,32 @@ test('culling regression: glass/leaves cluster — see-through blocks not over-c
|
|
|
279
355
|
assertMesherParity(world, 20)
|
|
280
356
|
})
|
|
281
357
|
|
|
358
|
+
test('culling regression: cactus on grass — ground top face not culled (issue #73)', () => {
|
|
359
|
+
const world = buildWorld([
|
|
360
|
+
{ x: 0, y: 0, z: 0, name: 'grass_block' },
|
|
361
|
+
{ x: 0, y: 1, z: 0, name: 'cactus' }
|
|
362
|
+
])
|
|
363
|
+
// Pre-fix: grass top culled by cactus cosmetic cap → 14 quads. Post-fix: grass top drawn → 15.
|
|
364
|
+
assertMesherParity(world, 15)
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
test('culling regression: cactus stacked on cactus — no over-cull holes', () => {
|
|
368
|
+
const world = buildWorld([
|
|
369
|
+
{ x: 0, y: 0, z: 0, name: 'cactus' },
|
|
370
|
+
{ x: 0, y: 1, z: 0, name: 'cactus' }
|
|
371
|
+
])
|
|
372
|
+
assertMesherParity(world, 12)
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
test('culling regression: cactus beside solid block — side faces still drawn', () => {
|
|
376
|
+
const world = buildWorld([
|
|
377
|
+
{ x: 0, y: 0, z: 0, name: 'dirt' },
|
|
378
|
+
{ x: 1, y: 0, z: 0, name: 'cactus' }
|
|
379
|
+
])
|
|
380
|
+
expect(countQuadsFromLegacy(world)).toBeGreaterThan(9)
|
|
381
|
+
expect(countQuadsFromWasm(world, false)).toBeGreaterThan(9)
|
|
382
|
+
})
|
|
383
|
+
|
|
282
384
|
test('shader cubes: dirt UP face culled under farmland', () => {
|
|
283
385
|
const world = buildWorld([
|
|
284
386
|
{ x: 0, y: 0, z: 0, name: 'dirt' },
|
|
@@ -305,3 +407,59 @@ test('shader cubes: dirt side not culled beside bottom slab', () => {
|
|
|
305
407
|
expect(wasmShader).toBe(legacy)
|
|
306
408
|
expect(wasmShader).toBeGreaterThan(5)
|
|
307
409
|
})
|
|
410
|
+
|
|
411
|
+
test('culling regression: east-facing stair + stone to east — full silhouette, no inset riser drop (issue #81)', () => {
|
|
412
|
+
const world = buildWorld(stairWithStoneNeighbor('east', { x: 1, y: 0, z: 0 }))
|
|
413
|
+
assertMesherParity(world, 14)
|
|
414
|
+
})
|
|
415
|
+
|
|
416
|
+
test('culling regression: west-facing stair + stone to east — inset riser kept (issue #81)', () => {
|
|
417
|
+
const world = buildWorld(stairWithStoneNeighbor('west', { x: 1, y: 0, z: 0 }))
|
|
418
|
+
assertMesherParity(world, 16)
|
|
419
|
+
expect(hasInsetHorizontalRiser(renderWasmSection(world), 0, 0, 0, 1)).toBe(true)
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
test('culling regression: south-facing stair + stone to south — inset riser kept (issue #81)', () => {
|
|
423
|
+
const world = buildWorld(stairWithStoneNeighbor('south', { x: 0, y: 0, z: 1 }))
|
|
424
|
+
// Tall half faces south toward stone (east/west analog); stone's north face is culled.
|
|
425
|
+
assertMesherParity(world, 14)
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
test('culling regression: north-facing stair + stone to south — inset riser kept (issue #81)', () => {
|
|
429
|
+
const world = buildWorld([
|
|
430
|
+
{ x: 0, y: 0, z: 0, name: 'cut_copper_stairs', props: { facing: 'north', half: 'bottom', shape: 'straight', waterlogged: false } },
|
|
431
|
+
{ x: 0, y: 0, z: 1, name: 'stone' }
|
|
432
|
+
])
|
|
433
|
+
assertMesherParity(world, 16)
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
test('culling regression: all horizontal facings beside occluding cube — parity table (issue #81)', () => {
|
|
437
|
+
const cases: Array<{ blocks: BlockSpec[]; quads: number }> = [
|
|
438
|
+
{ blocks: stairWithStoneNeighbor('east', { x: 1, y: 0, z: 0 }), quads: 14 },
|
|
439
|
+
{ blocks: stairWithStoneNeighbor('west', { x: 1, y: 0, z: 0 }), quads: 16 },
|
|
440
|
+
{ blocks: stairWithStoneNeighbor('south', { x: 0, y: 0, z: 1 }), quads: 14 },
|
|
441
|
+
{
|
|
442
|
+
blocks: [
|
|
443
|
+
{ x: 0, y: 0, z: 0, name: 'cut_copper_stairs', props: { facing: 'north', half: 'bottom', shape: 'straight', waterlogged: false } },
|
|
444
|
+
{ x: 0, y: 0, z: 1, name: 'stone' }
|
|
445
|
+
],
|
|
446
|
+
quads: 16
|
|
447
|
+
}
|
|
448
|
+
]
|
|
449
|
+
for (const { blocks, quads } of cases) {
|
|
450
|
+
assertMesherParity(buildWorld(blocks), quads)
|
|
451
|
+
}
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
test('culling regression: sofa scene — acacia stairs risers beside stripped log (issue #81)', () => {
|
|
455
|
+
const world = buildWorld([
|
|
456
|
+
{ x: 1, y: 0, z: 0, name: 'stripped_acacia_log' },
|
|
457
|
+
{ x: 1, y: 1, z: 0, name: 'acacia_pressure_plate' },
|
|
458
|
+
{ x: 0, y: 0, z: 0, name: 'acacia_stairs', props: { facing: 'west', half: 'bottom', shape: 'straight', waterlogged: false } },
|
|
459
|
+
{ x: 2, y: 0, z: 0, name: 'acacia_stairs', props: { facing: 'east', half: 'bottom', shape: 'straight', waterlogged: false } }
|
|
460
|
+
])
|
|
461
|
+
assertMesherParity(world, 31)
|
|
462
|
+
const section = renderWasmSection(world)
|
|
463
|
+
expect(hasInsetHorizontalRiser(section, 0, 0, 0, 1)).toBe(true)
|
|
464
|
+
expect(hasInsetHorizontalRiser(section, 2, 0, 0, -1)).toBe(true)
|
|
465
|
+
})
|