minecraft-renderer 0.1.62 → 0.1.64

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 (36) hide show
  1. package/README.md +1 -1
  2. package/dist/mesherWasm.js +22 -22
  3. package/dist/minecraft-renderer.js +54 -54
  4. package/dist/minecraft-renderer.js.meta.json +1 -1
  5. package/dist/threeWorker.js +407 -407
  6. package/package.json +1 -1
  7. package/src/graphicsBackend/config.ts +3 -3
  8. package/src/graphicsBackend/rendererDefaultOptions.ts +41 -24
  9. package/src/graphicsBackend/rendererOptionsSync.ts +23 -23
  10. package/src/graphicsBackend/types.ts +3 -3
  11. package/src/index.ts +8 -8
  12. package/src/lib/bindAbortableListener.test.ts +65 -0
  13. package/src/lib/bindAbortableListener.ts +41 -0
  14. package/src/lib/workerProxy.ts +238 -118
  15. package/src/lib/workerSyncOps.test.ts +154 -0
  16. package/src/lib/worldrendererCommon.removeColumn.test.ts +182 -0
  17. package/src/lib/worldrendererCommon.ts +86 -54
  18. package/src/three/documentRenderer.ts +1 -1
  19. package/src/three/entities.ts +21 -11
  20. package/src/three/graphicsBackendBase.ts +18 -8
  21. package/src/three/menuBackground/activeView.ts +1 -1
  22. package/src/three/menuBackground/config.ts +9 -9
  23. package/src/three/menuBackground/index.ts +10 -10
  24. package/src/three/menuBackground/renderer.ts +12 -12
  25. package/src/three/menuBackground/types.ts +9 -9
  26. package/src/three/menuBackground/{futuristic.ts → v2.ts} +110 -59
  27. package/src/three/menuBackground/{futuristicMeta.ts → v2Meta.ts} +6 -6
  28. package/src/three/modules/rain.ts +1 -1
  29. package/src/three/worldRendererThree.ts +2 -1
  30. package/src/wasm-mesher/tests/mesherWasmRequestTracker.test.ts +29 -0
  31. package/src/wasm-mesher/worker/mesherWasm.ts +7 -0
  32. package/src/wasm-mesher/worker/mesherWasmRequestTracker.ts +10 -0
  33. package/src/worldView/worldView.spiral.test.ts +38 -0
  34. package/src/worldView/worldView.ts +41 -8
  35. package/src/worldView/worldViewWorkerBridge.test.ts +59 -0
  36. package/src/lib/workerProxy.restore.test.ts +0 -29
