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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minecraft-renderer",
3
- "version": "0.1.89",
3
+ "version": "0.1.91",
4
4
  "description": "The most Modular Minecraft world renderer with Three.js WebGL backend",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -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: (position: { x: number; y: number; z: number }, path: string, volume?: number, pitch?: number, timeout?: number) => void
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(position: { x: number; y: number; z: number }, path: string, volume = 1, pitch = 1, timeout = 500) {
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
- this.soundVolumes.set(sound, volume)
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.setRefDistance(20)
46
- sound.setVolume(volume * this.baseVolume)
47
- sound.setPlaybackRate(pitch) // set the 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, individualVolume] of this.soundVolumes) {
80
- sound.setVolume(individualVolume * this.baseVolume)
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.playSound(this.worldRenderer.camera.position, '/sound.mp3')
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
- if (faceIdx !== undefined && (block.visible_faces & (1 << faceIdx)) === 0) {
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
+ })