minecraft-renderer 0.1.90 → 0.1.92

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.
Files changed (35) hide show
  1. package/dist/mesher.js +76 -76
  2. package/dist/mesher.js.map +4 -4
  3. package/dist/mesherWasm.js +202 -202
  4. package/dist/minecraft-renderer.js +67 -67
  5. package/dist/minecraft-renderer.js.meta.json +1 -1
  6. package/dist/threeWorker.js +424 -424
  7. package/package.json +1 -1
  8. package/src/graphicsBackend/config.ts +3 -0
  9. package/src/graphicsBackend/types.ts +8 -1
  10. package/src/lib/worldrendererCommon.ts +4 -1
  11. package/src/mesher-shared/exportedGeometryTypes.ts +1 -0
  12. package/src/mesher-shared/models.ts +10 -0
  13. package/src/mesher-shared/occludingBlocks.ts +53 -0
  14. package/src/mesher-shared/shared.ts +2 -0
  15. package/src/mesher-shared/tests/visGraph.test.ts +62 -0
  16. package/src/mesher-shared/visGraph.ts +155 -0
  17. package/src/mesher-shared/visibilitySet.ts +124 -0
  18. package/src/three/chunkMeshManager.ts +103 -4
  19. package/src/three/entities.ts +4 -1
  20. package/src/three/globalBlockBuffer.ts +6 -0
  21. package/src/three/globalLegacyBuffer.ts +6 -0
  22. package/src/three/occlusion/sectionOcclusionCull.ts +90 -0
  23. package/src/three/occlusion/sectionOcclusionGraph.ts +272 -0
  24. package/src/three/positionalAudioAttenuation.ts +35 -0
  25. package/src/three/tests/chunkMeshManagerLegacy.test.ts +7 -7
  26. package/src/three/tests/positionalAudioAttenuation.test.ts +51 -0
  27. package/src/three/tests/pr3PerFrameCpuCuts.test.ts +9 -9
  28. package/src/three/tests/sectionOcclusionCull.test.ts +51 -0
  29. package/src/three/tests/sectionOcclusionGraph.test.ts +134 -0
  30. package/src/three/tests/threeJsSound.test.ts +110 -0
  31. package/src/three/threeJsSound.ts +29 -11
  32. package/src/three/worldRendererThree.ts +30 -6
  33. package/src/wasm-mesher/bridge/convertChunk.ts +5 -51
  34. package/src/wasm-mesher/tests/sectionVisibilityFromWorld.test.ts +108 -0
  35. package/src/wasm-mesher/worker/mesherWasm.ts +13 -2
