pages235/prismarine-viewer/viewer/lib/worldrendererCommon.ts

380 lines
13 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' // handled correctly in esbuild plugin
import blocksAtlases from 'mc-assets/dist/blocksAtlases.json'
import blocksAtlasLatest from 'mc-assets/dist/blocksAtlasLatest.png'
import blocksAtlasLegacy from 'mc-assets/dist/blocksAtlasLegacy.png'
import itemsAtlases from 'mc-assets/dist/itemsAtlases.json'
import itemsAtlasLatest from 'mc-assets/dist/itemsAtlasLatest.png'
import itemsAtlasLegacy from 'mc-assets/dist/itemsAtlasLegacy.png'
import { AtlasParser } from 'mc-assets'
import { dynamicMcDataFiles } from '../../buildMesherConfig.mjs'
import { getResourcepackTiles } from '../../../src/resourcePack'
import { toMajorVersion } from '../../../src/utils'
import { buildCleanupDecorator } from './cleanupDecorator'
import { defaultMesherConfig } from './mesher/shared'
import { loadTexture } from './utils.web'
import { loadJSON } from './utils'
import { chunkPos } from './simpleUtils'
function mod (x, n) {
return ((x % n) + n) % n
}
export const worldCleanup = buildCleanupDecorator('resetWorld')
export const defaultWorldRendererConfig = {
showChunkBorders: false,
numWorkers: 4
}
export type WorldRendererConfig = typeof defaultWorldRendererConfig
type CustomTexturesData = {
tileSize: number | undefined
textures: Record<string, HTMLImageElement>
}
export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any> {
worldConfig = { minY: 0, worldHeight: 256 }
material = new THREE.MeshLambertMaterial({ vertexColors: true, transparent: true, alphaTest: 0.1 })
@worldCleanup()
active = false
version = undefined as string | undefined
@worldCleanup()
loadedChunks = {} as Record<string, boolean>
@worldCleanup()
finishedChunks = {} as Record<string, boolean>
@worldCleanup()
sectionsOutstanding = new Map<string, number>()
@worldCleanup()
renderUpdateEmitter = new EventEmitter()
customTexturesDataUrl = undefined as string | undefined
currentTextureImage = undefined as any
workers: any[] = []
viewerPosition?: Vec3
lastCamUpdate = 0
droppedFpsPercentage = 0
initialChunksLoad = true
enableChunksLoadDelay = false
texturesVersion?: string
viewDistance = -1
chunksLength = 0
@worldCleanup()
allChunksFinished = false
handleResize = () => { }
mesherConfig = defaultMesherConfig
camera: THREE.PerspectiveCamera
blockstatesModels: any
customBlockStates: Record<string, any> | undefined
customModels: Record<string, any> | undefined
itemsAtlasParser: AtlasParser | undefined
blocksAtlasParser: AtlasParser | undefined
blocksAtlases = blocksAtlases
itemsAtlases = itemsAtlases
customTextures: {
items?: CustomTexturesData
blocks?: CustomTexturesData
} = {}
abstract outputFormat: 'threeJs' | 'webgl'
constructor (public config: WorldRendererConfig) {
// this.initWorkers(1) // preload script on page load
this.snapshotInitialValues()
}
snapshotInitialValues () { }
initWorkers (numWorkers = this.config.numWorkers) {
// init workers
for (let i = 0; i < numWorkers; 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
const worker: any = new Worker(src)
const handleMessage = (data) => {
if (!this.active) return
this.handleWorkerMessage(data)
if (data.type === 'sectionFinished') {
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)
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()]
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
}
}
if (this.sectionsOutstanding.size === 0) {
const allFinished = Object.keys(this.finishedChunks).length === this.chunksLength
if (allFinished) {
this.allChunksLoaded?.()
this.allChunksFinished = true
}
}
this.renderUpdateEmitter.emit('update')
}
}
worker.onmessage = ({ data }) => {
if (Array.isArray(data)) {
// eslint-disable-next-line unicorn/no-array-for-each
data.forEach(handleMessage)
return
}
handleMessage(data)
}
if (worker.on) worker.on('message', (data) => { worker.onmessage({ data }) })
this.workers.push(worker)
}
}
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 = []
this.currentTextureImage = undefined
this.blocksAtlasParser = undefined
this.itemsAtlasParser = undefined
}
// new game load happens here
async setVersion (version, texturesVersion = version) {
if (!this.blockstatesModels) throw new Error('Blockstates models is not loaded yet')
this.version = version
this.texturesVersion = texturesVersion
this.resetWorld()
this.initWorkers()
this.active = true
this.mesherConfig.outputFormat = this.outputFormat
this.mesherConfig.version = this.version!
this.sendMesherMcData()
await this.updateTexturesData()
}
sendMesherMcData () {
const allMcData = mcDataRaw.pc[this.version] ?? mcDataRaw.pc[toMajorVersion(this.version)]
const mcData = Object.fromEntries(Object.entries(allMcData).filter(([key]) => dynamicMcDataFiles.includes(key)))
mcData.version = JSON.parse(JSON.stringify(mcData.version))
for (const worker of this.workers) {
worker.postMessage({ type: 'mcData', mcData, config: this.mesherConfig })
}
}
async updateTexturesData () {
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) => {
const texture = this.customTextures?.blocks?.textures[textureName]
if (!texture) return
return texture
}, this.customTextures?.blocks?.tileSize)
const { atlas: itemsAtlas, canvas: itemsCanvas } = await itemsAssetsParser.makeNewAtlas(this.texturesVersion ?? this.version ?? 'latest', (textureName) => {
const texture = this.customTextures?.items?.textures[textureName]
if (!texture) return
return texture
}, this.customTextures?.items?.tileSize)
this.blocksAtlasParser = new AtlasParser({ latest: blocksAtlas }, blocksCanvas.toDataURL())
this.itemsAtlasParser = new AtlasParser({ latest: itemsAtlas }, itemsCanvas.toDataURL())
const texture = await new THREE.TextureLoader().loadAsync(this.blocksAtlasParser.latestImage)
texture.magFilter = THREE.NearestFilter
texture.minFilter = THREE.NearestFilter
texture.flipY = false
this.material.map = texture
this.currentTextureImage = this.material.map.image
this.mesherConfig.textureSize = this.material.map.image.width
for (const worker of this.workers) {
const { blockstatesModels } = this
if (this.customBlockStates) {
// TODO! remove from other versions as well
blockstatesModels.blockstates.latest = {
...blockstatesModels.blockstates.latest,
...this.customBlockStates
}
}
if (this.customModels) {
blockstatesModels.models.latest = {
...blockstatesModels.models.latest,
...this.customModels
}
}
worker.postMessage({
type: 'mesherData',
blocksAtlas: {
latest: blocksAtlas
},
blockstatesModels,
config: this.mesherConfig,
})
}
this.renderUpdateEmitter.emit('textureDownloaded')
console.log('texture loaded')
}
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
for (const worker of this.workers) {
// todo optimize
worker.postMessage({ type: 'chunk', x, z, chunk })
}
for (let y = this.worldConfig.minY; y < this.worldConfig.worldHeight; y += 16) {
const loc = new Vec3(x, y, z)
this.setSectionDirty(loc)
if (!isLightUpdate || this.mesherConfig.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))
}
}
}
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}`]
}
setBlockStateId (pos: Vec3, stateId: number) {
for (const worker of this.workers) {
worker.postMessage({ type: 'blockUpdate', pos, stateId })
}
this.setSectionDirty(pos)
if ((pos.x & 15) === 0) this.setSectionDirty(pos.offset(-16, 0, 0))
if ((pos.x & 15) === 15) this.setSectionDirty(pos.offset(16, 0, 0))
if ((pos.y & 15) === 0) this.setSectionDirty(pos.offset(0, -16, 0))
if ((pos.y & 15) === 15) this.setSectionDirty(pos.offset(0, 16, 0))
if ((pos.z & 15) === 0) this.setSectionDirty(pos.offset(0, 0, -16))
if ((pos.z & 15) === 15) this.setSectionDirty(pos.offset(0, 0, 16))
}
queueAwaited = false
messagesQueue = {} as { [workerIndex: string]: any[] }
setSectionDirty (pos: Vec3, value = true) {
if (this.viewDistance === -1) throw new Error('viewDistance not set')
this.allChunksFinished = false
const distance = this.getDistance(pos)
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 = 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.messagesQueue[hash] ??= []
this.messagesQueue[hash].push({
// this.workers[hash].postMessage({
type: 'dirty',
x: pos.x,
y: pos.y,
z: pos.z,
value,
config: this.mesherConfig,
})
this.dispatchMessages()
}
dispatchMessages () {
if (this.queueAwaited) return
this.queueAwaited = true
setTimeout(() => {
// group messages and send as one
for (const workerIndex in this.messagesQueue) {
const worker = this.workers[Number(workerIndex)]
worker.postMessage(this.messagesQueue[workerIndex])
}
this.messagesQueue = {}
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.sectionsOutstanding].length === 0) {
resolve()
return
}
const updateHandler = () => {
if (this.sectionsOutstanding.size === 0) {
this.renderUpdateEmitter.removeListener('update', updateHandler)
resolve()
}
}
this.renderUpdateEmitter.on('update', updateHandler)
})
}
}