minecraft-renderer 0.1.61 → 0.1.63

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.
@@ -0,0 +1,154 @@
1
+ //@ts-nocheck
2
+ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
3
+ import { proxy, subscribe } from 'valtio'
4
+ import { Vec3 } from 'vec3'
5
+ import {
6
+ applySyncOps,
7
+ getWorkerSyncStatsForTest,
8
+ resetWorkerSyncStatsForTest,
9
+ sendWorkerSyncOps,
10
+ setByPath,
11
+ type WireSyncOp,
12
+ } from './workerProxy'
13
+ import { defaultPerformanceInstabilityFactors } from '../performanceMonitor'
14
+
15
+ const fakeWorker = () => {
16
+ const listeners: Array<(event: { data: any }) => void> = []
17
+ return {
18
+ postMessage: vi.fn((data: any) => {
19
+ for (const listener of listeners) {
20
+ listener({ data })
21
+ }
22
+ }),
23
+ addEventListener: vi.fn((_type: string, listener: (event: { data: any }) => void) => {
24
+ listeners.push(listener)
25
+ }),
26
+ removeEventListener: vi.fn(),
27
+ } as unknown as Worker
28
+ }
29
+
30
+ const makeRendererState = () => proxy({
31
+ world: {
32
+ chunksLoaded: {} as Record<string, true>,
33
+ heightmaps: {} as Record<string, Int16Array>,
34
+ allChunksLoaded: false,
35
+ mesherWork: false,
36
+ instabilityFactors: defaultPerformanceInstabilityFactors(),
37
+ intersectMedia: null as null | object,
38
+ },
39
+ renderer: '...',
40
+ preventEscapeMenu: false,
41
+ })
42
+
43
+ describe('workerSyncOps', () => {
44
+ it('set op round-trips mesherWork', () => {
45
+ const source = makeRendererState()
46
+ const target = makeRendererState()
47
+ const ops: WireSyncOp[] = [{ kind: 'set', path: ['world', 'mesherWork'], value: true }]
48
+ applySyncOps(target, ops, fakeWorker())
49
+ expect(target.world.mesherWork).toBe(true)
50
+ expect(source.world.mesherWork).toBe(false)
51
+ })
52
+
53
+ it('top-level set round-trips renderer', () => {
54
+ const target = makeRendererState()
55
+ applySyncOps(target, [{ kind: 'set', path: ['renderer'], value: 'WebGL2 r123' }], fakeWorker())
56
+ expect(target.renderer).toBe('WebGL2 r123')
57
+ })
58
+
59
+ it('nested set creates chunksLoaded key on receiver', () => {
60
+ const target = makeRendererState()
61
+ applySyncOps(target, [{ kind: 'set', path: ['world', 'chunksLoaded', '1,2'], value: true }], fakeWorker())
62
+ expect(target.world.chunksLoaded['1,2']).toBe(true)
63
+ })
64
+
65
+ it('delete op removes heightmap key on receiver', () => {
66
+ const target = makeRendererState()
67
+ target.world.heightmaps['1,2'] = new Int16Array(256)
68
+ applySyncOps(target, [{ kind: 'delete', path: ['world', 'heightmaps', '1,2'] }], fakeWorker())
69
+ expect(target.world.heightmaps['1,2']).toBeUndefined()
70
+ })
71
+
72
+ it('Int16Array value survives copy without neutering sender buffer', () => {
73
+ const source = new Int16Array([1, 2, 3])
74
+ const sender = makeRendererState()
75
+ sender.world.heightmaps['0,0'] = source
76
+ const receiver = makeRendererState()
77
+ const buf = sender.world.heightmaps['0,0']!
78
+ applySyncOps(receiver, [{
79
+ kind: 'set',
80
+ path: ['world', 'heightmaps', '0,0'],
81
+ value: new Int16Array(buf),
82
+ }], fakeWorker())
83
+ expect([...receiver.world.heightmaps['0,0']!]).toEqual([1, 2, 3])
84
+ expect(sender.world.heightmaps['0,0']![0]).toBe(1)
85
+ })
86
+
87
+ it('Vec3 value survives via restorer', () => {
88
+ const target = makeRendererState() as any
89
+ const vec = new Vec3(1, 2, 3)
90
+ applySyncOps(target, [{
91
+ kind: 'set',
92
+ path: ['world', 'intersectMedia'],
93
+ value: { pos: { x: 1, y: 2, z: 3, __restorer: 'Vec3' } },
94
+ }], fakeWorker())
95
+ expect(target.world.intersectMedia.pos).toBeInstanceOf(Vec3)
96
+ expect(target.world.intersectMedia.pos.x).toBe(1)
97
+ })
98
+
99
+ it('batched ops in one tick produce one message with multiple ops', async () => {
100
+ const worker = fakeWorker()
101
+ const syncId = 'test-sync'
102
+ const source = makeRendererState()
103
+ let messageCount = 0
104
+ ;(worker.postMessage as ReturnType<typeof vi.fn>).mockImplementation((data: any) => {
105
+ messageCount++
106
+ expect(data.ops.length).toBeGreaterThanOrEqual(2)
107
+ })
108
+ await new Promise<void>((resolve) => {
109
+ subscribe(source, (ops) => {
110
+ sendWorkerSyncOps(syncId, ops, worker, 'toWorker', 'test')
111
+ resolve()
112
+ })
113
+ source.world.mesherWork = true
114
+ source.renderer = 'batch'
115
+ })
116
+ expect(messageCount).toBe(1)
117
+ })
118
+
119
+ describe('debugWorkerSyncStats', () => {
120
+ beforeEach(() => {
121
+ resetWorkerSyncStatsForTest()
122
+ })
123
+
124
+ afterEach(() => {
125
+ resetWorkerSyncStatsForTest()
126
+ vi.useRealTimers()
127
+ })
128
+
129
+ it('one postMessage increments toWorker by 1 regardless of op count', () => {
130
+ const worker = fakeWorker()
131
+ sendWorkerSyncOps('id', [
132
+ ['set', ['world', 'mesherWork'], true, false],
133
+ ['set', ['renderer'], 'x', '...'],
134
+ ], worker, 'toWorker', 'test')
135
+ expect(worker.postMessage).toHaveBeenCalledTimes(1)
136
+ expect(getWorkerSyncStatsForTest().toWorker).toBe(1)
137
+ })
138
+
139
+ it('applySyncOps with fromWorker counts one receive per message', () => {
140
+ const target = makeRendererState()
141
+ applySyncOps(target, [
142
+ { kind: 'set', path: ['world', 'mesherWork'], value: true },
143
+ { kind: 'set', path: ['renderer'], value: 'y' },
144
+ ], fakeWorker(), 'fromWorker')
145
+ expect(getWorkerSyncStatsForTest().fromWorker).toBe(1)
146
+ })
147
+ })
148
+
149
+ it('setByPath handles length-1 path', () => {
150
+ const target = { renderer: 'old' }
151
+ setByPath(target, ['renderer'], 'new')
152
+ expect(target.renderer).toBe('new')
153
+ })
154
+ })
@@ -19,6 +19,7 @@ import { MesherLogReader } from './mesherlogReader'
19
19
  import { setSkinsConfig } from './utils/skins'