@@ -0,0 +1,182 @@
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 { WorldRendererCommon } from './worldrendererCommon'
7
+ import { defaultWorldRendererConfig } from '../graphicsBackend/config'
8
+ import { getInitialPlayerState } from '../playerState/playerState'
9
+ import type { DisplayWorldOptions, GraphicsInitOptions } from '../graphicsBackend/types'
10
+
11
+ vi.mock('./ui/newStats', () => ({
12
+ addNewStat: vi.fn(() => ({ updateText: vi.fn(), setVisibility: vi.fn() })),
13
+ updateStatText: vi.fn(),
14
+ removeAllStats: vi.fn(),
15
+ updatePanesVisibility: vi.fn(),
16
+ MC_RENDERER_DEBUG_OVERLAY_CLASS: 'mc-renderer-debug-overlay',
17
+ }))
18
+
19
+ vi.mock('./utils/skins', () => ({
20
+ setSkinsConfig: vi.fn(),
21
+ steveTexture: {},
22
+ stevePngUrl: '',
23
+ }))
24
+
25
+ function ensurePromiseWithResolvers() {
26
+ if (!Promise.withResolvers) {
27
+ Promise.withResolvers = function <T>() {
28
+ let resolve!: (value: T | PromiseLike<T>) => void
29
+ let reject!: (reason?: unknown) => void
30
+ const promise = new Promise<T>((res, rej) => {
31
+ resolve = res
32
+ reject = rej
33
+ })
34
+ return { promise, resolve, reject }
35
+ }
36
+ }
37
+ }
38
+
39
+ class TestWorldRenderer extends WorldRendererCommon {
40
+ outputFormat = 'threeJs' as const
41
+
42
+ changeBackgroundColor() {}
43
+ changeCardinalLight() {}
44
+ handleWorkerMessage() {}
45
+ updateCamera() {}
46
+ render() {}
47
+ updateShowChunksBorder() {}
48
+ }
49
+
50
+ function createRenderer() {
51
+ const rendererState = proxy({
52
+ world: {
53
+ chunksLoaded: new Set<string>(),
54
+ heightmaps: new Map<string, Int16Array>(),
55
+ allChunksLoaded: false,
56
+ mesherWork: false,
57
+ instabilityFactors: {},
58
+ intersectMedia: null,
59
+ },
60
+ renderer: '',
61
+ preventEscapeMenu: false,
62
+ })
63
+
64
+ const displayOptions = {
65
+ version: '1.21.1',
66
+ worldView: new EventEmitter() as DisplayWorldOptions['worldView'],
67
+ inWorldRenderingConfig: { ...defaultWorldRendererConfig },
68
+ playerStateReactive: getInitialPlayerState(),
69
+ rendererState,
70
+ nonReactiveState: {
71
+ fps: 0,
72
+ worstRenderTime: 0,
73
+ avgRenderTime: 0,
74
+ world: {
75
+ chunksLoaded: new Set<string>(),
76
+ chunksTotalNumber: 0,
77
+ chunksFullInfo: '',
78
+ },
79
+ renderer: {
80
+ timeline: { live: [], frozen: [], lastSecond: [] },
81
+ },
82
+ },
83
+ resourcesManager: {} as DisplayWorldOptions['resourcesManager'],
84
+ }
85
+
86
+ const initOptions: GraphicsInitOptions = {
87
+ config: { sceneBackground: '#000' },
88
+ rendererSpecificSettings: {},
89
+ callbacks: {
90
+ displayCriticalError: vi.fn(),
91
+ setRendererSpecificSettings: vi.fn(),
92
+ fireCustomEvent: vi.fn(),
93
+ },
94
+ }
95
+
96
+ const renderer = new TestWorldRenderer(displayOptions.resourcesManager, displayOptions, initOptions)
97
+ renderer.active = true
98
+ renderer.workers = [{ postMessage: vi.fn() }, { postMessage: vi.fn() }]
99
+ renderer.viewDistance = 16
100
+ renderer.viewerChunkPosition = new Vec3(0, 64, 0)
101
+ renderer.worldSizeParams = { minY: 0, worldHeight: 256 }
102
+ renderer.loadedChunks['160,0'] = true
103
+ return renderer
104
+ }
105
+
106
+ function sectionKeysForColumn(renderer: TestWorldRenderer, x: number, z: number): string[] {
107
+ const keys: string[] = []
108
+ const sectionHeight = renderer.getSectionHeight()
109
+ for (let y = renderer.worldMinYRender; y < renderer.worldSizeParams.worldHeight; y += sectionHeight) {
110
+ keys.push(`${x},${y},${z}`)
111
+ }
112
+ return keys
113
+ }
114
+
115
+ describe('WorldRendererCommon.removeColumn sectionsWaiting reconciliation', () => {
116
+ beforeEach(() => {
117
+ ensurePromiseWithResolvers()
118
+ vi.useFakeTimers()
119
+ vi.stubGlobal('location', { href: 'http://localhost/' })
120
+ })
121
+
122
+ afterEach(() => {
123
+ vi.useRealTimers()
124
+ vi.unstubAllGlobals()
125
+ })
126
+
127
+ test('clears sectionsWaiting when viewDistance gate blocks setSectionDirty(false)', () => {
128
+ const renderer = createRenderer()
129
+ const columnX = 160
130
+ const columnZ = 0
131
+ const sectionPos = new Vec3(columnX, 64, columnZ)
132
+
133
+ renderer.setSectionDirty(sectionPos, true)
134
+ expect(renderer.sectionsWaiting.get(`${columnX},64,${columnZ}`)).toBe(1)
135
+
136
+ renderer.viewDistance = 4
137
+ renderer.removeColumn(columnX, columnZ)
138
+
139
+ for (const key of sectionKeysForColumn(renderer, columnX, columnZ)) {
140
+ expect(renderer.sectionsWaiting.has(key)).toBe(false)
141
+ }
142
+ })
143
+
144
+ test('treats late sectionFinished as a no-op after removeColumn', () => {
145
+ const renderer = createRenderer()
146
+ const sectionKey = '160,64,0'
147
+ const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {})
148
+
149
+ renderer.sectionsWaiting.set(sectionKey, 1)
150
+ renderer.viewDistance = 4
151
+ renderer.removeColumn(160, 0)
152
+
153
+ expect(() => {
154
+ renderer.handleMessage({ type: 'sectionFinished', key: sectionKey, workerIndex: 0 })
155
+ }).not.toThrow()
156
+
157
+ expect(renderer.sectionsWaiting.has(sectionKey)).toBe(false)
158
+ expect(debugSpy).toHaveBeenCalledWith(
159
+ expect.stringContaining('sectionFinished for non-outstanding section'),
160
+ )
161
+
162
+ debugSpy.mockRestore()
163
+ })
164
+
165
+ test('clears sectionsWaiting when unload happens before batched dirty flush', () => {
166
+ const renderer = createRenderer()
167
+ renderer.forceCallFromMesherReplayer = false
168
+ const columnX = 160
169
+ const columnZ = 0
170
+
171
+ renderer.setSectionDirty(new Vec3(columnX, 64, columnZ), true)
172
+ expect(renderer.sectionsWaiting.get(`${columnX},64,${columnZ}`)).toBe(1)
173
+
174
+ renderer.viewDistance = 4
175
+ renderer.removeColumn(columnX, columnZ)
176
+ vi.advanceTimersByTime(0)
177
+
178
+ for (const key of sectionKeysForColumn(renderer, columnX, columnZ)) {
179
+ expect(renderer.sectionsWaiting.has(key)).toBe(false)
180
+ }
181
+ })
182
+ })
@@ -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) {
@@ -410,7 +424,10 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
410
424
  }
