minecraft-renderer 0.1.71 → 0.1.72

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minecraft-renderer",
3
- "version": "0.1.71",
3
+ "version": "0.1.72",
4
4
  "description": "The most Modular Minecraft world renderer with Three.js WebGL backend",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -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,8 +193,8 @@ 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
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.',
197
+ requiresChunksReload: true,
198
198
  },
199
199
  rendererShaderCubeBlocks: {
200
200
  text: '(UNSTABLE) Instanced shader cubes',
@@ -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
+ })
@@ -127,6 +127,12 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
127
127
  debugStopGeometryUpdate = false
128
128
 
129
129
  protocolCustomBlocks = new Map<string, CustomBlockModels>()
130
+ private mesherPoolSnapshot = {
131
+ mesherWorkers: -1,
132
+ wasmMesher: false,
133
+ dedicatedChangeWorker: false,
134
+ }
135
+ private mesherReconfigureQueue: Promise<void> = Promise.resolve()
130
136
  private heightmapDebounceTimers = new Map<string, ReturnType<typeof setTimeout>>()
131
137
 
132
138
  // Geometry throttle: first dirty per section is instant, subsequent within window are grouped
@@ -272,6 +278,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
272
278
 
273
279
  this.watchReactivePlayerState()
274
280
  this.watchReactiveConfig()
281
+ this.watchMesherPoolConfig()
275
282
  this.worldReadyResolvers.resolve()
276
283
  }
277
284
 
@@ -309,18 +316,127 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
309
316
  }
310
317
  }
311
318
 
319
+ private getMesherWorkerScript(): 'wasm' | 'legacy' {
320
+ return this.worldRendererConfig.wasmMesher ? 'wasm' : 'legacy'
321
+ }
322
+
323
+ private createMesherWorker() {
324
+ const script = this.getMesherWorkerScript()
325
+ return initMesherWorker((data) => {
326
+ if (Array.isArray(data)) {
327
+ this.messageQueue.push(...data)
328
+ } else {
329
+ this.messageQueue.push(data)
330
+ }
331
+ void this.processMessageQueue('worker')
332
+ }, script === 'wasm' ? 'mesherWasm.js' : 'mesher.js')
333
+ }
334
+
312
335
  initWorkers(numWorkers = this.worldRendererConfig.mesherWorkers) {
313
- // init workers
314
- for (let i = 0; i < numWorkers + 0; i++) {
315
- const worker = initMesherWorker((data) => {
316
- if (Array.isArray(data)) {
317
- this.messageQueue.push(...data)
318
- } else {
319
- this.messageQueue.push(data)
320
- }
321
- void this.processMessageQueue('worker')
322
- }, this.worldRendererConfig.wasmMesher ? 'mesherWasm.js' : 'mesher.js')
323
- this.workers.push(worker)
336
+ for (let i = 0; i < numWorkers; i++) {
337
+ this.workers.push(this.createMesherWorker())
338
+ }
339
+ }
340
+
341
+ private syncMesherPoolSnapshot() {
342
+ this.mesherPoolSnapshot = {
343
+ mesherWorkers: this.worldRendererConfig.mesherWorkers,
344
+ wasmMesher: this.worldRendererConfig.wasmMesher,
345
+ dedicatedChangeWorker: this.worldRendererConfig.dedicatedChangeWorker,
346
+ }
347
+ }
348
+
349
+ private watchMesherPoolConfig() {
350
+ this.syncMesherPoolSnapshot()
351
+
352
+ const tryReconfigure = () => {
353
+ const cfg = this.worldRendererConfig
354
+ const snap = this.mesherPoolSnapshot
355
+ if (
356
+ cfg.mesherWorkers === snap.mesherWorkers &&
357
+ cfg.wasmMesher === snap.wasmMesher &&
358
+ cfg.dedicatedChangeWorker === snap.dedicatedChangeWorker
359
+ ) {
360
+ return
361
+ }
362
+ this.syncMesherPoolSnapshot()
363
+ this.enqueueMesherWorkersReconfigure()
364
+ }
365
+
366
+ for (const key of ['mesherWorkers', 'wasmMesher', 'dedicatedChangeWorker'] as const) {
367
+ this.valtioUnsubs.push(
368
+ this.onReactiveConfigUpdated(key, tryReconfigure, false)
369
+ )
370
+ }
371
+ }
372
+
373
+ private enqueueMesherWorkersReconfigure() {
374
+ this.mesherReconfigureQueue = this.mesherReconfigureQueue
375
+ .then(() => this.reconfigureMesherWorkers())
376
+ .catch((err) => {
377
+ console.error('[Mesher] Failed to reconfigure workers:', err)
378
+ })
379
+ }
380
+ private clearMesherPendingState() {
381
+ this.sectionsWaiting.clear()
382
+ this.toWorkerMessagesQueue = {}
383
+ this.queueAwaited = false
384
+ this.messageQueue = []
385
+ this.isProcessingQueue = false
386
+ for (const timer of this.sectionDirtyTimers.values()) {
387
+ clearTimeout(timer)
388
+ }
389
+ this.sectionDirtyTimers.clear()
390
+ this.sectionDirtyCount.clear()
391
+ this.sectionDirtyPendingArgs.clear()
392
+ this.reactiveState.world.mesherWork = false
393
+ }
394
+
395
+ private terminateAllMesherWorkers() {
396
+ for (const worker of this.workers) {
397
+ worker.terminate()
398
+ }
399
+ this.workers = []
400
+ }
401
+
402
+ private async bootstrapMesherWorkers() {
403
+ if (this.workers.length === 0) return
404
+
405
+ this.sendMesherMcData()
406
+ await this.updateAssetsData()
407
+ this.logWorkerWork('# mesher workers bootstrapped')
408
+ }
409
+
410
+ async reconfigureMesherWorkers() {
411
+ if (!this.active) return
412
+
413
+ this.clearMesherPendingState()
414
+ this.terminateAllMesherWorkers()
415
+ this.initWorkers()
416
+ await this.bootstrapMesherWorkers()
417
+ if (!this.active) return
418
+
419
+ await this.requestLoadedChunksReload()
420
+ }
421
+
422
+ private async requestLoadedChunksReload() {
423
+ try {
424
+ const worldView = this.displayOptions.worldView as {
425
+ reloadLoadedChunks?: () => void | Promise<void>
426
+ }
427
+
428
+ if (typeof worldView.reloadLoadedChunks === 'function') {
429
+ await worldView.reloadLoadedChunks()
430
+ return
431
+ }
432
+
433
+ const workerScope = globalThis as typeof globalThis & { WorkerGlobalScope?: typeof WorkerGlobalScope }
434
+ if (typeof workerScope.WorkerGlobalScope !== 'undefined' && globalThis instanceof workerScope.WorkerGlobalScope) {
435
+ // eslint-disable-next-line no-restricted-globals
436
+ self.postMessage({ type: 'reloadLoadedChunks' })
437
+ }
438
+ } catch (err) {
439
+ console.error('[Mesher] Failed to reload chunks after worker reconfigure:', err)
324
440
  }
325
441
  }
326
442
 
@@ -331,13 +447,18 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
331
447
  return subscribeKey(this.playerStateReactive, key, callback)
332
448
  }
