minecraft-renderer 0.1.89 → 0.1.91
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/mesherWasm.js +8 -8
- package/dist/minecraft-renderer.js +61 -61
- package/dist/minecraft-renderer.js.meta.json +1 -1
- package/dist/threeWorker.js +401 -401
- package/package.json +1 -1
- package/src/graphicsBackend/types.ts +8 -1
- package/src/three/positionalAudioAttenuation.ts +35 -0
- package/src/three/tests/positionalAudioAttenuation.test.ts +51 -0
- package/src/three/tests/threeJsSound.test.ts +110 -0
- package/src/three/threeJsSound.ts +29 -11
- package/src/wasm-mesher/bridge/render-from-wasm.ts +6 -2
- package/src/wasm-mesher/tests/cullingRegression.test.ts +132 -0
package/package.json
CHANGED
|
@@ -18,7 +18,14 @@ import { WorldRendererConfig } from './config'
|
|
|
18
18
|
export type MaybePromise<T> = Promise<T> | T
|
|
19
19
|
|
|
20
20
|
export interface SoundSystem {
|
|
21
|
-
playSound: (
|
|
21
|
+
playSound: (
|
|
22
|
+
position: { x: number; y: number; z: number },
|
|
23
|
+
path: string,
|
|
24
|
+
volume?: number,
|
|
25
|
+
pitch?: number,
|
|
26
|
+
timeout?: number,
|
|
27
|
+
attenuationDistance?: number
|
|
28
|
+
) => void
|
|
22
29
|
destroy: () => void
|
|
23
30
|
}
|
|
24
31
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
//@ts-nocheck
|
|
2
|
+
export const DEFAULT_ATTENUATION_DISTANCE = 16
|
|
3
|
+
|
|
4
|
+
export function computeEffectiveVolume(soundEntryVolume: number, packetVolume: number): number {
|
|
5
|
+
return soundEntryVolume * Math.max(packetVolume, 0)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function computeIndividualGain(effectiveVolume: number): number {
|
|
9
|
+
return Math.min(Math.max(effectiveVolume, 0), 1)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function computePositionalAudioParams(effectiveVolume: number, attenuationDistance: number): { individualGain: number; maxDistance: number } {
|
|
13
|
+
return {
|
|
14
|
+
individualGain: computeIndividualGain(effectiveVolume),
|
|
15
|
+
maxDistance: Math.max(effectiveVolume, 1) * attenuationDistance
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function applyVanillaLinearPanner(panner: PannerNode, maxDistance: number): void {
|
|
20
|
+
panner.distanceModel = 'linear'
|
|
21
|
+
panner.refDistance = 0
|
|
22
|
+
panner.rolloffFactor = 1
|
|
23
|
+
panner.maxDistance = maxDistance
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function setPannerPositionImmediate(panner: PannerNode, audioContext: AudioContext, x: number, y: number, z: number): void {
|
|
27
|
+
const t = audioContext.currentTime
|
|
28
|
+
if (panner.positionX) {
|
|
29
|
+
panner.positionX.setValueAtTime(x, t)
|
|
30
|
+
panner.positionY.setValueAtTime(y, t)
|
|
31
|
+
panner.positionZ.setValueAtTime(z, t)
|
|
32
|
+
} else {
|
|
33
|
+
panner.setPosition(x, y, z)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
//@ts-nocheck
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
import { computeEffectiveVolume, computeIndividualGain, computePositionalAudioParams, DEFAULT_ATTENUATION_DISTANCE } from '../positionalAudioAttenuation'
|
|
4
|
+
|
|
5
|
+
describe('computeEffectiveVolume', () => {
|
|
6
|
+
it('multiplies entry volume by non-negative packet volume', () => {
|
|
7
|
+
expect(computeEffectiveVolume(1, 1)).toBe(1)
|
|
8
|
+
expect(computeEffectiveVolume(12, 2)).toBe(24)
|
|
9
|
+
expect(computeEffectiveVolume(1, 0.5)).toBe(0.5)
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
it('does not clamp packet volume above 1', () => {
|
|
13
|
+
expect(computeEffectiveVolume(1, 2)).toBe(2)
|
|
14
|
+
expect(computeEffectiveVolume(12, 2)).toBe(24)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('treats negative packet volume as zero', () => {
|
|
18
|
+
expect(computeEffectiveVolume(1, -1)).toBe(0)
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('computePositionalAudioParams', () => {
|
|
23
|
+
it('uses default attenuation distance semantics', () => {
|
|
24
|
+
expect(computePositionalAudioParams(1, DEFAULT_ATTENUATION_DISTANCE)).toEqual({
|
|
25
|
+
individualGain: 1,
|
|
26
|
+
maxDistance: 16
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('clamps individual gain to [0, 1] while extending range for volume > 1', () => {
|
|
31
|
+
expect(computePositionalAudioParams(24, 16)).toEqual({
|
|
32
|
+
individualGain: 1,
|
|
33
|
+
maxDistance: 384
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('keeps individual gain independent of master volume', () => {
|
|
38
|
+
expect(computeIndividualGain(1)).toBe(1)
|
|
39
|
+
expect(computeIndividualGain(2)).toBe(1)
|
|
40
|
+
expect(computeIndividualGain(0.5)).toBe(0.5)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('matches vanilla linear falloff checkpoints at distance 0/8/16', () => {
|
|
44
|
+
const { individualGain, maxDistance } = computePositionalAudioParams(1, 16)
|
|
45
|
+
const linearMultiplier = (distance: number) => 1 - distance / maxDistance
|
|
46
|
+
expect(individualGain * linearMultiplier(0)).toBe(1)
|
|
47
|
+
expect(individualGain * linearMultiplier(8)).toBe(0.5)
|
|
48
|
+
expect(individualGain * linearMultiplier(16)).toBe(0)
|
|
49
|
+
expect(individualGain * linearMultiplier(24)).toBeLessThanOrEqual(0)
|
|
50
|
+
})
|
|
51
|
+
})
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
//@ts-nocheck
|
|
2
|
+
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
|
3
|
+
|
|
4
|
+
const mockSetVolume = vi.fn()
|
|
5
|
+
const mockPlay = vi.fn()
|
|
6
|
+
const loadAsyncDeferreds: Array<{ resolve: (buffer: unknown) => void }> = []
|
|
7
|
+
|
|
8
|
+
vi.mock('three', () => {
|
|
9
|
+
class MockPositionalAudio {
|
|
10
|
+
panner = {
|
|
11
|
+
distanceModel: '',
|
|
12
|
+
refDistance: 0,
|
|
13
|
+
rolloffFactor: 0,
|
|
14
|
+
maxDistance: 0,
|
|
15
|
+
positionX: { setValueAtTime: vi.fn() },
|
|
16
|
+
positionY: { setValueAtTime: vi.fn() },
|
|
17
|
+
positionZ: { setValueAtTime: vi.fn() }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
position = { set: vi.fn() }
|
|
21
|
+
matrixWorld = { decompose: vi.fn() }
|
|
22
|
+
setBuffer = vi.fn()
|
|
23
|
+
setVolume = mockSetVolume
|
|
24
|
+
setPlaybackRate = vi.fn()
|
|
25
|
+
play = mockPlay
|
|
26
|
+
updateMatrixWorld = vi.fn()
|
|
27
|
+
onEnded: (() => void) | undefined
|
|
28
|
+
source = null
|
|
29
|
+
disconnect = vi.fn()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
class MockAudioListener {
|
|
33
|
+
context = { currentTime: 0 }
|
|
34
|
+
removeFromParent = vi.fn()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
class MockAudioLoader {
|
|
38
|
+
manager = { itemEnd: vi.fn() }
|
|
39
|
+
loadAsync() {
|
|
40
|
+
return new Promise(resolve => {
|
|
41
|
+
loadAsyncDeferreds.push({ resolve })
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
class MockVector3 {
|
|
47
|
+
x = 0
|
|
48
|
+
y = 0
|
|
49
|
+
z = 0
|
|
50
|
+
set = vi.fn()
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
PositionalAudio: MockPositionalAudio,
|
|
55
|
+
AudioListener: MockAudioListener,
|
|
56
|
+
AudioLoader: MockAudioLoader,
|
|
57
|
+
Vector3: MockVector3,
|
|
58
|
+
Quaternion: class {}
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
import { ThreeJsSound } from '../threeJsSound'
|
|
63
|
+
|
|
64
|
+
function makeWorldRenderer() {
|
|
65
|
+
return {
|
|
66
|
+
onWorldSwitched: [] as Array<() => void>,
|
|
67
|
+
onReactiveConfigUpdated: vi.fn(),
|
|
68
|
+
camera: { add: vi.fn() },
|
|
69
|
+
cameraWorldPos: { x: 0, y: 64, z: 0 },
|
|
70
|
+
sceneOrigin: {
|
|
71
|
+
addAndTrack: vi.fn(),
|
|
72
|
+
removeAndUntrack: vi.fn()
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe('ThreeJsSound', () => {
|
|
78
|
+
beforeEach(() => {
|
|
79
|
+
mockSetVolume.mockClear()
|
|
80
|
+
mockPlay.mockClear()
|
|
81
|
+
loadAsyncDeferreds.length = 0
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('applies master volume 0 then unmute without NaN', async () => {
|
|
85
|
+
const soundSystem = new ThreeJsSound(makeWorldRenderer() as any)
|
|
86
|
+
soundSystem.baseVolume = 0
|
|
87
|
+
soundSystem.playSound({ x: 1, y: 2, z: 3 }, '/test.mp3', 1)
|
|
88
|
+
|
|
89
|
+
loadAsyncDeferreds[0].resolve({})
|
|
90
|
+
await Promise.resolve()
|
|
91
|
+
|
|
92
|
+
expect(mockSetVolume).toHaveBeenLastCalledWith(0)
|
|
93
|
+
|
|
94
|
+
soundSystem.changeVolume(1)
|
|
95
|
+
expect(mockSetVolume).toHaveBeenLastCalledWith(1)
|
|
96
|
+
expect(Number.isNaN(mockSetVolume.mock.calls.at(-1)?.[0])).toBe(false)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('uses current master volume after async load completes', async () => {
|
|
100
|
+
const soundSystem = new ThreeJsSound(makeWorldRenderer() as any)
|
|
101
|
+
soundSystem.baseVolume = 1
|
|
102
|
+
soundSystem.playSound({ x: 0, y: 0, z: 0 }, '/test.mp3', 0.8)
|
|
103
|
+
|
|
104
|
+
soundSystem.changeVolume(0.25)
|
|
105
|
+
loadAsyncDeferreds[0].resolve({})
|
|
106
|
+
await Promise.resolve()
|
|
107
|
+
|
|
108
|
+
expect(mockSetVolume).toHaveBeenLastCalledWith(0.2)
|
|
109
|
+
})
|
|
110
|
+
})
|
|
@@ -2,11 +2,17 @@
|
|
|
2
2
|
import * as THREE from 'three'
|
|
3
3
|
import { WorldRendererThree } from './worldRendererThree'
|
|
4
4
|
import { SoundSystem } from '../graphicsBackend/types'
|
|
5
|
+
import { applyVanillaLinearPanner, computePositionalAudioParams, DEFAULT_ATTENUATION_DISTANCE, setPannerPositionImmediate } from './positionalAudioAttenuation'
|
|
6
|
+
|
|
7
|
+
const _pannerPosition = /*@__PURE__*/ new THREE.Vector3()
|
|
8
|
+
const _pannerQuaternion = /*@__PURE__*/ new THREE.Quaternion()
|
|
9
|
+
const _pannerScale = /*@__PURE__*/ new THREE.Vector3()
|
|
5
10
|
|
|
6
11
|
export class ThreeJsSound implements SoundSystem {
|
|
7
12
|
audioListener: THREE.AudioListener | undefined
|
|
8
13
|
private readonly activeSounds = new Set<THREE.PositionalAudio>()
|
|
9
14
|
private readonly audioContext: AudioContext | undefined
|
|
15
|
+
/** Normalized individual gain (clamped to [0, 1], excluding master volume). */
|
|
10
16
|
private readonly soundVolumes = new Map<THREE.PositionalAudio, number>()
|
|
11
17
|
baseVolume = 1
|
|
12
18
|
|
|
@@ -26,28 +32,40 @@ export class ThreeJsSound implements SoundSystem {
|
|
|
26
32
|
this.worldRenderer.camera.add(this.audioListener)
|
|
27
33
|
}
|
|
28
34
|
|
|
29
|
-
playSound(
|
|
35
|
+
playSound(
|
|
36
|
+
position: { x: number; y: number; z: number },
|
|
37
|
+
path: string,
|
|
38
|
+
volume = 1,
|
|
39
|
+
pitch = 1,
|
|
40
|
+
timeout = 500,
|
|
41
|
+
attenuationDistance = DEFAULT_ATTENUATION_DISTANCE
|
|
42
|
+
) {
|
|
30
43
|
this.initAudioListener()
|
|
31
44
|
|
|
32
45
|
const sound = new THREE.PositionalAudio(this.audioListener!)
|
|
33
46
|
this.activeSounds.add(sound)
|
|
34
|
-
|
|
47
|
+
|
|
48
|
+
const { individualGain, maxDistance } = computePositionalAudioParams(volume, attenuationDistance)
|
|
49
|
+
this.soundVolumes.set(sound, individualGain)
|
|
35
50
|
|
|
36
51
|
const audioLoader = new THREE.AudioLoader()
|
|
37
52
|
const start = Date.now()
|
|
38
53
|
void audioLoader.loadAsync(path).then(buffer => {
|
|
39
54
|
if (Date.now() - start > timeout) {
|
|
40
55
|
console.warn('Ignored playing sound', path, 'due to timeout:', timeout, 'ms <', Date.now() - start, 'ms')
|
|
56
|
+
this.activeSounds.delete(sound)
|
|
57
|
+
this.soundVolumes.delete(sound)
|
|
41
58
|
return
|
|
42
59
|
}
|
|
43
|
-
// play
|
|
44
60
|
sound.setBuffer(buffer)
|
|
45
|
-
sound.
|
|
46
|
-
sound.setVolume(
|
|
47
|
-
sound.setPlaybackRate(pitch)
|
|
48
|
-
// set sound position
|
|
61
|
+
applyVanillaLinearPanner(sound.panner, maxDistance)
|
|
62
|
+
sound.setVolume(individualGain * this.baseVolume)
|
|
63
|
+
sound.setPlaybackRate(pitch)
|
|
49
64
|
this.worldRenderer.sceneOrigin.addAndTrack(sound)
|
|
50
65
|
sound.position.set(position.x, position.y, position.z)
|
|
66
|
+
sound.updateMatrixWorld(true)
|
|
67
|
+
sound.matrixWorld.decompose(_pannerPosition, _pannerQuaternion, _pannerScale)
|
|
68
|
+
setPannerPositionImmediate(sound.panner, this.audioListener!.context, _pannerPosition.x, _pannerPosition.y, _pannerPosition.z)
|
|
51
69
|
sound.onEnded = () => {
|
|
52
70
|
this.worldRenderer.sceneOrigin.removeAndUntrack(sound)
|
|
53
71
|
if (sound.source) {
|
|
@@ -76,14 +94,13 @@ export class ThreeJsSound implements SoundSystem {
|
|
|
76
94
|
|
|
77
95
|
changeVolume(volume: number) {
|
|
78
96
|
this.baseVolume = volume
|
|
79
|
-
for (const [sound,
|
|
80
|
-
sound.setVolume(
|
|
97
|
+
for (const [sound, individualGain] of this.soundVolumes) {
|
|
98
|
+
sound.setVolume(individualGain * this.baseVolume)
|
|
81
99
|
}
|
|
82
100
|
}
|
|
83
101
|
|
|
84
102
|
destroy() {
|
|
85
103
|
this.stopAll()
|
|
86
|
-
// Remove and cleanup audio listener
|
|
87
104
|
if (this.audioListener) {
|
|
88
105
|
this.audioListener.removeFromParent()
|
|
89
106
|
this.audioListener = undefined
|
|
@@ -91,6 +108,7 @@ export class ThreeJsSound implements SoundSystem {
|
|
|
91
108
|
}
|
|
92
109
|
|
|
93
110
|
playTestSound() {
|
|
94
|
-
this.
|
|
111
|
+
const { x, y, z } = this.worldRenderer.cameraWorldPos
|
|
112
|
+
this.playSound({ x, y, z }, '/sound.mp3')
|
|
95
113
|
}
|
|
96
114
|
}
|
|
@@ -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++) {
|
|
@@ -331,3 +407,59 @@ test('shader cubes: dirt side not culled beside bottom slab', () => {
|
|
|
331
407
|
expect(wasmShader).toBe(legacy)
|
|
332
408
|
expect(wasmShader).toBeGreaterThan(5)
|
|
333
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
|
+
})
|