411
425
  if (data.type === 'sectionFinished') { // on after load & unload section
412
426
  this.logWorkerWork(`<- ${data.workerIndex} sectionFinished ${data.key} ${JSON.stringify({ processTime: data.processTime })}`)
413
- if (!this.sectionsWaiting.has(data.key)) throw new Error(`sectionFinished event for non-outstanding section ${data.key}`)
427
+ if (!this.sectionsWaiting.has(data.key)) {
428
+ console.debug(`sectionFinished for non-outstanding section ${data.key} (viewDistance=${this.viewDistance})`)
429
+ return
430
+ }
414
431
  this.sectionsWaiting.set(data.key, this.sectionsWaiting.get(data.key)! - 1)
415
432
  if (this.sectionsWaiting.get(data.key) === 0) {
416
433
  this.sectionsWaiting.delete(data.key)
@@ -432,7 +449,8 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
432
449
  // CHUNK FINISHED
433
450
  this.finishedChunks[chunkKey] = true
434
451
  const CHUNK_SIZE = 16
435
- this.reactiveState.world.chunksLoaded.add(`${Math.floor(chunkCoords[0] / CHUNK_SIZE)},${Math.floor(chunkCoords[2] / CHUNK_SIZE)}`)
452
+ const gridKey = `${Math.floor(chunkCoords[0] / CHUNK_SIZE)},${Math.floor(chunkCoords[2] / CHUNK_SIZE)}`
453
+ this.reactiveState.world.chunksLoaded[gridKey] = true
436
454
  this.renderUpdateEmitter.emit(`chunkFinished`, `${chunkCoords[0]},${chunkCoords[2]}`)
437
455
  this.checkAllFinished()
438
456
  // merge highest blocks by sections into highest blocks by chunks
@@ -513,7 +531,8 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
513
531
  }
514
532
 
515
533
  if (data.type === 'heightmap') {
516
- this.reactiveState.world.heightmaps.set(data.key, new Int16Array(data.heightmap))
534
+ const heightmap = new Int16Array(data.heightmap)
535
+ this.reactiveState.world.heightmaps[data.key] = heightmap
517
536
  }
518
537
  }