20
20
  import { calculateSkyLightSimple } from './skyLight'
21
21
  import { WorldViewWorker } from '../worldView'
22
+ import { bindAbortableEmitterListener, bindAbortableListener } from './bindAbortableListener'
22
23
  import { generateSpiralMatrix } from './spiral'
23
24
  import { PlayerStateReactive } from '../playerState/playerState'
24
25
  import { IndexedData } from 'minecraft-data'
@@ -170,6 +171,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
170
171
  stopMesherMessagesProcessing = false
171
172
 
172
173
  abortController = new AbortController()
174
+ private valtioUnsubs: Array<() => void> = []
173
175
  lastRendered = 0
174
176
  renderingActive = true
175
177
  geometryReceiveCountPerSec = 0
@@ -257,10 +259,16 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
257
259
  })()
258
260
  ])
259
261
 
260
- this.resourcesManager.on('assetsTexturesUpdated', async () => {
262
+ const onAssetsTexturesUpdated = async () => {
261
263
  if (!this.active) return
262
264
  await this.updateAssetsData()
263
- })
265
+ }
266
+ bindAbortableEmitterListener(
267
+ this.resourcesManager,
268
+ 'assetsTexturesUpdated',
269
+ onAssetsTexturesUpdated,
270
+ this.abortController.signal
271
+ )
264
272
 
