minecraft-renderer 0.1.71 → 0.1.73

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 (65) hide show
  1. package/README.md +3 -3
  2. package/dist/mesher.js +81 -81
  3. package/dist/mesher.js.map +3 -3
  4. package/dist/mesherWasm.js +1183 -943
  5. package/dist/minecraft-renderer.js +250 -79
  6. package/dist/minecraft-renderer.js.meta.json +1 -1
  7. package/dist/threeWorker.js +1732 -1001
  8. package/package.json +3 -3
  9. package/src/graphicsBackend/rendererDefaultOptions.ts +5 -10
  10. package/src/graphicsBackend/rendererOptionsSync.ts +1 -1
  11. package/src/lib/bakeLegacyLight.ts +17 -0
  12. package/src/lib/blockEntityLightRegistry.test.ts +18 -0
  13. package/src/lib/blockEntityLightRegistry.ts +75 -0
  14. package/src/lib/blockEntityLighting.test.ts +30 -0
  15. package/src/lib/blockEntityLighting.ts +53 -0
  16. package/src/lib/worldrendererCommon.reconfigure.test.ts +202 -0
  17. package/src/lib/worldrendererCommon.ts +152 -22
  18. package/src/mesher-shared/blockEntityMetadata.test.ts +33 -0
  19. package/src/mesher-shared/blockEntityMetadata.ts +19 -3
  20. package/src/mesher-shared/exportedGeometryTypes.ts +11 -0
  21. package/src/mesher-shared/models.ts +161 -92
  22. package/src/mesher-shared/shared.ts +15 -4
  23. package/src/mesher-shared/tests/liquidQuadInvariant.test.ts +40 -0
  24. package/src/mesher-shared/world.ts +12 -0
  25. package/src/mesher-shared/worldLighting.test.ts +54 -0
  26. package/src/playground/baseScene.ts +1 -1
  27. package/src/three/bannerRenderer.ts +10 -3
  28. package/src/three/chunkMeshManager.ts +663 -69
  29. package/src/three/cubeDrawSpans.ts +74 -0
  30. package/src/three/cubeMultiDraw.ts +119 -0
  31. package/src/three/documentRenderer.ts +0 -2
  32. package/src/three/entities.ts +5 -6
  33. package/src/three/entity/EntityMesh.ts +7 -5
  34. package/src/three/entity/gltfAnimationUtils.ts +5 -3
  35. package/src/three/globalBlockBuffer.ts +208 -12
  36. package/src/three/globalLegacyBuffer.ts +701 -0
  37. package/src/three/graphicsBackendOffThread.ts +16 -1
  38. package/src/three/itemMesh.ts +5 -2
  39. package/src/three/legacySectionCull.ts +85 -0
  40. package/src/three/modules/sciFiWorldReveal.ts +347 -703
  41. package/src/three/modules/starfield.ts +3 -2
  42. package/src/three/sectionRaycastAabb.ts +25 -0
  43. package/src/three/shaders/cubeBlockShader.ts +80 -17
  44. package/src/three/shaders/legacyBlockShader.ts +292 -0
  45. package/src/three/skyboxRenderer.ts +1 -1
  46. package/src/three/tests/chunkMeshManagerLegacy.test.ts +286 -0
  47. package/src/three/tests/cubeDrawSpans.test.ts +73 -0
  48. package/src/three/tests/globalLegacyBuffer.test.ts +360 -0
  49. package/src/three/tests/legacySectionCull.test.ts +80 -0
  50. package/src/three/tests/signTextureCache.test.ts +83 -0
  51. package/src/three/threeJsMedia.ts +2 -2
  52. package/src/three/waypointSprite.ts +2 -2
  53. package/src/three/world/cursorBlock.ts +1 -0
  54. package/src/three/world/vr.ts +2 -2
  55. package/src/three/worldGeometryExport.ts +83 -26
  56. package/src/three/worldRendererThree.ts +94 -25
  57. package/src/wasm-mesher/bridge/render-from-wasm.ts +214 -72
  58. package/src/wasm-mesher/bridge/shaderCubeBridge.ts +18 -6
  59. package/src/wasm-mesher/runtime-build/wasm_mesher_bg.wasm +0 -0
  60. package/src/wasm-mesher/tests/sectionRaycastAabb.test.ts +20 -0
  61. package/src/wasm-mesher/tests/shaderCubeInstances.test.ts +67 -5
  62. package/src/wasm-mesher/worker/mesherWasm.ts +70 -14
  63. package/src/wasm-mesher/worker/mesherWasmLightDirty.test.ts +11 -0
  64. package/src/wasm-mesher/worker/mesherWasmLightDirty.ts +15 -0
  65. package/src/worldView/worldView.ts +11 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minecraft-renderer",