519
538
 
@@ -684,7 +703,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
684
703
 
685
704
  updateChunksStats() {
686
705
  const loadedChunks = Object.keys(this.finishedChunks)
687
- this.displayOptions.nonReactiveState.world.chunksLoaded = new Set(loadedChunks)
706
+ this.displayOptions.nonReactiveState.world.chunksLoadedCount = loadedChunks.length
688
707
  this.displayOptions.nonReactiveState.world.chunksTotalNumber = this.chunksLength
689
708
  this.reactiveState.world.allChunksLoaded = this.allChunksFinished
690
709
 
@@ -771,9 +790,11 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
771
790
  this.sectionDirtyPendingArgs.delete(key)
772
791
  }
773
792
  }
774
- for (const worker of this.workers) {
775
- worker.postMessage({ type: 'unloadChunk', x, z })
793
+ for (let i = 0; i < this.workers.length; i++) {
794
+ this.toWorkerMessagesQueue[i] ??= []
795
+ this.toWorkerMessagesQueue[i].push({ type: 'unloadChunk', x, z })
776
796
  }
797
+ this.dispatchMessages()
777
798
  this.logWorkerWork(`-> unloadChunk ${JSON.stringify({ x, z })}`)
778
799
  delete this.finishedChunks[`${x},${z}`]
779
800
  this.allChunksFinished = Object.keys(this.finishedChunks).length === this.chunksLength
@@ -782,12 +803,18 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
782
803
  this.initialChunkLoadWasStartedIn = undefined
783
804
  }
784
805
  const sectionHeight = this.getSectionHeight()
785
- for (let y = this.worldSizeParams.minY; y < this.worldSizeParams.worldHeight; y += sectionHeight) {
786
- this.setSectionDirty(new Vec3(x, y, z), false)
787
- delete this.finishedSections[`${x},${y},${z}`]
806
+ for (let y = this.worldMinYRender; y < this.worldSizeParams.worldHeight; y += sectionHeight) {
807
+ const sectionKey = `${x},${y},${z}`
808
+ const waitingCount = this.sectionsWaiting.get(sectionKey)
809
+ if (waitingCount !== undefined && waitingCount > 0) {
810
+ console.debug(`[removeColumn] clearing non-zero sectionsWaiting for ${sectionKey}: ${waitingCount} (chunk ${x},${z}, viewDistance=${this.viewDistance})`)
811
+ }
812
+ this.sectionsWaiting.delete(sectionKey)
813
+ delete this.finishedSections[sectionKey]
788
814
  }
789
815
  this.highestBlocksByChunks.delete(`${x},${z}`)
790
- this.reactiveState.world.heightmaps.delete(`${Math.floor(x / 16)},${Math.floor(z / 16)}`)
816
+ const heightmapKey = `${Math.floor(x / 16)},${Math.floor(z / 16)}`
817
+ delete this.reactiveState.world.heightmaps[heightmapKey]
791
818
 
792
819
  this.updateChunksStats()
793
820
 
@@ -826,22 +853,23 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
826
853
 
827
854
  connect(worldView: WorldViewWorker) {
828
855
  const worldEmitter = worldView
856
+ const signal = this.abortController.signal
829
857
 
830
- worldEmitter.on('entity', (e) => {
858
+ bindAbortableListener(worldEmitter, 'entity', (e) => {
831
859
  this.updateEntity(e, false)
832
- })
833
- worldEmitter.on('entityMoved', (e) => {
860
+ }, signal)
861
+ bindAbortableListener(worldEmitter, 'entityMoved', (e) => {
834
862
  this.updateEntity(e, true)
835
- })
836
- worldEmitter.on('playerEntity', (e) => {
863
+ }, signal)
864
+ bindAbortableListener(worldEmitter, 'playerEntity', (e) => {
837
865
  this.updatePlayerEntity?.(e)
838
- })
866
+ }, signal)
839
867
 
840
868
  let currentLoadChunkBatch = null as {
841
869
  timeout
842
870
  data
843
871
  } | null
844
- worldEmitter.on('loadChunk', ({ x, z, chunk, worldConfig, isLightUpdate }) => {
872
+ bindAbortableListener(worldEmitter, 'loadChunk', ({ x, z, chunk, worldConfig, isLightUpdate }) => {
845
873
  this.worldSizeParams = worldConfig
846
874
  this.queuedChunks.add(`${x},${z}`)
847
875
  const args = [x, z, chunk, isLightUpdate]
@@ -863,45 +891,44 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
863
891
  }
864
892
  }
865
893
  currentLoadChunkBatch.data.push(args)
866
- })
894
+ }, signal)
867
895
  // todo remove and use other architecture instead so data flow is clear
