minecraft-renderer 0.1.90 → 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/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/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
|
}
|