265
273
  this.watchReactivePlayerState()
266
274
  this.watchReactiveConfig()
@@ -338,18 +346,24 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
338
346
  }
339
347
 
340
348
  watchReactivePlayerState() {
341
- this.onReactivePlayerStateUpdated('backgroundColor', (value) => {
342
- this.changeBackgroundColor(value)
343
- })
344
- this.onReactivePlayerStateUpdated('cardinalLight', (value) => {
345
- this.changeCardinalLight(value)
346
- })
349
+ this.valtioUnsubs.push(
350
+ this.onReactivePlayerStateUpdated('backgroundColor', (value) => {
351
+ this.changeBackgroundColor(value)
352
+ })
353
+ )
354
+ this.valtioUnsubs.push(
355
+ this.onReactivePlayerStateUpdated('cardinalLight', (value) => {
356
+ this.changeCardinalLight(value)
357
+ })
358
+ )
347
359
  }
348
360
 
349
361
  watchReactiveConfig() {
350
- this.onReactiveConfigUpdated('fetchPlayerSkins', (value) => {
351
- setSkinsConfig({ apiEnabled: value })
352
- })
362
+ this.valtioUnsubs.push(
363
+ this.onReactiveConfigUpdated('fetchPlayerSkins', (value) => {
364
+ setSkinsConfig({ apiEnabled: value })
365
+ })
366
+ )
353
367
  }
354
368
 
355
369
  async processMessageQueue(source: string) {
@@ -432,7 +446,8 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
432
446
  // CHUNK FINISHED
433
447
  this.finishedChunks[chunkKey] = true
434
448
  const CHUNK_SIZE = 16
435
- this.reactiveState.world.chunksLoaded.add(`${Math.floor(chunkCoords[0] / CHUNK_SIZE)},${Math.floor(chunkCoords[2] / CHUNK_SIZE)}`)
449
+ const gridKey = `${Math.floor(chunkCoords[0] / CHUNK_SIZE)},${Math.floor(chunkCoords[2] / CHUNK_SIZE)}`
450
+ this.reactiveState.world.chunksLoaded[gridKey] = true
436
451
  this.renderUpdateEmitter.emit(`chunkFinished`, `${chunkCoords[0]},${chunkCoords[2]}`)
437
452
  this.checkAllFinished()
438
453
  // merge highest blocks by sections into highest blocks by chunks
@@ -513,7 +528,8 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
513
528
  }
514
529
 
515
530
  if (data.type === 'heightmap') {
516
- this.reactiveState.world.heightmaps.set(data.key, new Int16Array(data.heightmap))
531
+ const heightmap = new Int16Array(data.heightmap)
532
+ this.reactiveState.world.heightmaps[data.key] = heightmap
517
533
  }
518
534
  }
519
535
 
@@ -684,7 +700,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
684
700
 
685
701
  updateChunksStats() {
686
702
  const loadedChunks = Object.keys(this.finishedChunks)
687
- this.displayOptions.nonReactiveState.world.chunksLoaded = new Set(loadedChunks)
703
+ this.displayOptions.nonReactiveState.world.chunksLoadedCount = loadedChunks.length
688
704
  this.displayOptions.nonReactiveState.world.chunksTotalNumber = this.chunksLength
689
705
  this.reactiveState.world.allChunksLoaded = this.allChunksFinished
690
706
 
@@ -787,7 +803,8 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
787
803
  delete this.finishedSections[`${x},${y},${z}`]
788
804
  }
