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.
- package/dist/mesher.js +76 -76
- package/dist/mesher.js.map +4 -4
- package/dist/mesherWasm.js +202 -202
- package/dist/minecraft-renderer.js +67 -67
- package/dist/minecraft-renderer.js.meta.json +1 -1
- package/dist/threeWorker.js +424 -424
- package/package.json +1 -1
- package/src/graphicsBackend/config.ts +3 -0
- package/src/graphicsBackend/types.ts +8 -1
- package/src/lib/worldrendererCommon.ts +4 -1
- package/src/mesher-shared/exportedGeometryTypes.ts +1 -0
- package/src/mesher-shared/models.ts +10 -0
- package/src/mesher-shared/occludingBlocks.ts +53 -0
- package/src/mesher-shared/shared.ts +2 -0
- package/src/mesher-shared/tests/visGraph.test.ts +62 -0
- package/src/mesher-shared/visGraph.ts +155 -0
- package/src/mesher-shared/visibilitySet.ts +124 -0
- package/src/three/chunkMeshManager.ts +103 -4
- package/src/three/entities.ts +4 -1
- package/src/three/globalBlockBuffer.ts +6 -0
- package/src/three/globalLegacyBuffer.ts +6 -0
- package/src/three/occlusion/sectionOcclusionCull.ts +90 -0
- package/src/three/occlusion/sectionOcclusionGraph.ts +272 -0
- package/src/three/positionalAudioAttenuation.ts +35 -0
- package/src/three/tests/chunkMeshManagerLegacy.test.ts +7 -7
- package/src/three/tests/positionalAudioAttenuation.test.ts +51 -0
- package/src/three/tests/pr3PerFrameCpuCuts.test.ts +9 -9
- package/src/three/tests/sectionOcclusionCull.test.ts +51 -0
- package/src/three/tests/sectionOcclusionGraph.test.ts +134 -0
- package/src/three/tests/threeJsSound.test.ts +110 -0
- package/src/three/threeJsSound.ts +29 -11
- package/src/three/worldRendererThree.ts +30 -6
- package/src/wasm-mesher/bridge/convertChunk.ts +5 -51
- package/src/wasm-mesher/tests/sectionVisibilityFromWorld.test.ts +108 -0
- 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(
|
|
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
|
}
|
|
@@ -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()
|