Skip to content

Commit

Permalink
fix: fix race condition when state id change was received from server…
Browse files Browse the repository at this point in the history
… 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
  • Loading branch information
zardoy committed Dec 6, 2024
1 parent 5783b98 commit 3bacef1
Show file tree
Hide file tree
Showing 5 changed files with 128 additions and 61 deletions.
55 changes: 39 additions & 16 deletions prismarine-viewer/viewer/lib/viewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,15 @@ export class Viewer {
// this.primitives.clear()
}

setVersion (userVersion: string, texturesVersion = userVersion) {
setVersion (userVersion: string, texturesVersion = userVersion): void | Promise<void> {
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) {
Expand All @@ -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 () {
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -205,13 +212,15 @@ 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
currentLoadChunkBatch = {
data: [],
timeout: setTimeout(() => {
for (const args of currentLoadChunkBatch!.data) {
this.world.queuedChunks.delete(`${args[0]},${args[1]}`)
this.addColumn(...args as Parameters<typeof this.addColumn>)
}
currentLoadChunkBatch = null
Expand All @@ -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 }) => {
Expand All @@ -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) => {
Expand All @@ -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 () {
Expand Down
18 changes: 7 additions & 11 deletions prismarine-viewer/viewer/lib/worldDataEmitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -23,6 +24,7 @@ export class WorldDataEmitter extends EventEmitter {
keepChunksDistance = 0
addWaitTime = 1
_handDisplay = false
isPlayground = false
get handDisplay () {
return this._handDisplay
}
Expand Down Expand Up @@ -173,19 +175,11 @@ export class WorldDataEmitter extends EventEmitter {
}

async _loadChunks (positions: Vec3[], sliceSize = 5) {
let i = 0
const promises = [] as Array<Promise<void>>
return new Promise<void>(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 () {
Expand Down Expand Up @@ -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)
Expand Down
93 changes: 67 additions & 26 deletions prismarine-viewer/viewer/lib/worldrendererCommon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,17 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>

version = undefined as string | undefined
@worldCleanup()
loadedChunks = {} as Record<string, boolean>
loadedChunks = {} as Record<string, boolean> // data is added for these chunks and they might be still processing

@worldCleanup()
finishedChunks = {} as Record<string, boolean>
finishedChunks = {} as Record<string, boolean> // these chunks are fully loaded into the world (scene)

@worldCleanup()
sectionsOutstanding = new Map<string, number>()
// loading sections (chunks)
sectionsWaiting = new Map<string, number>()

@worldCleanup()
queuedChunks = new Set<string>()

@worldCleanup()
renderUpdateEmitter = new EventEmitter() as unknown as TypedEmitter<{
Expand Down Expand Up @@ -109,13 +113,15 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>

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})`)
})
}

Expand All @@ -135,38 +141,31 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
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) {
Expand All @@ -189,6 +188,16 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
}
}

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 { }

Expand Down Expand Up @@ -270,7 +279,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
}
}

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) => {
Expand Down Expand Up @@ -327,11 +336,16 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
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 })
Expand All @@ -348,13 +362,19 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
}
}

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)
}
Expand All @@ -365,7 +385,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
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}`]
}
}
}
Expand Down Expand Up @@ -400,7 +420,7 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
// 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({
Expand Down Expand Up @@ -432,18 +452,39 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
// of sections not rendered are 0
async waitForChunksToRender () {
return new Promise<void>((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()
}
}
this.renderUpdateEmitter.on('update', updateHandler)
})
}

async waitForChunkToLoad (pos: Vec3) {
return new Promise<void>((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')
}
}
Loading

0 comments on commit 3bacef1

Please sign in to comment.