789
805
  this.highestBlocksByChunks.delete(`${x},${z}`)
790
- this.reactiveState.world.heightmaps.delete(`${Math.floor(x / 16)},${Math.floor(z / 16)}`)
806
+ const heightmapKey = `${Math.floor(x / 16)},${Math.floor(z / 16)}`
807
+ delete this.reactiveState.world.heightmaps[heightmapKey]
791
808
 
792
809
  this.updateChunksStats()
793
810
 
@@ -826,22 +843,23 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
826
843
 
827
844
  connect(worldView: WorldViewWorker) {
828
845
  const worldEmitter = worldView
846
+ const signal = this.abortController.signal
829
847
 
830
- worldEmitter.on('entity', (e) => {
848
+ bindAbortableListener(worldEmitter, 'entity', (e) => {
831
849
  this.updateEntity(e, false)
832
- })
833
- worldEmitter.on('entityMoved', (e) => {
850
+ }, signal)
851
+ bindAbortableListener(worldEmitter, 'entityMoved', (e) => {
834
852
  this.updateEntity(e, true)
835
- })
836
- worldEmitter.on('playerEntity', (e) => {
853
+ }, signal)
854
+ bindAbortableListener(worldEmitter, 'playerEntity', (e) => {
837
855
  this.updatePlayerEntity?.(e)
838
- })
856
+ }, signal)
839
857
 
840
858
  let currentLoadChunkBatch = null as {
841
859
  timeout
842
860
  data
843
861
  } | null
844
- worldEmitter.on('loadChunk', ({ x, z, chunk, worldConfig, isLightUpdate }) => {
862
+ bindAbortableListener(worldEmitter, 'loadChunk', ({ x, z, chunk, worldConfig, isLightUpdate }) => {
845
863
  this.worldSizeParams = worldConfig
846
864
  this.queuedChunks.add(`${x},${z}`)
847
865
  const args = [x, z, chunk, isLightUpdate]
@@ -863,45 +881,44 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
863
881
  }
864
882
  }
865
883
  currentLoadChunkBatch.data.push(args)
866
- })
884
+ }, signal)
867
885
  // todo remove and use other architecture instead so data flow is clear
868
- worldEmitter.on('blockEntities', (blockEntities) => {
886
+ bindAbortableListener(worldEmitter, 'blockEntities', (blockEntities) => {
869
887
  this.blockEntities = blockEntities
870
- })
888
+ }, signal)
871
889
 
872
- worldEmitter.on('unloadChunk', ({ x, z }) => {
890
+ bindAbortableListener(worldEmitter, 'unloadChunk', ({ x, z }) => {
873
891
  this.removeColumn(x, z)
874
- })
892
+ }, signal)
875
893
 
876
- worldEmitter.on('blockUpdate', ({ pos, stateId }) => {
894
+ bindAbortableListener(worldEmitter, 'blockUpdate', ({ pos, stateId }) => {
877
895
  this.setBlockStateId(new Vec3(pos.x, pos.y, pos.z), stateId)
878
- })
896
+ }, signal)
879
897
 
880
- worldEmitter.on('chunkPosUpdate', ({ pos }) => {
898
+ bindAbortableListener(worldEmitter, 'chunkPosUpdate', ({ pos }) => {
881
899
  this.updateViewerPosition(pos)
882
- })
900
+ }, signal)
883
901
 
884
- worldEmitter.on('end', () => {
902
+ bindAbortableListener(worldEmitter, 'end', () => {
885
903
  this.worldStop?.()
886
- })
887
-
904
+ }, signal)
888
905
 