3
- "version": "0.1.71",
3
+ "version": "0.1.73",
4
4
  "description": "The most Modular Minecraft world renderer with Three.js WebGL backend",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -55,7 +55,7 @@
55
55
  "skinview3d": "^3.4.1",
56
56
  "stats-gl": "^1.0.5",
57
57
  "stats.js": "^0.17.0",
58
- "three": "0.154.0",
58
+ "three": "0.184.0",
59
59
  "three-stdlib": "^2.36.1",
60
60
  "type-fest": "^5.3.0",
61
61
  "typed-emitter": "^2.1.0",
@@ -70,7 +70,7 @@
70
70
  "@types/lodash": "^4.17.21",
71
71
  "@types/react": "^19.2.7",
72
72
  "@types/stats.js": "^0.17.1",
73
- "@types/three": "0.154.0",
73
+ "@types/three": "0.184.0",
74
74
  "@zardoy/react-util": "^0.2.7",
75
75
  "@zardoy/tsconfig": "^1.5.1",
76
76
  "contro-max": "*",
@@ -63,7 +63,6 @@ export const RENDERER_DEFAULT_OPTIONS = {
63
63
  rendererPerfDebugOverlay: false as boolean,
64
64
  disableBlockEntityTextures: false as boolean,
65
65
  rendererMesher: 'wasm' as RendererMesherPipeline,
66
- rendererShaderCubeBlocks: false as boolean,
67
66
  rendererShaderCubeDebugMode: 'off' as RendererShaderCubeDebugMode,
68
67
  showChunkBorders: false as boolean,
69
68
  renderEntities: true as boolean,
@@ -110,6 +109,7 @@ export function migrateRendererOptions(saved: Record<string, unknown>): void {
110
109
  }
111
110
  delete saved.wasmExperimentalMesher
112
111
  delete saved.rendererWasmMesher
112
+ delete saved.rendererShaderCubeBlocks
113
113
 
114
114
  if (saved.menuBackgroundMode === 'futuristic') {
115
115
  saved.menuBackgroundMode = 'v2'
@@ -166,8 +166,8 @@ export const RENDERER_OPTIONS_META: Partial<Record<RendererDefaultOptionKey, Ren
166
166
  },
167
167
  rendererWorldPerformance: {
168
168
  text: 'World performance',
169
- tooltip: 'Background workers for chunk geometry. Reload to apply.',
170
- requiresRestart: true,
169
+ tooltip: 'Background workers for chunk geometry. Recreates mesher workers and reloads chunks.',
170
+ requiresChunksReload: true,
171
171
  possibleValues: [
172
172
  ['low-energy', 'Low Energy'],
173
173
  ['normal', 'Normal'],
@@ -193,12 +193,7 @@ export const RENDERER_OPTIONS_META: Partial<Record<RendererDefaultOptionKey, Ren
193
193
  rendererMesher: {
194
194
  possibleValues: [['wasm', 'WASM'], ['legacy-js', 'Legacy JS']],
195
195
  text: 'Mesher pipeline',
196
- tooltip: 'WASM is faster. Use JS if WASM is not working. Requires reload.',
197
- requiresRestart: true
198
- },
199
- rendererShaderCubeBlocks: {
200
- text: '(UNSTABLE) Instanced shader cubes',
201
- tooltip: 'Render full blocks through the global GPU instanced path. Requires WASM mesher and WebGL2.',
196
+ tooltip: 'Browser technology for processing world geometry before render. WASM is the fastest; if you see a dead tab icon, reloads, or other errors, switch to Legacy JS.',
202
197
  requiresChunksReload: true,
203
198
  },
204
199
  rendererShaderCubeDebugMode: {
@@ -341,7 +336,7 @@ export const RENDERER_RENDER_GUI_SECTIONS: ReadonlyArray<{
341
336
  },
342
337
  {
343
338
  title: 'Mesher',
344
- keys: ['rendererMesher', 'rendererShaderCubeBlocks']
339
+ keys: ['rendererMesher']
345
340
  },
346
341
  {
347
342
  title: 'Renderer debug',
@@ -163,7 +163,7 @@ export function applyRendererOptions(
163
163
  cfg.fetchPlayerSkins = o.loadPlayerSkins
164
164
  cfg.highlightBlockColor = o.highlightBlockColor
165
165
  cfg.wasmMesher = wasmActive
166
- cfg.shaderCubeBlocks = o.rendererShaderCubeBlocks && wasmActive
166
+ cfg.shaderCubeBlocks = wasmActive
167
167
  cfg.disableMesherConversionCache = !!ctx.isSafari
168
168
 
169
169
  setSkinsConfig({ apiEnabled: o.loadPlayerSkins })
@@ -0,0 +1,17 @@
1
+ //@ts-nocheck
2
+ /** Bake tint×AO colors with sky/block channels for static export (no live u_skyLevel uniform). */
3
+ export function bakeLegacyVertexColors (
4
+ colors: ArrayLike<number>,
5
+ skyLights: ArrayLike<number>,
6
+ blockLights: ArrayLike<number>,
7
+ skyLevel: number,
8
+ ): number[] {
9
+ const vertCount = colors.length / 3
10
+ const out: number[] = []
11
+ for (let v = 0; v < vertCount; v++) {
12
+ const L = Math.max(blockLights[v] ?? 0, Math.min(skyLights[v] ?? 1, skyLevel))
13
+ const i = v * 3
14
+ out.push(colors[i]! * L, colors[i + 1]! * L, colors[i + 2]! * L)
15
+ }
16
+ return out
17
+ }
@@ -0,0 +1,18 @@
1
+ //@ts-nocheck
2
+ import { describe, expect, it } from 'vitest'
3
+ import * as THREE from 'three'
4
+ import { blockEntityBrightness } from './blockEntityLighting'
5
+ import { BlockEntityLightRegistry } from './blockEntityLightRegistry'
6
+
7
+ describe('BlockEntityLightRegistry', () => {
8
+ it('refreshes overlay brightness when sky level changes', () => {
9
+ const registry = new BlockEntityLightRegistry()
10
+ const material = new THREE.MeshBasicMaterial()
11
+ registry.register({ material, blockLightNorm: 0, skyLightNorm: 1 })
12
+ const nightSky = 4 / 15
13
+ registry.setSkyLevel(nightSky)
14
+ expect(material.color.r).toBeCloseTo(blockEntityBrightness(0, 1, nightSky), 5)
15
+ registry.setSkyLevel(1)
16
+ expect(material.color.r).toBe(1)
17
+ })
18
+ })
@@ -0,0 +1,75 @@
1
+ //@ts-nocheck
2
+ import * as THREE from 'three'
3
+ import {
4
+ blockEntityBrightness,
5
+ DEFAULT_LIGHTMAP_PARAMS,
6
+ type BlockLightmapParams,
7
+ } from './blockEntityLighting'
8
+
9
+ export type BlockEntityOverlayLight = {
10
+ material: THREE.MeshBasicMaterial
11
+ blockLightNorm: number
12
+ skyLightNorm: number
13
+ }
14
+
15
+ export class BlockEntityLightRegistry {
16
+ private readonly entries = new Set<BlockEntityOverlayLight>()
17
+ private skyLevel = 1
18
+ private lightmapParams: BlockLightmapParams = { ...DEFAULT_LIGHTMAP_PARAMS }
19
+
20
+ register (entry: BlockEntityOverlayLight): void {
21
+ this.entries.add(entry)
22
+ this.applyBrightness(entry)
23
+ }
24
+
25
+ unregister (material: THREE.Material): void {
26
+ for (const entry of this.entries) {
27
+ if (entry.material === material) {
28
+ this.entries.delete(entry)
29
+ break
30
+ }
31
+ }
32
+ }
33
+
34
+ setSkyLevel (value: number): void {
35
+ this.skyLevel = value
36
+ this.refreshAll()
37
+ }
38
+
39
+ setLightmapParams (params: BlockLightmapParams): void {
40
+ this.lightmapParams = { ...this.lightmapParams, ...params }
41
+ this.refreshAll()
42
+ }
43
+
44
+ getSkyLevel (): number {
45
+ return this.skyLevel
46
+ }
47
+
48
+ private refreshAll (): void {
49
+ for (const entry of this.entries) {
50
+ this.applyBrightness(entry)
51
+ }
52
+ }
53
+
54
+ private applyBrightness (entry: BlockEntityOverlayLight): void {
55
+ const brightness = blockEntityBrightness(
56
+ entry.blockLightNorm,
57
+ entry.skyLightNorm,
58
+ this.skyLevel,
59
+ this.lightmapParams,
60
+ )
61
+ entry.material.color.setScalar(brightness)
62
+ }
63
+ }
64
+
65
+ export function tintBannerMaterial (
66
+ material: THREE.MeshBasicMaterial,
67
+ blockLightNorm: number,
68
+ skyLightNorm: number,
69
+ skyLevel: number,
70
+ lightmapParams: BlockLightmapParams = DEFAULT_LIGHTMAP_PARAMS,
71
+ ): number {
72
+ const brightness = blockEntityBrightness(blockLightNorm, skyLightNorm, skyLevel, lightmapParams)
73
+ material.color.setScalar(brightness)
74
+ return brightness
75
+ }
@@ -0,0 +1,30 @@
1
+ //@ts-nocheck
2
+ import { describe, expect, it } from 'vitest'
3
+ import {
4
+ applyLightmap,
5
+ blockEntityBrightness,
6
+ combinedBlockLight,
7
+ DEFAULT_LIGHTMAP_PARAMS,
8
+ } from './blockEntityLighting'
9
+
10
+ describe('blockEntityLighting', () => {
11
+ it('applyLightmap(1) === 1 for default params', () => {
12
+ expect(applyLightmap(1, DEFAULT_LIGHTMAP_PARAMS)).toBe(1)
13
+ })
14
+
15
+ it('combinedBlockLight caps sky by skyLevel', () => {
16
+ expect(combinedBlockLight(0, 1, 4 / 15)).toBeCloseTo(4 / 15, 5)
17
+ expect(combinedBlockLight(0.5, 1, 4 / 15)).toBeCloseTo(0.5, 5)
18
+ })
19
+
20
+ it('night outdoor blockEntityBrightness matches cap + lightmap', () => {
21
+ const skyLevel = 4 / 15
22
+ const L = combinedBlockLight(0, 1, skyLevel)
23
+ expect(blockEntityBrightness(0, 1, skyLevel)).toBeCloseTo(applyLightmap(L), 5)
24
+ })
25
+
26
+ it('linear curve matches raw L at minBrightness 0', () => {
27
+ const p = { curve: 0, minBrightness: 0, gamma: 1 }
28
+ expect(applyLightmap(0.5, p)).toBeCloseTo(0.5, 5)
29
+ })
30
+ })
@@ -0,0 +1,53 @@
1
+ //@ts-nocheck
2
+ /**
3
+ * Shared block lighting math — keep in sync with APPLY_LIGHTMAP_GLSL in shaders.
4
+ */
5
+
6
+ export type BlockLightmapParams = {
7
+ curve?: number
8
+ minBrightness?: number
9
+ gamma?: number
10
+ }
11
+
12
+ export const DEFAULT_LIGHTMAP_PARAMS: Required<BlockLightmapParams> = {
13
+ curve: 0,
14
+ minBrightness: 0.12,
15
+ gamma: 1,
16
+ }
17
+
18
+ /** GLSL body for applyLightmap — requires u_lightCurve, u_minBrightness, u_lightGamma uniforms. */
19
+ export const APPLY_LIGHTMAP_GLSL = /* glsl */ `
20
+ float applyLightmap(float L) {
21
+ float curved = L / (4.0 - 3.0 * L);
22
+ float shaped = mix(L, curved, u_lightCurve);
23
+ shaped = mix(u_minBrightness, 1.0, shaped);
24
+ return clamp(pow(shaped, u_lightGamma), 0.0, 1.0);
25
+ }
26
+ `
27
+
28
+ export function applyLightmap (L: number, params: BlockLightmapParams = DEFAULT_LIGHTMAP_PARAMS): number {
29
+ const curve = params.curve ?? DEFAULT_LIGHTMAP_PARAMS.curve
30
+ const minBrightness = params.minBrightness ?? DEFAULT_LIGHTMAP_PARAMS.minBrightness
31
+ const gamma = params.gamma ?? DEFAULT_LIGHTMAP_PARAMS.gamma
32
+
33
+ const curved = L / (4 - 3 * L)
34
+ let shaped = L * (1 - curve) + curved * curve
35
+ shaped = minBrightness + shaped * (1 - minBrightness)
36
+ return Math.min(1, Math.max(0, shaped ** gamma))
37
+ }
38
+
39
+ /** Same cap as block shaders: max(block, min(sky, skyLevel)). */
40
+ export function combinedBlockLight (block: number, sky: number, skyLevel: number): number {
41
+ return Math.max(block, Math.min(sky, skyLevel))
42
+ }
43
+
44
+ /** 0..1 brightness for MeshBasicMaterial.color.setScalar on block-entity overlays. */
45
+ export function blockEntityBrightness (
46
+ blockNorm: number,
47
+ skyNorm: number,
48
+ skyLevel: number,
49
+ lightmapParams: BlockLightmapParams = DEFAULT_LIGHTMAP_PARAMS,
50
+ ): number {
51
+ const L = combinedBlockLight(blockNorm, skyNorm, skyLevel)
52
+ return applyLightmap(L, lightmapParams)
53
+ }
@@ -0,0 +1,202 @@
1
+ //@ts-nocheck
2
+ import { EventEmitter } from 'events'
3
+ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
4
+ import { Vec3 } from 'vec3'
5
+ import { proxy } from 'valtio'
6
+ import * as worldRendererModule from './worldrendererCommon'
7
+ import { WorldRendererCommon } from './worldrendererCommon'
8
+ import { defaultWorldRendererConfig } from '../graphicsBackend/config'
9
+ import { getInitialPlayerState } from '../playerState/playerState'
10
+ import type { DisplayWorldOptions, GraphicsInitOptions } from '../graphicsBackend/types'
11
+
12
+ vi.mock('./ui/newStats', () => ({
13
+ addNewStat: vi.fn(() => ({ updateText: vi.fn(), setVisibility: vi.fn() })),
14
+ updateStatText: vi.fn(),
15
+ removeAllStats: vi.fn(),
16
+ updatePanesVisibility: vi.fn(),
17
+ MC_RENDERER_DEBUG_OVERLAY_CLASS: 'mc-renderer-debug-overlay',
18
+ }))
19
+
20
+ vi.mock('./utils/skins', () => ({
21
+ setSkinsConfig: vi.fn(),
22
+ steveTexture: {},
23
+ stevePngUrl: '',
24
+ }))
25
+
26
+ function ensurePromiseWithResolvers() {
27
+ if (!Promise.withResolvers) {
28
+ Promise.withResolvers = function <T>() {
29
+ let resolve!: (value: T | PromiseLike<T>) => void
30
+ let reject!: (reason?: unknown) => void
31
+ const promise = new Promise<T>((res, rej) => {
32
+ resolve = res
33
+ reject = rej
34
+ })
35
+ return { promise, resolve, reject }
36
+ }
37
+ }
38
+ }
39
+
40
+ class TestWorldRenderer extends WorldRendererCommon {
41
+ outputFormat = 'threeJs' as const
42
+
43
+ changeBackgroundColor() {}
44
+ changeCardinalLight() {}
45
+ handleWorkerMessage() {}
46
+ updateCamera() {}
47
+ render() {}
48
+ updateShowChunksBorder() {}
49
+ }
50
+
51
+ function createRenderer(workerCount = 2, worldView?: DisplayWorldOptions['worldView']) {
52
+ const reloadLoadedChunks = vi.fn(async () => {})
53
+ const rendererState = proxy({
54
+ world: {
55
+ chunksLoaded: {} as Record<string, true>,
56
+ heightmaps: {} as Record<string, Int16Array>,
57
+ allChunksLoaded: false,
58
+ mesherWork: false,
59
+ instabilityFactors: {},
60
+ intersectMedia: null,
61
+ },
62
+ renderer: '',
63
+ preventEscapeMenu: false,
64
+ })
65
+
66
+ const displayOptions: DisplayWorldOptions = {
67
+ version: '1.21.1',
68
+ worldView: (worldView ?? Object.assign(new EventEmitter(), { reloadLoadedChunks })) as DisplayWorldOptions['worldView'],
69
+ inWorldRenderingConfig: proxy({ ...defaultWorldRendererConfig, mesherWorkers: workerCount }),
70
+ playerStateReactive: getInitialPlayerState(),
71
+ rendererState,
72
+ nonReactiveState: {
73
+ fps: 0,
74
+ worstRenderTime: 0,
75
+ avgRenderTime: 0,
76
+ world: {
77
+ chunksLoadedCount: 0,
78
+ chunksTotalNumber: 0,
79
+ chunksFullInfo: '',
80
+ },
81
+ renderer: {
82
+ timeline: { live: [], frozen: [], lastSecond: [] },
83
+ },
84
+ },
85
+ resourcesManager: {
86
+ currentResources: {
87
+ mcData: { version: {} },
88
+ blocksAtlasJson: {},
89
+ blockstatesModels: {},
90
+ },
91
+ } as DisplayWorldOptions['resourcesManager'],
92
+ }
93
+
94
+ const initOptions: GraphicsInitOptions = {
95
+ config: { sceneBackground: '#000' },
96
+ rendererSpecificSettings: {},
97
+ callbacks: {
98
+ displayCriticalError: vi.fn(),
99
+ setRendererSpecificSettings: vi.fn(),
100
+ fireCustomEvent: vi.fn(),
101
+ },
102
+ }
103
+
104
+ const renderer = new TestWorldRenderer(displayOptions.resourcesManager, displayOptions, initOptions)
105
+ renderer.active = true
106
+ renderer.workers = Array.from({ length: workerCount }, () => ({
107
+ postMessage: vi.fn(),
108
+ terminate: vi.fn(),
109
+ }))
110
+ renderer['syncMesherPoolSnapshot']()
111
+ renderer.viewDistance = 8
112
+ renderer.viewerChunkPosition = new Vec3(0, 64, 0)
113
+ renderer.worldSizeParams = { minY: 0, worldHeight: 256 }
114
+ return { renderer, reloadLoadedChunks }
115
+ }
116
+
117
+ describe('WorldRendererCommon.reconfigureMesherWorkers', () => {
118
+ beforeEach(() => {
119
+ ensurePromiseWithResolvers()
120
+ vi.stubGlobal('location', { href: 'http://localhost/' })
121
+ vi.stubGlobal('Worker', class MockWorker {
122
+ postMessage = vi.fn()
123
+ terminate = vi.fn()
124
+ addEventListener = vi.fn()
125
+ onmessage: ((event: MessageEvent) => void) | null = null
126
+ })
127
+ vi.spyOn(worldRendererModule, 'meshersSendMcData').mockImplementation(() => {})
128
+ })
129
+
130
+ afterEach(() => {
131
+ vi.restoreAllMocks()
132
+ vi.unstubAllGlobals()
133
+ })
134
+
135
+ test('recreates workers with new count and reloads chunks', async () => {
136
+ const { renderer, reloadLoadedChunks } = createRenderer(3)
137
+ const terminated = renderer.workers.map((worker) => worker.terminate)
138
+ renderer.worldRendererConfig.mesherWorkers = 1
139
+
140
+ await renderer.reconfigureMesherWorkers()
141
+
142
+ for (const terminate of terminated) {
143
+ expect(terminate).toHaveBeenCalled()
144
+ }
145
+ expect(renderer.workers).toHaveLength(1)
146
+ expect(reloadLoadedChunks).toHaveBeenCalledTimes(1)
147
+ })
148
+
149
+ test('recreates workers when mesher pipeline changes', async () => {
150
+ const { renderer, reloadLoadedChunks } = createRenderer(2)
151
+ const terminated = renderer.workers.map((worker) => worker.terminate)
152
+ renderer.worldRendererConfig.wasmMesher = false
153
+
154
+ await renderer.reconfigureMesherWorkers()
155
+
156
+ for (const terminate of terminated) {
157
+ expect(terminate).toHaveBeenCalled()
158
+ }
159
+ expect(renderer.workers).toHaveLength(2)
160
+ expect(reloadLoadedChunks).toHaveBeenCalledTimes(1)
161
+ })
162
+
163
+ test('watchMesherPoolConfig registers valtio unsubscribe functions', () => {
164
+ const { renderer } = createRenderer(2)
165
+
166
+ renderer['watchMesherPoolConfig']()
167
+ expect(renderer['valtioUnsubs']).toHaveLength(3)
168
+ for (const unsub of renderer['valtioUnsubs']) {
169
+ expect(typeof unsub).toBe('function')
170
+ }
171
+
172
+ expect(() => {
173
+ for (const unsub of renderer['valtioUnsubs']) unsub()
174
+ renderer.destroy()
175
+ }).not.toThrow()
176
+ })
177
+
178
+ test('config watcher enqueues reconfigure when mesherWorkers changes', async () => {
179
+ const enqueueSpy = vi.spyOn(TestWorldRenderer.prototype as any, 'enqueueMesherWorkersReconfigure')
180
+ const { renderer } = createRenderer(2)
181
+
182
+ renderer['watchMesherPoolConfig']()
183
+ renderer.worldRendererConfig.mesherWorkers = 4
184
+
185
+ await vi.waitFor(() => {
186
+ expect(enqueueSpy).toHaveBeenCalled()
187
+ })
188
+
189
+ enqueueSpy.mockRestore()
190
+ })
191
+
192
+ test('skips tail work when destroyed during bootstrap', async () => {
193
+ const { renderer, reloadLoadedChunks } = createRenderer(2)
194
+ vi.spyOn(renderer, 'updateAssetsData').mockImplementation(async () => {
195
+ renderer.active = false
196
+ })
197
+
198
+ await renderer.reconfigureMesherWorkers()
199
+
200
+ expect(reloadLoadedChunks).not.toHaveBeenCalled()
201
+ })
202
+ })