868
- worldEmitter.on('blockEntities', (blockEntities) => {
896
+ bindAbortableListener(worldEmitter, 'blockEntities', (blockEntities) => {
869
897
  this.blockEntities = blockEntities
870
- })
898
+ }, signal)
871
899
 
872
- worldEmitter.on('unloadChunk', ({ x, z }) => {
900
+ bindAbortableListener(worldEmitter, 'unloadChunk', ({ x, z }) => {
873
901
  this.removeColumn(x, z)
874
- })
902
+ }, signal)
875
903
 
876
- worldEmitter.on('blockUpdate', ({ pos, stateId }) => {
904
+ bindAbortableListener(worldEmitter, 'blockUpdate', ({ pos, stateId }) => {
877
905
  this.setBlockStateId(new Vec3(pos.x, pos.y, pos.z), stateId)
878
- })
906
+ }, signal)
879
907
 
880
- worldEmitter.on('chunkPosUpdate', ({ pos }) => {
908
+ bindAbortableListener(worldEmitter, 'chunkPosUpdate', ({ pos }) => {
881
909
  this.updateViewerPosition(pos)
882
- })
910
+ }, signal)
883
911
 
884
- worldEmitter.on('end', () => {
912
+ bindAbortableListener(worldEmitter, 'end', () => {
885
913
  this.worldStop?.()
886
- })
914
+ }, signal)
887
915
 
888
-
889
- worldEmitter.on('renderDistance', (d) => {
916
+ bindAbortableListener(worldEmitter, 'renderDistance', (d) => {
890
917
  this.viewDistance = d
891
918
  this.chunksLength = d === 0 ? 1 : generateSpiralMatrix(d).length
892
919
  this.allChunksFinished = Object.keys(this.finishedChunks).length === this.chunksLength
893
920
  this.onRenderDistanceChanged?.(d)
894
- })
921
+ }, signal)
895
922
 
896
- worldEmitter.on('markAsLoaded', ({ x, z }) => {
923
+ bindAbortableListener(worldEmitter, 'markAsLoaded', ({ x, z }) => {
897
924
  this.markAsLoaded(x, z)
898
- })
925
+ }, signal)
899
926
 
900
- worldEmitter.on('updateLight', ({ pos }) => {
927
+ bindAbortableListener(worldEmitter, 'updateLight', ({ pos }) => {
901
928
  this.lightUpdate(pos.x, pos.z)
902
- })
929
+ }, signal)
903
930
 
904
- worldEmitter.on('onWorldSwitch', () => {
931
+ bindAbortableListener(worldEmitter, 'onWorldSwitch', () => {
905
932
  for (const fn of this.onWorldSwitched) {
906
933
  try {
907
934
  fn()
@@ -912,9 +939,9 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
912
939
  }, 0)
913
940
  }
914
941
  }
915
- })
942
+ }, signal)
916
943
 
917
- worldEmitter.on('time', (timeOfDay) => {
944
+ bindAbortableListener(worldEmitter, 'time', (timeOfDay) => {
918
945
  if (!this.worldRendererConfig.dayCycle) return
919
946
  this.timeUpdated?.(timeOfDay)
920
947
 
@@ -925,15 +952,15 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
925
952
  // if (this instanceof WorldRendererThree) {
926
953
  // (this).rerenderAllChunks?.()
927
954
  // }
928
- })
955
+ }, signal)
929
956
 