889
- worldEmitter.on('renderDistance', (d) => {
906
+ bindAbortableListener(worldEmitter, 'renderDistance', (d) => {
890
907
  this.viewDistance = d
891
908
  this.chunksLength = d === 0 ? 1 : generateSpiralMatrix(d).length
892
909
  this.allChunksFinished = Object.keys(this.finishedChunks).length === this.chunksLength
893
910
  this.onRenderDistanceChanged?.(d)
894
- })
911
+ }, signal)
895
912
 
896
- worldEmitter.on('markAsLoaded', ({ x, z }) => {
913
+ bindAbortableListener(worldEmitter, 'markAsLoaded', ({ x, z }) => {
897
914
  this.markAsLoaded(x, z)
898
- })
915
+ }, signal)
899
916
 
900
- worldEmitter.on('updateLight', ({ pos }) => {
917
+ bindAbortableListener(worldEmitter, 'updateLight', ({ pos }) => {
901
918
  this.lightUpdate(pos.x, pos.z)
902
- })
919
+ }, signal)
903
920
 
904
- worldEmitter.on('onWorldSwitch', () => {
921
+ bindAbortableListener(worldEmitter, 'onWorldSwitch', () => {
905
922
  for (const fn of this.onWorldSwitched) {
906
923
  try {
907
924
  fn()
@@ -912,9 +929,9 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
912
929
  }, 0)
913
930
  }
914
931
  }
915
- })
932
+ }, signal)
916
933
 
917
- worldEmitter.on('time', (timeOfDay) => {
934
+ bindAbortableListener(worldEmitter, 'time', (timeOfDay) => {
918
935
  if (!this.worldRendererConfig.dayCycle) return
919
936
  this.timeUpdated?.(timeOfDay)
920
937
 
@@ -925,15 +942,15 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
925
942
  // if (this instanceof WorldRendererThree) {
926
943
  // (this).rerenderAllChunks?.()
927
944
  // }
928
- })
945
+ }, signal)
929
946
 
930
- worldEmitter.on('biomeUpdate', ({ biome }) => {
947
+ bindAbortableListener(worldEmitter, 'biomeUpdate', ({ biome }) => {
931
948
  this.biomeUpdated?.(biome)
932
- })
949
+ }, signal)
933
950
 
934
- worldEmitter.on('biomeReset', () => {
951
+ bindAbortableListener(worldEmitter, 'biomeReset', () => {
935
952
  this.biomeReset?.()
936
- })
953
+ }, signal)
937
954
  }
938
955
 
939
956
  setBlockStateIdInner(pos: Vec3, stateId: number | undefined, needAoRecalculation = true) {
@@ -1217,6 +1234,11 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
1217
1234
  }
1218
1235
 
