From 3bacef125118bee6bb814f88826708a384956ec1 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Sat, 7 Dec 2024 01:54:00 +0300 Subject: [PATCH] fix: fix race condition when state id change was received from server faster than chunk was processed by mesher: now the change will await for the chunk load first instead of ignoring it. sync common code from webgpu --- prismarine-viewer/viewer/lib/viewer.ts | 55 +++++++---- .../viewer/lib/worldDataEmitter.ts | 18 ++-- .../viewer/lib/worldrendererCommon.ts | 93 +++++++++++++------ src/react/HotbarRenderApp.tsx | 13 ++- src/reactUi.tsx | 10 +- 5 files changed, 128 insertions(+), 61 deletions(-) diff --git a/prismarine-viewer/viewer/lib/viewer.ts b/prismarine-viewer/viewer/lib/viewer.ts index d16dbe811..2671ba07c 100644 --- a/prismarine-viewer/viewer/lib/viewer.ts +++ b/prismarine-viewer/viewer/lib/viewer.ts @@ -78,15 +78,15 @@ export class Viewer { // this.primitives.clear() } - setVersion (userVersion: string, texturesVersion = userVersion) { + setVersion (userVersion: string, texturesVersion = userVersion): void | Promise { console.log('[viewer] Using version:', userVersion, 'textures:', texturesVersion) - void this.world.setVersion(userVersion, texturesVersion).then(async () => { + this.entities.clear() + // this.primitives.clear() + return this.world.setVersion(userVersion, texturesVersion).then(async () => { return new THREE.TextureLoader().loadAsync(this.world.itemsAtlasParser!.latestImage) }).then((texture) => { this.entities.itemsTexture = texture }) - this.entities.clear() - // this.primitives.clear() } addColumn (x, z, chunk, isLightUpdate = false) { @@ -98,10 +98,18 @@ export class Viewer { } setBlockStateId (pos: Vec3, stateId: number) { - if (!this.world.loadedChunks[`${Math.floor(pos.x / 16) * 16},${Math.floor(pos.z / 16) * 16}`]) { - console.debug('[should be unreachable] setBlockStateId called for unloaded chunk', pos) + const set = async () => { + const sectionX = Math.floor(pos.x / 16) * 16 + const sectionZ = Math.floor(pos.z / 16) * 16 + if (this.world.queuedChunks.has(`${sectionX},${sectionZ}`)) { + await this.world.waitForChunkToLoad(pos) + } + if (!this.world.loadedChunks[`${sectionX},${sectionZ}`]) { + console.debug('[should be unreachable] setBlockStateId called for unloaded chunk', pos) + } + this.world.setBlockStateId(pos, stateId) } - this.world.setBlockStateId(pos, stateId) + void set() } demoModel () { @@ -153,9 +161,8 @@ export class Viewer { let yOffset = this.playerHeight if (this.isSneaking) yOffset -= 0.3 - if (this.world instanceof WorldRendererThree) { - this.world.camera = cam as THREE.PerspectiveCamera - } + this.world.camera = cam as THREE.PerspectiveCamera + this.world.updateCamera(pos?.offset(0, yOffset, 0) ?? null, yaw, pitch) } @@ -205,6 +212,7 @@ export class Viewer { } | null worldEmitter.on('loadChunk', ({ x, z, chunk, worldConfig, isLightUpdate }) => { this.world.worldConfig = worldConfig + this.world.queuedChunks.add(`${x},${z}`) const args = [x, z, chunk, isLightUpdate] if (!currentLoadChunkBatch) { // add a setting to use debounce instead @@ -212,6 +220,7 @@ export class Viewer { data: [], timeout: setTimeout(() => { for (const args of currentLoadChunkBatch!.data) { + this.world.queuedChunks.delete(`${args[0]},${args[1]}`) this.addColumn(...args as Parameters) } currentLoadChunkBatch = null @@ -222,7 +231,7 @@ export class Viewer { }) // todo remove and use other architecture instead so data flow is clear worldEmitter.on('blockEntities', (blockEntities) => { - if (this.world instanceof WorldRendererThree) this.world.blockEntities = blockEntities + if (this.world instanceof WorldRendererThree) (this.world).blockEntities = blockEntities }) worldEmitter.on('unloadChunk', ({ x, z }) => { @@ -237,14 +246,24 @@ export class Viewer { this.world.updateViewerPosition(pos) }) + + worldEmitter.on('renderDistance', (d) => { + this.world.viewDistance = d + this.world.chunksLength = d === 0 ? 1 : generateSpiralMatrix(d).length + }) + worldEmitter.on('renderDistance', (d) => { this.world.viewDistance = d this.world.chunksLength = d === 0 ? 1 : generateSpiralMatrix(d).length this.world.allChunksFinished = Object.keys(this.world.finishedChunks).length === this.world.chunksLength }) + worldEmitter.on('markAsLoaded', ({ x, z }) => { + this.world.markAsLoaded(x, z) + }) + worldEmitter.on('updateLight', ({ pos }) => { - if (this.world instanceof WorldRendererThree) this.world.updateLight(pos.x, pos.z) + if (this.world instanceof WorldRendererThree) (this.world).updateLight(pos.x, pos.z) }) worldEmitter.on('time', (timeOfDay) => { @@ -264,16 +283,20 @@ export class Viewer { skyLight = Math.floor(skyLight) // todo: remove this after optimization if (this.world.mesherConfig.skyLight === skyLight) return - this.world.mesherConfig.skyLight = skyLight; - (this.world as WorldRendererThree).rerenderAllChunks?.() + this.world.mesherConfig.skyLight = skyLight + if (this.world instanceof WorldRendererThree) { + (this.world).rerenderAllChunks?.() + } }) worldEmitter.emit('listening') } render () { - this.world.render() - this.entities.render() + if (this.world instanceof WorldRendererThree) { + (this.world).render() + this.entities.render() + } } async waitForChunksToRender () { diff --git a/prismarine-viewer/viewer/lib/worldDataEmitter.ts b/prismarine-viewer/viewer/lib/worldDataEmitter.ts index 51c0a08b8..0223b855b 100644 --- a/prismarine-viewer/viewer/lib/worldDataEmitter.ts +++ b/prismarine-viewer/viewer/lib/worldDataEmitter.ts @@ -6,6 +6,7 @@ import { generateSpiralMatrix, ViewRect } from 'flying-squid/dist/utils' import { Vec3 } from 'vec3' import { BotEvents } from 'mineflayer' import { getItemFromBlock } from '../../../src/chatUtils' +import { delayedIterator } from '../../examples/shared' import { chunkPos } from './simpleUtils' export type ChunkPosKey = string @@ -23,6 +24,7 @@ export class WorldDataEmitter extends EventEmitter { keepChunksDistance = 0 addWaitTime = 1 _handDisplay = false + isPlayground = false get handDisplay () { return this._handDisplay } @@ -173,19 +175,11 @@ export class WorldDataEmitter extends EventEmitter { } async _loadChunks (positions: Vec3[], sliceSize = 5) { - let i = 0 const promises = [] as Array> - return new Promise(resolve => { - const interval = setInterval(() => { - if (i >= positions.length) { - clearInterval(interval) - void Promise.all(promises).then(() => resolve()) - return - } - promises.push(this.loadChunk(positions[i])) - i++ - }, this.addWaitTime) + await delayedIterator(positions, this.addWaitTime, (pos) => { + promises.push(this.loadChunk(pos)) }) + await Promise.all(promises) } readdDebug () { @@ -221,6 +215,8 @@ export class WorldDataEmitter extends EventEmitter { //@ts-expect-error this.emitter.emit('loadChunk', { x: pos.x, z: pos.z, chunk, blockEntities: column.blockEntities, worldConfig, isLightUpdate }) this.loadedChunks[`${pos.x},${pos.z}`] = true + } else if (this.isPlayground) { // don't allow in real worlds pre-flag chunks as loaded to avoid race condition when the chunk might still be loading. In playground it's assumed we always pre-load all chunks first + this.emitter.emit('markAsLoaded', { x: pos.x, z: pos.z }) } } else { // console.debug('skipped loading chunk', dx, dz, '>', this.viewDistance) diff --git a/prismarine-viewer/viewer/lib/worldrendererCommon.ts b/prismarine-viewer/viewer/lib/worldrendererCommon.ts index 7beb0a924..995e7d29c 100644 --- a/prismarine-viewer/viewer/lib/worldrendererCommon.ts +++ b/prismarine-viewer/viewer/lib/worldrendererCommon.ts @@ -49,13 +49,17 @@ export abstract class WorldRendererCommon version = undefined as string | undefined @worldCleanup() - loadedChunks = {} as Record + loadedChunks = {} as Record // data is added for these chunks and they might be still processing @worldCleanup() - finishedChunks = {} as Record + finishedChunks = {} as Record // these chunks are fully loaded into the world (scene) @worldCleanup() - sectionsOutstanding = new Map() + // loading sections (chunks) + sectionsWaiting = new Map() + + @worldCleanup() + queuedChunks = new Set() @worldCleanup() renderUpdateEmitter = new EventEmitter() as unknown as TypedEmitter<{ @@ -109,13 +113,15 @@ export abstract class WorldRendererCommon abstract outputFormat: 'threeJs' | 'webgpu' + abstract changeBackgroundColor (color: [number, number, number]): void + constructor (public config: WorldRendererConfig) { // this.initWorkers(1) // preload script on page load this.snapshotInitialValues() this.renderUpdateEmitter.on('update', () => { const loadedChunks = Object.keys(this.finishedChunks).length - updateStatText('loaded-chunks', `${loadedChunks}/${this.chunksLength} chunks (${this.lastChunkDistance})`) + updateStatText('loaded-chunks', `${loadedChunks}/${this.chunksLength} chunks (${this.lastChunkDistance}/${this.viewDistance})`) }) } @@ -135,38 +141,31 @@ export abstract class WorldRendererCommon this.handleWorkerMessage(data) if (data.type === 'geometry') { const geometry = data.geometry as MesherGeometryOutput - for (const [key, highest] of geometry.highestBlocks.entries()) { - const currHighest = this.highestBlocks.get(key) - if (!currHighest || currHighest.y < highest.y) { - this.highestBlocks.set(key, highest) + for (const key in geometry.highestBlocks) { + const highest = geometry.highestBlocks[key] + if (!this.highestBlocks[key] || this.highestBlocks[key].y < highest.y) { + this.highestBlocks[key] = highest } } const chunkCoords = data.key.split(',').map(Number) this.lastChunkDistance = Math.max(...this.getDistance(new Vec3(chunkCoords[0], 0, chunkCoords[2]))) } if (data.type === 'sectionFinished') { // on after load & unload section - if (!this.sectionsOutstanding.get(data.key)) throw new Error(`sectionFinished event for non-outstanding section ${data.key}`) - this.sectionsOutstanding.set(data.key, this.sectionsOutstanding.get(data.key)! - 1) - if (this.sectionsOutstanding.get(data.key) === 0) this.sectionsOutstanding.delete(data.key) + if (!this.sectionsWaiting.get(data.key)) throw new Error(`sectionFinished event for non-outstanding section ${data.key}`) + this.sectionsWaiting.set(data.key, this.sectionsWaiting.get(data.key)! - 1) + if (this.sectionsWaiting.get(data.key) === 0) this.sectionsWaiting.delete(data.key) const chunkCoords = data.key.split(',').map(Number) if (this.loadedChunks[`${chunkCoords[0]},${chunkCoords[2]}`]) { // ensure chunk data was added, not a neighbor chunk update - const loadingKeys = [...this.sectionsOutstanding.keys()] + const loadingKeys = [...this.sectionsWaiting.keys()] if (!loadingKeys.some(key => { const [x, y, z] = key.split(',').map(Number) return x === chunkCoords[0] && z === chunkCoords[2] })) { this.finishedChunks[`${chunkCoords[0]},${chunkCoords[2]}`] = true - this.renderUpdateEmitter.emit(`chunkFinished`, `${chunkCoords[0] / 16},${chunkCoords[2] / 16}`) - } - } - if (this.sectionsOutstanding.size === 0) { - const allFinished = Object.keys(this.finishedChunks).length === this.chunksLength - if (allFinished) { - this.allChunksLoaded?.() - this.allChunksFinished = true } } + this.checkAllFinished() this.renderUpdateEmitter.emit('update') if (data.processTime) { @@ -189,6 +188,16 @@ export abstract class WorldRendererCommon } } + checkAllFinished () { + if (this.sectionsWaiting.size === 0) { + const allFinished = Object.keys(this.finishedChunks).length === this.chunksLength + if (allFinished) { + this.allChunksLoaded?.() + this.allChunksFinished = true + } + } + } + onHandItemSwitch (item: HandItemBlock | undefined): void { } changeHandSwingingState (isAnimationPlaying: boolean): void { } @@ -270,7 +279,7 @@ export abstract class WorldRendererCommon } } - async updateTexturesData () { + async updateTexturesData (resourcePackUpdate = false) { const blocksAssetsParser = new AtlasParser(this.blocksAtlases, blocksAtlasLatest, blocksAtlasLegacy) const itemsAssetsParser = new AtlasParser(this.itemsAtlases, itemsAtlasLatest, itemsAtlasLegacy) const { atlas: blocksAtlas, canvas: blocksCanvas } = await blocksAssetsParser.makeNewAtlas(this.texturesVersion ?? this.version ?? 'latest', (textureName) => { @@ -327,11 +336,16 @@ export abstract class WorldRendererCommon return Math.floor(Math.max(this.worldConfig.minY, this.mesherConfig.clipWorldBelowY ?? -Infinity) / 16) * 16 } + updateDownloadedChunksText () { + updateStatText('downloaded-chunks', `${Object.keys(this.loadedChunks).length}/${this.chunksLength} chunks D`) + } + addColumn (x: number, z: number, chunk: any, isLightUpdate: boolean) { if (!this.active) return if (this.workers.length === 0) throw new Error('workers not initialized yet') this.initialChunksLoad = false this.loadedChunks[`${x},${z}`] = true + this.updateDownloadedChunksText() for (const worker of this.workers) { // todo optimize worker.postMessage({ type: 'chunk', x, z, chunk }) @@ -348,13 +362,19 @@ export abstract class WorldRendererCommon } } + markAsLoaded (x, z) { + this.loadedChunks[`${x},${z}`] = true + this.finishedChunks[`${x},${z}`] = true + this.checkAllFinished() + } + removeColumn (x, z) { delete this.loadedChunks[`${x},${z}`] for (const worker of this.workers) { worker.postMessage({ type: 'unloadChunk', x, z }) } - this.allChunksFinished = Object.keys(this.finishedChunks).length === this.chunksLength delete this.finishedChunks[`${x},${z}`] + this.allChunksFinished = Object.keys(this.finishedChunks).length === this.chunksLength for (let y = this.worldConfig.minY; y < this.worldConfig.worldHeight; y += 16) { this.setSectionDirty(new Vec3(x, y, z), false) } @@ -365,7 +385,7 @@ export abstract class WorldRendererCommon const endZ = Math.ceil((z + 1) / 16) * 16 for (let x = startX; x < endX; x += 16) { for (let z = startZ; z < endZ; z += 16) { - this.highestBlocks.delete(`${x},${z}`) + delete this.highestBlocks[`${x},${z}`] } } } @@ -400,7 +420,7 @@ export abstract class WorldRendererCommon // This guarantees uniformity accross workers and that a given section // is always dispatched to the same worker const hash = mod(Math.floor(pos.x / 16) + Math.floor(pos.y / 16) + Math.floor(pos.z / 16), this.workers.length) - this.sectionsOutstanding.set(key, (this.sectionsOutstanding.get(key) ?? 0) + 1) + this.sectionsWaiting.set(key, (this.sectionsWaiting.get(key) ?? 0) + 1) this.messagesQueue[hash] ??= [] this.messagesQueue[hash].push({ // this.workers[hash].postMessage({ @@ -432,13 +452,13 @@ export abstract class WorldRendererCommon // of sections not rendered are 0 async waitForChunksToRender () { return new Promise((resolve, reject) => { - if ([...this.sectionsOutstanding].length === 0) { + if ([...this.sectionsWaiting].length === 0) { resolve() return } const updateHandler = () => { - if (this.sectionsOutstanding.size === 0) { + if (this.sectionsWaiting.size === 0) { this.renderUpdateEmitter.removeListener('update', updateHandler) resolve() } @@ -446,4 +466,25 @@ export abstract class WorldRendererCommon this.renderUpdateEmitter.on('update', updateHandler) }) } + + async waitForChunkToLoad (pos: Vec3) { + return new Promise((resolve, reject) => { + const key = `${Math.floor(pos.x / 16) * 16},${Math.floor(pos.z / 16) * 16}` + if (this.loadedChunks[key]) { + resolve() + return + } + const updateHandler = () => { + if (this.loadedChunks[key]) { + this.renderUpdateEmitter.removeListener('update', updateHandler) + resolve() + } + } + this.renderUpdateEmitter.on('update', updateHandler) + }) + } + + destroy () { + console.warn('world destroy is not implemented') + } } diff --git a/src/react/HotbarRenderApp.tsx b/src/react/HotbarRenderApp.tsx index 5b65f890f..1c94905f4 100644 --- a/src/react/HotbarRenderApp.tsx +++ b/src/react/HotbarRenderApp.tsx @@ -73,7 +73,7 @@ const ItemName = ({ itemKey }: { itemKey: string }) => { } -export default () => { +const Inner = () => { const container = useRef(null!) const [itemKey, setItemKey] = useState('') const hasModals = useSnapshot(activeModalStack).length @@ -221,6 +221,17 @@ export default () => { } +export default () => { + const [gameMode, setGameMode] = useState(bot.game?.gameMode ?? 'creative') + useEffect(() => { + bot.on('game', () => { + setGameMode(bot.game.gameMode) + }) + }, []) + + return gameMode === 'spectator' ? null : +} + const Portal = ({ children, to = document.body }) => { return createPortal(children, to) } diff --git a/src/reactUi.tsx b/src/reactUi.tsx index a490d6c71..f0144d140 100644 --- a/src/reactUi.tsx +++ b/src/reactUi.tsx @@ -107,12 +107,8 @@ const InGameUi = () => { const hasModals = modalsSnapshot.length > 0 const showUI = showUIRaw || hasModals const displayFullmap = modalsSnapshot.some(modal => modal.reactType === 'full-map') - const [gameMode, setGameMode] = useState(bot.game.gameMode) - useEffect(() => { - bot.on('game', () => { - setGameMode(bot.game.gameMode) - }) - }, []) + // bot can't be used here + if (!gameLoaded || !bot || disabledUiParts.includes('*')) return return <> @@ -140,7 +136,7 @@ const InGameUi = () => { {!disabledUiParts.includes('hud-bars') && } - {showUI && gameMode !== 'spectator' && !disabledUiParts.includes('hotbar') && } + {showUI && !disabledUiParts.includes('hotbar') && }