930
- worldEmitter.on('biomeUpdate', ({ biome }) => {
957
+ bindAbortableListener(worldEmitter, 'biomeUpdate', ({ biome }) => {
931
958
  this.biomeUpdated?.(biome)
932
- })
959
+ }, signal)
933
960
 
934
- worldEmitter.on('biomeReset', () => {
961
+ bindAbortableListener(worldEmitter, 'biomeReset', () => {
935
962
  this.biomeReset?.()
936
- })
963
+ }, signal)
937
964
  }
938
965
 
939
966
  setBlockStateIdInner(pos: Vec3, stateId: number | undefined, needAoRecalculation = true) {
@@ -1217,6 +1244,11 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
1217
1244
  }
1218
1245
 
1219
1246
  destroy() {
1247
+ for (const unsub of this.valtioUnsubs) {
1248
+ unsub()
1249
+ }
1250
+ this.valtioUnsubs = []
1251
+
1220
1252
  // Cancel all pending heightmap debounce timers
1221
1253
  for (const timer of this.heightmapDebounceTimers.values()) {
1222
1254
  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']()
@@ -1392,21 +1393,30 @@ export class Entities {
1392
1393
  })
1393
1394
  .start()
1394
1395
  }
1395
- if (typeof entity.yaw === 'number' && Number.isFinite(entity.yaw)) {
1396
- const dy = shortestYawRadians(e.rotation.y, entity.yaw)
1397
- new TWEEN.Tween(e.rotation).to({ y: e.rotation.y + dy }, ANIMATION_DURATION).start()
1396
+ /** World yaw for the whole model: for PlayerObject skins, rotate body to head look dir; head mesh stays yaw-fixed (pitch only). */
1397
+ let targetYaw: number | undefined
1398
+ if (e.playerObject && overrides?.rotation?.head) {
1399
+ const hy = overrides.rotation.head.y
1400
+ const headYawWorld =
1401
+ typeof hy === 'number' && Number.isFinite(hy) ? hy : entity.yaw
1402
+ if (typeof headYawWorld === 'number' && Number.isFinite(headYawWorld)) {
1403
+ targetYaw = headYawWorld
1404
+ }
1405
+ } else if (typeof entity.yaw === 'number' && Number.isFinite(entity.yaw)) {
1406
+ targetYaw = entity.yaw
1407
+ }
1408
+ if (typeof targetYaw === 'number' && Number.isFinite(targetYaw)) {
1409
+ const dy = shortestYawRadians(e.rotation.y, targetYaw)
1410
+ // Stop previous rotation tween to prevent accumulation (mirror _posTween)
1411
+ e.userData._rotTween?.stop()
1412
+ e.userData._rotTween = new TWEEN.Tween(e.rotation)
1413
+ .to({ y: e.rotation.y + dy }, ANIMATION_DURATION)
1414
+ .start()
1398
1415
  }
1399
1416
 
1400
1417
  if (e?.playerObject && overrides?.rotation?.head) {
1401
1418
  const { playerObject } = e
1402
- const hy = overrides.rotation.head.y
1403
- const headYawWorld =
1404
- typeof hy === 'number' && Number.isFinite(hy) ? hy : entity.yaw
1405
- const headYawOffset =
1406
- typeof headYawWorld === 'number' && typeof entity.yaw === 'number' && Number.isFinite(headYawWorld) && Number.isFinite(entity.yaw)
1407
- ? shortestYawRadians(entity.yaw, headYawWorld)
1408
- : 0
1409
- playerObject.skin.head.rotation.y = headYawOffset
1419
+ playerObject.skin.head.rotation.y = 0
1410
1420
 
1411
1421
  const hp = overrides.rotation.head.x
1412
1422
  playerObject.skin.head.rotation.x =
@@ -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
@@ -1,7 +1,7 @@
1
1
  //@ts-nocheck
2
2
  import * as THREE from 'three'
3
3
 
4
- /** Contract for a main-menu background implementation (classic cubemap, futuristic scene, etc.). */
4
+ /** Contract for a main-menu background implementation (classic cubemap, v2 scene, etc.). */
5
5
  export interface MenuBackgroundView {
6
6
  readonly scene: THREE.Scene
7
7
  readonly camera: THREE.PerspectiveCamera
@@ -1,23 +1,23 @@
1
1
  //@ts-nocheck
2
2
  import type { MenuBackgroundMode } from './types'
3
- import type { FuturisticCameraId, FuturisticSceneId, MinecraftBlockGroupId } from './futuristic'
3
+ import type { V2CameraId, V2SceneId, MinecraftBlockGroupId } from './v2'
4
4
 
5
5
  /** Single source of truth for menu-background defaults (settings + runtime fallbacks). */
6
6
  export const MENU_BACKGROUND_OPTION_DEFAULTS = {
7
- mode: 'futuristic' as MenuBackgroundMode,
7
+ mode: 'v2' as MenuBackgroundMode,
8
8
  minecraftTextures: true as boolean,
9
- futuristicScene: 'light' as FuturisticSceneId,
10
- futuristicCamera: 'dive' as FuturisticCameraId,
11
- futuristicBlockGroup: 'stainedGlass' as MinecraftBlockGroupId,
9
+ v2Scene: 'light' as V2SceneId,
10
+ v2Camera: 'dive' as V2CameraId,
11
+ v2BlockGroup: 'stainedGlass' as MinecraftBlockGroupId,
12
12
  /** 0–200 (%). 100 = 1× motion. */
13
- futuristicCameraSpeedPercent: 80,
14
- futuristicBlockSpeedPercent: 40
13
+ v2CameraSpeedPercent: 80,
14
+ v2BlockSpeedPercent: 40
15
15
  } as const
16
16
 
17
17
  export const menuBackgroundSpeedToMultiplier = (percent: number) => percent / 100
18
18
 
19
19
  /** Default camera / block motion multipliers (1 = 100%). */
20
20
  export const MENU_BACKGROUND_MOTION_DEFAULTS = {
21
- camera: menuBackgroundSpeedToMultiplier(MENU_BACKGROUND_OPTION_DEFAULTS.futuristicCameraSpeedPercent),
22
- block: menuBackgroundSpeedToMultiplier(MENU_BACKGROUND_OPTION_DEFAULTS.futuristicBlockSpeedPercent)
21
+ camera: menuBackgroundSpeedToMultiplier(MENU_BACKGROUND_OPTION_DEFAULTS.v2CameraSpeedPercent),
22
+ block: menuBackgroundSpeedToMultiplier(MENU_BACKGROUND_OPTION_DEFAULTS.v2BlockSpeedPercent)
23
23
  } as const
@@ -6,21 +6,21 @@ export type { MenuBackgroundMode, MenuBackgroundOptions } from './types'
6
6
  export { resolveMenuBackgroundMode } from './types'
7
7
  export { ClassicMenuBackground } from './classic'
8
8
  export type {
9
- FuturisticSceneId,
10
- FuturisticCameraId,
11
- FuturisticMenuBackgroundOptions,
9
+ V2SceneId,
10
+ V2CameraId,
11
+ V2MenuBackgroundOptions,
12
12
  MinecraftBlockGroupId
13
- } from './futuristic'
13
+ } from './v2'
14
14
  export {
15
- FuturisticMenuBackground,
16
- FUTURISTIC_SCENE_IDS,
17
- FUTURISTIC_CAMERA_IDS,
18
- FUTURISTIC_SCENE_LABELS,
19
- FUTURISTIC_CAMERA_LABELS,
15
+ V2MenuBackground,
16
+ V2_SCENE_IDS,
17
+ V2_CAMERA_IDS,
18
+ V2_SCENE_LABELS,
19
+ V2_CAMERA_LABELS,
20
20
  MINECRAFT_BLOCK_GROUPS,
21
21
  MINECRAFT_BLOCK_GROUP_IDS,
22
22
  MINECRAFT_BLOCK_GROUP_LABELS
23
- } from './futuristic'
23
+ } from './v2'
24
24
  export { WorldBlocksMenuBackground } from './worldBlocks'
25
25
  export { MenuBackgroundRenderer } from './renderer'
26
26
  export {