@@ -243,13 +243,13 @@ test('ChunkMeshManager: hidden section excluded from draw spans', () => {
243
243
  camera.lookAt(8, 8, 8)
244
244
  camera.updateMatrixWorld()
245
245
 
246
- manager.updateSectionCullAndSort(camera, 8, 8, 20)
246
+ manager.updateSectionCullAndSort(camera, 8, 8, 20, false)
247
247
  expect(manager.globalLegacyBlendBuffer?.getVisibleIndexSpans().length).toBe(0)
248
248
 
249
249
  section.visible = true
250
250
  const blendBuf = manager.globalLegacyBlendBuffer!
251
251
  while (blendBuf.hasPendingUploads()) blendBuf.uploadDirtyRange()
252
- manager.updateSectionCullAndSort(camera, 8, 8, 20)
252
+ manager.updateSectionCullAndSort(camera, 8, 8, 20, false)
253
253
  expect(manager.globalLegacyBlendBuffer?.getVisibleIndexSpans().length).toBeGreaterThan(0)
254
254
 
255
255
  manager.cleanupSection(key)
@@ -323,15 +323,15 @@ test('ChunkMeshManager: hidden cube section excluded from draw spans', () => {
323
323
  drainCubeUploads(manager)
324
324
 
325
325
  const camera = makeCullCamera()
326
- manager.updateSectionCullAndSort(camera, 8, 8, 20)
326
+ manager.updateSectionCullAndSort(camera, 8, 8, 20, false)
327
327
  expect(manager.globalBlockBuffer?.getVisibleSpans().length).toBeGreaterThan(0)
328
328
 
329
329
  section.visible = false
330
- manager.updateSectionCullAndSort(camera, 8, 8, 20)
330
+ manager.updateSectionCullAndSort(camera, 8, 8, 20, false)
331
331
  expect(manager.globalBlockBuffer?.getVisibleSpans().length).toBe(0)
332
332
 
333
333
  section.visible = true
334
- manager.updateSectionCullAndSort(camera, 8, 8, 20)
334
+ manager.updateSectionCullAndSort(camera, 8, 8, 20, false)
335
335
  expect(manager.globalBlockBuffer?.getVisibleSpans().length).toBeGreaterThan(0)
336
336
 
337
337
  manager.cleanupSection(key)
@@ -346,7 +346,7 @@ test('ChunkMeshManager: finishChunkDisplay reveals cube spans and marks cull dir
346
346
  expect(manager.globalBlockBuffer?.hasSection(key)).toBe(true)
347
347
 
348
348
  const camera = makeCullCamera()
349
- manager.updateSectionCullAndSort(camera, 8, 8, 20)
349
+ manager.updateSectionCullAndSort(camera, 8, 8, 20, false)
350
350
  expect(manager.globalBlockBuffer?.getVisibleSpans().length).toBe(0)
351
351
 
352
352
  const markCullDirtySpy = vi.spyOn(manager, 'markCullDirty')
@@ -355,7 +355,7 @@ test('ChunkMeshManager: finishChunkDisplay reveals cube spans and marks cull dir
355
355
  expect(markCullDirtySpy).toHaveBeenCalled()
356
356
 
357
357
  drainCubeUploads(manager)
358
- manager.updateSectionCullAndSort(camera, 8, 8, 20)
358
+ manager.updateSectionCullAndSort(camera, 8, 8, 20, false)
359
359
  expect(manager.globalBlockBuffer?.getVisibleSpans().length).toBeGreaterThan(0)
360
360
 
361
361
  markCullDirtySpy.mockRestore()
@@ -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
+ })
@@ -217,16 +217,16 @@ test('updateSectionCullAndSort: same visible set skips span rebuild on camera mo
217
217
  const updateDrawSpansSpy = vi.spyOn(blendBuffer, 'updateDrawSpans')
218
218
 
219
219
  const camera1 = makeCamera(8, 8, 20)
220
- manager.updateSectionCullAndSort(camera1, 8, 8, 20)
220
+ manager.updateSectionCullAndSort(camera1, 8, 8, 20, false)
221
221
  expect(updateDrawSpansSpy).toHaveBeenCalledTimes(1)
222
222
 
223
223
  while (blendBuffer.hasPendingUploads()) blendBuffer.uploadDirtyRange()
224
224
 
225
- manager.updateSectionCullAndSort(camera1, 8, 8, 20)
225
+ manager.updateSectionCullAndSort(camera1, 8, 8, 20, false)
226
226
  expect(updateDrawSpansSpy).toHaveBeenCalledTimes(2)
227
227
 
228
228
  const camera2 = makeCamera(8, 8, 18)
229
- manager.updateSectionCullAndSort(camera2, 8, 8, 18)
229
+ manager.updateSectionCullAndSort(camera2, 8, 8, 18, false)
230
230
  expect(updateDrawSpansSpy).toHaveBeenCalledTimes(2)
231
231
 
232
232
  manager.cleanupSection(key)
@@ -242,14 +242,14 @@ test('updateSectionCullAndSort: layoutVersion change forces span rebuild', () =>
242
242
  const updateDrawSpansSpy = vi.spyOn(blendBuffer, 'updateDrawSpans')
243
243
 
244
244
  const camera = makeCamera(8, 8, 20)
245
- manager.updateSectionCullAndSort(camera, 8, 8, 20)
245
+ manager.updateSectionCullAndSort(camera, 8, 8, 20, false)
246
246
  expect(updateDrawSpansSpy).toHaveBeenCalledTimes(1)
247
247
 
248
248
  blendBuffer.removeSection(key)
249
249
  blendBuffer.addSection(key, makeBlendOnlyGeometry().blend!, 8, 8, 8)
250
250
  ;(manager as unknown as ManagerInternals).registerLegacyCullSection(key, 8, 8, 8)
251
251
 
252
- manager.updateSectionCullAndSort(camera, 8, 8, 20)
252
+ manager.updateSectionCullAndSort(camera, 8, 8, 20, false)
253
253
  expect(updateDrawSpansSpy).toHaveBeenCalledTimes(2)
254
254
 
255
255
  manager.cleanupSection(key)
@@ -268,7 +268,7 @@ function simulateRenderCullGate(manager: ChunkMeshManager, camera: THREE.Perspec
268
268
  }
269
269
  manager.markCullDirtyIfBufferStateChanged()
270
270
  if (manager.cullDirty) {
271
- manager.updateSectionCullAndSort(camera, x, y, z)
271
+ manager.updateSectionCullAndSort(camera, x, y, z, false)
272
272
  manager.clearCullDirty()
273
273
  }
274
274
  }
@@ -282,7 +282,7 @@ test('markCullDirtyIfBufferStateChanged: finalize layout bump marks cull dirty',
282
282
  drainLegacyUploads(opaqueBuf)
283
283
 
284
284
  const camera = makeCamera(8, 8, 20)
285
- manager.updateSectionCullAndSort(camera, 8, 8, 20)
285
+ manager.updateSectionCullAndSort(camera, 8, 8, 20, false)
286
286
  manager.clearCullDirty()
287
287
 
288
288
  opaqueBuf.addSection(
@@ -353,7 +353,7 @@ test('updateSectionCullAndSort: defrag finalize forces span rebuild with static
353
353
  const updateDrawSpansSpy = vi.spyOn(opaqueBuf, 'updateDrawSpans')
354
354
  const camera = makeCamera(8, 8, 20)
355
355
 
356
- manager.updateSectionCullAndSort(camera, 8, 8, 20)
356
+ manager.updateSectionCullAndSort(camera, 8, 8, 20, false)
357
357
  expect(updateDrawSpansSpy).toHaveBeenCalledTimes(1)
358
358
 
359
359
  opaqueBuf.removeSection('1,0,0')
@@ -362,7 +362,7 @@ test('updateSectionCullAndSort: defrag finalize forces span rebuild with static
362
362
  drainLegacyUploads(opaqueBuf)
363
363
  opaqueBuf.compactStep()
364
364
 
365
- manager.updateSectionCullAndSort(camera, 8, 8, 20)
365
+ manager.updateSectionCullAndSort(camera, 8, 8, 20, false)
366
366
  expect(updateDrawSpansSpy).toHaveBeenCalledTimes(2)
367
367
 
368
368
  for (const key of keys) manager.cleanupSection(key)
@@ -0,0 +1,51 @@
1
+ //@ts-nocheck
2
+ import { test, expect } from 'vitest'
3
+ import { SectionOcclusionCull } from '../occlusion/sectionOcclusionCull'
4
+ import { computeSectionVisibilitySet } from '../../mesher-shared/visGraph'
5
+
6
+ const ALL_OPEN = computeSectionVisibilitySet(16, () => false)
7
+
8
+ test('isSectionVisible fails open for unregistered sections', () => {
9
+ const cull = new SectionOcclusionCull()
10
+ cull.registerSection('0,0,0', ALL_OPEN, 8, 8, 8)
11
+ cull.update({
12
+ smartCull: true,
13
+ cameraWorldX: 8,
14
+ cameraWorldY: 8,
15
+ cameraWorldZ: 8,
16
+ viewDistance: 4,
17
+ sectionHeight: 16,
18
+ worldMinY: 0,
19
+ worldMaxY: 256
20
+ })
21
+ expect(cull.isSectionVisible('64,0,64')).toBe(true)
22
+ })
23
+
24
+ test('notifySmartCullChanged invalidates graph', () => {
25
+ const cull = new SectionOcclusionCull()
26
+ cull.registerSection('0,0,0', ALL_OPEN, 8, 8, 8)
27
+ cull.update({
28
+ smartCull: false,
29
+ cameraWorldX: 8,
30
+ cameraWorldY: 8,
31
+ cameraWorldZ: 8,
32
+ viewDistance: 4,
33
+ sectionHeight: 16,
34
+ worldMinY: 0,
35
+ worldMaxY: 256
36
+ })
37
+ const offVisible = cull.getVisibleKeys()
38
+ expect(offVisible.has('0,0,0')).toBe(true)
39
+ cull.invalidate()
40
+ cull.update({
41
+ smartCull: true,
42
+ cameraWorldX: 8,
43
+ cameraWorldY: 8,
44
+ cameraWorldZ: 8,
45
+ viewDistance: 4,
46
+ sectionHeight: 16,
47
+ worldMinY: 0,
48
+ worldMaxY: 256
49
+ })
50
+ expect(cull.isSectionVisible('0,0,0')).toBe(true)
51
+ })
@@ -0,0 +1,134 @@
1
+ //@ts-nocheck
2
+ import { test, expect } from 'vitest'
3
+ import { Direction } from '../../mesher-shared/visibilitySet'
4
+ import { SectionOcclusionGraph } from '../occlusion/sectionOcclusionGraph'
5
+ import { computeSectionVisibilitySet } from '../../mesher-shared/visGraph'
6
+
7
+ const ALL_OPEN = computeSectionVisibilitySet(16, () => false)
8
+ const SOLID = computeSectionVisibilitySet(16, () => true)
9
+
10
+ function reg(graph: SectionOcclusionGraph, key: string, vis = ALL_OPEN) {
11
+ const [x, y, z] = key.split(',').map(Number)
12
+ graph.registerSection(key, { visibilitySet: vis, worldX: x!, worldY: y!, worldZ: z! })
13
+ }
14
+
15
+ test('BFS from camera section reaches neighbors when smart cull on and faces connect', () => {
16
+ const graph = new SectionOcclusionGraph()
17
+ reg(graph, '0,0,0')
18
+ reg(graph, '16,0,0')
19
+ reg(graph, '-16,0,0')
20
+
21
+ const visible = graph.update({
22
+ smartCull: true,
23
+ cameraWorldX: 8,
24
+ cameraWorldY: 8,
25
+ cameraWorldZ: 8,
26
+ viewDistance: 4,
27
+ sectionHeight: 16,
28
+ worldMinY: 0,
29
+ worldMaxY: 256
30
+ })
31
+
32
+ expect(visible.has('0,0,0')).toBe(true)
33
+ expect(visible.has('16,0,0')).toBe(true)
34
+ expect(visible.has('-16,0,0')).toBe(true)
35
+ })
36
+
37
+ test('solid section blocks traversal to neighbor behind it', () => {
38
+ const graph = new SectionOcclusionGraph()
39
+ reg(graph, '0,0,0', ALL_OPEN)
40
+ reg(graph, '16,0,0', SOLID)
41
+ reg(graph, '32,0,0', ALL_OPEN)
42
+
43
+ const visible = graph.update({
44
+ smartCull: true,
45
+ cameraWorldX: 8,
46
+ cameraWorldY: 8,
47
+ cameraWorldZ: 8,
48
+ viewDistance: 4,
49
+ sectionHeight: 16,
50
+ worldMinY: 0,
51
+ worldMaxY: 256
52
+ })
53
+
54
+ expect(visible.has('0,0,0')).toBe(true)
55
+ expect(visible.has('16,0,0')).toBe(true)
56
+ expect(visible.has('32,0,0')).toBe(false)
57
+ })
58
+
59
+ test('smart cull off shows all registered sections', () => {
60
+ const graph = new SectionOcclusionGraph()
61
+ reg(graph, '0,0,0')
62
+ reg(graph, '64,0,0', SOLID)
63
+
64
+ const visible = graph.update({
65
+ smartCull: false,
66
+ cameraWorldX: 8,
67
+ cameraWorldY: 8,
68
+ cameraWorldZ: 8,
69
+ viewDistance: 2,
70
+ sectionHeight: 16,
71
+ worldMinY: 0,
72
+ worldMaxY: 256
73
+ })
74
+
75
+ expect(visible.has('64,0,0')).toBe(true)
76
+ })
77
+
78
+ test('BFS assigns increasing step values', () => {
79
+ const graph = new SectionOcclusionGraph()
80
+ reg(graph, '0,0,0')
81
+ reg(graph, '16,0,0')
82
+
83
+ graph.update({
84
+ smartCull: true,
85
+ cameraWorldX: 8,
86
+ cameraWorldY: 8,
87
+ cameraWorldZ: 8,
88
+ viewDistance: 4,
89
+ sectionHeight: 16,
90
+ worldMinY: 0,
91
+ worldMaxY: 256
92
+ })
93
+
94
+ expect(graph.getStep('0,0,0')).toBe(0)
95
+ expect(graph.getStep('16,0,0')).toBe(1)
96
+ })
97
+
98
+ test('N-S tunnel section only connects through north/south faces', () => {
99
+ const tunnelVis = computeSectionVisibilitySet(16, (lx, _ly, _lz) => lx === 0 || lx === 15)
100
+ const graph = new SectionOcclusionGraph()
101
+ reg(graph, '0,0,0', tunnelVis)
102
+ reg(graph, '0,0,16', ALL_OPEN)
103
+
104
+ const visible = graph.update({
105
+ smartCull: true,
106
+ cameraWorldX: 8,
107
+ cameraWorldY: 8,
108
+ cameraWorldZ: 8,
109
+ viewDistance: 4,
110
+ sectionHeight: 16,
111
+ worldMinY: 0,
112
+ worldMaxY: 256
113
+ })
114
+
115
+ expect(visible.has('0,0,16')).toBe(true)
116
+ })
117
+
118
+ test('missing camera section seeds from surface ring', () => {
119
+ const graph = new SectionOcclusionGraph()
120
+ reg(graph, '0,0,0')
121
+
122
+ const visible = graph.update({
123
+ smartCull: true,
124
+ cameraWorldX: 8,
125
+ cameraWorldY: -32,
126
+ cameraWorldZ: 8,
127
+ viewDistance: 4,
128
+ sectionHeight: 16,
129
+ worldMinY: 0,
130
+ worldMaxY: 256
131
+ })
132
+
133
+ expect(visible.has('0,0,0')).toBe(true)
134
+ })
@@ -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
  }
@@ -49,6 +49,25 @@ export class WorldRendererThree extends WorldRendererCommon {
49
49
  protected override isShaderCubeBlocksEnabled(): boolean {
50
50
  return this.worldRendererConfig.shaderCubeBlocks === true && !!this.renderer?.capabilities?.isWebGL2
51
51
  }
52
+
53
+ /** Section occlusion graph (smart / cave cull); forced off in spectator per issue #77. */
54
+ isSmartCullEnabled(): boolean {
55
+ return this.worldRendererConfig.smartCull !== false && this.reactiveDebugParams.smartCull !== false && this.playerStateReactive.gameMode !== 'spectator'
56
+ }
57
+
58
+ isSectionOcclusionVisible(sectionKey: string): boolean {
59
+ if (!this.isSmartCullEnabled()) return true
60
+ return this.chunkMeshManager.isSectionOcclusionVisible(sectionKey)
61
+ }
62
+
63
+ entitySectionKey(worldX: number, worldY: number, worldZ: number): string {
64
+ const sectionHeight = this.getSectionHeight()
65
+ const x = Math.floor(worldX / 16) * 16
66
+ const y = Math.floor(worldY / sectionHeight) * sectionHeight
67
+ const z = Math.floor(worldZ / 16) * 16
68
+ return `${x},${y},${z}`
69
+ }
70
+
52
71
  chunkMeshManager: ChunkMeshManager
53
72
  get sectionObjects() {
54
73
  return this.chunkMeshManager.sectionObjects
@@ -787,6 +806,8 @@ export class WorldRendererThree extends WorldRendererCommon {
787
806
  const s = gb.legacyBlend
788
807
  text += `LEG-B: ${formatCompact(s.used)}/${formatCompact(s.capacity)}q `
789
808
  }
809
+ const drawn = this.chunkMeshManager.getDrawnStats()
810
+ text += `DRAWN: ${formatCompact(drawn.cubeFaces)}f ${formatCompact(drawn.legacyOpaqueQuads)}q ${formatCompact(drawn.legacyBlendQuads)}q `
790
811
  text += `MEM: ${this.chunkMeshManager.getEstimatedMemoryUsage().total} `
791
812
  const camCollisionBytes = this.cameraCollisionBlockCache.getAllocatedBytes()
792
813
  if (camCollisionBytes > 0) {
@@ -1285,11 +1306,6 @@ export class WorldRendererThree extends WorldRendererCommon {
1285
1306
  }
1286
1307
 
1287
1308
  let entitiesRenderMs = 0
1288
- if (!this.reactiveDebugParams.disableEntities) {
1289
- const entitiesStart = performance.now()
1290
- this.entities.render()
1291
- entitiesRenderMs = performance.now() - entitiesStart
1292
- }
1293
1309
 
1294
1310
  // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style
1295
1311
  const cam =
@@ -1340,10 +1356,18 @@ export class WorldRendererThree extends WorldRendererCommon {
1340
1356
  this.chunkMeshManager.markCullDirtyIfBufferStateChanged()
1341
1357
  this.chunkMeshManager.setLegacyCameraOrigin(camX, camY, camZ)
1342
1358
  this.chunkMeshManager.updateCullDirtyFromCamera(cam, camX, camY, camZ)
1359
+ const smartCull = this.isSmartCullEnabled()
1360
+ this.chunkMeshManager.notifySmartCullChanged(smartCull)
1343
1361
  if (this.chunkMeshManager.cullDirty) {
1344
- this.chunkMeshManager.updateSectionCullAndSort(cam, this.cameraWorldPos.x, this.cameraWorldPos.y, this.cameraWorldPos.z)
1362
+ this.chunkMeshManager.updateSectionCullAndSort(cam, this.cameraWorldPos.x, this.cameraWorldPos.y, this.cameraWorldPos.z, smartCull)
1345
1363
  this.chunkMeshManager.clearCullDirty()
1346
1364
  }
1365
+ this.chunkMeshManager.updateCaveCullingDebug(this.reactiveDebugParams.caveCullingDebug, smartCull)
1366
+ if (!this.reactiveDebugParams.disableEntities) {
1367
+ const entitiesStart = performance.now()
1368
+ this.entities.render()
1369
+ entitiesRenderMs = performance.now() - entitiesStart
1370
+ }
1347
1371
  this.chunkMeshManager.sortVisibleBlendSections(camX, camY, camZ)
1348
1372
  if (!didBlendAllAttrUpload && globalLegacyBlendBuffer?.hasPendingIndexUploads()) {
1349
1373
  globalLegacyBlendBuffer.uploadDirtyIndexRange()