333
449
 
334
- onReactiveConfigUpdated<T extends keyof typeof this.worldRendererConfig>(key: T, callback: (value: typeof this.worldRendererConfig[T]) => void) {
335
- callback(this.worldRendererConfig[key])
450
+ onReactiveConfigUpdated<T extends keyof typeof this.worldRendererConfig>(
451
+ key: T,
452
+ callback: (value: typeof this.worldRendererConfig[T]) => void,
453
+ initial = true
454
+ ) {
455
+ if (initial) {
456
+ callback(this.worldRendererConfig[key])
457
+ }
336
458
  if ((key as any) === '*') {
337
- subscribe(this.worldRendererConfig, callback as any)
338
- } else {
339
- subscribeKey(this.worldRendererConfig, key, callback)
459
+ return subscribe(this.worldRendererConfig, callback as any)
340
460
  }
461
+ return subscribeKey(this.worldRendererConfig, key, callback)
341
462
  }
342
463
 
343
464
  onReactiveDebugUpdated<T extends keyof typeof this.reactiveDebugParams>(key: T, callback: (value: typeof this.reactiveDebugParams[T]) => void) {
@@ -637,6 +758,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
637
758
 
638
759
  this.initWorkers()
639
760
  this.active = true
761
+ this.syncMesherPoolSnapshot()
640
762
 
641
763
  this.sendMesherMcData()
642
764
  }
@@ -9,6 +9,7 @@ import type { MenuBackgroundOptions } from './menuBackground/types'
9
9
  import { MENU_BACKGROUND_MC_VERSION } from './menuBackground/shared'
10
10
  import { createGraphicsBackendBase, type ThreeJsBackendMethods } from './graphicsBackendBase'
11
11
  import { addCanvasForWorker } from './documentRenderer'
12
+ import type { WorldView } from '../worldView'
12
13
 
13
14
  function initThreeWorker(onGotMessage: (data: any) => void) {
14
15
  // Node environment needs an absolute path, but browser needs the url of the file
@@ -31,7 +32,12 @@ function initThreeWorker(onGotMessage: (data: any) => void) {
31
32
  }
32
33
 
33
34
  export const createGraphicsBackendOffThread: GraphicsBackendLoader = async (initOptions) => {
34
- const worker = initThreeWorker(() => { })
35
+ const workerSideChannel = {
36
+ onMessage: (_data: unknown) => {},
37
+ }
38
+ const worker = initThreeWorker((data) => {
39
+ workerSideChannel.onMessage(data)
40
+ })
35
41
  type WorkerType = ReturnType<ReturnType<typeof createGraphicsBackendBase>['workerProxy']>
36
42
 
37
43
  const proxy = useWorkerProxy<WorkerType>(worker)
@@ -79,6 +85,15 @@ export const createGraphicsBackendOffThread: GraphicsBackendLoader = async (init
79
85
  }
80
86
  },
81
87
  async startWorld(options) {
88
+ const worldView = options.worldView as unknown as WorldView
89
+ workerSideChannel.onMessage = (data: any) => {
90
+ if (data?.type === 'reloadLoadedChunks') {
91
+ void worldView.reloadLoadedChunks().catch((err) => {
92
+ console.error('[Renderer] Failed to reload chunks after mesher reconfigure:', err)
93
+ })
94
+ }
95
+ }
96
+
82
97
  const workerThreeSendData = {
83
98
  ...dynamicMcDataFiles,
84
99
  items: 'itemsArray',
@@ -320,6 +320,17 @@ export class WorldView extends (EventEmitter as new () => TypedEmitter<WorldView
320
320
  }
321
321
  }
322
322
 
323
+ /**
324
+ * Re-fetch and re-emit every loaded chunk (e.g. after mesher workers are recreated).
325
+ */
326
+ async reloadLoadedChunks(): Promise<void> {
327
+ const coords = Object.keys(this.loadedChunks)
328
+ for (const key of coords) {
329
+ const [x, z] = key.split(',').map(Number)
330
+ await this.loadChunk({ x, z }, false, 'mesher-reconfigure')
331
+ }
332
+ }
333
+
323
334
  /**
324
335
  * Unload all chunks.
325
336
  */