1219
1236
  destroy() {
1237
+ for (const unsub of this.valtioUnsubs) {
1238
+ unsub()
1239
+ }
1240
+ this.valtioUnsubs = []
1241
+
1220
1242
  // Cancel all pending heightmap debounce timers
1221
1243
  for (const timer of this.heightmapDebounceTimers.values()) {
1222
1244
  clearTimeout(timer)
@@ -34,7 +34,7 @@ export interface NonReactiveState {
34
34
  worstRenderTime: number
35
35
  avgRenderTime: number
36
36
  world: {
37
- chunksLoaded: Set<string>
37
+ chunksLoadedCount: number
38
38
  chunksTotalNumber: number
39
39
  }
40
40
  renderer: {
@@ -1080,6 +1080,7 @@ export class Entities {
1080
1080
  if (entity.delete) {
1081
1081
  if (!e) return
1082
1082
  e.userData._posTween?.stop()
1083
+ e.userData._rotTween?.stop()
1083
1084
  if (e.additionalCleanup) e.additionalCleanup()
1084
1085
  e.traverse(c => {
1085
1086
  if (c['additionalCleanup']) c['additionalCleanup']()
@@ -1394,7 +1395,11 @@ export class Entities {
1394
1395
  }
1395
1396
  if (typeof entity.yaw === 'number' && Number.isFinite(entity.yaw)) {
1396
1397
  const dy = shortestYawRadians(e.rotation.y, entity.yaw)
1397
- new TWEEN.Tween(e.rotation).to({ y: e.rotation.y + dy }, ANIMATION_DURATION).start()
1398
+ // Stop previous rotation tween to prevent accumulation (mirror _posTween)
1399
+ e.userData._rotTween?.stop()
1400
+ e.userData._rotTween = new TWEEN.Tween(e.rotation)
1401
+ .to({ y: e.rotation.y + dy }, ANIMATION_DURATION)
1402
+ .start()
1398
1403
  }
1399
1404
 
1400
1405
  if (e?.playerObject && overrides?.rotation?.head) {
@@ -191,25 +191,35 @@ export const createGraphicsBackendBase = () => {
191
191
  }
192
192
 
193
193
  const startWorld = async (displayOptionsArg: DisplayWorldOptions) => {
194
- const displayOptionsRestorers = [ResourcesManager, WorldViewWorker]
195
- const displayOptions: DisplayWorldOptions = isWebWorker ? restoreTransferred(displayOptionsArg, displayOptionsRestorers, globalThis as unknown as Worker) : displayOptionsArg
196
-
197
194
  if (!documentRenderer) throw new Error('Document renderer not initialized')
198
195
 
199
- documentRenderer.nonReactiveState = displayOptions.nonReactiveState
200
- // Set resourcesManager globally for world rendering
201
- ; (globalThis as any).resourcesManager = displayOptions.resourcesManager
202
-
203
196
  if (menuBackgroundRenderer) {
204
197
  menuBackgroundRenderer.dispose()
205
198
  menuBackgroundRenderer = null
206
199
  }
207
200
 
201
+ if (worldRenderer) {
202
+ worldRenderer.destroy()
203
+ worldRenderer = null
204
+ frameTimingCollector = null
205
+ ;(globalThis as any).world = undefined
206
+ ;(globalThis as any).frameTimingCollector = undefined
207
+ }
208
+
209
+ const displayOptionsRestorers = [ResourcesManager, WorldViewWorker]
210
+ const displayOptions: DisplayWorldOptions = isWebWorker ? restoreTransferred(displayOptionsArg, displayOptionsRestorers, globalThis as unknown as Worker) : displayOptionsArg
211
+
212
+ documentRenderer.nonReactiveState = displayOptions.nonReactiveState
213
+ // Set resourcesManager globally for world rendering
214
+ ; (globalThis as any).resourcesManager = displayOptions.resourcesManager
215
+
208
216
  worldRenderer = new WorldRendererThree(documentRenderer.renderer, initOptions, displayOptions)
209
217
 
210
218
  await worldRenderer.worldReadyPromise
211
219
 
212
- frameTimingCollector = new FrameTimingCollector(displayOptions.nonReactiveState)
220
+ frameTimingCollector = displayOptions.inWorldRenderingConfig.enableDebugOverlay
221
+ ? new FrameTimingCollector(displayOptions.nonReactiveState)
222
+ : null
213
223
  ; (globalThis as any).frameTimingCollector = frameTimingCollector
214
224
 
215
225
  const originalRender = documentRenderer.render
@@ -93,7 +93,7 @@ export class RainModule implements RendererModuleController {
93
93
  const chunkX = Math.floor(worldX / 16)
94
94
  const chunkZ = Math.floor(worldZ / 16)
95
95
  if (chunkX !== prevChunkX || chunkZ !== prevChunkZ) {
96
- cachedHeightmap = heightmaps.get(`${chunkX},${chunkZ}`)
96
+ cachedHeightmap = heightmaps[`${chunkX},${chunkZ}`]
97
97
  prevChunkX = chunkX
98
98
  prevChunkZ = chunkZ
99
99
  }
@@ -142,9 +142,9 @@ void main() {
142
142
  } else if (faceId == 2u || faceId == 3u) {
143
143
  v_uv = vec2(v, u);
144
144
  } else if (faceId == 4u) {
145
- v_uv = vec2(u, 1.0 - v);
145
+ v_uv = vec2(u, v);
146
146
  } else { // faceId == 5u
147
- v_uv = vec2(1.0 - u, 1.0 - v);
147
+ v_uv = vec2(1.0 - u, v);
148
148
  }
149
149
 
150
150
  // --- Position: section base (multiples of 16) + face quad + block-local 0..15 ---
@@ -167,7 +167,8 @@ export class WorldRendererThree extends WorldRendererCommon {
167
167
  this.performanceMonitor = new PerformanceMonitor(this.reactiveState.world.instabilityFactors)
168
168
 
169
169
  this.renderer = renderer
170
- displayOptions.rendererState.renderer = WorldRendererThree.getRendererInfo(renderer) ?? '...'
170
+ const rendererInfo = WorldRendererThree.getRendererInfo(renderer) ?? '...'
171
+ displayOptions.rendererState.renderer = rendererInfo
171
172
 
172
173
  // Initialize chunk mesh manager
173
174
  this.chunkMeshManager = new ChunkMeshManager(this, this.scene, this.material, this.worldSizeParams.worldHeight, this.viewDistance)
@@ -44,6 +44,13 @@ export const delayedIterator = async <T>(
44
44
  }
45
45
  }
46
46
 
47
+ type WorkerWorldViewBridge = {
48
+ activeWorldView: WorldViewWorker
49
+ handler: (event: MessageEvent) => void
50
+ }
51
+
52
+ const workerWorldViewBridges = new WeakMap<Worker, WorkerWorldViewBridge>()
53
+
47
54
  /**
48
55
  * WorldView for worker thread communication.
49
56
  * This is a lightweight version that receives events from the main thread.
@@ -51,19 +58,43 @@ export const delayedIterator = async <T>(
51
58
  export class WorldViewWorker extends (EventEmitter as new () => TypedEmitter<WorldViewEvents>) {
52
59
  static readonly restorerName = 'WorldViewWorker'
53
60
 
54
- static restoreTransferred(data: any, worker?: Worker): WorldViewWorker {
61
+ static restoreTransferred(_data: any, worker?: Worker): WorldViewWorker {
55
62
  const worldView = new WorldViewWorker()
56
- if (worker) {
57
- worker.addEventListener('message', ({ data }) => {
58
- if (data.class === WorldViewWorker.restorerName) {
59
- if (data.type === 'event') {
60
- worldView.emit(data.eventName, ...data.args)
61
- }
63
+ if (!worker) {
64
+ return worldView
65
+ }
66
+
67
+ let bridge = workerWorldViewBridges.get(worker)
68
+ if (!bridge) {
69
+ const handler = ({ data }: MessageEvent) => {
70
+ const state = workerWorldViewBridges.get(worker)
71
+ const active = state?.activeWorldView
72
+ if (!active || data?.class !== WorldViewWorker.restorerName) return
73
+ if (data.type === 'event') {
74
+ active.emit(data.eventName, ...data.args)
62
75
  }
63
- })
76
+ }
77
+ worker.addEventListener('message', handler as EventListener)
78
+ bridge = { activeWorldView: worldView, handler }
79
+ workerWorldViewBridges.set(worker, bridge)
80
+ } else {
81
+ bridge.activeWorldView = worldView
64
82
  }
65
83
  return worldView
66
84
  }
85
+
86
+ /** @internal vitest — remove bridge listener from worker */
87
+ static clearWorkerBridgeForTest(worker: Worker): void {
88
+ const bridge = workerWorldViewBridges.get(worker)
89
+ if (!bridge) return
90
+ worker.removeEventListener('message', bridge.handler as EventListener)
91
+ workerWorldViewBridges.delete(worker)
92
+ }
93
+
94
+ /** @internal vitest — count of bridge listeners on worker */
95
+ static getWorkerBridgeListenerCountForTest(worker: Worker): number {
96
+ return workerWorldViewBridges.has(worker) ? 1 : 0
97
+ }
67
98
  }
68
99
 
69
100
  /**
@@ -0,0 +1,59 @@
1
+ //@ts-nocheck
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import { WorldViewWorker } from './worldView'
4
+
5
+ describe('WorldViewWorker.restoreTransferred bridge', () => {
6
+ it('registers only one message listener per worker across restores', () => {
7
+ const addSpy = vi.fn()
8
+ const worker = {
9
+ addEventListener: addSpy,
10
+ removeEventListener: vi.fn(),
11
+ } as unknown as Worker
12
+
13
+ WorldViewWorker.restoreTransferred({}, worker)
14
+ WorldViewWorker.restoreTransferred({}, worker)
15
+ WorldViewWorker.restoreTransferred({}, worker)
16
+
17
+ expect(addSpy).toHaveBeenCalledTimes(1)
18
+ expect(WorldViewWorker.getWorkerBridgeListenerCountForTest(worker)).toBe(1)
19
+
20
+ WorldViewWorker.clearWorkerBridgeForTest(worker)
21
+ })
22
+
23
+ it('routes events to the latest restored worldView only', () => {
24
+ const listeners: Array<(event: MessageEvent) => void> = []
25
+ const worker = {
26
+ addEventListener: (_type: string, handler: (event: MessageEvent) => void) => {
27
+ listeners.push(handler)
28
+ },
29
+ removeEventListener: () => {},
30
+ } as unknown as Worker
31
+
32
+ const first = WorldViewWorker.restoreTransferred({}, worker)
33
+ const second = WorldViewWorker.restoreTransferred({}, worker)
34
+
35
+ let firstCalls = 0
36
+ let secondCalls = 0
37
+ first.on('loadChunk', () => {
38
+ firstCalls++
39
+ })
40
+ second.on('loadChunk', () => {
41
+ secondCalls++
42
+ })
43
+
44
+ const handler = listeners[0]!
45
+ handler({
46
+ data: {
47
+ class: WorldViewWorker.restorerName,
48
+ type: 'event',
49
+ eventName: 'loadChunk',
50
+ args: [{ x: 0, z: 0, chunk: {}, worldConfig: {}, isLightUpdate: false }],
51
+ },
52
+ } as MessageEvent)
53
+
54
+ expect(firstCalls).toBe(0)
55
+ expect(secondCalls).toBe(1)
56
+
57
+ WorldViewWorker.clearWorkerBridgeForTest(worker)
58
+ })
59
+ })
@@ -1,29 +0,0 @@
1
- //@ts-nocheck
2
- import { describe, expect, it } from 'vitest'
3
- import { restoreTransferred } from './workerProxy'
4
-
5
- describe('restoreTransferred Set/Map', () => {
6
- const worker = null as unknown as Worker
7
-
8
- it('restores Set from __setValues', () => {
9
- const out = restoreTransferred({ __restorer: 'Set', __setValues: ['a', 'b'] }, [], worker, false)
10
- expect(out).toEqual(new Set(['a', 'b']))
11
- })
12
-
13
- it('does not throw when legacy values is a function (Map.values collision)', () => {
14
- const map = new Map([['k', 1]])
15
- const out = restoreTransferred({ __restorer: 'Set', values: map.values.bind(map) }, [], worker, false)
16
- expect(out).toEqual(new Set())
17
- })
18
-
19
- it('restores Map from __mapEntries', () => {
20
- const out = restoreTransferred({ __restorer: 'Map', __mapEntries: [['a', 1]] }, [], worker, false)
21
- expect(out).toEqual(new Map([['a', 1]]))
22
- })
23
-
24
- it('does not throw when legacy entries is a function', () => {
25
- const m = new Map()
26
- const out = restoreTransferred({ __restorer: 'Map', entries: m.entries.bind(m) }, [], worker, false)
27
- expect(out).toEqual(new Map())
28
- })
29
- })