rewrite renderers to allow custom ones! worker -> mesher (#102)

This commit is contained in:
Vitaly 2024-04-16 09:12:16 +03:00 committed by GitHub
commit 2cc524a4ab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 760 additions and 628 deletions

View file

@ -10,11 +10,11 @@ You can try this out at [mcraft.fun](https://mcraft.fun/), [pcm.gg](https://pcm.
### Big Features
- Connect to any offline server* (it's possible because of proxy servers, see below)
- Open any zip world file or even folder in read-write mode!
- Singleplayer mode with simple world generation
- Connect to cracked servers* (it's possible because of proxy servers, see below)
- Singleplayer mode with simple world generations!
- Works offline
- Play with friends over global network! (P2P is powered by Peer.js servers)
- Play with friends over internet! (P2P is powered by Peer.js discovery servers)
- First-class touch (mobile) & controller support
- Resource pack support
- even even more!
@ -32,6 +32,14 @@ See the [Mineflayer](https://github.com/PrismarineJS/mineflayer) repo for the li
There is a builtin proxy, but you can also host a your one! Just clone the repo, run `pnpm i` (following CONTRIBUTING.MD) and run `pnpm prod-start`, then you can specify `http://localhost:8080` in the proxy field.
MS account authentication will be supported soon.
### Rendering
#### Three.js Renderer
- Uses WebGL2. Chunks are rendered using Geometry Buffers prepared by 4 mesher workers.
- Supports FXAA
- Doesn't support culling
<!-- TODO proxy server communication graph -->
### Things that are not planned yet

View file

@ -11,7 +11,9 @@ import { build } from 'esbuild'
//@ts-ignore
try { await import('./localSettings.mjs') } catch { }
fs.writeFileSync('dist/index.html', fs.readFileSync('index.html', 'utf8').replace('<!-- inject script -->', '<script src="index.js"></script>'), 'utf8')
const entrypoint = 'index.ts'
fs.writeFileSync('dist/index.html', fs.readFileSync('index.html', 'utf8').replace('<!-- inject script -->', `<script src="${entrypoint.replace(/\.tsx?/, '.js')}"></script>`), 'utf8')
const watch = process.argv.includes('--watch') || process.argv.includes('-w')
const prod = process.argv.includes('--prod')
@ -30,7 +32,7 @@ const buildingVersion = new Date().toISOString().split(':')[0]
/** @type {import('esbuild').BuildOptions} */
const buildOptions = {
bundle: true,
entryPoints: ['src/index.ts'],
entryPoints: [`src/${entrypoint}`],
target: ['es2020'],
jsx: 'automatic',
jsxDev: dev,
@ -76,7 +78,9 @@ const buildOptions = {
loader: {
// todo use external or resolve issues with duplicating
'.png': 'dataurl',
'.map': 'empty'
'.map': 'empty',
'.vert': 'text',
'.frag': 'text'
},
write: false,
// todo would be better to enable?

View file

@ -17,7 +17,12 @@
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build && node scripts/build.js moveStorybookFiles",
"start-experiments": "vite --config experiments/vite.config.ts",
"watch-worker": "node prismarine-viewer/buildWorker.mjs -w"
"watch-other-workers": "echo NOT IMPLEMENTED",
"watch-worker": "node prismarine-viewer/buildMesherWorker.mjs -w",
"run-playground": "run-p watch-worker watch-other-workers playground-server watch-playground",
"run-all": "run-p start run-playground",
"playground-server": "live-server --port=9090 prismarine-viewer/public",
"watch-playground": "node prismarine-viewer/esbuild.mjs -w"
},
"keywords": [
"prismarine",

View file

@ -5,7 +5,7 @@ import { polyfillNode } from 'esbuild-plugin-polyfill-node'
import path from 'path'
import { fileURLToPath } from 'url'
import fs from 'fs'
import { dynamicMcDataFiles } from './buildWorkerConfig.mjs'
import { dynamicMcDataFiles } from './buildMesherConfig.mjs'
const allowedBundleFiles = ['legacy', 'versions', 'protocolVersions', 'features']
@ -20,7 +20,7 @@ const buildOptions = {
js: `globalThis.global = globalThis;process = {env: {}, versions: {} };`,
},
platform: 'browser',
entryPoints: [path.join(__dirname, './viewer/lib/worker.js')],
entryPoints: [path.join(__dirname, './viewer/lib/mesher/mesher.ts')],
minify: true,
logLevel: 'info',
drop: !watch ? [
@ -30,6 +30,9 @@ const buildOptions = {
write: false,
metafile: true,
outdir: path.join(__dirname, './public'),
define: {
'process.env.BROWSER': '"true"',
},
plugins: [
{
name: 'external-json',
@ -101,14 +104,14 @@ const buildOptions = {
resolveDir: process.cwd(),
}
})
build.onEnd(({metafile, outputFiles}) => {
build.onEnd(({ metafile, outputFiles }) => {
if (!metafile) return
fs.writeFileSync(path.join(__dirname, './public/metafile.json'), JSON.stringify(metafile))
for (const outDir of ['../dist/', './public/']) {
for (const outputFile of outputFiles) {
if (outDir === '../dist/' && outputFile.path.endsWith('.map')) {
// skip writing & browser loading sourcemap there, worker debugging should be done in playground
continue
// continue
}
fs.mkdirSync(outDir, { recursive: true })
fs.writeFileSync(path.join(__dirname, outDir, path.basename(outputFile.path)), outputFile.text)

View file

@ -15,7 +15,7 @@ import Entity from '../viewer/lib/entity/Entity'
globalThis.THREE = THREE
//@ts-ignore
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
const gui = new GUI()
@ -137,7 +137,6 @@ async function main () {
viewer.entities.setDebugMode('basic')
viewer.setVersion(version)
viewer.entities.onSkinUpdate = () => {
viewer.update()
viewer.render()
}
@ -257,39 +256,6 @@ async function main () {
}
}
// const jsonData = await fetch('https://bluecolored.de/bluemap/maps/overworld/tiles/0/x-2/2/z1/6.json?584662').then(r => r.json())
// const uniforms = {
// distance: { value: 0 },
// sunlightStrength: { value: 1 },
// ambientLight: { value: 0 },
// skyColor: { value: new THREE.Color(0.5, 0.5, 1) },
// voidColor: { value: new THREE.Color(0, 0, 0) },
// hiresTileMap: {
// value: {
// map: null,
// size: 100,
// scale: new THREE.Vector2(1, 1),
// translate: new THREE.Vector2(),
// pos: new THREE.Vector2(),
// }
// }
// }
// const shader1 = new THREE.ShaderMaterial({
// uniforms: uniforms,
// vertexShader: [0, 0, 0, 0],
// fragmentShader: fragmentShader,
// transparent: false,
// depthWrite: true,
// depthTest: true,
// vertexColors: true,
// side: THREE.FrontSide,
// wireframe: false
// })
//@ts-ignore
const controls = new OrbitControls(viewer.camera, renderer.domElement)
controls.target.set(targetPos.x + 0.5, targetPos.y + 0.5, targetPos.z + 0.5)
@ -315,7 +281,7 @@ async function main () {
id: 'id', name: params.entity, pos: targetPos.offset(0.5, 1, 0.5), width: 1, height: 1, username: localStorage.testUsername, yaw: Math.PI, pitch: 0
})
const enableSkeletonDebug = (obj) => {
const {children, isSkeletonHelper} = obj
const { children, isSkeletonHelper } = obj
if (!Array.isArray(children)) return
if (isSkeletonHelper) {
obj.visible = true
@ -327,7 +293,6 @@ async function main () {
}
enableSkeletonDebug(viewer.entities.entities['id'])
setTimeout(() => {
viewer.update()
viewer.render()
}, TWEEN_DURATION)
}
@ -441,9 +406,6 @@ async function main () {
}
})
viewer.waitForChunksToRender().then(async () => {
await new Promise(resolve => {
setTimeout(resolve, 0)
})
for (const update of Object.values(onUpdate)) {
update()
}
@ -454,7 +416,6 @@ async function main () {
const animate = () => {
// if (controls) controls.update()
// worldView.updatePosition(controls.target)
viewer.update()
viewer.render()
// window.requestAnimationFrame(animate)
}

View file

@ -4,7 +4,7 @@
"description": "Web based viewer",
"main": "index.js",
"scripts": {
"postinstall": "pnpm generate-textures && node buildWorker.mjs",
"postinstall": "pnpm generate-textures && node buildMesherWorker.mjs",
"generate-textures": "tsx viewer/prepare/postinstall.ts"
},
"author": "PrismarineJS",

View file

@ -1,21 +1,21 @@
//@ts-check
/* global postMessage self */
if (!global.self) {
import { World } from './world'
import { Vec3 } from 'vec3'
import { getSectionGeometry, setRendererData } from './models'
if (module.require) {
// If we are in a node environement, we need to fake some env variables
/* eslint-disable no-eval */
const r = eval('require') // yeah I know bad spooky eval, booouh
const r = module.require
const { parentPort } = r('worker_threads')
global.self = parentPort
global.postMessage = (value, transferList) => { parentPort.postMessage(value, transferList) }
global.performance = r('perf_hooks').performance
}
const { Vec3 } = require('vec3')
const { World } = require('./world')
const { getSectionGeometry, setBlockStates } = require('./models')
let world = null
let dirtySections = {}
let world: World
let dirtySections: Map<string, number> = new Map()
let blockStatesReady = false
function sectionKey (x, y, z) {
@ -26,24 +26,30 @@ function setSectionDirty (pos, value = true) {
const x = Math.floor(pos.x / 16) * 16
const y = Math.floor(pos.y / 16) * 16
const z = Math.floor(pos.z / 16) * 16
const chunk = world.getColumn(x, z)
const key = sectionKey(x, y, z)
if (!value) {
delete dirtySections[key]
dirtySections.delete(key)
postMessage({ type: 'sectionFinished', key })
} else if (chunk?.getSection(pos)) {
dirtySections[key] = value
return
}
const chunk = world.getColumn(x, z)
if (chunk?.getSection(pos)) {
dirtySections.set(key, (dirtySections.get(key) || 0) + 1)
} else {
postMessage({ type: 'sectionFinished', key })
}
}
self.onmessage = ({ data }) => {
const globalVar: any = globalThis
if (data.type === 'mcData') {
globalThis.mcData = data.mcData
globalVar.mcData = data.mcData
world = new World(data.version)
} else if (data.type === 'blockStates') {
setBlockStates(data.json)
} else if (data.type === 'rendererData') {
setRendererData(data.json/* , data.textureSize */)
world.outputFormat = data.outputFormat ?? world.outputFormat
blockStatesReady = true
} else if (data.type === 'dirty') {
const loc = new Vec3(data.x, data.y, data.z)
@ -56,36 +62,39 @@ self.onmessage = ({ data }) => {
const loc = new Vec3(data.pos.x, data.pos.y, data.pos.z).floored()
world.setBlockStateId(loc, data.stateId)
} else if (data.type === 'reset') {
world = null
blocksStates = null
dirtySections = {}
world = undefined as any
// blocksStates = null
dirtySections = new Map()
// todo also remove cached
globalThis.mcData = null
globalVar.mcData = null
blockStatesReady = false
}
}
setInterval(() => {
if (world === null || !blockStatesReady) return
const sections = Object.keys(dirtySections)
if (sections.length === 0) return
if (dirtySections.size === 0) return
// console.log(sections.length + ' dirty sections')
// const start = performance.now()
for (const key of sections) {
let [x, y, z] = key.split(',')
x = parseInt(x, 10)
y = parseInt(y, 10)
z = parseInt(z, 10)
for (const key of dirtySections.keys()) {
let [x, y, z] = key.split(',').map(v => parseInt(v, 10))
const chunk = world.getColumn(x, z)
if (chunk?.getSection(new Vec3(x, y, z))) {
delete dirtySections[key]
const geometry = getSectionGeometry(x, y, z, world)
const transferable = [geometry.positions.buffer, geometry.normals.buffer, geometry.colors.buffer, geometry.uvs.buffer]
//@ts-ignore
postMessage({ type: 'geometry', key, geometry }, transferable)
} else {
console.info('[mesher] Missing section', x, y, z)
}
postMessage({ type: 'sectionFinished', key })
const dirtyTimes = dirtySections.get(key)
if (!dirtyTimes) throw new Error('dirtySections.get(key) is falsy')
for (let i = 0; i < dirtyTimes; i++) {
postMessage({ type: 'sectionFinished', key })
}
dirtySections.delete(key)
}
// const time = performance.now() - start
// console.log(`Processed ${sections.length} sections in ${time} ms (${time / sections.length} ms/section)`)

View file

@ -1,5 +1,5 @@
import { Vec3 } from 'vec3'
import { BlockStatesOutput } from '../prepare/modelsBuilder'
import type { BlockStatesOutput } from '../../prepare/modelsBuilder'
import { World } from './world'
import { Block } from 'prismarine-block'
@ -536,6 +536,6 @@ function getModelVariants (block: import('prismarine-block').Block) {
return []
}
export const setBlockStates = (_blockStates: BlockStatesOutput | null) => {
export const setRendererData = (_blockStates: BlockStatesOutput | null) => {
blockStates = _blockStates!
}

View file

@ -2,7 +2,7 @@ import Chunks from 'prismarine-chunk'
import mcData from 'minecraft-data'
import { Block } from "prismarine-block"
import { Vec3 } from 'vec3'
import moreBlockDataGeneratedJson from './moreBlockDataGenerated.json'
import moreBlockDataGeneratedJson from '../moreBlockDataGenerated.json'
const ignoreAoBlocks = Object.keys(moreBlockDataGeneratedJson.noOcclusions)
@ -30,12 +30,13 @@ export type WorldBlock = Block & {
}
export class World {
outputFormat = 'threeJs' as 'threeJs' | 'webgl'
Chunk: any/* import('prismarine-chunk/types/index').PCChunk */
columns = {}
blockCache = {}
biomeCache: { [id: number]: mcData.Biome }
constructor (version) {
constructor(version) {
this.Chunk = Chunks(version)
this.biomeCache = mcData(version).biomes
}

View file

@ -2,9 +2,11 @@
const THREE = require('three')
const textureCache = {}
function loadTexture (texture, cb) {
function loadTexture (texture, cb, onLoad) {
if (!textureCache[texture]) {
textureCache[texture] = new THREE.TextureLoader().load(texture)
textureCache[texture] = new THREE.TextureLoader().load(texture, onLoad)
} else {
onLoad?.()
}
cb(textureCache[texture])
}

View file

@ -1,21 +1,21 @@
import * as THREE from 'three'
import * as tweenJs from '@tweenjs/tween.js'
import { Vec3 } from 'vec3'
import { WorldRenderer } from './worldrenderer'
import { Entities } from './entities'
import { Primitives } from './primitives'
import { getVersion } from './version'
import EventEmitter from 'events'
import { EffectComposer, RenderPass, ShaderPass, FXAAShader } from 'three-stdlib'
import { WorldRendererThree } from './worldrendererThree'
import { generateSpiralMatrix } from 'flying-squid/dist/utils'
import { WorldRendererCommon } from './worldrendererCommon'
export class Viewer {
scene: THREE.Scene
ambientLight: THREE.AmbientLight
directionalLight: THREE.DirectionalLight
camera: THREE.PerspectiveCamera
world: WorldRenderer
world: WorldRendererCommon
entities: Entities
primitives: Primitives
// primitives: Primitives
domElement: HTMLCanvasElement
playerHeight = 1.62
isSneaking = false
@ -24,11 +24,8 @@ export class Viewer {
audioListener: THREE.AudioListener
renderingUntilNoUpdates = false
processEntityOverrides = (e, overrides) => overrides
composer?: EffectComposer
fxaaPass: ShaderPass
renderPass: RenderPass
constructor(public renderer: THREE.WebGLRenderer, numWorkers?: number, public enableFXAA = false) {
constructor(public renderer: THREE.WebGLRenderer, numWorkers?: number) {
// https://discourse.threejs.org/t/updates-to-color-management-in-three-js-r152/50791
THREE.ColorManagement.enabled = false
renderer.outputColorSpace = THREE.LinearSRGBColorSpace
@ -36,12 +33,9 @@ export class Viewer {
this.scene = new THREE.Scene()
this.scene.matrixAutoUpdate = false // for perf
this.resetScene()
if (this.enableFXAA) {
this.enableFxaaScene()
}
this.world = new WorldRenderer(this.scene, numWorkers)
this.world = new WorldRendererThree(this.scene, this.renderer, this.camera, numWorkers)
this.entities = new Entities(this.scene)
this.primitives = new Primitives(this.scene, this.camera)
// this.primitives = new Primitives(this.scene, this.camera)
this.domElement = renderer.domElement
}
@ -67,7 +61,7 @@ export class Viewer {
this.resetScene()
this.world.resetWorld()
this.entities.clear()
this.primitives.clear()
// this.primitives.clear()
}
setVersion (userVersion: string) {
@ -76,7 +70,7 @@ export class Viewer {
this.version = userVersion
this.world.setVersion(userVersion, texturesVersion)
this.entities.clear()
this.primitives.clear()
// this.primitives.clear()
}
addColumn (x, z, chunk) {
@ -103,18 +97,13 @@ export class Viewer {
}))
}
updatePrimitive (p) {
this.primitives.update(p)
}
setFirstPersonCamera (pos: Vec3 | null, yaw: number, pitch: number, roll = 0) {
const cam = this.cameraObjectOverride || this.camera
if (pos) {
let y = pos.y + this.playerHeight
if (this.isSneaking) y -= 0.3
new tweenJs.Tween(cam.position).to({ x: pos.x, y, z: pos.z }, 50).start()
}
cam.rotation.set(pitch, yaw, roll, 'ZYX')
let yOffset = this.playerHeight
if (this.isSneaking) yOffset -= 0.3
if (this.world instanceof WorldRendererThree) this.world.camera = cam as THREE.PerspectiveCamera
this.world.updateCamera(pos?.offset(0, yOffset, 0) ?? null, yaw, pitch)
}
playSound (position: Vec3, path: string, volume = 1) {
@ -151,7 +140,7 @@ export class Viewer {
})
emitter.on('primitive', (p) => {
this.updatePrimitive(p)
// this.updatePrimitive(p)
})
emitter.on('loadChunk', ({ x, z, chunk, worldConfig }) => {
@ -160,7 +149,7 @@ export class Viewer {
})
// todo remove and use other architecture instead so data flow is clear
emitter.on('blockEntities', (blockEntities) => {
this.world.blockEntities = blockEntities
if (this.world instanceof WorldRendererThree) this.world.blockEntities = blockEntities
})
emitter.on('unloadChunk', ({ x, z }) => {
@ -175,64 +164,20 @@ export class Viewer {
this.world.updateViewerPosition(pos)
})
emitter.emit('listening')
this.domElement.addEventListener?.('pointerdown', (evt) => {
const raycaster = new THREE.Raycaster()
const mouse = new THREE.Vector2()
mouse.x = (evt.clientX / this.domElement.clientWidth) * 2 - 1
mouse.y = -(evt.clientY / this.domElement.clientHeight) * 2 + 1
raycaster.setFromCamera(mouse, this.camera)
const { ray } = raycaster
emitter.emit('mouseClick', { origin: ray.origin, direction: ray.direction, button: evt.button })
emitter.on('renderDistance', (d) => {
this.world.viewDistance = d
this.world.chunksLength = d === 0 ? 1 : generateSpiralMatrix(d).length
})
}
update () {
tweenJs.update()
emitter.emit('listening')
}
render () {
if (this.composer) {
this.renderPass.camera = this.camera
this.composer.render()
} else {
this.renderer.render(this.scene, this.camera)
}
this.world.render()
this.entities.render()
}
async waitForChunksToRender () {
await this.world.waitForChunksToRender()
}
enableFxaaScene () {
let renderTarget
if (this.renderer.capabilities.isWebGL2) {
// Use float precision depth if possible
// see https://github.com/bs-community/skinview3d/issues/111
renderTarget = new THREE.WebGLRenderTarget(0, 0, {
depthTexture: new THREE.DepthTexture(0, 0, THREE.FloatType),
})
}
this.composer = new EffectComposer(this.renderer, renderTarget)
this.renderPass = new RenderPass(this.scene, this.camera)
this.composer.addPass(this.renderPass)
this.fxaaPass = new ShaderPass(FXAAShader)
this.composer.addPass(this.fxaaPass)
this.updateComposerSize()
this.enableFXAA = true
}
// todo
updateComposerSize (): void {
if (!this.composer) return
const { width, height } = this.renderer.getSize(new THREE.Vector2())
this.composer.setSize(width, height)
// todo auto-update
const pixelRatio = this.renderer.getPixelRatio()
this.composer.setPixelRatio(pixelRatio)
this.fxaaPass.material.uniforms["resolution"].value.x = 1 / (width * pixelRatio)
this.fxaaPass.material.uniforms["resolution"].value.y = 1 / (height * pixelRatio)
}
}

View file

@ -0,0 +1,120 @@
import { statsEnd, statsStart } from '../../../src/topRightStats'
// wrapper for now
export class ViewerWrapper {
previousWindowWidth: number
previousWindowHeight: number
globalObject = globalThis as any
stopRenderOnBlur = true
addedToPage = false
renderInterval = 0
fpsInterval
constructor(public canvas: HTMLCanvasElement, public renderer?: THREE.WebGLRenderer) {
}
addToPage (startRendering = true) {
if (this.addedToPage) throw new Error('Already added to page')
let pixelRatio = window.devicePixelRatio || 1 // todo this value is too high on ios, need to check, probably we should use avg, also need to make it configurable
if (this.renderer) {
if (!this.renderer.capabilities.isWebGL2) pixelRatio = 1 // webgl1 has issues with high pixel ratio (sometimes screen is clipped)
this.renderer.setPixelRatio(pixelRatio)
this.renderer.setSize(window.innerWidth, window.innerHeight)
} else {
this.canvas.width = window.innerWidth * pixelRatio
this.canvas.height = window.innerHeight * pixelRatio
}
this.previousWindowWidth = window.innerWidth
this.previousWindowHeight = window.innerHeight
this.canvas.id = 'viewer-canvas'
document.body.appendChild(this.canvas)
if (this.renderer) this.globalObject.renderer = this.renderer
this.addedToPage = true
let max = 0
this.fpsInterval = setInterval(() => {
if (max > 0) {
viewer.world.droppedFpsPercentage = this.renderedFps / max
}
max = Math.max(this.renderedFps, max)
this.renderedFps = 0
}, 1000)
if (startRendering) {
this.globalObject.requestAnimationFrame(this.render.bind(this))
}
if (typeof window !== 'undefined') {
// this.trackWindowFocus()
}
}
windowFocused = true
trackWindowFocus () {
window.addEventListener('focus', () => {
this.windowFocused = true
})
window.addEventListener('blur', () => {
this.windowFocused = false
})
}
dispose () {
if (!this.addedToPage) throw new Error('Not added to page')
document.body.removeChild(this.canvas)
this.renderer?.dispose()
// this.addedToPage = false
clearInterval(this.fpsInterval)
}
renderedFps = 0
lastTime = performance.now()
delta = 0
preRender = () => { }
postRender = () => { }
render (time: DOMHighResTimeStamp) {
if (this.globalObject.stopLoop) return
for (const fn of beforeRenderFrame) fn()
this.globalObject.requestAnimationFrame(this.render.bind(this))
if (this.globalObject.stopRender || this.renderer?.xr.isPresenting || (this.stopRenderOnBlur && !this.windowFocused)) return
if (this.renderInterval) {
this.delta += time - this.lastTime
this.lastTime = time
if (this.delta > this.renderInterval) {
this.delta %= this.renderInterval
// continue rendering
} else {
return
}
}
this.preRender()
statsStart()
// ios bug: viewport dimensions are updated after the resize event
if (this.previousWindowWidth !== window.innerWidth || this.previousWindowHeight !== window.innerHeight) {
this.resizeHandler()
this.previousWindowWidth = window.innerWidth
this.previousWindowHeight = window.innerHeight
}
viewer.render()
this.renderedFps++
statsEnd()
this.postRender()
}
resizeHandler () {
const width = window.innerWidth
const height = window.innerHeight
viewer.camera.aspect = width / height
viewer.camera.updateProjectionMatrix()
if (this.renderer) {
this.renderer.setSize(width, height)
}
// canvas updated by renderer
// if (viewer.composer) {
// viewer.updateComposerSize()
// }
}
}

View file

@ -36,6 +36,11 @@ export class WorldDataEmitter extends EventEmitter {
})
}
updateViewDistance (viewDistance: number) {
this.viewDistance = viewDistance
this.emitter.emit('renderDistance', viewDistance)
}
listenToBot (bot: typeof __type_bot) {
const emitEntity = (e) => {
if (!e || e === bot.entity) return
@ -73,6 +78,7 @@ export class WorldDataEmitter extends EventEmitter {
return bot.world.getBlock(new Vec3(x, y, z)).entity
},
}))
this.emitter.emit('renderDistance', this.viewDistance)
})
// node.js stream data event pattern
if (this.emitter.listenerCount('blockEntities')) {
@ -97,6 +103,7 @@ export class WorldDataEmitter extends EventEmitter {
}
async init (pos: Vec3) {
this.updateViewDistance(this.viewDistance)
this.emitter.emit('chunkPosUpdate', { pos })
const [botX, botZ] = chunkPos(pos)

View file

@ -1,373 +0,0 @@
import * as THREE from 'three'
import { Vec3 } from 'vec3'
import { loadTexture, loadJSON } from './utils'
import { EventEmitter } from 'events'
import mcDataRaw from 'minecraft-data/data.js' // handled correctly in esbuild plugin
import nbt from 'prismarine-nbt'
import { dynamicMcDataFiles } from '../../buildWorkerConfig.mjs'
import { dispose3 } from './dispose'
import { toMajor } from './version.js'
import PrismarineChatLoader from 'prismarine-chat'
import { renderSign } from '../sign-renderer/'
import { chunkPos, sectionPos } from './simpleUtils'
function mod (x, n) {
return ((x % n) + n) % n
}
export class WorldRenderer {
worldConfig = { minY: 0, worldHeight: 256 }
// todo @sa2urami set alphaTest back to 0.1 and instead properly sort transparent and solid objects (needs to be done in worker too)
material = new THREE.MeshLambertMaterial({ vertexColors: true, transparent: true, alphaTest: 0.5 })
blockEntities = {}
sectionObjects: Record<string, THREE.Object3D> = {}
showChunkBorders = false
active = false
version = undefined as string | undefined
chunkTextures = new Map<string, { [pos: string]: THREE.Texture }>()
loadedChunks = {}
sectionsOutstanding = new Set()
renderUpdateEmitter = new EventEmitter()
customBlockStatesData = undefined as any
customTexturesDataUrl = undefined as string | undefined
downloadedBlockStatesData = undefined as any
downloadedTextureImage = undefined as any
workers: any[] = []
viewerPosition?: Vec3
lastCamUpdate = 0
droppedFpsPercentage = 0
initialChunksLoad = true
enableChunksLoadDelay = false
texturesVersion?: string
promisesQueue = [] as Promise<any>[]
constructor(public scene: THREE.Scene, numWorkers = 4) {
// init workers
for (let i = 0; i < numWorkers; i++) {
// Node environment needs an absolute path, but browser needs the url of the file
let src = __dirname
if (typeof window === 'undefined') src += '/worker.js'
else src = 'worker.js'
const worker: any = new Worker(src)
worker.onmessage = async ({ data }) => {
if (!this.active) return
await new Promise(resolve => {
setTimeout(resolve, 0)
})
if (data.type === 'geometry') {
let object: THREE.Object3D = this.sectionObjects[data.key]
if (object) {
this.scene.remove(object)
dispose3(object)
delete this.sectionObjects[data.key]
}
const chunkCoords = data.key.split(',')
if (!this.loadedChunks[chunkCoords[0] + ',' + chunkCoords[2]] || !data.geometry.positions.length || !this.active) return
// if (!this.initialChunksLoad && this.enableChunksLoadDelay) {
// const newPromise = new Promise(resolve => {
// if (this.droppedFpsPercentage > 0.5) {
// setTimeout(resolve, 1000 / 50 * this.droppedFpsPercentage)
// } else {
// setTimeout(resolve)
// }
// })
// this.promisesQueue.push(newPromise)
// for (const promise of this.promisesQueue) {
// await promise
// }
// }
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.BufferAttribute(data.geometry.positions, 3))
geometry.setAttribute('normal', new THREE.BufferAttribute(data.geometry.normals, 3))
geometry.setAttribute('color', new THREE.BufferAttribute(data.geometry.colors, 3))
geometry.setAttribute('uv', new THREE.BufferAttribute(data.geometry.uvs, 2))
geometry.setIndex(data.geometry.indices)
const mesh = new THREE.Mesh(geometry, this.material)
mesh.position.set(data.geometry.sx, data.geometry.sy, data.geometry.sz)
mesh.name = 'mesh'
object = new THREE.Group()
object.add(mesh)
const boxHelper = new THREE.BoxHelper(mesh, 0xffff00)
boxHelper.name = 'helper'
object.add(boxHelper)
object.name = 'chunk'
if (!this.showChunkBorders) {
boxHelper.visible = false
}
// should not compute it once
if (Object.keys(data.geometry.signs).length) {
for (const [posKey, { isWall, rotation }] of Object.entries(data.geometry.signs)) {
const [x, y, z] = posKey.split(',')
const signBlockEntity = this.blockEntities[posKey]
if (!signBlockEntity) continue
const sign = this.renderSign(new Vec3(+x, +y, +z), rotation, isWall, nbt.simplify(signBlockEntity));
if (!sign) continue
object.add(sign)
}
}
this.sectionObjects[data.key] = object
this.updatePosDataChunk(data.key)
this.scene.add(object)
} else if (data.type === 'sectionFinished') {
this.sectionsOutstanding.delete(data.key)
this.renderUpdateEmitter.emit('update')
}
}
if (worker.on) worker.on('message', (data) => { worker.onmessage({ data }) })
this.workers.push(worker)
}
}
/**
* Optionally update data that are depedendent on the viewer position
*/
updatePosDataChunk (key: string) {
if (!this.viewerPosition) return
const [x, y, z] = key.split(',').map(x => Math.floor(+x / 16))
const [xPlayer, yPlayer, zPlayer] = this.viewerPosition.toArray().map(x => Math.floor(x / 16))
// sum of distances: x + y + z
const chunkDistance = Math.abs(x - xPlayer) + Math.abs(y - yPlayer) + Math.abs(z - zPlayer)
const section = this.sectionObjects[key].children.find(child => child.name === 'mesh')!
section.renderOrder = 500 - chunkDistance
}
updateViewerPosition (pos: Vec3) {
this.viewerPosition = pos
for (const key of Object.keys(this.sectionObjects)) {
this.updatePosDataChunk(key)
}
}
signsCache = new Map<string, any>()
getSignTexture (position: Vec3, blockEntity, backSide = false) {
const chunk = chunkPos(position)
let textures = this.chunkTextures.get(`${chunk[0]},${chunk[1]}`)
if (!textures) {
textures = {}
this.chunkTextures.set(`${chunk[0]},${chunk[1]}`, textures)
}
const texturekey = `${position.x},${position.y},${position.z}`;
// todo investigate bug and remove this so don't need to clean in section dirty
if (textures[texturekey]) return textures[texturekey]
const PrismarineChat = PrismarineChatLoader(this.version!)
const canvas = renderSign(blockEntity, PrismarineChat)
if (!canvas) return
const tex = new THREE.Texture(canvas)
tex.magFilter = THREE.NearestFilter
tex.minFilter = THREE.NearestFilter
tex.needsUpdate = true
textures[texturekey] = tex
return tex
}
renderSign (position: Vec3, rotation: number, isWall: boolean, blockEntity) {
const tex = this.getSignTexture(position, blockEntity)
if (!tex) return
// todo implement
// const key = JSON.stringify({ position, rotation, isWall })
// if (this.signsCache.has(key)) {
// console.log('cached', key)
// } else {
// this.signsCache.set(key, tex)
// }
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), new THREE.MeshBasicMaterial({ map: tex, transparent: true, }))
mesh.renderOrder = 999
// todo @sa2urami shouldnt all this be done in worker?
mesh.scale.set(1, 7 / 16, 1)
if (isWall) {
mesh.position.set(0, 0, -(8 - 1.5) / 16 + 0.001)
} else {
// standing
const faceEnd = 8.75
mesh.position.set(0, 0, (faceEnd - 16 / 2) / 16 + 0.001)
}
const group = new THREE.Group()
group.rotation.set(0, -THREE.MathUtils.degToRad(
rotation * (isWall ? 90 : 45 / 2)
), 0)
group.add(mesh)
const y = isWall ? 4.5 / 16 + mesh.scale.y / 2 : (1 - (mesh.scale.y / 2))
group.position.set(position.x + 0.5, position.y + y, position.z + 0.5)
return group
}
updateShowChunksBorder (value: boolean) {
this.showChunkBorders = value
for (const object of Object.values(this.sectionObjects)) {
for (const child of object.children) {
if (child.name === 'helper') {
child.visible = value
}
}
}
}
resetWorld () {
this.active = false
for (const mesh of Object.values(this.sectionObjects)) {
this.scene.remove(mesh)
}
this.sectionObjects = {}
this.loadedChunks = {}
this.sectionsOutstanding = new Set()
for (const worker of this.workers) {
worker.postMessage({ type: 'reset' })
}
}
setVersion (version, texturesVersion = version) {
this.version = version
this.texturesVersion = texturesVersion
this.resetWorld()
this.active = true
const allMcData = mcDataRaw.pc[this.version] ?? mcDataRaw.pc[toMajor(this.version)]
for (const worker of this.workers) {
const mcData = Object.fromEntries(Object.entries(allMcData).filter(([key]) => dynamicMcDataFiles.includes(key)))
mcData.version = JSON.parse(JSON.stringify(mcData.version))
worker.postMessage({ type: 'mcData', mcData, version: this.version })
}
this.updateTexturesData()
}
updateTexturesData () {
loadTexture(this.customTexturesDataUrl || `textures/${this.texturesVersion}.png`, (texture: import('three').Texture) => {
texture.magFilter = THREE.NearestFilter
texture.minFilter = THREE.NearestFilter
texture.flipY = false
this.material.map = texture
this.material.map.onUpdate = () => {
this.downloadedTextureImage = this.material.map!.image
}
})
const loadBlockStates = async () => {
return new Promise(resolve => {
if (this.customBlockStatesData) return resolve(this.customBlockStatesData)
return loadJSON(`/blocksStates/${this.texturesVersion}.json`, (data) => {
this.downloadedBlockStatesData = data
// todo
this.renderUpdateEmitter.emit('blockStatesDownloaded')
resolve(data)
})
})
}
loadBlockStates().then((blockStates) => {
for (const worker of this.workers) {
worker.postMessage({ type: 'blockStates', json: blockStates })
}
})
}
getLoadedChunksRelative (pos: Vec3, includeY = false) {
const [currentX, currentY, currentZ] = sectionPos(pos)
return Object.fromEntries(Object.entries(this.sectionObjects).map(([key, o]) => {
const [xRaw, yRaw, zRaw] = key.split(',').map(Number)
const [x, y, z] = sectionPos({ x: xRaw, y: yRaw, z: zRaw })
const setKey = includeY ? `${x - currentX},${y - currentY},${z - currentZ}` : `${x - currentX},${z - currentZ}`
return [setKey, o]
}))
}
addColumn (x, z, chunk) {
this.initialChunksLoad = false
this.loadedChunks[`${x},${z}`] = true
for (const worker of this.workers) {
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)
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))
}
}
cleanChunkTextures (x, z) {
const textures = this.chunkTextures.get(`${Math.floor(x / 16)},${Math.floor(z / 16)}`) ?? {}
for (const key of Object.keys(textures)) {
textures[key].dispose()
delete textures[key]
}
}
removeColumn (x, z) {
this.cleanChunkTextures(x, z)
delete this.loadedChunks[`${x},${z}`]
for (const worker of this.workers) {
worker.postMessage({ type: 'unloadChunk', x, z })
}
for (let y = this.worldConfig.minY; y < this.worldConfig.worldHeight; y += 16) {
this.setSectionDirty(new Vec3(x, y, z), false)
const key = `${x},${y},${z}`
const mesh = this.sectionObjects[key]
if (mesh) {
this.scene.remove(mesh)
dispose3(mesh)
}
delete this.sectionObjects[key]
}
}
setBlockStateId (pos, stateId) {
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))
}
setSectionDirty (pos, value = true) {
this.renderUpdateEmitter.emit('dirty', pos, value)
this.cleanChunkTextures(pos.x, pos.z) // todo don't do this!
// 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.workers[hash].postMessage({ type: 'dirty', x: pos.x, y: pos.y, z: pos.z, value })
this.sectionsOutstanding.add(`${Math.floor(pos.x / 16) * 16},${Math.floor(pos.y / 16) * 16},${Math.floor(pos.z / 16) * 16}`)
}
// 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)
})
}
}

View file

@ -0,0 +1,246 @@
import * as THREE from 'three'
import { Vec3 } from 'vec3'
import { loadJSON } from './utils'
import { loadTexture } from './utils.web'
import { EventEmitter } from 'events'
import mcDataRaw from 'minecraft-data/data.js' // handled correctly in esbuild plugin
import { dynamicMcDataFiles } from '../../buildMesherConfig.mjs'
import { toMajor } from './version.js'
import { chunkPos } from './simpleUtils'
function mod (x, n) {
return ((x % n) + n) % n
}
export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any> {
worldConfig = { minY: 0, worldHeight: 256 }
// todo @sa2urami set alphaTest back to 0.1 and instead properly sort transparent and solid objects (needs to be done in worker too)
material = new THREE.MeshLambertMaterial({ vertexColors: true, transparent: true, alphaTest: 0.5 })
showChunkBorders = false
active = false
version = undefined as string | undefined
loadedChunks = {} as Record<string, boolean>
finishedChunks = {} as Record<string, boolean>
sectionsOutstanding = new Map<string, number>()
renderUpdateEmitter = new EventEmitter()
customBlockStatesData = undefined as any
customTexturesDataUrl = undefined as string | undefined
downloadedBlockStatesData = undefined as any
downloadedTextureImage = undefined as any
workers: any[] = []
viewerPosition?: Vec3
lastCamUpdate = 0
droppedFpsPercentage = 0
initialChunksLoad = true
enableChunksLoadDelay = false
texturesVersion?: string
viewDistance = -1
chunksLength = 0
abstract outputFormat: 'threeJs' | 'webgl'
constructor(numWorkers: number) {
// 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'
const src = typeof window === 'undefined' ? `${__dirname}/${workerName}` : workerName
const worker: any = new Worker(src)
worker.onmessage = async ({ data }) => {
if (!this.active) return
this.handleWorkerMessage(data)
await new Promise(resolve => {
setTimeout(resolve, 0)
})
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.renderUpdateEmitter.emit('update')
}
}
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
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 () {
this.active = false
this.loadedChunks = {}
this.sectionsOutstanding = new Map()
for (const worker of this.workers) {
worker.postMessage({ type: 'reset' })
}
}
setVersion (version, texturesVersion = version) {
this.version = version
this.texturesVersion = texturesVersion
this.resetWorld()
this.active = true
const allMcData = mcDataRaw.pc[this.version] ?? mcDataRaw.pc[toMajor(this.version)]
for (const worker of this.workers) {
const mcData = Object.fromEntries(Object.entries(allMcData).filter(([key]) => dynamicMcDataFiles.includes(key)))
mcData.version = JSON.parse(JSON.stringify(mcData.version))
worker.postMessage({ type: 'mcData', mcData, version: this.version })
}
this.updateTexturesData()
}
updateTexturesData () {
loadTexture(this.customTexturesDataUrl || `textures/${this.texturesVersion}.png`, (texture: import('three').Texture) => {
texture.magFilter = THREE.NearestFilter
texture.minFilter = THREE.NearestFilter
texture.flipY = false
this.material.map = texture
}, (tex) => {
this.downloadedTextureImage = this.material.map!.image
const loadBlockStates = async () => {
return new Promise(resolve => {
if (this.customBlockStatesData) return resolve(this.customBlockStatesData)
return loadJSON(`/blocksStates/${this.texturesVersion}.json`, (data) => {
this.downloadedBlockStatesData = data
// todo
this.renderUpdateEmitter.emit('blockStatesDownloaded')
resolve(data)
})
})
}
loadBlockStates().then((blockStates) => {
for (const worker of this.workers) {
worker.postMessage({ type: 'rendererData', json: blockStates, textureSize: tex.image.width, outputFormat: this.outputFormat })
}
})
})
}
addColumn (x, z, chunk) {
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)
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 })
}
}
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))
}
setSectionDirty (pos: Vec3, value = true) {
if (this.viewDistance === -1) throw new Error('viewDistance not set')
const distance = this.getDistance(pos)
if (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.workers[hash].postMessage({ type: 'dirty', x: pos.x, y: pos.y, z: pos.z, value })
}
// 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)
})
}
}

View file

@ -0,0 +1,236 @@
import * as THREE from 'three'
import { Vec3 } from 'vec3'
import nbt from 'prismarine-nbt'
import { dispose3 } from './dispose'
import PrismarineChatLoader from 'prismarine-chat'
import { renderSign } from '../sign-renderer/'
import { chunkPos, sectionPos } from './simpleUtils'
import { WorldRendererCommon } from './worldrendererCommon'
import * as tweenJs from '@tweenjs/tween.js'
function mod (x, n) {
return ((x % n) + n) % n
}
export class WorldRendererThree extends WorldRendererCommon {
outputFormat = 'threeJs' as const
blockEntities = {}
sectionObjects: Record<string, THREE.Object3D> = {}
showChunkBorders = false
chunkTextures = new Map<string, { [pos: string]: THREE.Texture }>()
signsCache = new Map<string, any>()
constructor(public scene: THREE.Scene, public renderer: THREE.WebGLRenderer, public camera: THREE.PerspectiveCamera, numWorkers = 4) {
super(numWorkers)
}
/**
* Optionally update data that are depedendent on the viewer position
*/
updatePosDataChunk (key: string) {
if (!this.viewerPosition) return
const [x, y, z] = key.split(',').map(x => Math.floor(+x / 16))
const [xPlayer, yPlayer, zPlayer] = this.viewerPosition.toArray().map(x => Math.floor(x / 16))
// sum of distances: x + y + z
const chunkDistance = Math.abs(x - xPlayer) + Math.abs(y - yPlayer) + Math.abs(z - zPlayer)
const section = this.sectionObjects[key].children.find(child => child.name === 'mesh')!
section.renderOrder = 500 - chunkDistance
}
updateViewerPosition (pos: Vec3): void {
this.viewerPosition = pos
for (const [key, value] of Object.entries(this.sectionObjects)) {
if (!value) continue
this.updatePosDataChunk(key)
}
}
handleWorkerMessage (data: any): void {
if (data.type !== 'geometry') return
let object: THREE.Object3D = this.sectionObjects[data.key]
if (object) {
this.scene.remove(object)
dispose3(object)
delete this.sectionObjects[data.key]
}
const chunkCoords = data.key.split(',')
if (!this.loadedChunks[chunkCoords[0] + ',' + chunkCoords[2]] || !data.geometry.positions.length || !this.active) return
// if (!this.initialChunksLoad && this.enableChunksLoadDelay) {
// const newPromise = new Promise(resolve => {
// if (this.droppedFpsPercentage > 0.5) {
// setTimeout(resolve, 1000 / 50 * this.droppedFpsPercentage)
// } else {
// setTimeout(resolve)
// }
// })
// this.promisesQueue.push(newPromise)
// for (const promise of this.promisesQueue) {
// await promise
// }
// }
const geometry = new THREE.BufferGeometry()
geometry.setAttribute('position', new THREE.BufferAttribute(data.geometry.positions, 3))
geometry.setAttribute('normal', new THREE.BufferAttribute(data.geometry.normals, 3))
geometry.setAttribute('color', new THREE.BufferAttribute(data.geometry.colors, 3))
geometry.setAttribute('uv', new THREE.BufferAttribute(data.geometry.uvs, 2))
geometry.setIndex(data.geometry.indices)
const mesh = new THREE.Mesh(geometry, this.material)
mesh.position.set(data.geometry.sx, data.geometry.sy, data.geometry.sz)
mesh.name = 'mesh'
object = new THREE.Group()
object.add(mesh)
const boxHelper = new THREE.BoxHelper(mesh, 0xffff00)
boxHelper.name = 'helper'
object.add(boxHelper)
object.name = 'chunk'
if (!this.showChunkBorders) {
boxHelper.visible = false
}
// should not compute it once
if (Object.keys(data.geometry.signs).length) {
for (const [posKey, { isWall, rotation }] of Object.entries(data.geometry.signs)) {
const [x, y, z] = posKey.split(',')
const signBlockEntity = this.blockEntities[posKey]
if (!signBlockEntity) continue
const sign = this.renderSign(new Vec3(+x, +y, +z), rotation, isWall, nbt.simplify(signBlockEntity))
if (!sign) continue
object.add(sign)
}
}
this.sectionObjects[data.key] = object
this.updatePosDataChunk(data.key)
this.scene.add(object)
}
getSignTexture (position: Vec3, blockEntity, backSide = false) {
const chunk = chunkPos(position)
let textures = this.chunkTextures.get(`${chunk[0]},${chunk[1]}`)
if (!textures) {
textures = {}
this.chunkTextures.set(`${chunk[0]},${chunk[1]}`, textures)
}
const texturekey = `${position.x},${position.y},${position.z}`
// todo investigate bug and remove this so don't need to clean in section dirty
if (textures[texturekey]) return textures[texturekey]
const PrismarineChat = PrismarineChatLoader(this.version!)
const canvas = renderSign(blockEntity, PrismarineChat)
if (!canvas) return
const tex = new THREE.Texture(canvas)
tex.magFilter = THREE.NearestFilter
tex.minFilter = THREE.NearestFilter
tex.needsUpdate = true
textures[texturekey] = tex
return tex
}
updateCamera (pos: Vec3 | null, yaw: number, pitch: number): void {
if (pos) {
new tweenJs.Tween(this.camera.position).to({ x: pos.x, y: pos.y, z: pos.z }, 50).start()
}
this.camera.rotation.set(pitch, yaw, 0, 'ZYX')
}
render () {
tweenJs.update()
this.renderer.render(this.scene, this.camera)
}
renderSign (position: Vec3, rotation: number, isWall: boolean, blockEntity) {
const tex = this.getSignTexture(position, blockEntity)
if (!tex) return
// todo implement
// const key = JSON.stringify({ position, rotation, isWall })
// if (this.signsCache.has(key)) {
// console.log('cached', key)
// } else {
// this.signsCache.set(key, tex)
// }
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), new THREE.MeshBasicMaterial({ map: tex, transparent: true, }))
mesh.renderOrder = 999
// todo @sa2urami shouldnt all this be done in worker?
mesh.scale.set(1, 7 / 16, 1)
if (isWall) {
mesh.position.set(0, 0, -(8 - 1.5) / 16 + 0.001)
} else {
// standing
const faceEnd = 8.75
mesh.position.set(0, 0, (faceEnd - 16 / 2) / 16 + 0.001)
}
const group = new THREE.Group()
group.rotation.set(0, -THREE.MathUtils.degToRad(
rotation * (isWall ? 90 : 45 / 2)
), 0)
group.add(mesh)
const y = isWall ? 4.5 / 16 + mesh.scale.y / 2 : (1 - (mesh.scale.y / 2))
group.position.set(position.x + 0.5, position.y + y, position.z + 0.5)
return group
}
updateShowChunksBorder (value: boolean) {
this.showChunkBorders = value
for (const object of Object.values(this.sectionObjects)) {
for (const child of object.children) {
if (child.name === 'helper') {
child.visible = value
}
}
}
}
resetWorld () {
super.resetWorld()
for (const mesh of Object.values(this.sectionObjects)) {
this.scene.remove(mesh)
}
}
getLoadedChunksRelative (pos: Vec3, includeY = false) {
const [currentX, currentY, currentZ] = sectionPos(pos)
return Object.fromEntries(Object.entries(this.sectionObjects).map(([key, o]) => {
const [xRaw, yRaw, zRaw] = key.split(',').map(Number)
const [x, y, z] = sectionPos({ x: xRaw, y: yRaw, z: zRaw })
const setKey = includeY ? `${x - currentX},${y - currentY},${z - currentZ}` : `${x - currentX},${z - currentZ}`
return [setKey, o]
}))
}
cleanChunkTextures (x, z) {
const textures = this.chunkTextures.get(`${Math.floor(x / 16)},${Math.floor(z / 16)}`) ?? {}
for (const key of Object.keys(textures)) {
textures[key].dispose()
delete textures[key]
}
}
removeColumn (x, z) {
super.removeColumn(x, z)
this.cleanChunkTextures(x, z)
for (let y = this.worldConfig.minY; y < this.worldConfig.worldHeight; y += 16) {
this.setSectionDirty(new Vec3(x, y, z), false)
const key = `${x},${y},${z}`
const mesh = this.sectionObjects[key]
if (mesh) {
this.scene.remove(mesh)
dispose3(mesh)
}
delete this.sectionObjects[key]
}
}
setSectionDirty (pos, value = true) {
this.cleanChunkTextures(pos.x, pos.z) // todo don't do this!
super.setSectionDirty(pos, value)
}
}

View file

@ -13,7 +13,7 @@ const entityMcAssets = McAssets('1.16.4')
// these files could be copied at build time eg with copy plugin, but copy plugin slows down the config so we copy them there, alternative we could inline it in esbuild config
const filesToCopy = [
{ from: `${prismarineViewerBase}/public/blocksStates/`, to: 'dist/blocksStates/' },
{ from: `${prismarineViewerBase}/public/worker.js`, to: 'dist/worker.js' },
{ from: `${prismarineViewerBase}/public/mesher.js`, to: 'dist/mesher.js' },
{ from: './assets/', to: './dist/' },
{ from: './config.json', to: 'dist/config.json' },
{ from: path.join(entityMcAssets.directory, 'entity'), to: 'dist/textures/1.16.4/entity' },
@ -60,7 +60,8 @@ exports.getSwAdditionalEntries = () => {
'*.ttf',
'*.png',
'*.woff',
'worker.js',
'mesher.js',
'worldSaveWorker.js',
// todo-low preload entity atlas?
`textures/${singlePlayerVersion}.png`,
`textures/1.16.4/entity/squid.png`,
@ -68,7 +69,8 @@ exports.getSwAdditionalEntries = () => {
const filesNeedsCacheKey = [
'index.js',
'index.css',
'worker.js',
'mesher.js',
'worldSaveWorker.js',
]
const output = []
console.log('Generating sw additional entries...')
@ -92,7 +94,7 @@ exports.getSwAdditionalEntries = () => {
}
exports.moveStorybookFiles = () => {
fsExtra.moveSync('storybook-static', 'dist/storybook', {overwrite: true,})
fsExtra.moveSync('storybook-static', 'dist/storybook', { overwrite: true, })
fsExtra.copySync('dist/storybook', '.vercel/output/static/storybook')
}

View file

@ -12,6 +12,11 @@ const { supportedVersions } = MCProtocol
const prod = process.argv.includes('--prod')
let connectedClients = []
const watchExternal = [
'dist/mesher.js',
'dist/webglRendererWorker.js'
]
/** @type {import('esbuild').Plugin[]} */
const plugins = [
{
@ -41,6 +46,8 @@ const plugins = [
return {
contents: `window.mcData ??= ${JSON.stringify(defaultVersionsObj)};module.exports = { pc: window.mcData }`,
loader: 'js',
// todo use external watchers
watchFiles: watchExternal,
}
})
build.onResolve({
@ -123,8 +130,26 @@ const plugins = [
let count = 0
let time
let prevHash
let prevWorkersMtime
const updateMtime = async () => {
const workersMtime = watchExternal.map(file => {
try {
return fs.statSync(file).mtimeMs
} catch (err) {
console.log('missing file', file)
return 0
}
})
if (workersMtime.some((mtime, i) => mtime !== prevWorkersMtime?.[i])) {
prevWorkersMtime = workersMtime
return true
}
return false
}
build.onStart(() => {
time = Date.now()
updateMtime()
})
build.onEnd(({ errors, outputFiles: _outputFiles, metafile, warnings }) => {
/** @type {import('esbuild').OutputFile[]} */
@ -147,7 +172,9 @@ const plugins = [
/** @type {import('esbuild').OutputFile} */
//@ts-ignore
const outputFile = outputFiles.find(x => x.path.endsWith('.js'))
if (outputFile.hash === prevHash) {
const updateWorkers = updateMtime()
if (outputFile.hash === prevHash && !updateWorkers) {
// todo also check workers and css
console.log('Ignoring reload as contents the same')
return
}

View file

@ -1,5 +1,6 @@
// global variables useful for debugging
import { WorldRendererThree } from 'prismarine-viewer/viewer/lib/worldrendererThree'
import { getEntityCursor } from './worldInteractions'
// Object.defineProperty(window, 'cursorBlock', )
@ -19,6 +20,6 @@ window.inspectPlayer = () => require('fs').promises.readFile('/world/playerdata/
Object.defineProperty(window, 'debugSceneChunks', {
get () {
return viewer.world.getLoadedChunksRelative(bot.entity.position, true)
return (viewer.world as WorldRendererThree).getLoadedChunksRelative?.(bot.entity.position, true)
},
})

View file

@ -96,6 +96,7 @@ import { handleMovementStickDelta, joystickPointer } from './react/TouchAreasCon
import { possiblyHandleStateVariable } from './googledrive'
import flyingSquidEvents from './flyingSquidEvents'
import { hideNotification, notificationProxy } from './react/NotificationProvider'
import { ViewerWrapper } from 'prismarine-viewer/viewer/lib/viewerWrapper'
window.debug = debug
window.THREE = THREE
@ -121,13 +122,11 @@ try {
// renderer.localClippingEnabled = true
initWithRenderer(renderer.domElement)
window.renderer = renderer
let pixelRatio = window.devicePixelRatio || 1 // todo this value is too high on ios, need to check, probably we should use avg, also need to make it configurable
if (!renderer.capabilities.isWebGL2) pixelRatio = 1 // webgl1 has issues with high pixel ratio (sometimes screen is clipped)
renderer.setPixelRatio(pixelRatio)
renderer.setSize(window.innerWidth, window.innerHeight)
renderer.domElement.id = 'viewer-canvas'
document.body.appendChild(renderer.domElement)
const renderWrapper = new ViewerWrapper(renderer.domElement, renderer)
renderWrapper.addToPage()
watchValue(options, (o) => {
renderWrapper.renderInterval = o.frameLimit ? 1000 / o.frameLimit : 0
})
const isFirefox = ua.getBrowser().name === 'Firefox'
if (isFirefox) {
@ -166,68 +165,6 @@ viewer.entities.entitiesOptions = {
watchOptionsAfterViewerInit()
watchTexturepackInViewer(viewer)
let renderInterval: number | false
watchValue(options, (o) => {
renderInterval = o.frameLimit && 1000 / o.frameLimit
})
let postRenderFrameFn = () => { }
let delta = 0
let lastTime = performance.now()
let previousWindowWidth = window.innerWidth
let previousWindowHeight = window.innerHeight
let max = 0
let rendered = 0
const renderFrame = (time: DOMHighResTimeStamp) => {
if (window.stopLoop) return
for (const fn of beforeRenderFrame) fn()
window.requestAnimationFrame(renderFrame)
if (window.stopRender || renderer.xr.isPresenting) return
if (renderInterval) {
delta += time - lastTime
lastTime = time
if (delta > renderInterval) {
delta %= renderInterval
// continue rendering
} else {
return
}
}
// ios bug: viewport dimensions are updated after the resize event
if (previousWindowWidth !== window.innerWidth || previousWindowHeight !== window.innerHeight) {
resizeHandler()
previousWindowWidth = window.innerWidth
previousWindowHeight = window.innerHeight
}
statsStart()
viewer.update()
viewer.render()
rendered++
postRenderFrameFn()
statsEnd()
}
renderFrame(performance.now())
setInterval(() => {
if (max > 0) {
viewer.world.droppedFpsPercentage = rendered / max
}
max = Math.max(rendered, max)
rendered = 0
}, 1000)
const resizeHandler = () => {
const width = window.innerWidth
const height = window.innerHeight
viewer.camera.aspect = width / height
viewer.camera.updateProjectionMatrix()
renderer.setSize(width, height)
if (viewer.composer) {
viewer.updateComposerSize()
}
}
const hud = document.getElementById('hud')
const pauseMenu = document.getElementById('pause-screen')
@ -345,7 +282,7 @@ async function connect (connectOptions: {
viewer.resetAll()
localServer = window.localServer = window.server = undefined
postRenderFrameFn = () => { }
renderWrapper.postRender = () => { }
if (bot) {
bot.end()
// ensure mineflayer plugins receive this event for cleanup
@ -640,7 +577,7 @@ async function connect (connectOptions: {
void initVR()
postRenderFrameFn = () => {
renderWrapper.postRender = () => {
viewer.setFirstPersonCamera(null, bot.entity.yaw, bot.entity.pitch)
}

View file

@ -50,7 +50,6 @@ export const guiOptionsScheme: {
dayCycleAndLighting: {
text: 'Day Cycle',
},
antiAliasing: {},
},
],
main: [

View file

@ -57,7 +57,7 @@ const defaultOptions = {
unimplementedContainers: false,
dayCycleAndLighting: true,
loadPlayerSkins: true,
antiAliasing: false,
// antiAliasing: false,
showChunkBorders: false, // todo rename option
frameLimit: false as number | false,

View file

@ -1,10 +1,11 @@
import { useEffect, useRef } from 'react'
import { WorldRendererThree } from 'prismarine-viewer/viewer/lib/worldrendererThree'
import FullScreenWidget from './FullScreenWidget'
export const name = 'signs'
export default () => {
const signs = [...viewer.world.chunkTextures.values()].flatMap(textures => {
const signs = viewer.world instanceof WorldRendererThree ? [...viewer.world.chunkTextures.values()].flatMap(textures => {
return Object.entries(textures).map(([signPosKey, texture]) => {
const pos = signPosKey.split(',').map(Number)
return <div key={signPosKey}>
@ -14,7 +15,7 @@ export default () => {
</div>
</div>
})
})
}) : []
return <FullScreenWidget name='signs' title='Loaded Signs'>
<div>

View file

@ -33,15 +33,6 @@ export const watchOptionsAfterViewerInit = () => {
viewer.entities.setDebugMode(o.showChunkBorders ? 'basic' : 'none')
})
watchValue(options, o => {
if (o.antiAliasing) {
viewer.enableFxaaScene()
} else {
viewer.enableFXAA = false
viewer.composer = undefined
}
})
watchValue(options, o => {
viewer.entities.setVisible(o.renderEntities)
})