From 2cc524a4abd548f12b30567a7a35625a2fa2b454 Mon Sep 17 00:00:00 2001 From: Vitaly Date: Tue, 16 Apr 2024 09:12:16 +0300 Subject: [PATCH] rewrite renderers to allow custom ones! worker -> mesher (#102) --- README.MD | 14 +- esbuild.mjs | 10 +- package.json | 7 +- ...WorkerConfig.mjs => buildMesherConfig.mjs} | 0 ...{buildWorker.mjs => buildMesherWorker.mjs} | 11 +- prismarine-viewer/examples/playground.ts | 43 +- prismarine-viewer/package.json | 2 +- .../lib/{worker.js => mesher/mesher.ts} | 67 ++-- .../viewer/lib/{ => mesher}/models.ts | 4 +- .../viewer/lib/{ => mesher}/world.ts | 5 +- prismarine-viewer/viewer/lib/utils.web.js | 6 +- prismarine-viewer/viewer/lib/viewer.ts | 99 ++--- prismarine-viewer/viewer/lib/viewerWrapper.ts | 120 ++++++ .../viewer/lib/worldDataEmitter.ts | 7 + prismarine-viewer/viewer/lib/worldrenderer.ts | 373 ------------------ .../viewer/lib/worldrendererCommon.ts | 246 ++++++++++++ .../viewer/lib/worldrendererThree.ts | 236 +++++++++++ scripts/build.js | 10 +- scripts/esbuildPlugins.mjs | 29 +- src/devtools.ts | 3 +- src/index.ts | 79 +--- src/optionsGuiScheme.tsx | 1 - src/optionsStorage.ts | 2 +- src/react/ModuleSignsViewer.tsx | 5 +- src/watchOptions.ts | 9 - 25 files changed, 760 insertions(+), 628 deletions(-) rename prismarine-viewer/{buildWorkerConfig.mjs => buildMesherConfig.mjs} (100%) rename prismarine-viewer/{buildWorker.mjs => buildMesherWorker.mjs} (93%) rename prismarine-viewer/viewer/lib/{worker.js => mesher/mesher.ts} (63%) rename prismarine-viewer/viewer/lib/{ => mesher}/models.ts (99%) rename prismarine-viewer/viewer/lib/{ => mesher}/world.ts (94%) create mode 100644 prismarine-viewer/viewer/lib/viewerWrapper.ts delete mode 100644 prismarine-viewer/viewer/lib/worldrenderer.ts create mode 100644 prismarine-viewer/viewer/lib/worldrendererCommon.ts create mode 100644 prismarine-viewer/viewer/lib/worldrendererThree.ts diff --git a/README.MD b/README.MD index d4c3c6e8..dd6806f4 100644 --- a/README.MD +++ b/README.MD @@ -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 + ### Things that are not planned yet diff --git a/esbuild.mjs b/esbuild.mjs index 2d18ae3a..6f7f903e 100644 --- a/esbuild.mjs +++ b/esbuild.mjs @@ -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('', ''), 'utf8') +const entrypoint = 'index.ts' + +fs.writeFileSync('dist/index.html', fs.readFileSync('index.html', 'utf8').replace('', ``), '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? diff --git a/package.json b/package.json index d6969dc2..c6ca5bd2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prismarine-viewer/buildWorkerConfig.mjs b/prismarine-viewer/buildMesherConfig.mjs similarity index 100% rename from prismarine-viewer/buildWorkerConfig.mjs rename to prismarine-viewer/buildMesherConfig.mjs diff --git a/prismarine-viewer/buildWorker.mjs b/prismarine-viewer/buildMesherWorker.mjs similarity index 93% rename from prismarine-viewer/buildWorker.mjs rename to prismarine-viewer/buildMesherWorker.mjs index a8a5c138..819324de 100644 --- a/prismarine-viewer/buildWorker.mjs +++ b/prismarine-viewer/buildMesherWorker.mjs @@ -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) diff --git a/prismarine-viewer/examples/playground.ts b/prismarine-viewer/examples/playground.ts index a1bd6d0f..8563791e 100644 --- a/prismarine-viewer/examples/playground.ts +++ b/prismarine-viewer/examples/playground.ts @@ -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) } diff --git a/prismarine-viewer/package.json b/prismarine-viewer/package.json index 7d7b6b81..7fd2507f 100644 --- a/prismarine-viewer/package.json +++ b/prismarine-viewer/package.json @@ -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", diff --git a/prismarine-viewer/viewer/lib/worker.js b/prismarine-viewer/viewer/lib/mesher/mesher.ts similarity index 63% rename from prismarine-viewer/viewer/lib/worker.js rename to prismarine-viewer/viewer/lib/mesher/mesher.ts index e1ef45c3..a241a6f1 100644 --- a/prismarine-viewer/viewer/lib/worker.js +++ b/prismarine-viewer/viewer/lib/mesher/mesher.ts @@ -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 = 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)`) diff --git a/prismarine-viewer/viewer/lib/models.ts b/prismarine-viewer/viewer/lib/mesher/models.ts similarity index 99% rename from prismarine-viewer/viewer/lib/models.ts rename to prismarine-viewer/viewer/lib/mesher/models.ts index 6e43597a..4ed00e98 100644 --- a/prismarine-viewer/viewer/lib/models.ts +++ b/prismarine-viewer/viewer/lib/mesher/models.ts @@ -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! } diff --git a/prismarine-viewer/viewer/lib/world.ts b/prismarine-viewer/viewer/lib/mesher/world.ts similarity index 94% rename from prismarine-viewer/viewer/lib/world.ts rename to prismarine-viewer/viewer/lib/mesher/world.ts index a4ffd69c..4cc64a4d 100644 --- a/prismarine-viewer/viewer/lib/world.ts +++ b/prismarine-viewer/viewer/lib/mesher/world.ts @@ -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 } diff --git a/prismarine-viewer/viewer/lib/utils.web.js b/prismarine-viewer/viewer/lib/utils.web.js index 72d715b9..cbb94222 100644 --- a/prismarine-viewer/viewer/lib/utils.web.js +++ b/prismarine-viewer/viewer/lib/utils.web.js @@ -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]) } diff --git a/prismarine-viewer/viewer/lib/viewer.ts b/prismarine-viewer/viewer/lib/viewer.ts index f58a7ba3..ebc84300 100644 --- a/prismarine-viewer/viewer/lib/viewer.ts +++ b/prismarine-viewer/viewer/lib/viewer.ts @@ -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) - } } diff --git a/prismarine-viewer/viewer/lib/viewerWrapper.ts b/prismarine-viewer/viewer/lib/viewerWrapper.ts new file mode 100644 index 00000000..43604231 --- /dev/null +++ b/prismarine-viewer/viewer/lib/viewerWrapper.ts @@ -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() + // } + } +} diff --git a/prismarine-viewer/viewer/lib/worldDataEmitter.ts b/prismarine-viewer/viewer/lib/worldDataEmitter.ts index fb17a994..c88c4355 100644 --- a/prismarine-viewer/viewer/lib/worldDataEmitter.ts +++ b/prismarine-viewer/viewer/lib/worldDataEmitter.ts @@ -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) diff --git a/prismarine-viewer/viewer/lib/worldrenderer.ts b/prismarine-viewer/viewer/lib/worldrenderer.ts deleted file mode 100644 index a3c0f79c..00000000 --- a/prismarine-viewer/viewer/lib/worldrenderer.ts +++ /dev/null @@ -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 = {} - showChunkBorders = false - active = false - version = undefined as string | undefined - chunkTextures = new Map() - 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[] - - 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() - - 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((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) - }) - } -} diff --git a/prismarine-viewer/viewer/lib/worldrendererCommon.ts b/prismarine-viewer/viewer/lib/worldrendererCommon.ts new file mode 100644 index 00000000..d5d11004 --- /dev/null +++ b/prismarine-viewer/viewer/lib/worldrendererCommon.ts @@ -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 { + 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 + finishedChunks = {} as Record + sectionsOutstanding = new Map() + 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((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) + }) + } +} diff --git a/prismarine-viewer/viewer/lib/worldrendererThree.ts b/prismarine-viewer/viewer/lib/worldrendererThree.ts new file mode 100644 index 00000000..d8115b9a --- /dev/null +++ b/prismarine-viewer/viewer/lib/worldrendererThree.ts @@ -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 = {} + showChunkBorders = false + chunkTextures = new Map() + signsCache = new Map() + + 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) + } +} diff --git a/scripts/build.js b/scripts/build.js index 9840e5d3..9fcc6d2c 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -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') } diff --git a/scripts/esbuildPlugins.mjs b/scripts/esbuildPlugins.mjs index 95d88b8e..e5af5ec8 100644 --- a/scripts/esbuildPlugins.mjs +++ b/scripts/esbuildPlugins.mjs @@ -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 } diff --git a/src/devtools.ts b/src/devtools.ts index 165d4667..9477d8d9 100644 --- a/src/devtools.ts +++ b/src/devtools.ts @@ -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) }, }) diff --git a/src/index.ts b/src/index.ts index edbd8a92..bccc1e71 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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) } diff --git a/src/optionsGuiScheme.tsx b/src/optionsGuiScheme.tsx index c6feb683..025324c3 100644 --- a/src/optionsGuiScheme.tsx +++ b/src/optionsGuiScheme.tsx @@ -50,7 +50,6 @@ export const guiOptionsScheme: { dayCycleAndLighting: { text: 'Day Cycle', }, - antiAliasing: {}, }, ], main: [ diff --git a/src/optionsStorage.ts b/src/optionsStorage.ts index 3333117f..783c724a 100644 --- a/src/optionsStorage.ts +++ b/src/optionsStorage.ts @@ -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, diff --git a/src/react/ModuleSignsViewer.tsx b/src/react/ModuleSignsViewer.tsx index ea9be503..b321bd79 100644 --- a/src/react/ModuleSignsViewer.tsx +++ b/src/react/ModuleSignsViewer.tsx @@ -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
@@ -14,7 +15,7 @@ export default () => {
}) - }) + }) : [] return
diff --git a/src/watchOptions.ts b/src/watchOptions.ts index a6eea6e7..6bb97b29 100644 --- a/src/watchOptions.ts +++ b/src/watchOptions.ts @@ -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) })