pages235/renderer/viewer/lib/worldrendererCommon.ts
2025-04-07 02:21:37 +03:00

911 lines
33 KiB
TypeScript

/* eslint-disable guard-for-in */
import { EventEmitter } from 'events'
import { Vec3 } from 'vec3'
import * as THREE from 'three'
import mcDataRaw from 'minecraft-data/data.js' // note: using alias
import TypedEmitter from 'typed-emitter'
import { ItemsRenderer } from 'mc-assets/dist/itemsRenderer'
import { WorldBlockProvider } from 'mc-assets/dist/worldBlockProvider'
import { generateSpiralMatrix } from 'flying-squid/dist/utils'
import { subscribeKey } from 'valtio/utils'
import { dynamicMcDataFiles } from '../../buildMesherConfig.mjs'
import { toMajorVersion } from '../../../src/utils'
import { ResourcesManager } from '../../../src/resourcesManager'
import { DisplayWorldOptions, RendererReactiveState } from '../../../src/appViewer'
import { SoundSystem } from '../three/threeJsSound'
import { buildCleanupDecorator } from './cleanupDecorator'
import { HighestBlockInfo, MesherGeometryOutput, CustomBlockModels, BlockStateModelInfo, getBlockAssetsCacheKey, MesherConfig } from './mesher/shared'
import { chunkPos } from './simpleUtils'
import { removeAllStats, removeStat, updateStatText } from './ui/newStats'
import { WorldDataEmitter } from './worldDataEmitter'
import { IPlayerState } from './basePlayerState'
function mod (x, n) {
return ((x % n) + n) % n
}
export const worldCleanup = buildCleanupDecorator('resetWorld')
export const defaultWorldRendererConfig = {
showChunkBorders: false,
mesherWorkers: 4,
isPlayground: false,
renderEars: true,
// game renderer setting actually
showHand: false,
viewBobbing: false,
extraBlockRenderers: true,
clipWorldBelowY: undefined as number | undefined,
smoothLighting: true,
enableLighting: true,
starfield: true,
addChunksBatchWaitTime: 200,
vrSupport: true,
renderEntities: true,
fov: 75,
fetchPlayerSkins: true,
highlightBlockColor: 'blue',
foreground: true,
_experimentalSmoothChunkLoading: true,
_renderByChunks: false
}
export type WorldRendererConfig = typeof defaultWorldRendererConfig
export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any> {
displayStats = true
worldSizeParams = { minY: 0, worldHeight: 256 }
active = false
// #region CHUNK & SECTIONS TRACKING
@worldCleanup()
loadedChunks = {} as Record<string, boolean> // data is added for these chunks and they might be still processing
@worldCleanup()
finishedChunks = {} as Record<string, boolean> // these chunks are fully loaded into the world (scene)
@worldCleanup()
finishedSections = {} as Record<string, boolean> // these sections are fully loaded into the world (scene)
@worldCleanup()
// loading sections (chunks)
sectionsWaiting = new Map<string, number>()
@worldCleanup()
queuedChunks = new Set<string>()
queuedFunctions = [] as Array<() => void>
// #endregion
renderUpdateEmitter = new EventEmitter() as unknown as TypedEmitter<{
dirty (pos: Vec3, value: boolean): void
update (/* pos: Vec3, value: boolean */): void
chunkFinished (key: string): void
}>
customTexturesDataUrl = undefined as string | undefined
workers: any[] = []
viewerPosition?: Vec3
lastCamUpdate = 0
droppedFpsPercentage = 0
initialChunkLoadWasStartedIn: number | undefined
initialChunksLoad = true
enableChunksLoadDelay = false
texturesVersion?: string
viewDistance = -1
chunksLength = 0
allChunksFinished = false
messageQueue: any[] = []
isProcessingQueue = false
ONMESSAGE_TIME_LIMIT = 30 // ms
handleResize = () => { }
highestBlocksByChunks = {} as Record<string, { [chunkKey: string]: HighestBlockInfo }>
highestBlocksBySections = {} as Record<string, { [sectionKey: string]: HighestBlockInfo }>
blockEntities = {}
workersProcessAverageTime = 0
workersProcessAverageTimeCount = 0
maxWorkersProcessTime = 0
geometryReceiveCount = {} as Record<number, number>
allLoadedIn: undefined | number
onWorldSwitched = [] as Array<() => void>
renderTimeMax = 0
renderTimeAvg = 0
renderTimeAvgCount = 0
edgeChunks = {} as Record<string, boolean>
lastAddChunk = null as null | {
timeout: any
x: number
z: number
}
neighborChunkUpdates = true
lastChunkDistance = 0
debugStopGeometryUpdate = false
protocolCustomBlocks = new Map<string, CustomBlockModels>()
@worldCleanup()
blockStateModelInfo = new Map<string, BlockStateModelInfo>()
abstract outputFormat: 'threeJs' | 'webgpu'
worldBlockProvider: WorldBlockProvider
soundSystem: SoundSystem | undefined
abstract changeBackgroundColor (color: [number, number, number]): void
worldRendererConfig: WorldRendererConfig
playerState: IPlayerState
reactiveState: RendererReactiveState
abortController = new AbortController()
lastRendered = 0
renderingActive = true
geometryReceiveCountPerSec = 0
workerLogger = {
contents: [] as string[],
active: new URL(location.href).searchParams.get('mesherlog') === 'true'
}
constructor (public readonly resourcesManager: ResourcesManager, public displayOptions: DisplayWorldOptions, public version: string) {
// this.initWorkers(1) // preload script on page load
this.snapshotInitialValues()
this.worldRendererConfig = displayOptions.inWorldRenderingConfig
this.playerState = displayOptions.playerState
this.reactiveState = displayOptions.rendererState
this.renderUpdateEmitter.on('update', () => {
const loadedChunks = Object.keys(this.finishedChunks).length
updateStatText('loaded-chunks', `${loadedChunks}/${this.chunksLength} chunks (${this.lastChunkDistance}/${this.viewDistance})`)
})
this.connect(this.displayOptions.worldView)
setInterval(() => {
this.geometryReceiveCountPerSec = Object.values(this.geometryReceiveCount).reduce((acc, curr) => acc + curr, 0)
this.geometryReceiveCount = {}
this.updateChunksStats()
}, 1000)
}
logWorkerWork (message: string | (() => string)) {
if (!this.workerLogger.active) return
this.workerLogger.contents.push(typeof message === 'function' ? message() : message)
}
init () {
if (this.active) throw new Error('WorldRendererCommon is already initialized')
this.watchReactivePlayerState()
void this.setVersion(this.version).then(() => {
this.resourcesManager.on('assetsTexturesUpdated', () => {
if (!this.active) return
void this.updateAssetsData()
})
if (this.resourcesManager.currentResources) {
void this.updateAssetsData()
}
})
}
snapshotInitialValues () { }
wasChunkSentToWorker (chunkKey: string) {
return this.loadedChunks[chunkKey]
}
async getHighestBlocks (chunkKey: string) {
return this.highestBlocksByChunks[chunkKey]
}
updateCustomBlock (chunkKey: string, blockPos: string, model: string) {
this.protocolCustomBlocks.set(chunkKey, {
...this.protocolCustomBlocks.get(chunkKey),
[blockPos]: model
})
this.logWorkerWork(() => `-> updateCustomBlock ${chunkKey} ${blockPos} ${model} ${this.wasChunkSentToWorker(chunkKey)}`)
if (this.wasChunkSentToWorker(chunkKey)) {
const [x, y, z] = blockPos.split(',').map(Number)
this.setBlockStateId(new Vec3(x, y, z), undefined)
}
}
async getBlockInfo (blockPos: { x: number, y: number, z: number }, stateId: number) {
const chunkKey = `${Math.floor(blockPos.x / 16) * 16},${Math.floor(blockPos.z / 16) * 16}`
const customBlockName = this.protocolCustomBlocks.get(chunkKey)?.[`${blockPos.x},${blockPos.y},${blockPos.z}`]
const cacheKey = getBlockAssetsCacheKey(stateId, customBlockName)
const modelInfo = this.blockStateModelInfo.get(cacheKey)
return {
customBlockName,
modelInfo
}
}
initWorkers (numWorkers = this.worldRendererConfig.mesherWorkers) {
// init workers
for (let i = 0; i < numWorkers + 1; i++) {
// Node environment needs an absolute path, but browser needs the url of the file
const workerName = 'mesher.js'
// eslint-disable-next-line node/no-path-concat
const src = typeof window === 'undefined' ? `${__dirname}/${workerName}` : workerName
let worker: any
if (process.env.SINGLE_FILE_BUILD) {
const workerCode = document.getElementById('mesher-worker-code')!.textContent!
const blob = new Blob([workerCode], { type: 'text/javascript' })
worker = new Worker(window.URL.createObjectURL(blob))
} else {
worker = new Worker(src)
}
worker.onmessage = ({ data }) => {
if (Array.isArray(data)) {
this.messageQueue.push(...data)
} else {
this.messageQueue.push(data)
}
void this.processMessageQueue('worker')
}
if (worker.on) worker.on('message', (data) => { worker.onmessage({ data }) })
this.workers.push(worker)
}
}
onReactiveValueUpdated<T extends keyof typeof this.displayOptions.playerState.reactive>(key: T, callback: (value: typeof this.displayOptions.playerState.reactive[T]) => void) {
callback(this.displayOptions.playerState.reactive[key])
subscribeKey(this.displayOptions.playerState.reactive, key, callback)
}
watchReactivePlayerState () {
this.onReactiveValueUpdated('backgroundColor', (value) => {
this.changeBackgroundColor(value)
})
}
async processMessageQueue (source: string) {
if (this.isProcessingQueue || this.messageQueue.length === 0) return
this.logWorkerWork(`# ${source} processing queue`)
if (this.lastRendered && performance.now() - this.lastRendered > 30 && this.worldRendererConfig._experimentalSmoothChunkLoading && this.renderingActive) {
const start = performance.now()
await new Promise(resolve => {
requestAnimationFrame(resolve)
})
this.logWorkerWork(`# processing got delayed by ${performance.now() - start}ms`)
}
this.isProcessingQueue = true
const startTime = performance.now()
let processedCount = 0
while (this.messageQueue.length > 0) {
const data = this.messageQueue.shift()!
this.handleMessage(data)
processedCount++
// Check if we've exceeded the time limit
if (performance.now() - startTime > this.ONMESSAGE_TIME_LIMIT && this.renderingActive && this.worldRendererConfig._experimentalSmoothChunkLoading) {
// If we have more messages and exceeded time limit, schedule next batch
if (this.messageQueue.length > 0) {
requestAnimationFrame(async () => {
this.isProcessingQueue = false
void this.processMessageQueue('queue-delay')
})
return
}
break
}
}
this.isProcessingQueue = false
}
handleMessage (data) {
if (!this.active) return
if (data.type !== 'geometry' || !this.debugStopGeometryUpdate) {
this.handleWorkerMessage(data)
}
if (data.type === 'geometry') {
this.logWorkerWork(() => `-> ${data.workerIndex} geometry ${data.key} ${JSON.stringify({ dataSize: JSON.stringify(data).length })}`)
this.geometryReceiveCount[data.workerIndex] ??= 0
this.geometryReceiveCount[data.workerIndex]++
const geometry = data.geometry as MesherGeometryOutput
this.highestBlocksBySections[data.key] = geometry.highestBlocks
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
this.logWorkerWork(`-> ${data.workerIndex} sectionFinished ${data.key} ${JSON.stringify({ processTime: data.processTime })}`)
if (!this.sectionsWaiting.has(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)
this.finishedSections[data.key] = true
}
const chunkCoords = data.key.split(',').map(Number)
const chunkKey = `${chunkCoords[0]},${chunkCoords[2]}`
if (this.loadedChunks[chunkKey]) { // ensure chunk data was added, not a neighbor chunk update
let loaded = true
for (let y = this.worldMinYRender; y < this.worldSizeParams.worldHeight; y += 16) {
if (!this.finishedSections[`${chunkCoords[0]},${y},${chunkCoords[2]}`]) {
loaded = false
break
}
}
if (loaded) {
// CHUNK FINISHED
this.finishedChunks[chunkKey] = true
this.renderUpdateEmitter.emit(`chunkFinished`, `${chunkCoords[0]},${chunkCoords[2]}`)
this.checkAllFinished()
// merge highest blocks by sections into highest blocks by chunks
// for (let y = this.worldMinYRender; y < this.worldSizeParams.worldHeight; y += 16) {
// const sectionKey = `${chunkCoords[0]},${y},${chunkCoords[2]}`
// for (let x = 0; x < 16; x++) {
// for (let z = 0; z < 16; z++) {
// const posInsideKey = `${chunkCoords[0] + x},${chunkCoords[2] + z}`
// let block = null as HighestBlockInfo | null
// const highestBlock = this.highestBlocksBySections[sectionKey]?.[posInsideKey]
// if (!highestBlock) continue
// if (!block || highestBlock.y > block.y) {
// block = highestBlock
// }
// if (block) {
// this.highestBlocksByChunks[chunkKey] ??= {}
// this.highestBlocksByChunks[chunkKey][posInsideKey] = block
// }
// }
// }
// delete this.highestBlocksBySections[sectionKey]
// }
}
}
this.renderUpdateEmitter.emit('update')
if (data.processTime) {
this.workersProcessAverageTimeCount++
this.workersProcessAverageTime = ((this.workersProcessAverageTime * (this.workersProcessAverageTimeCount - 1)) + data.processTime) / this.workersProcessAverageTimeCount
this.maxWorkersProcessTime = Math.max(this.maxWorkersProcessTime, data.processTime)
}
}
if (data.type === 'blockStateModelInfo') {
for (const [cacheKey, info] of Object.entries(data.info)) {
this.blockStateModelInfo.set(cacheKey, info)
}
}
}
downloadMesherLog () {
const a = document.createElement('a')
a.href = 'data:text/plain;charset=utf-8,' + encodeURIComponent(this.workerLogger.contents.join('\n'))
a.download = 'mesher.log'
a.click()
}
checkAllFinished () {
if (this.sectionsWaiting.size === 0) {
this.reactiveState.world.mesherWork = false
}
// todo check exact surrounding chunks
const allFinished = Object.keys(this.finishedChunks).length >= this.chunksLength
if (allFinished) {
this.allChunksLoaded?.()
this.allChunksFinished = true
this.allLoadedIn ??= Date.now() - this.initialChunkLoadWasStartedIn!
}
this.updateChunksStats()
}
changeHandSwingingState (isAnimationPlaying: boolean, isLeftHand: boolean): void { }
abstract handleWorkerMessage (data: WorkerReceive): void
abstract updateCamera (pos: Vec3 | null, yaw: number, pitch: number): void
abstract render (): void
/**
* Optionally update data that are depedendent on the viewer position
*/
updatePosDataChunk? (key: string): void
allChunksLoaded? (): void
timeUpdated? (newTime: number): void
updateViewerPosition (pos: Vec3) {
this.viewerPosition = pos
for (const [key, value] of Object.entries(this.loadedChunks)) {
if (!value) continue
this.updatePosDataChunk?.(key)
}
}
sendWorkers (message: WorkerSend) {
for (const worker of this.workers) {
worker.postMessage(message)
}
}
getDistance (posAbsolute: Vec3) {
const [botX, botZ] = chunkPos(this.viewerPosition!)
const dx = Math.abs(botX - Math.floor(posAbsolute.x / 16))
const dz = Math.abs(botZ - Math.floor(posAbsolute.z / 16))
return [dx, dz] as [number, number]
}
abstract updateShowChunksBorder (value: boolean): void
resetWorld () {
// destroy workers
for (const worker of this.workers) {
worker.terminate()
}
this.workers = []
}
// new game load happens here
async setVersion (version: string) {
this.version = version
this.resetWorld()
// for workers in single file build
if (document?.readyState === 'loading') {
await new Promise(resolve => {
document.addEventListener('DOMContentLoaded', resolve)
})
}
this.initWorkers()
this.active = true
await this.resourcesManager.loadMcData(version)
this.sendMesherMcData()
if (!this.resourcesManager.currentResources) {
await this.resourcesManager.updateAssetsData({ })
}
}
getMesherConfig (): MesherConfig {
return {
version: this.version,
enableLighting: this.worldRendererConfig.enableLighting,
skyLight: 15,
smoothLighting: this.worldRendererConfig.smoothLighting,
outputFormat: this.outputFormat,
textureSize: this.resourcesManager.currentResources!.blocksAtlasParser.atlas.latest.width,
debugModelVariant: undefined,
clipWorldBelowY: this.worldRendererConfig.clipWorldBelowY,
disableSignsMapsSupport: !this.worldRendererConfig.extraBlockRenderers
}
}
sendMesherMcData () {
const allMcData = mcDataRaw.pc[this.version] ?? mcDataRaw.pc[toMajorVersion(this.version)]
const mcData = {
version: JSON.parse(JSON.stringify(allMcData.version))
}
for (const key of dynamicMcDataFiles) {
mcData[key] = allMcData[key]
}
for (const worker of this.workers) {
worker.postMessage({ type: 'mcData', mcData, config: this.getMesherConfig() })
}
this.logWorkerWork('# mcData sent')
}
async updateAssetsData () {
const resources = this.resourcesManager.currentResources!
if (this.workers.length === 0) throw new Error('workers not initialized yet')
for (const [i, worker] of this.workers.entries()) {
const { blockstatesModels } = resources
worker.postMessage({
type: 'mesherData',
workerIndex: i,
blocksAtlas: {
latest: resources.blocksAtlasParser.atlas.latest
},
blockstatesModels,
config: this.getMesherConfig(),
})
}
this.logWorkerWork('# mesherData sent')
console.log('textures loaded')
}
get worldMinYRender () {
return Math.floor(Math.max(this.worldSizeParams.minY, this.worldRendererConfig.clipWorldBelowY ?? -Infinity) / 16) * 16
}
updateChunksStats () {
const loadedChunks = Object.keys(this.finishedChunks)
this.displayOptions.nonReactiveState.world.chunksLoaded = loadedChunks
this.displayOptions.nonReactiveState.world.chunksTotalNumber = this.chunksLength
this.reactiveState.world.allChunksLoaded = this.allChunksFinished
updateStatText('downloaded-chunks', `${Object.keys(this.loadedChunks).length}/${this.chunksLength} chunks D (${this.workers.length}:${this.workersProcessAverageTime.toFixed(0)}ms/${this.geometryReceiveCountPerSec}ss/${this.allLoadedIn?.toFixed(1) ?? '-'}s)`)
}
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.initialChunkLoadWasStartedIn ??= Date.now()
this.loadedChunks[`${x},${z}`] = true
this.updateChunksStats()
const chunkKey = `${x},${z}`
const customBlockModels = this.protocolCustomBlocks.get(chunkKey)
for (const worker of this.workers) {
worker.postMessage({
type: 'chunk',
x,
z,
chunk,
customBlockModels: customBlockModels || undefined
})
}
this.logWorkerWork(`-> chunk ${JSON.stringify({ x, z, chunkLength: chunk.length, customBlockModelsLength: customBlockModels ? Object.keys(customBlockModels).length : 0 })}`)
for (let y = this.worldMinYRender; y < this.worldSizeParams.worldHeight; y += 16) {
const loc = new Vec3(x, y, z)
this.setSectionDirty(loc)
if (this.neighborChunkUpdates && (!isLightUpdate || this.worldRendererConfig.smoothLighting)) {
this.setSectionDirty(loc.offset(-16, 0, 0))
this.setSectionDirty(loc.offset(16, 0, 0))
this.setSectionDirty(loc.offset(0, 0, -16))
this.setSectionDirty(loc.offset(0, 0, 16))
}
}
}
markAsLoaded (x, z) {
this.loadedChunks[`${x},${z}`] = true
this.finishedChunks[`${x},${z}`] = true
this.logWorkerWork(`-> markAsLoaded ${JSON.stringify({ x, z })}`)
this.checkAllFinished()
}
removeColumn (x, z) {
delete this.loadedChunks[`${x},${z}`]
for (const worker of this.workers) {
worker.postMessage({ type: 'unloadChunk', x, z })
}
this.logWorkerWork(`-> unloadChunk ${JSON.stringify({ x, z })}`)
delete this.finishedChunks[`${x},${z}`]
this.allChunksFinished = Object.keys(this.finishedChunks).length === this.chunksLength
if (!this.allChunksFinished) {
this.allLoadedIn = undefined
this.initialChunkLoadWasStartedIn = undefined
}
for (let y = this.worldSizeParams.minY; y < this.worldSizeParams.worldHeight; y += 16) {
this.setSectionDirty(new Vec3(x, y, z), false)
delete this.finishedSections[`${x},${y},${z}`]
delete this.highestBlocksBySections[`${x},${y},${z}`]
}
delete this.highestBlocksByChunks[`${x},${z}`]
this.updateChunksStats()
if (Object.keys(this.loadedChunks).length === 0) {
this.workerLogger.contents = []
this.logWorkerWork('# all chunks unloaded. New log started')
}
}
setBlockStateId (pos: Vec3, stateId: number | undefined, needAoRecalculation = true) {
const set = async () => {
const sectionX = Math.floor(pos.x / 16) * 16
const sectionZ = Math.floor(pos.z / 16) * 16
if (this.queuedChunks.has(`${sectionX},${sectionZ}`)) {
await new Promise<void>(resolve => {
this.queuedFunctions.push(() => {
resolve()
})
})
}
if (!this.loadedChunks[`${sectionX},${sectionZ}`]) {
// console.debug('[should be unreachable] setBlockStateId called for unloaded chunk', pos)
}
this.setBlockStateIdInner(pos, stateId, needAoRecalculation)
}
void set()
}
updateEntity (e: any, isUpdate = false) { }
lightUpdate (chunkX: number, chunkZ: number) { }
connect (worldView: WorldDataEmitter) {
const worldEmitter = worldView
worldEmitter.on('entity', (e) => {
this.updateEntity(e, false)
})
worldEmitter.on('entityMoved', (e) => {
this.updateEntity(e, true)
})
let currentLoadChunkBatch = null as {
timeout
data
} | null
worldEmitter.on('loadChunk', ({ x, z, chunk, worldConfig, isLightUpdate }) => {
this.worldSizeParams = worldConfig
this.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.queuedChunks.delete(`${args[0]},${args[1]}`)
this.addColumn(...args as Parameters<typeof this.addColumn>)
}
for (const fn of this.queuedFunctions) {
fn()
}
this.queuedFunctions = []
currentLoadChunkBatch = null
}, this.worldRendererConfig.addChunksBatchWaitTime)
}
}
currentLoadChunkBatch.data.push(args)
})
// todo remove and use other architecture instead so data flow is clear
worldEmitter.on('blockEntities', (blockEntities) => {
this.blockEntities = blockEntities
})
worldEmitter.on('unloadChunk', ({ x, z }) => {
this.removeColumn(x, z)
})
worldEmitter.on('blockUpdate', ({ pos, stateId }) => {
this.setBlockStateId(new Vec3(pos.x, pos.y, pos.z), stateId)
})
worldEmitter.on('chunkPosUpdate', ({ pos }) => {
this.updateViewerPosition(pos)
})
worldEmitter.on('end', () => {
this.worldStop?.()
})
worldEmitter.on('renderDistance', (d) => {
this.viewDistance = d
this.chunksLength = d === 0 ? 1 : generateSpiralMatrix(d).length
})
worldEmitter.on('renderDistance', (d) => {
this.viewDistance = d
this.chunksLength = d === 0 ? 1 : generateSpiralMatrix(d).length
this.allChunksFinished = Object.keys(this.finishedChunks).length === this.chunksLength
})
worldEmitter.on('markAsLoaded', ({ x, z }) => {
this.markAsLoaded(x, z)
})
worldEmitter.on('updateLight', ({ pos }) => {
this.lightUpdate(pos.x, pos.z)
})
worldEmitter.on('onWorldSwitch', () => {
for (const fn of this.onWorldSwitched) fn()
})
worldEmitter.on('time', (timeOfDay) => {
this.timeUpdated?.(timeOfDay)
let skyLight = 15
if (timeOfDay < 0 || timeOfDay > 24_000) {
throw new Error('Invalid time of day. It should be between 0 and 24000.')
} else if (timeOfDay <= 6000 || timeOfDay >= 18_000) {
skyLight = 15
} else if (timeOfDay > 6000 && timeOfDay < 12_000) {
skyLight = 15 - ((timeOfDay - 6000) / 6000) * 15
} else if (timeOfDay >= 12_000 && timeOfDay < 18_000) {
skyLight = ((timeOfDay - 12_000) / 6000) * 15
}
skyLight = Math.floor(skyLight) // todo: remove this after optimization
// if (this.worldRendererConfig.skyLight === skyLight) return
// this.worldRendererConfig.skyLight = skyLight
// if (this instanceof WorldRendererThree) {
// (this).rerenderAllChunks?.()
// }
})
worldEmitter.emit('listening')
}
setBlockStateIdInner (pos: Vec3, stateId: number | undefined, needAoRecalculation = true) {
const chunkKey = `${Math.floor(pos.x / 16) * 16},${Math.floor(pos.z / 16) * 16}`
const blockPosKey = `${pos.x},${pos.y},${pos.z}`
const customBlockModels = this.protocolCustomBlocks.get(chunkKey) || {}
for (const worker of this.workers) {
worker.postMessage({
type: 'blockUpdate',
pos,
stateId,
customBlockModels
})
}
this.logWorkerWork(`-> blockUpdate ${JSON.stringify({ pos, stateId, customBlockModels })}`)
this.setSectionDirty(pos, true, true)
if (this.neighborChunkUpdates) {
if ((pos.x & 15) === 0) this.setSectionDirty(pos.offset(-16, 0, 0), true, true)
if ((pos.x & 15) === 15) this.setSectionDirty(pos.offset(16, 0, 0), true, true)
if ((pos.y & 15) === 0) this.setSectionDirty(pos.offset(0, -16, 0), true, true)
if ((pos.y & 15) === 15) this.setSectionDirty(pos.offset(0, 16, 0), true, true)
if ((pos.z & 15) === 0) this.setSectionDirty(pos.offset(0, 0, -16), true, true)
if ((pos.z & 15) === 15) this.setSectionDirty(pos.offset(0, 0, 16), true, true)
if (needAoRecalculation) {
// top view neighbors
if ((pos.x & 15) === 0 && (pos.z & 15) === 0) this.setSectionDirty(pos.offset(-16, 0, -16), true, true)
if ((pos.x & 15) === 15 && (pos.z & 15) === 0) this.setSectionDirty(pos.offset(16, 0, -16), true, true)
if ((pos.x & 15) === 0 && (pos.z & 15) === 15) this.setSectionDirty(pos.offset(-16, 0, 16), true, true)
if ((pos.x & 15) === 15 && (pos.z & 15) === 15) this.setSectionDirty(pos.offset(16, 0, 16), true, true)
// side view neighbors (but ignore updates above)
// z view neighbors
if ((pos.x & 15) === 0 && (pos.y & 15) === 0) this.setSectionDirty(pos.offset(-16, -16, 0), true, true)
if ((pos.x & 15) === 15 && (pos.y & 15) === 0) this.setSectionDirty(pos.offset(16, -16, 0), true, true)
// x view neighbors
if ((pos.z & 15) === 0 && (pos.y & 15) === 0) this.setSectionDirty(pos.offset(0, -16, -16), true, true)
if ((pos.z & 15) === 15 && (pos.y & 15) === 0) this.setSectionDirty(pos.offset(0, -16, 16), true, true)
// x & z neighbors
if ((pos.y & 15) === 0 && (pos.x & 15) === 0 && (pos.z & 15) === 0) this.setSectionDirty(pos.offset(-16, -16, -16), true, true)
if ((pos.y & 15) === 0 && (pos.x & 15) === 15 && (pos.z & 15) === 0) this.setSectionDirty(pos.offset(16, -16, -16), true, true)
if ((pos.y & 15) === 0 && (pos.x & 15) === 0 && (pos.z & 15) === 15) this.setSectionDirty(pos.offset(-16, -16, 16), true, true)
if ((pos.y & 15) === 0 && (pos.x & 15) === 15 && (pos.z & 15) === 15) this.setSectionDirty(pos.offset(16, -16, 16), true, true)
}
}
}
abstract worldStop? ()
queueAwaited = false
toWorkerMessagesQueue = {} as { [workerIndex: string]: any[] }
getWorkerNumber (pos: Vec3, updateAction = false) {
if (updateAction) {
const key = `${Math.floor(pos.x / 16) * 16},${Math.floor(pos.y / 16) * 16},${Math.floor(pos.z / 16) * 16}`
const cantUseChangeWorker = this.sectionsWaiting.get(key) && !this.finishedSections[key]
if (!cantUseChangeWorker) return 0
}
const hash = mod(Math.floor(pos.x / 16) + Math.floor(pos.y / 16) + Math.floor(pos.z / 16), this.workers.length - 1)
return hash + 1
}
async debugGetWorkerCustomBlockModel (pos: Vec3) {
const data = [] as Array<Promise<string>>
for (const worker of this.workers) {
data.push(new Promise((resolve) => {
worker.addEventListener('message', (e) => {
if (e.data.type === 'customBlockModel') {
resolve(e.data.customBlockModel)
}
})
}))
worker.postMessage({
type: 'getCustomBlockModel',
pos
})
}
return Promise.all(data)
}
setSectionDirty (pos: Vec3, value = true, useChangeWorker = false) { // value false is used for unloading chunks
if (this.viewDistance === -1) throw new Error('viewDistance not set')
this.reactiveState.world.mesherWork = true
const distance = this.getDistance(pos)
// todo shouldnt we check loadedChunks instead?
if (!this.workers.length || distance[0] > this.viewDistance || distance[1] > this.viewDistance) return
const key = `${Math.floor(pos.x / 16) * 16},${Math.floor(pos.y / 16) * 16},${Math.floor(pos.z / 16) * 16}`
// if (this.sectionsOutstanding.has(key)) return
this.renderUpdateEmitter.emit('dirty', pos, value)
// Dispatch sections to workers based on position
// This guarantees uniformity accross workers and that a given section
// is always dispatched to the same worker
const hash = this.getWorkerNumber(pos, useChangeWorker)
this.sectionsWaiting.set(key, (this.sectionsWaiting.get(key) ?? 0) + 1)
this.toWorkerMessagesQueue[hash] ??= []
this.toWorkerMessagesQueue[hash].push({
// this.workers[hash].postMessage({
type: 'dirty',
x: pos.x,
y: pos.y,
z: pos.z,
value,
config: this.getMesherConfig(),
})
this.dispatchMessages()
}
dispatchMessages () {
if (this.queueAwaited) return
this.queueAwaited = true
setTimeout(() => {
// group messages and send as one
for (const workerIndex in this.toWorkerMessagesQueue) {
const worker = this.workers[Number(workerIndex)]
worker.postMessage(this.toWorkerMessagesQueue[workerIndex])
for (const message of this.toWorkerMessagesQueue[workerIndex]) {
this.logWorkerWork(`-> ${workerIndex} dispatchMessages ${message.type} ${JSON.stringify({ x: message.x, y: message.y, z: message.z, value: message.value })}`)
}
}
this.toWorkerMessagesQueue = {}
this.queueAwaited = false
})
}
// Listen for chunk rendering updates emitted if a worker finished a render and resolve if the number
// of sections not rendered are 0
async waitForChunksToRender () {
return new Promise<void>((resolve, reject) => {
if ([...this.sectionsWaiting].length === 0) {
resolve()
return
}
const updateHandler = () => {
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 () {
// Stop all workers
for (const worker of this.workers) {
worker.terminate()
}
this.workers = []
// Stop and destroy sound system
if (this.soundSystem) {
this.soundSystem.destroy()
this.soundSystem = undefined
}
this.active = false
this.renderUpdateEmitter.removeAllListeners()
this.displayOptions.worldView.removeAllListeners() // todo
this.abortController.abort()
removeAllStats()
}
}