diff --git a/README.MD b/README.MD index e9127a73..f92629c0 100644 --- a/README.MD +++ b/README.MD @@ -38,7 +38,6 @@ All components that are in [Storybook](https://minimap.mcraft.fun/storybook/) ar - Controls -> **Touch Controls Type** -> **Joystick** - Controls -> **Auto Full Screen** -> **On** - To avoid ctrl+w issue -- Interface -> **Enable Minimap** -> **Always** - To enable useful minimap (why not?) - Controls -> **Raw Input** -> **On** - This will make the controls more precise (UPD: already enabled by default) - Interface -> **Chat Select** -> **On** - To select chat messages (UPD: already enabled by default) diff --git a/experiments/three.ts b/experiments/three.ts index 21142b5f..cd82f5ec 100644 --- a/experiments/three.ts +++ b/experiments/three.ts @@ -1,4 +1,8 @@ import * as THREE from 'three' +import globalTexture from 'mc-assets/dist/blocksAtlasLegacy.png' + +// Import the renderBlockThree function +import { renderBlockThree } from '../renderer/viewer/lib/mesher/standaloneRenderer' // Create scene, camera and renderer const scene = new THREE.Scene() @@ -8,53 +12,292 @@ renderer.setSize(window.innerWidth, window.innerHeight) document.body.appendChild(renderer.domElement) // Position camera -camera.position.z = 5 +camera.position.set(3, 3, 3) +camera.lookAt(0, 0, 0) -// Create a canvas with some content -const canvas = document.createElement('canvas') -canvas.width = 256 -canvas.height = 256 -const ctx = canvas.getContext('2d') +// Dark background +scene.background = new THREE.Color(0x333333) -scene.background = new THREE.Color(0x444444) +// Add some lighting +const ambientLight = new THREE.AmbientLight(0xffffff, 0.6) +scene.add(ambientLight) +const directionalLight = new THREE.DirectionalLight(0xffffff, 0.4) +directionalLight.position.set(1, 1, 1) +scene.add(directionalLight) -// Draw something on the canvas -ctx.fillStyle = '#444444' -// ctx.fillRect(0, 0, 256, 256) -ctx.fillStyle = 'red' -ctx.font = '48px Arial' -ctx.textAlign = 'center' -ctx.textBaseline = 'middle' -ctx.fillText('Hello!', 128, 128) +// Add grid helper for orientation +const gridHelper = new THREE.GridHelper(10, 10) +scene.add(gridHelper) -// Create bitmap and texture -async function createTexturedBox() { - const canvas2 = new OffscreenCanvas(256, 256) - const ctx2 = canvas2.getContext('2d')! - ctx2.drawImage(canvas, 0, 0) - const texture = new THREE.Texture(canvas2) +// Create shared material that will be used by all blocks +const sharedMaterial = new THREE.MeshLambertMaterial({ + vertexColors: true, + transparent: true, + alphaTest: 0.1, + // wireframe: true // Add wireframe for debugging +}) + +// Create simple block models for testing +function createFullBlockModel(textureObj: any): any { + return [[{ + elements: [{ + from: [0, 0, 0], + to: [16, 16, 16], + faces: { + up: { + texture: textureObj, + uv: [0, 0, 16, 16] + }, + down: { + texture: textureObj, + uv: [0, 0, 16, 16] + }, + north: { + texture: textureObj, + uv: [0, 0, 16, 16] + }, + south: { + texture: textureObj, + uv: [0, 0, 16, 16] + }, + east: { + texture: textureObj, + uv: [0, 0, 16, 16] + }, + west: { + texture: textureObj, + uv: [0, 0, 16, 16] + } + } + }] + }]] +} + +function createHalfBlockModel(textureObj: any): any { + return [[{ + elements: [{ + from: [0, 0, 0], + to: [16, 8, 16], // Half height (8 instead of 16) + faces: { + up: { + texture: textureObj, + uv: [0, 0, 16, 16] + }, + down: { + texture: textureObj, + uv: [0, 0, 16, 16] + }, + north: { + texture: textureObj, + uv: [0, 0, 16, 8] // Half height UV + }, + south: { + texture: textureObj, + uv: [0, 0, 16, 8] // Half height UV + }, + east: { + texture: textureObj, + uv: [0, 0, 16, 8] // Half height UV + }, + west: { + texture: textureObj, + uv: [0, 0, 16, 8] // Half height UV + } + } + }] + }]] +} + +let currentFullBlockInstancedMesh: THREE.InstancedMesh | null = null +let currentHalfBlockInstancedMesh: THREE.InstancedMesh | null = null + +async function createInstancedBlock() { + try { + // Clean up previous meshes if they exist + if (currentFullBlockInstancedMesh) { + scene.remove(currentFullBlockInstancedMesh) + currentFullBlockInstancedMesh.geometry.dispose() + } + if (currentHalfBlockInstancedMesh) { + scene.remove(currentHalfBlockInstancedMesh) + currentHalfBlockInstancedMesh.geometry.dispose() + } + + // Load the blocks atlas texture + const textureLoader = new THREE.TextureLoader() + const texture = await textureLoader.loadAsync(globalTexture) + + // Configure texture for pixel art texture.magFilter = THREE.NearestFilter texture.minFilter = THREE.NearestFilter - texture.needsUpdate = true + texture.generateMipmaps = false texture.flipY = false - // Create box with texture - const geometry = new THREE.BoxGeometry(2, 2, 2) - const material = new THREE.MeshBasicMaterial({ - map: texture, - side: THREE.DoubleSide, - premultipliedAlpha: false, - }) - const cube = new THREE.Mesh(geometry, material) - scene.add(cube) + // Set the texture on our shared material + sharedMaterial.map = texture + sharedMaterial.needsUpdate = true + + console.log('Texture loaded:', texture.image.width, 'x', texture.image.height) + + // Calculate UV coordinates for the first tile (top-left, 16x16) + const atlasWidth = texture.image.width + const atlasHeight = texture.image.height + const tileSize = 16 + + const textureInfo = { + u: 0 / atlasWidth, // Left edge (first column) + v: 2 * tileSize / atlasHeight, // Top edge (third row) + su: tileSize / atlasWidth, // Width of one tile + sv: tileSize / atlasHeight // Height of one tile + } + + console.log('Atlas size:', atlasWidth, 'x', atlasHeight) + console.log('Calculated texture info:', textureInfo) + + // Create mock texture object that matches what the renderer expects + const mockTexture = { + u: textureInfo.u, + v: textureInfo.v, + su: textureInfo.su, + sv: textureInfo.sv, + debugName: 'test_texture' + } + + // Create block models with the mock texture + const fullBlockModel = createFullBlockModel(mockTexture) + const halfBlockModel = createHalfBlockModel(mockTexture) + + // Mock data for the renderBlockThree function + const mockBlock = undefined // No specific block data needed for this test + const mockBiome = 'plains' + const mockMcData = {} as any + const mockVariants = [] + const mockNeighbors = {} + + // Render the full block + const fullBlockGeometry = renderBlockThree( + fullBlockModel, + mockBlock, + mockBiome, + mockMcData, + mockVariants, + mockNeighbors + ) + + // Render the half block + const halfBlockGeometry = renderBlockThree( + halfBlockModel, + mockBlock, + mockBiome, + mockMcData, + mockVariants, + mockNeighbors + ) + + // Create instanced mesh for full blocks + currentFullBlockInstancedMesh = new THREE.InstancedMesh(fullBlockGeometry, sharedMaterial, 2) // Support 2 instances + const matrix = new THREE.Matrix4() + + // First instance (full block) + matrix.setPosition(-1.5, 0.5, 0.5) + currentFullBlockInstancedMesh.setMatrixAt(0, matrix) + + // Second instance (full block) + matrix.setPosition(1.5, 0.5, 0.5) + currentFullBlockInstancedMesh.setMatrixAt(1, matrix) + + currentFullBlockInstancedMesh.count = 2 + currentFullBlockInstancedMesh.instanceMatrix.needsUpdate = true + scene.add(currentFullBlockInstancedMesh) + + // Create instanced mesh for half blocks + currentHalfBlockInstancedMesh = new THREE.InstancedMesh(halfBlockGeometry, sharedMaterial, 1) // Support 1 instance + const halfMatrix = new THREE.Matrix4() + + // Half block instance + halfMatrix.setPosition(0, 0.75, 0.5) // Positioned higher so top aligns with full blocks + currentHalfBlockInstancedMesh.setMatrixAt(0, halfMatrix) + + currentHalfBlockInstancedMesh.count = 1 + currentHalfBlockInstancedMesh.instanceMatrix.needsUpdate = true + scene.add(currentHalfBlockInstancedMesh) + + console.log('Instanced blocks created successfully') + console.log('Full block geometry:', fullBlockGeometry) + console.log('Half block geometry:', halfBlockGeometry) + + } catch (error) { + console.error('Error creating instanced blocks:', error) + + // Fallback: create colored cubes + const geometry = new THREE.BoxGeometry(1, 1, 1) + const material = new THREE.MeshLambertMaterial({ color: 0xff0000, wireframe: true }) + const fallbackMesh = new THREE.Mesh(geometry, material) + fallbackMesh.position.set(0, 0.5, 0.5) + scene.add(fallbackMesh) + + console.log('Created fallback colored cube') + } } -// Create the textured box -createTexturedBox() +// Create the instanced block +createInstancedBlock().then(() => { + render() +}) -// Animation loop -function animate() { - requestAnimationFrame(animate) - renderer.render(scene, camera) +// Simple render loop (no animation) +function render() { + renderer.render(scene, camera) } -animate() + +// Add mouse controls for better viewing +let mouseDown = false +let mouseX = 0 +let mouseY = 0 + +renderer.domElement.addEventListener('mousedown', (event) => { + mouseDown = true + mouseX = event.clientX + mouseY = event.clientY +}) + +renderer.domElement.addEventListener('mousemove', (event) => { + if (!mouseDown) return + + const deltaX = event.clientX - mouseX + const deltaY = event.clientY - mouseY + + // Rotate camera around the center + const spherical = new THREE.Spherical() + spherical.setFromVector3(camera.position) + spherical.theta -= deltaX * 0.01 + spherical.phi += deltaY * 0.01 + spherical.phi = Math.max(0.1, Math.min(Math.PI - 0.1, spherical.phi)) + + camera.position.setFromSpherical(spherical) + camera.lookAt(0, 0, 0) + + mouseX = event.clientX + mouseY = event.clientY + + render() +}) + +renderer.domElement.addEventListener('mouseup', () => { + mouseDown = false +}) + +// Add button to recreate blocks (for testing) +const button = document.createElement('button') +button.textContent = 'Recreate Blocks' +button.style.position = 'fixed' +button.style.top = '10px' +button.style.left = '10px' +button.addEventListener('click', () => { + createInstancedBlock() + render() +}) +document.body.appendChild(button) + +// Initial render +render() diff --git a/renderer/viewer/lib/mesher/instancingUtils.ts b/renderer/viewer/lib/mesher/instancingUtils.ts new file mode 100644 index 00000000..7e5cc2a1 --- /dev/null +++ b/renderer/viewer/lib/mesher/instancingUtils.ts @@ -0,0 +1,7 @@ +import { WorldBlock as Block, World } from './world' + +export const isBlockInstanceable = (world: World, block: Block): boolean => { + const instancedBlocks = world?.instancedBlocks + if (!instancedBlocks) return false + return instancedBlocks.includes(block.stateId) +} diff --git a/renderer/viewer/lib/mesher/mesher.ts b/renderer/viewer/lib/mesher/mesher.ts index a063d77f..abc4d44d 100644 --- a/renderer/viewer/lib/mesher/mesher.ts +++ b/renderer/viewer/lib/mesher/mesher.ts @@ -1,7 +1,7 @@ import { Vec3 } from 'vec3' import { World } from './world' import { getSectionGeometry, setBlockStatesData as setMesherData } from './models' -import { BlockStateModelInfo } from './shared' +import { BlockStateModelInfo, InstancingMode } from './shared' import { INVISIBLE_BLOCKS } from './worldConstants' globalThis.structuredClone ??= (value) => JSON.parse(JSON.stringify(value)) @@ -17,7 +17,7 @@ if (module.require) { let workerIndex = 0 let world: World -let dirtySections = new Map() +let dirtySections = new Map() let allDataReady = false function sectionKey (x, y, z) { @@ -47,7 +47,7 @@ function drainQueue (from, to) { queuedMessages = queuedMessages.slice(to) } -function setSectionDirty (pos, value = true) { +function setSectionDirty (pos, value = true, instancingMode = InstancingMode.None) { const x = Math.floor(pos.x / 16) * 16 const y = Math.floor(pos.y / 16) * 16 const z = Math.floor(pos.z / 16) * 16 @@ -60,7 +60,7 @@ function setSectionDirty (pos, value = true) { const chunk = world.getColumn(x, z) if (chunk?.getSection(pos)) { - dirtySections.set(key, (dirtySections.get(key) || 0) + 1) + dirtySections.set(key, { key, instancingMode, times: (dirtySections.get(key)?.times || 0) + 1 }) } else { postMessage({ type: 'sectionFinished', key, workerIndex }) } @@ -68,8 +68,7 @@ function setSectionDirty (pos, value = true) { const softCleanup = () => { // clean block cache and loaded chunks - world = new World(world.config.version) - globalThis.world = world + world.blockCache = {} } const handleMessage = data => { @@ -98,12 +97,14 @@ const handleMessage = data => { setMesherData(data.blockstatesModels, data.blocksAtlas, data.config.outputFormat === 'webgpu') allDataReady = true workerIndex = data.workerIndex + world.instancedBlocks = data.instancedBlocks + world.instancedBlockIds = data.instancedBlockIds || {} break } case 'dirty': { const loc = new Vec3(data.x, data.y, data.z) - setSectionDirty(loc, data.value) + setSectionDirty(loc, data.value, data.instancingMode || InstancingMode.None) break } @@ -190,6 +191,18 @@ self.onmessage = ({ data }) => { handleMessage(data) } +// Debug flag to spam last geometry output +globalThis.DEBUG_GEOMETRY_SPAM = false // set to true to enable geometry spam for performance testing +globalThis.lastGeometryKey = null + +// Track last 50 unique geometry objects with their respective keys for aggressive debugging +interface GeometryEntry { + key: string + geometry: any +} +const lastGeometryEntries: GeometryEntry[] = [] +const MAX_GEOMETRY_ENTRIES = 50 + setInterval(() => { if (world === null || !allDataReady) return @@ -197,23 +210,37 @@ setInterval(() => { // console.log(sections.length + ' dirty sections') // const start = performance.now() - for (const key of dirtySections.keys()) { + for (const [key, { instancingMode }] of dirtySections.entries()) { const [x, y, z] = key.split(',').map(v => parseInt(v, 10)) const chunk = world.getColumn(x, z) let processTime = 0 if (chunk?.getSection(new Vec3(x, y, z))) { const start = performance.now() - const geometry = getSectionGeometry(x, y, z, world) - const transferable = [geometry.positions?.buffer, geometry.normals?.buffer, geometry.colors?.buffer, geometry.uvs?.buffer].filter(Boolean) - //@ts-expect-error - postMessage({ type: 'geometry', key, geometry, workerIndex }, transferable) + const geometry = getSectionGeometry(x, y, z, world, instancingMode) + const transferable = [geometry.positions?.buffer, geometry.normals?.buffer, geometry.colors?.buffer, geometry.uvs?.buffer].filter(Boolean) as any + postMessage({ type: 'geometry', key, geometry, workerIndex }/* , transferable */) processTime = performance.now() - start + // Store last geometry for debug spam + globalThis.lastGeometryKey = key + + // Track unique geometry entries for aggressive debugging + const existingIndex = lastGeometryEntries.findIndex(entry => entry.key === key) + if (existingIndex >= 0) { + // Update existing entry with new geometry + lastGeometryEntries[existingIndex].geometry = geometry + } else { + // Add new entry + lastGeometryEntries.push({ key, geometry }) + if (lastGeometryEntries.length > MAX_GEOMETRY_ENTRIES) { + lastGeometryEntries.shift() // Remove oldest + } + } } else { // console.info('[mesher] Missing section', x, y, z) } const dirtyTimes = dirtySections.get(key) if (!dirtyTimes) throw new Error('dirtySections.get(key) is falsy') - for (let i = 0; i < dirtyTimes; i++) { + for (let i = 0; i < dirtyTimes.times; i++) { postMessage({ type: 'sectionFinished', key, workerIndex, processTime }) processTime = 0 } @@ -237,3 +264,24 @@ setInterval(() => { // const time = performance.now() - start // console.log(`Processed ${sections.length} sections in ${time} ms (${time / sections.length} ms/section)`) }, 50) + +// Debug spam: repeatedly send last geometry output every 100ms +setInterval(() => { + if (globalThis.DEBUG_GEOMETRY_SPAM) { + // Send the last geometry + + // Aggressive debugging: send all tracked geometry entries with their respective geometries + // console.log(`[DEBUG] Sending ${lastGeometryEntries.length} unique geometry entries:`, lastGeometryEntries.map(e => e.key)) + + // Send each unique geometry entry with its respective geometry for maximum stress testing + for (const entry of lastGeometryEntries) { + postMessage({ + type: 'geometry', + key: entry.key, + geometry: entry.geometry, + workerIndex, + debug: true // Mark as debug message + }) + } + } +}, 20) diff --git a/renderer/viewer/lib/mesher/models.ts b/renderer/viewer/lib/mesher/models.ts index aca47e15..d38bcbee 100644 --- a/renderer/viewer/lib/mesher/models.ts +++ b/renderer/viewer/lib/mesher/models.ts @@ -5,8 +5,8 @@ import { BlockType } from '../../../playground/shared' import { World, BlockModelPartsResolved, WorldBlock as Block, WorldBlock } from './world' import { BlockElement, buildRotationMatrix, elemFaces, matmul3, matmulmat3, vecadd3, vecsub3 } from './modelsGeometryCommon' import { INVISIBLE_BLOCKS } from './worldConstants' -import { MesherGeometryOutput, HighestBlockInfo } from './shared' - +import { MesherGeometryOutput, InstancingMode } from './shared' +import { isBlockInstanceable } from './instancingUtils' let blockProvider: WorldBlockProvider @@ -132,7 +132,11 @@ const getVec = (v: Vec3, dir: Vec3) => { return v.plus(dir) } -function renderLiquid (world: World, cursor: Vec3, texture: any | undefined, type: number, biome: string, water: boolean, attr: MesherGeometryOutput, isRealWater: boolean) { +function renderLiquid (world: World, cursor: Vec3, texture: any | undefined, type: number, biome: string, water: boolean, attr: MesherGeometryOutput, isRealWater: boolean, instancingEnabled: boolean) { + if (instancingEnabled) { + return // todo + } + const heights: number[] = [] for (let z = -1; z <= 1; z++) { for (let x = -1; x <= 1; x++) { @@ -518,10 +522,64 @@ const isBlockWaterlogged = (block: Block) => { return block.getProperties().waterlogged === true || block.getProperties().waterlogged === 'true' || ALWAYS_WATERLOGGED.has(block.name) } +const shouldCullInstancedBlock = (world: World, cursor: Vec3, block: Block): boolean => { + // Early return for blocks that should never be culled + if (block.transparent) return false + + // Check if all 6 faces would be culled (hidden by neighbors) + const cullIfIdentical = block.name.includes('glass') || block.name.includes('ice') + + // Cache cursor offset to avoid creating new Vec3 instances + const offsetCursor = new Vec3(0, 0, 0) + + // eslint-disable-next-line guard-for-in + for (const face in elemFaces) { + const { dir } = elemFaces[face] + offsetCursor.set(cursor.x + dir[0], cursor.y + dir[1], cursor.z + dir[2]) + const neighbor = world.getBlock(offsetCursor, blockProvider, {}) + + // Face is exposed to air/void - block must be rendered + if (!neighbor) return false + + // Handle special case for identical blocks (glass/ice) + if (cullIfIdentical && neighbor.stateId === block.stateId) continue + + // If neighbor is not a full opaque cube, face is visible + if (neighbor.transparent || !isCube(neighbor)) return false + } + + // All faces are culled, block should not be rendered + return true +} + +// Add matrix calculation helper +function calculateInstanceMatrix (pos: { x: number, y: number, z: number }, offset = 0.5): number[] { + // Create a 4x4 matrix array (16 elements) + const matrix = Array.from({ length: 16 }).fill(0) as number[] + + // Set identity matrix + matrix[0] = 1 // m11 + matrix[5] = 1 // m22 + matrix[10] = 1 // m33 + matrix[15] = 1 // m44 + + // Set translation (position) + matrix[12] = pos.x + offset // tx + matrix[13] = pos.y + offset // ty + matrix[14] = pos.z + offset // tz + + return matrix +} + let unknownBlockModel: BlockModelPartsResolved -export function getSectionGeometry (sx: number, sy: number, sz: number, world: World) { + +export function getSectionGeometry (sx: number, sy: number, sz: number, world: World, instancingMode = InstancingMode.None): MesherGeometryOutput { let delayedRender = [] as Array<() => void> + // Check if instanced rendering is enabled for this section + const enableInstancedRendering = instancingMode !== InstancingMode.None + const forceInstancedOnly = instancingMode === InstancingMode.BlockInstancingOnly || instancingMode === InstancingMode.ColorOnly + const attr: MesherGeometryOutput = { sx: sx + 8, sy: sy + 8, @@ -543,7 +601,8 @@ export function getSectionGeometry (sx: number, sy: number, sz: number, world: W signs: {}, // isFull: true, hadErrors: false, - blocksCount: 0 + blocksCount: 0, + instancedBlocks: {} } const cursor = new Vec3(0, 0, 0) @@ -606,14 +665,56 @@ export function getSectionGeometry (sx: number, sy: number, sz: number, world: W const pos = cursor.clone() // eslint-disable-next-line @typescript-eslint/no-loop-func delayedRender.push(() => { - renderLiquid(world, pos, blockProvider.getTextureInfo('water_still'), block.type, biome, true, attr, !isWaterlogged) + renderLiquid(world, pos, blockProvider.getTextureInfo('water_still'), block.type, biome, true, attr, !isWaterlogged, forceInstancedOnly) }) attr.blocksCount++ } else if (block.name === 'lava') { - renderLiquid(world, cursor, blockProvider.getTextureInfo('lava_still'), block.type, biome, false, attr, false) + renderLiquid(world, cursor, blockProvider.getTextureInfo('lava_still'), block.type, biome, false, attr, false, forceInstancedOnly) attr.blocksCount++ } if (block.name !== 'water' && block.name !== 'lava' && !INVISIBLE_BLOCKS.has(block.name)) { + // Check if this block can use instanced rendering + if ((enableInstancedRendering && isBlockInstanceable(world, block))/* || forceInstancedOnly */) { + // Check if block should be culled (all faces hidden by neighbors) + // TODO validate this + if (shouldCullInstancedBlock(world, cursor, block)) { + // Block is completely surrounded, skip rendering + continue + } + + const blockKey = block.name + if (!attr.instancedBlocks[blockKey]) { + attr.instancedBlocks[blockKey] = { + stateId: block.stateId, + blockName: block.name, + positions: [], + matrices: [] // Add matrices array + } + } + + const pos = { + x: cursor.x, + y: cursor.y, + z: cursor.z + } + + // Pre-calculate transformation matrix + const offset = instancingMode === InstancingMode.ColorOnly ? 0 : 0.5 + const matrix = calculateInstanceMatrix(pos, offset) + + attr.instancedBlocks[blockKey].positions.push(pos) + attr.instancedBlocks[blockKey].matrices.push(matrix) + + attr.blocksCount++ + continue // Skip regular geometry generation for instanceable blocks + } + + // Skip buffer geometry generation if force instanced only mode is enabled + if (forceInstancedOnly) { + // In force instanced only mode, skip all non-instanceable blocks + continue + } + // cache let { models } = block diff --git a/renderer/viewer/lib/mesher/shared.ts b/renderer/viewer/lib/mesher/shared.ts index 230db6b9..53fc78ff 100644 --- a/renderer/viewer/lib/mesher/shared.ts +++ b/renderer/viewer/lib/mesher/shared.ts @@ -1,5 +1,12 @@ import { BlockType } from '../../../playground/shared' +export enum InstancingMode { + None = 'none', + ColorOnly = 'color_only', + BlockInstancing = 'block_instancing', + BlockInstancingOnly = 'block_instancing_only' +} + // only here for easier testing export const defaultMesherConfig = { version: '', @@ -12,7 +19,7 @@ export const defaultMesherConfig = { // textureSize: 1024, // for testing debugModelVariant: undefined as undefined | number[], clipWorldBelowY: undefined as undefined | number, - disableSignsMapsSupport: false + disableSignsMapsSupport: false, } export type CustomBlockModels = { @@ -21,6 +28,19 @@ export type CustomBlockModels = { export type MesherConfig = typeof defaultMesherConfig +export interface InstancedBlockEntry { + stateId: number + blockName: string + positions: Array<{ x: number, y: number, z: number }> + matrices: number[][] // Pre-calculated transformation matrices from worker +} + +export type InstancingMesherData = { + blocks: { + [stateId: number]: number // instance id + } +} + export type MesherGeometryOutput = { sx: number, sy: number, @@ -45,6 +65,8 @@ export type MesherGeometryOutput = { hadErrors: boolean blocksCount: number customBlockModels?: CustomBlockModels + // New instanced blocks data + instancedBlocks: Record } export interface MesherMainEvents { diff --git a/renderer/viewer/lib/mesher/world.ts b/renderer/viewer/lib/mesher/world.ts index f2757ae6..59bc5a34 100644 --- a/renderer/viewer/lib/mesher/world.ts +++ b/renderer/viewer/lib/mesher/world.ts @@ -5,7 +5,7 @@ import { Vec3 } from 'vec3' import { WorldBlockProvider } from 'mc-assets/dist/worldBlockProvider' import moreBlockDataGeneratedJson from '../moreBlockDataGenerated.json' import legacyJson from '../../../../src/preflatMap.json' -import { defaultMesherConfig, CustomBlockModels, BlockStateModelInfo, getBlockAssetsCacheKey } from './shared' +import { defaultMesherConfig, CustomBlockModels, BlockStateModelInfo, getBlockAssetsCacheKey, MesherConfig } from './shared' import { INVISIBLE_BLOCKS } from './worldConstants' const ignoreAoBlocks = Object.keys(moreBlockDataGeneratedJson.noOcclusions) @@ -42,12 +42,14 @@ export class World { customBlockModels = new Map() // chunkKey -> blockModels sentBlockStateModels = new Set() blockStateModelInfo = new Map() + instancedBlocks: number[] = [] + instancedBlockIds = {} as Record - constructor (version) { + constructor (version: string) { this.Chunk = Chunks(version) as any this.biomeCache = mcData(version).biomes this.preflat = !mcData(version).supportFeature('blockStateId') - this.config.version = version + this.config = { ...defaultMesherConfig, version } } getLight (pos: Vec3, isNeighbor = false, skipMoreChecks = false, curBlockName = '') { @@ -121,7 +123,6 @@ export class World { if (!(pos instanceof Vec3)) pos = new Vec3(...pos as [number, number, number]) const key = columnKey(Math.floor(pos.x / 16) * 16, Math.floor(pos.z / 16) * 16) const blockPosKey = `${pos.x},${pos.y},${pos.z}` - const modelOverride = this.customBlockModels.get(key)?.[blockPosKey] const column = this.columns[key] // null column means chunk not loaded @@ -131,6 +132,7 @@ export class World { const locInChunk = posInChunk(loc) const stateId = column.getBlockStateId(locInChunk) + const modelOverride = stateId ? this.customBlockModels.get(key)?.[blockPosKey] : undefined const cacheKey = getBlockAssetsCacheKey(stateId, modelOverride) if (!this.blockCache[cacheKey]) { diff --git a/renderer/viewer/lib/worldDataEmitter.ts b/renderer/viewer/lib/worldDataEmitter.ts index e9153af7..8f5fb653 100644 --- a/renderer/viewer/lib/worldDataEmitter.ts +++ b/renderer/viewer/lib/worldDataEmitter.ts @@ -20,7 +20,7 @@ export type WorldDataEmitterEvents = { entityMoved: (data: any) => void playerEntity: (data: any) => void time: (data: number) => void - renderDistance: (viewDistance: number) => void + renderDistance: (viewDistance: number, keepChunksDistance: number) => void blockEntities: (data: Record | { blockEntities: Record }) => void markAsLoaded: (data: { x: number, z: number }) => void unloadChunk: (data: { x: number, z: number }) => void @@ -79,7 +79,7 @@ export class WorldDataEmitter extends (EventEmitter as new () => TypedEmitter abstract changeBackgroundColor (color: [number, number, number]): void + // Optional method for getting instanced blocks data (implemented by Three.js renderer) + getInstancedBlocksData? (): { instanceableBlocks?: Set, allBlocksStateIdToModelIdMap?: Record } | undefined + worldRendererConfig: WorldRendererConfig playerStateReactive: PlayerStateReactive playerStateUtils: PlayerStateUtils @@ -476,7 +488,6 @@ export abstract class WorldRendererCommon this.allChunksFinished = true this.allLoadedIn ??= Date.now() - this.initialChunkLoadWasStartedIn! } - this.updateChunksStats() } changeHandSwingingState (isAnimationPlaying: boolean, isLeftHand: boolean): void { } @@ -591,6 +602,10 @@ export abstract class WorldRendererCommon const resources = this.resourcesManager.currentResources if (this.workers.length === 0) throw new Error('workers not initialized yet') + + // Get instanceable blocks data if available (Three.js specific) + const instancedBlocksData = this.getInstancedBlocksData?.() + for (const [i, worker] of this.workers.entries()) { const { blockstatesModels } = resources @@ -602,6 +617,8 @@ export abstract class WorldRendererCommon }, blockstatesModels, config: this.getMesherConfig(), + instancedBlocks: instancedBlocksData?.instanceableBlocks ? [...instancedBlocksData.instanceableBlocks] : [], + instancedBlockIds: instancedBlocksData?.allBlocksStateIdToModelIdMap || {} }) } @@ -670,6 +687,11 @@ export abstract class WorldRendererCommon this.checkAllFinished() } + debugRemoveCurrentChunk () { + const [x, z] = chunkPos(this.viewerChunkPosition!) + this.removeColumn(x, z) + } + removeColumn (x, z) { delete this.loadedChunks[`${x},${z}`] for (const worker of this.workers) { @@ -915,7 +937,7 @@ export abstract class WorldRendererCommon return Promise.all(data) } - setSectionDirty (pos: Vec3, value = true, useChangeWorker = false) { // value false is used for unloading chunks + setSectionDirty (pos: Vec3, value = true, useChangeWorker = false, instancingMode = InstancingMode.None) { // value false is used for unloading chunks if (!this.forceCallFromMesherReplayer && this.mesherLogReader) return if (this.viewDistance === -1) throw new Error('viewDistance not set') @@ -929,7 +951,7 @@ export abstract class WorldRendererCommon // Dispatch sections to workers based on position // This guarantees uniformity accross workers and that a given section // is always dispatched to the same worker - const hash = this.getWorkerNumber(pos, useChangeWorker && this.mesherLogger.active) + const hash = this.getWorkerNumber(pos, this.mesherLogger.active) this.sectionsWaiting.set(key, (this.sectionsWaiting.get(key) ?? 0) + 1) if (this.forceCallFromMesherReplayer) { this.workers[hash].postMessage({ @@ -938,17 +960,18 @@ export abstract class WorldRendererCommon y: pos.y, z: pos.z, value, + instancingMode, config: this.getMesherConfig(), }) } else { this.toWorkerMessagesQueue[hash] ??= [] this.toWorkerMessagesQueue[hash].push({ - // this.workers[hash].postMessage({ type: 'dirty', x: pos.x, y: pos.y, z: pos.z, value, + instancingMode, config: this.getMesherConfig(), }) this.dispatchMessages() diff --git a/renderer/viewer/three/chunkMeshManager.ts b/renderer/viewer/three/chunkMeshManager.ts new file mode 100644 index 00000000..0e32ed49 --- /dev/null +++ b/renderer/viewer/three/chunkMeshManager.ts @@ -0,0 +1,743 @@ +import PrismarineChatLoader from 'prismarine-chat' +import * as THREE from 'three' +import * as nbt from 'prismarine-nbt' +import { Vec3 } from 'vec3' +import { MesherGeometryOutput } from '../lib/mesher/shared' +import { chunkPos } from '../lib/simpleUtils' +import { renderSign } from '../sign-renderer' +import { getMesh } from './entity/EntityMesh' +import type { WorldRendererThree } from './worldrendererThree' +import { armorModel } from './entity/armorModels' +import { disposeObject } from './threeJsUtils' + +export interface ChunkMeshPool { + mesh: THREE.Mesh + inUse: boolean + lastUsedTime: number + sectionKey?: string +} + +export interface SectionObject extends THREE.Group { + mesh?: THREE.Mesh + tilesCount?: number + blocksCount?: number + + signsContainer?: THREE.Group + headsContainer?: THREE.Group + boxHelper?: THREE.BoxHelper + fountain?: boolean +} + +export class ChunkMeshManager { + private readonly meshPool: ChunkMeshPool[] = [] + private readonly activeSections = new Map() + readonly sectionObjects: Record = {} + private poolSize: number + private maxPoolSize: number + private minPoolSize: number + private readonly signHeadsRenderer: SignHeadsRenderer + + // Performance tracking + private hits = 0 + private misses = 0 + + // Debug flag to bypass pooling + public bypassPooling = false + + // Performance monitoring + private readonly renderTimes: number[] = [] + private readonly maxRenderTimeSamples = 30 + private _performanceOverrideDistance?: number + private lastPerformanceCheck = 0 + private readonly performanceCheckInterval = 2000 // Check every 2 seconds + + get performanceOverrideDistance () { + return this._performanceOverrideDistance ?? 0 + } + set performanceOverrideDistance (value: number | undefined) { + this._performanceOverrideDistance = value + this.updateSectionsVisibility() + } + + constructor ( + public worldRenderer: WorldRendererThree, + public scene: THREE.Group, + public material: THREE.Material, + public worldHeight: number, + viewDistance = 3, + ) { + this.updateViewDistance(viewDistance) + this.signHeadsRenderer = new SignHeadsRenderer(worldRenderer) + + this.initializePool() + } + + private initializePool () { + // Create initial pool + for (let i = 0; i < this.poolSize; i++) { + const geometry = new THREE.BufferGeometry() + const mesh = new THREE.Mesh(geometry, this.material) + mesh.visible = false + mesh.matrixAutoUpdate = false + mesh.name = 'pooled-section-mesh' + + const poolEntry: ChunkMeshPool = { + mesh, + inUse: false, + lastUsedTime: 0 + } + + this.meshPool.push(poolEntry) + // Don't add to scene here - meshes will be added to containers + } + } + + /** + * Update or create a section with new geometry data + */ + updateSection (sectionKey: string, geometryData: MesherGeometryOutput): SectionObject | null { + // Remove existing section object from scene if it exists + let sectionObject = this.sectionObjects[sectionKey] + if (sectionObject) { + this.cleanupSection(sectionKey) + } + + // Get or create mesh from pool + let poolEntry = this.activeSections.get(sectionKey) + if (!poolEntry) { + poolEntry = this.acquireMesh() + if (!poolEntry) { + console.warn(`ChunkMeshManager: No available mesh in pool for section ${sectionKey}`) + return null + } + + this.activeSections.set(sectionKey, poolEntry) + poolEntry.sectionKey = sectionKey + } + + const { mesh } = poolEntry + + // Update geometry attributes efficiently + this.updateGeometryAttribute(mesh.geometry, 'position', geometryData.positions, 3) + this.updateGeometryAttribute(mesh.geometry, 'normal', geometryData.normals, 3) + this.updateGeometryAttribute(mesh.geometry, 'color', geometryData.colors, 3) + this.updateGeometryAttribute(mesh.geometry, 'uv', geometryData.uvs, 2) + + // Use direct index assignment for better performance (like before) + mesh.geometry.index = new THREE.BufferAttribute(geometryData.indices as Uint32Array | Uint16Array, 1) + + // Set bounding box and sphere for the 16x16x16 section + mesh.geometry.boundingBox = new THREE.Box3( + new THREE.Vector3(-8, -8, -8), + new THREE.Vector3(8, 8, 8) + ) + mesh.geometry.boundingSphere = new THREE.Sphere( + new THREE.Vector3(0, 0, 0), + Math.sqrt(3 * 8 ** 2) + ) + + // Position the mesh + mesh.position.set(geometryData.sx, geometryData.sy, geometryData.sz) + mesh.updateMatrix() + mesh.visible = true + mesh.name = 'mesh' + + poolEntry.lastUsedTime = performance.now() + + // Create or update the section object container + sectionObject = new THREE.Group() as SectionObject + sectionObject.add(mesh) + sectionObject.mesh = mesh as THREE.Mesh + + // Store metadata + sectionObject.tilesCount = geometryData.positions.length / 3 / 4 + sectionObject.blocksCount = geometryData.blocksCount + + try { + // Add signs container + if (Object.keys(geometryData.signs).length > 0) { + const signsContainer = new THREE.Group() + signsContainer.name = 'signs' + for (const [posKey, { isWall, isHanging, rotation }] of Object.entries(geometryData.signs)) { + const signBlockEntity = this.worldRenderer.blockEntities[posKey] + if (!signBlockEntity) continue + const [x, y, z] = posKey.split(',') + const sign = this.signHeadsRenderer.renderSign(new Vec3(+x, +y, +z), rotation, isWall, isHanging, nbt.simplify(signBlockEntity)) + if (!sign) continue + signsContainer.add(sign) + } + sectionObject.add(signsContainer) + sectionObject.signsContainer = signsContainer + } + + // Add heads container + if (Object.keys(geometryData.heads).length > 0) { + const headsContainer = new THREE.Group() + headsContainer.name = 'heads' + for (const [posKey, { isWall, rotation }] of Object.entries(geometryData.heads)) { + const headBlockEntity = this.worldRenderer.blockEntities[posKey] + if (!headBlockEntity) continue + const [x, y, z] = posKey.split(',') + const head = this.signHeadsRenderer.renderHead(new Vec3(+x, +y, +z), rotation, isWall, nbt.simplify(headBlockEntity)) + if (!head) continue + headsContainer.add(head) + } + sectionObject.add(headsContainer) + sectionObject.headsContainer = headsContainer + } + } catch (err) { + console.error('ChunkMeshManager: Error adding signs or heads to section', err) + } + + // Store and add to scene + this.sectionObjects[sectionKey] = sectionObject + this.scene.add(sectionObject) + sectionObject.matrixAutoUpdate = false + + return sectionObject + } + + cleanupSection (sectionKey: string) { + // Remove section object from scene + const sectionObject = this.sectionObjects[sectionKey] + if (sectionObject) { + this.scene.remove(sectionObject) + // Dispose signs and heads containers + if (sectionObject.signsContainer) { + this.disposeContainer(sectionObject.signsContainer) + } + if (sectionObject.headsContainer) { + this.disposeContainer(sectionObject.headsContainer) + } + delete this.sectionObjects[sectionKey] + } + } + + /** + * Release a section and return its mesh to the pool + */ + releaseSection (sectionKey: string): boolean { + this.cleanupSection(sectionKey) + + const poolEntry = this.activeSections.get(sectionKey) + if (!poolEntry) { + return false + } + + // Hide mesh and mark as available + poolEntry.mesh.visible = false + poolEntry.inUse = false + poolEntry.sectionKey = undefined + poolEntry.lastUsedTime = 0 + + // Clear geometry to free memory + this.clearGeometry(poolEntry.mesh.geometry) + + this.activeSections.delete(sectionKey) + + // Memory cleanup: if pool exceeds max size and we have free meshes, remove one + this.cleanupExcessMeshes() + + return true + } + + /** + * Get section object if it exists + */ + getSectionObject (sectionKey: string): SectionObject | undefined { + return this.sectionObjects[sectionKey] + } + + /** + * Update box helper for a section + */ + updateBoxHelper (sectionKey: string, showChunkBorders: boolean, chunkBoxMaterial: THREE.Material) { + const sectionObject = this.sectionObjects[sectionKey] + if (!sectionObject?.mesh) return + + if (showChunkBorders) { + if (!sectionObject.boxHelper) { + // mesh with static dimensions: 16x16x16 + const staticChunkMesh = new THREE.Mesh(new THREE.BoxGeometry(16, 16, 16), chunkBoxMaterial) + staticChunkMesh.position.copy(sectionObject.mesh.position) + const boxHelper = new THREE.BoxHelper(staticChunkMesh, 0xff_ff_00) + boxHelper.name = 'helper' + sectionObject.add(boxHelper) + sectionObject.name = 'chunk' + sectionObject.boxHelper = boxHelper + } + sectionObject.boxHelper.visible = true + } else if (sectionObject.boxHelper) { + sectionObject.boxHelper.visible = false + } + } + + /** + * Get mesh for section if it exists + */ + getSectionMesh (sectionKey: string): THREE.Mesh | undefined { + return this.activeSections.get(sectionKey)?.mesh + } + + /** + * Check if section is managed by this pool + */ + hasSection (sectionKey: string): boolean { + return this.activeSections.has(sectionKey) + } + + /** + * Update pool size based on new view distance + */ + updateViewDistance (maxViewDistance: number) { + // Calculate dynamic pool size based on view distance + const chunksInView = (maxViewDistance * 2 + 1) ** 2 + const maxSectionsPerChunk = this.worldHeight / 16 + const avgSectionsPerChunk = 5 + this.minPoolSize = Math.floor(chunksInView * avgSectionsPerChunk) + this.maxPoolSize = Math.floor(chunksInView * maxSectionsPerChunk) + 1 + this.poolSize ??= this.minPoolSize + + // Expand pool if needed to reach optimal size + if (this.minPoolSize > this.poolSize) { + const targetSize = Math.min(this.minPoolSize, this.maxPoolSize) + this.expandPool(targetSize) + } + + console.log(`ChunkMeshManager: Updated view max distance to ${maxViewDistance}, pool: ${this.poolSize}/${this.maxPoolSize}, optimal: ${this.minPoolSize}`) + } + + /** + * Get pool statistics + */ + getStats () { + const freeCount = this.meshPool.filter(entry => !entry.inUse).length + const hitRate = this.hits + this.misses > 0 ? (this.hits / (this.hits + this.misses) * 100).toFixed(1) : '0' + const memoryUsage = this.getEstimatedMemoryUsage() + + return { + poolSize: this.poolSize, + activeCount: this.activeSections.size, + freeCount, + hitRate: `${hitRate}%`, + hits: this.hits, + misses: this.misses, + memoryUsage + } + } + + /** + * Get total tiles rendered + */ + getTotalTiles (): number { + return Object.values(this.sectionObjects).reduce((acc, obj) => acc + (obj.tilesCount || 0), 0) + } + + /** + * Get total blocks rendered + */ + getTotalBlocks (): number { + return Object.values(this.sectionObjects).reduce((acc, obj) => acc + (obj.blocksCount || 0), 0) + } + + /** + * Estimate memory usage in MB + */ + getEstimatedMemoryUsage (): { total: string, breakdown: any } { + let totalBytes = 0 + let positionBytes = 0 + let normalBytes = 0 + let colorBytes = 0 + let uvBytes = 0 + let indexBytes = 0 + + for (const poolEntry of this.meshPool) { + if (poolEntry.inUse && poolEntry.mesh.geometry) { + const { geometry } = poolEntry.mesh + + const position = geometry.getAttribute('position') + if (position) { + const bytes = position.array.byteLength + positionBytes += bytes + totalBytes += bytes + } + + const normal = geometry.getAttribute('normal') + if (normal) { + const bytes = normal.array.byteLength + normalBytes += bytes + totalBytes += bytes + } + + const color = geometry.getAttribute('color') + if (color) { + const bytes = color.array.byteLength + colorBytes += bytes + totalBytes += bytes + } + + const uv = geometry.getAttribute('uv') + if (uv) { + const bytes = uv.array.byteLength + uvBytes += bytes + totalBytes += bytes + } + + if (geometry.index) { + const bytes = geometry.index.array.byteLength + indexBytes += bytes + totalBytes += bytes + } + } + } + + const totalMB = (totalBytes / (1024 * 1024)).toFixed(2) + + return { + total: `${totalMB} MB`, + breakdown: { + position: `${(positionBytes / (1024 * 1024)).toFixed(2)} MB`, + normal: `${(normalBytes / (1024 * 1024)).toFixed(2)} MB`, + color: `${(colorBytes / (1024 * 1024)).toFixed(2)} MB`, + uv: `${(uvBytes / (1024 * 1024)).toFixed(2)} MB`, + index: `${(indexBytes / (1024 * 1024)).toFixed(2)} MB`, + } + } + } + + /** + * Cleanup and dispose resources + */ + dispose () { + // Release all active sections + for (const [sectionKey] of this.activeSections) { + this.releaseSection(sectionKey) + } + + // Dispose all meshes and geometries + for (const poolEntry of this.meshPool) { + // Meshes will be removed from scene when their parent containers are removed + poolEntry.mesh.geometry.dispose() + } + + this.meshPool.length = 0 + this.activeSections.clear() + } + + // Private helper methods + + private acquireMesh (): ChunkMeshPool | undefined { + if (this.bypassPooling) { + return { + mesh: new THREE.Mesh(new THREE.BufferGeometry(), this.material), + inUse: true, + lastUsedTime: performance.now() + } + } + + // Find first available mesh + const availableMesh = this.meshPool.find(entry => !entry.inUse) + + if (availableMesh) { + availableMesh.inUse = true + this.hits++ + return availableMesh + } + + // No available mesh, expand pool to accommodate new sections + let newPoolSize = Math.min(this.poolSize + 16, this.maxPoolSize) + if (newPoolSize === this.poolSize) { + newPoolSize = this.poolSize + 8 + this.maxPoolSize = newPoolSize + console.warn(`ChunkMeshManager: Pool exhausted (${this.poolSize}/${this.maxPoolSize}). Emergency expansion to ${newPoolSize}`) + } + + this.misses++ + this.expandPool(newPoolSize) + return this.acquireMesh() + } + + private expandPool (newSize: number) { + const oldSize = this.poolSize + this.poolSize = newSize + + // console.log(`ChunkMeshManager: Expanding pool from ${oldSize} to ${newSize}`) + + // Add new meshes to pool + for (let i = oldSize; i < newSize; i++) { + const geometry = new THREE.BufferGeometry() + const mesh = new THREE.Mesh(geometry, this.material) + mesh.visible = false + mesh.matrixAutoUpdate = false + mesh.name = 'pooled-section-mesh' + + const poolEntry: ChunkMeshPool = { + mesh, + inUse: false, + lastUsedTime: 0 + } + + this.meshPool.push(poolEntry) + // Don't add to scene here - meshes will be added to containers + } + } + + private updateGeometryAttribute ( + geometry: THREE.BufferGeometry, + name: string, + array: Float32Array, + itemSize: number + ) { + const attribute = geometry.getAttribute(name) + + if (attribute && attribute.count === array.length / itemSize) { + // Reuse existing attribute + ;(attribute.array as Float32Array).set(array) + attribute.needsUpdate = true + } else { + // Create new attribute (this will dispose the old one automatically) + geometry.setAttribute(name, new THREE.BufferAttribute(array, itemSize)) + } + } + + private clearGeometry (geometry: THREE.BufferGeometry) { + // Clear attributes but keep the attribute objects for reuse + const attributes = ['position', 'normal', 'color', 'uv'] + for (const name of attributes) { + const attr = geometry.getAttribute(name) + if (attr) { + // Just mark as needing update but don't dispose to avoid recreation costs + attr.needsUpdate = true + } + } + + if (geometry.index) { + geometry.index.needsUpdate = true + } + } + + private cleanupExcessMeshes () { + // If pool size exceeds max and we have free meshes, remove some + if (this.poolSize > this.maxPoolSize) { + const freeCount = this.meshPool.filter(entry => !entry.inUse).length + if (freeCount > 0) { + const excessCount = Math.min(this.poolSize - this.maxPoolSize, freeCount) + for (let i = 0; i < excessCount; i++) { + const freeIndex = this.meshPool.findIndex(entry => !entry.inUse) + if (freeIndex !== -1) { + const poolEntry = this.meshPool[freeIndex] + poolEntry.mesh.geometry.dispose() + this.meshPool.splice(freeIndex, 1) + this.poolSize-- + } + } + // console.log(`ChunkMeshManager: Cleaned up ${excessCount} excess meshes. Pool size: ${this.poolSize}/${this.maxPoolSize}`) + } + } + } + + private disposeContainer (container: THREE.Group) { + disposeObject(container, true) + } + + /** + * Record render time for performance monitoring + */ + recordRenderTime (renderTime: number): void { + this.renderTimes.push(renderTime) + if (this.renderTimes.length > this.maxRenderTimeSamples) { + this.renderTimes.shift() + } + + // Check performance periodically + const now = performance.now() + if (now - this.lastPerformanceCheck > this.performanceCheckInterval) { + this.checkPerformance() + this.lastPerformanceCheck = now + } + } + + /** + * Get current effective render distance + */ + getEffectiveRenderDistance (): number { + return this.performanceOverrideDistance || this.worldRenderer.viewDistance + } + + /** + * Force reset performance override + */ + resetPerformanceOverride (): void { + this.performanceOverrideDistance = undefined + this.renderTimes.length = 0 + console.log('ChunkMeshManager: Performance override reset') + } + + /** + * Get average render time + */ + getAverageRenderTime (): number { + if (this.renderTimes.length === 0) return 0 + return this.renderTimes.reduce((sum, time) => sum + time, 0) / this.renderTimes.length + } + + /** + * Check if performance is degraded and adjust render distance + */ + private checkPerformance (): void { + if (this.renderTimes.length < this.maxRenderTimeSamples) return + + const avgRenderTime = this.getAverageRenderTime() + const targetRenderTime = 16.67 // 60 FPS target (16.67ms per frame) + const performanceThreshold = targetRenderTime * 1.5 // 25ms threshold + + if (avgRenderTime > performanceThreshold) { + // Performance is bad, reduce render distance + const currentViewDistance = this.worldRenderer.viewDistance + const newDistance = Math.max(1, Math.floor(currentViewDistance * 0.8)) + + if (!this.performanceOverrideDistance || newDistance < this.performanceOverrideDistance) { + this.performanceOverrideDistance = newDistance + console.warn(`ChunkMeshManager: Performance degraded (${avgRenderTime.toFixed(2)}ms avg). Reducing effective render distance to ${newDistance}`) + } + } else if (this.performanceOverrideDistance && avgRenderTime < targetRenderTime * 1.1) { + // Performance is good, gradually restore render distance + const currentViewDistance = this.worldRenderer.viewDistance + const newDistance = Math.min(currentViewDistance, this.performanceOverrideDistance + 1) + + if (newDistance !== this.performanceOverrideDistance) { + this.performanceOverrideDistance = newDistance >= currentViewDistance ? undefined : newDistance + console.log(`ChunkMeshManager: Performance improved. Restoring render distance to ${newDistance}`) + } + } + } + + /** + * Hide sections beyond performance override distance + */ + updateSectionsVisibility (): void { + const cameraPos = this.worldRenderer.cameraSectionPos + for (const [sectionKey, sectionObject] of Object.entries(this.sectionObjects)) { + if (!this.performanceOverrideDistance) { + sectionObject.visible = true + continue + } + + const [x, y, z] = sectionKey.split(',').map(Number) + const sectionPos = { x: x / 16, y: y / 16, z: z / 16 } + + // Calculate distance using hypot (same as render distance calculation) + const dx = sectionPos.x - cameraPos.x + const dz = sectionPos.z - cameraPos.z + const distance = Math.floor(Math.hypot(dx, dz)) + + sectionObject.visible = distance <= this.performanceOverrideDistance + } + } +} + + +class SignHeadsRenderer { + chunkTextures = new Map() + + constructor (public worldRendererThree: WorldRendererThree) { + } + + renderHead (position: Vec3, rotation: number, isWall: boolean, blockEntity) { + const textures = blockEntity.SkullOwner?.Properties?.textures[0] + if (!textures) return + + try { + const textureData = JSON.parse(Buffer.from(textures.Value, 'base64').toString()) + let skinUrl = textureData.textures?.SKIN?.url + const { skinTexturesProxy } = this.worldRendererThree.worldRendererConfig + if (skinTexturesProxy) { + skinUrl = skinUrl?.replace('http://textures.minecraft.net/', skinTexturesProxy) + .replace('https://textures.minecraft.net/', skinTexturesProxy) + } + + const mesh = getMesh(this.worldRendererThree, skinUrl, armorModel.head) + const group = new THREE.Group() + if (isWall) { + mesh.position.set(0, 0.3125, 0.3125) + } + // move head model down as armor have a different offset than blocks + mesh.position.y -= 23 / 16 + group.add(mesh) + group.position.set(position.x + 0.5, position.y + 0.045, position.z + 0.5) + group.rotation.set( + 0, + -THREE.MathUtils.degToRad(rotation * (isWall ? 90 : 45 / 2)), + 0 + ) + group.scale.set(0.8, 0.8, 0.8) + return group + } catch (err) { + console.error('Error decoding player texture:', err) + } + } + + renderSign (position: Vec3, rotation: number, isWall: boolean, isHanging: boolean, blockEntity) { + const tex = this.getSignTexture(position, blockEntity, isHanging) + + 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 + + const lineHeight = 7 / 16 + const scaleFactor = isHanging ? 1.3 : 1 + mesh.scale.set(1 * scaleFactor, lineHeight * scaleFactor, 1 * scaleFactor) + + const thickness = (isHanging ? 2 : 1.5) / 16 + const wallSpacing = 0.25 / 16 + if (isWall && !isHanging) { + mesh.position.set(0, 0, -0.5 + thickness + wallSpacing + 0.0001) + } else { + mesh.position.set(0, 0, thickness / 2 + 0.0001) + } + + const group = new THREE.Group() + group.rotation.set( + 0, + -THREE.MathUtils.degToRad(rotation * (isWall ? 90 : 45 / 2)), + 0 + ) + group.add(mesh) + const height = (isHanging ? 10 : 8) / 16 + const heightOffset = (isHanging ? 0 : isWall ? 4.333 : 9.333) / 16 + const textPosition = height / 2 + heightOffset + group.position.set(position.x + 0.5, position.y + textPosition, position.z + 0.5) + return group + } + + getSignTexture (position: Vec3, blockEntity, isHanging, 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.worldRendererThree.version) + const canvas = renderSign(blockEntity, isHanging, 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 + } +} diff --git a/renderer/viewer/three/entities.ts b/renderer/viewer/three/entities.ts index 6c6f8900..efb6b6b0 100644 --- a/renderer/viewer/three/entities.ts +++ b/renderer/viewer/three/entities.ts @@ -735,6 +735,7 @@ export class Entities { outerGroup.add(mesh) return { mesh: outerGroup, + meshGeometry: mesh.children.find(child => child instanceof THREE.Mesh)?.geometry, isBlock: true, itemsTexture: null, itemsTextureFlipped: null, @@ -1345,12 +1346,12 @@ export class Entities { } } - raycastSceneDebug () { + debugRaycastScene () { // return any object from scene. raycast from camera const raycaster = new THREE.Raycaster() raycaster.setFromCamera(new THREE.Vector2(0, 0), this.worldRenderer.camera) - const intersects = raycaster.intersectObjects(this.worldRenderer.scene.children) - return intersects[0]?.object + const intersects = raycaster.intersectObjects(this.worldRenderer.scene.children.filter(child => child.visible)) + return intersects.find(intersect => intersect.object.visible)?.object } private setupPlayerObject (entity: SceneEntity['originalEntity'], wrapper: THREE.Group, overrides: { texture?: string }): PlayerObjectType { diff --git a/renderer/viewer/three/getPreflatBlock.ts b/renderer/viewer/three/getPreflatBlock.ts new file mode 100644 index 00000000..2c4e36a6 --- /dev/null +++ b/renderer/viewer/three/getPreflatBlock.ts @@ -0,0 +1,30 @@ +import legacyJson from '../../../src/preflatMap.json' + +export const getPreflatBlock = (block, reportIssue?: () => void) => { + const b = block + b._properties = {} + + const namePropsStr = legacyJson.blocks[b.type + ':' + b.metadata] || findClosestLegacyBlockFallback(b.type, b.metadata, reportIssue) + if (namePropsStr) { + b.name = namePropsStr.split('[')[0] + const propsStr = namePropsStr.split('[')?.[1]?.split(']') + if (propsStr) { + const newProperties = Object.fromEntries(propsStr.join('').split(',').map(x => { + let [key, val] = x.split('=') + if (!isNaN(val)) val = parseInt(val, 10) + return [key, val] + })) + b._properties = newProperties + } + } + return b +} + +const findClosestLegacyBlockFallback = (id, metadata, reportIssue) => { + reportIssue?.() + for (const [key, value] of Object.entries(legacyJson.blocks)) { + const [idKey, meta] = key.split(':') + if (idKey === id) return value + } + return null +} diff --git a/renderer/viewer/three/holdingBlock.ts b/renderer/viewer/three/holdingBlock.ts index f9d00f0e..de719905 100644 --- a/renderer/viewer/three/holdingBlock.ts +++ b/renderer/viewer/three/holdingBlock.ts @@ -189,7 +189,7 @@ export default class HoldingBlock { this.swingAnimator?.stopSwing() } - render (originalCamera: THREE.PerspectiveCamera, renderer: THREE.WebGLRenderer, ambientLight: THREE.AmbientLight, directionalLight: THREE.DirectionalLight) { + render (renderer: THREE.WebGLRenderer) { if (!this.lastHeldItem) return const now = performance.now() if (this.lastUpdate && now - this.lastUpdate > 50) { // one tick @@ -205,15 +205,13 @@ export default class HoldingBlock { this.blockSwapAnimation?.switcher.update() - const scene = new THREE.Scene() + const scene = this.worldRenderer.templateScene scene.add(this.cameraGroup) // if (this.camera.aspect !== originalCamera.aspect) { // this.camera.aspect = originalCamera.aspect // this.camera.updateProjectionMatrix() // } this.updateCameraGroup() - scene.add(ambientLight.clone()) - scene.add(directionalLight.clone()) const viewerSize = renderer.getSize(new THREE.Vector2()) const minSize = Math.min(viewerSize.width, viewerSize.height) @@ -241,6 +239,8 @@ export default class HoldingBlock { if (offHandDisplay) { this.cameraGroup.scale.x = 1 } + + scene.remove(this.cameraGroup) } // worldTest () { diff --git a/renderer/viewer/three/instancedRenderer.ts b/renderer/viewer/three/instancedRenderer.ts new file mode 100644 index 00000000..0b461c51 --- /dev/null +++ b/renderer/viewer/three/instancedRenderer.ts @@ -0,0 +1,860 @@ +import * as THREE from 'three' +import { Vec3 } from 'vec3' +import { versionToNumber } from 'flying-squid/dist/utils' +import PrismarineBlock from 'prismarine-block' +import { IndexedBlock } from 'minecraft-data' +import moreBlockData from '../lib/moreBlockDataGenerated.json' +import { InstancingMode, MesherGeometryOutput } from '../lib/mesher/shared' +import { getPreflatBlock } from './getPreflatBlock' +import { WorldRendererThree } from './worldrendererThree' + +// Helper function to parse RGB color strings from moreBlockDataGenerated.json +function parseRgbColor (rgbString: string): number { + const match = /rgb\((\d+),\s*(\d+),\s*(\d+)\)/.exec(rgbString) + if (!match) return 0x99_99_99 // Default gray + + const r = parseInt(match[1], 10) + const g = parseInt(match[2], 10) + const b = parseInt(match[3], 10) + + return (r << 16) | (g << 8) | b +} + +export interface InstancedBlockData { + stateId: number + positions: Vec3[] + blockName: string +} + +export interface InstancedSectionData { + sectionKey: string + instancedBlocks: Map + shouldUseInstancedOnly: boolean +} + +export interface InstancedBlockModelData { + stateId: number + // textures: number[] + rotation: number[] + transparent?: boolean + emitLight?: number + filterLight?: number + textureInfos?: Array<{ u: number, v: number, su: number, sv: number }> +} + +export interface InstancedBlocksConfig { + instanceableBlocks: Set + blocksDataModel: Record + blockNameToStateIdMap: Record + interestedTextureTiles: Set +} + +export class InstancedRenderer { + isPreflat: boolean + + USE_APP_GEOMETRY = true + private readonly instancedMeshes = new Map() + private readonly sceneUsedMeshes = new Map() + private readonly blockCounts = new Map() + private readonly sectionInstances = new Map>() + private readonly cubeGeometry: THREE.BoxGeometry + private readonly tempMatrix = new THREE.Matrix4() + private readonly stateIdToName = new Map() + + // Cache for single color materials + private readonly colorMaterials = new Map() + + // Dynamic instance management + private readonly initialInstancesPerBlock = 2000 + private readonly maxInstancesPerBlock = 100_000 + private readonly maxTotalInstances = 10_000_000 + private currentTotalInstances = 0 + private readonly growthFactor = 1.5 // How much to grow when needed + + // Visibility control + private _instancedMeshesVisible = true + + // Memory tracking + private totalAllocatedInstances = 0 + + private instancedBlocksConfig: InstancedBlocksConfig | null = null + private sharedSolidMaterial: THREE.MeshLambertMaterial | null = null + + constructor (private readonly worldRenderer: WorldRendererThree) { + this.cubeGeometry = this.createCubeGeometry() + this.isPreflat = versionToNumber(this.worldRenderer.version) < versionToNumber('1.13') + + // Create shared solid material with no transparency + this.sharedSolidMaterial = new THREE.MeshLambertMaterial({ + transparent: false, + alphaTest: 0.1 + }) + } + + private getStateId (blockName: string): number { + if (!this.instancedBlocksConfig) { + throw new Error('Instanced blocks config not prepared') + } + + const stateId = this.instancedBlocksConfig.blockNameToStateIdMap[blockName] + if (stateId === undefined) { + throw new Error(`Block ${blockName} not found in blockNameToStateIdMap`) + } + + return stateId + } + + // Add getter/setter for visibility + get instancedMeshesVisible (): boolean { + return this._instancedMeshesVisible + } + + set instancedMeshesVisible (visible: boolean) { + this._instancedMeshesVisible = visible + // Update all instanced meshes visibility + for (const mesh of this.instancedMeshes.values()) { + mesh.visible = visible + } + } + + private getInitialInstanceCount (blockName: string): number { + // Start with small allocation, can grow later if needed + return Math.min(this.initialInstancesPerBlock, this.maxInstancesPerBlock) + } + + debugResizeMesh () { + // Debug helper to test resize operation + const blockName = 'grass_block' + const stateId = this.getStateId(blockName) + const mesh = this.instancedMeshes.get(stateId) + this.resizeInstancedMesh(stateId, mesh!.instanceMatrix.count * this.growthFactor) + } + + private resizeInstancedMesh (stateId: number, newSize: number): boolean { + const mesh = this.instancedMeshes.get(stateId) + if (!mesh) return false + + const blockName = this.stateIdToName.get(stateId) || 'unknown' + const oldSize = mesh.instanceMatrix.count + const actualInstanceCount = this.blockCounts.get(stateId) || 0 + + // console.log(`Growing instances for ${blockName}: ${oldSize} -> ${newSize} (${((newSize / oldSize - 1) * 100).toFixed(1)}% increase)`) + + const { geometry } = mesh + const { material } = mesh + + // Create new mesh with increased capacity + const newMesh = new THREE.InstancedMesh( + geometry, + material, + newSize + ) + newMesh.name = mesh.name + newMesh.frustumCulled = false + newMesh.visible = this._instancedMeshesVisible + + // Copy ALL existing instances using our tracked count + for (let i = 0; i < actualInstanceCount; i++) { + this.tempMatrix.identity() + mesh.getMatrixAt(i, this.tempMatrix) + newMesh.setMatrixAt(i, this.tempMatrix) + } + + newMesh.count = actualInstanceCount + newMesh.instanceMatrix.needsUpdate = true + + this.totalAllocatedInstances += (newSize - oldSize) + + this.worldRenderer.scene.add(newMesh) + this.instancedMeshes.set(stateId, newMesh) + this.worldRenderer.scene.remove(mesh) + + // Clean up old mesh + mesh.geometry.dispose() + if (Array.isArray(mesh.material)) { + for (const m of mesh.material) m.dispose() + } else { + mesh.material.dispose() + } + + // Verify instance count matches + // console.log(`Finished growing ${blockName}. Actual instances: ${actualInstanceCount}, New capacity: ${newSize}, Mesh count: ${newMesh.count}`) + + return true + } + + private canAddMoreInstances (stateId: number, count: number): boolean { + const currentForBlock = this.blockCounts.get(stateId) || 0 + const mesh = this.instancedMeshes.get(stateId) + if (!mesh) return false + + const blockName = this.stateIdToName.get(stateId) || 'unknown' + + // If we would exceed current capacity, try to grow + if (currentForBlock + count > mesh.instanceMatrix.count) { + const currentCapacity = mesh.instanceMatrix.count + const neededCapacity = currentForBlock + count + const newSize = Math.min( + this.maxInstancesPerBlock, + Math.ceil(Math.max( + neededCapacity, + currentCapacity * this.growthFactor + )) + ) + + // console.log(`Need to grow ${blockName}: current ${currentForBlock}/${currentCapacity}, need ${neededCapacity}, growing to ${newSize}`) + + // Check if growth would exceed total budget + const growthAmount = newSize - currentCapacity + if (this.totalAllocatedInstances + growthAmount > this.maxTotalInstances) { + console.warn(`Cannot grow instances for ${blockName}: would exceed total budget`) + return false + } + + // Try to grow + if (!this.resizeInstancedMesh(stateId, newSize)) { + console.warn(`Failed to grow instances for ${blockName}`) + return false + } + } + + // Check total instance budget + if (this.currentTotalInstances + count > this.maxTotalInstances) { + console.warn(`Total instance limit reached (${this.currentTotalInstances}/${this.maxTotalInstances})`) + return false + } + + return true + } + + prepareInstancedBlock (stateId: number, name: string, props: Record, mcBlockData?: IndexedBlock, defaultState = false) { + const config = this.instancedBlocksConfig! + + const possibleIssues = [] as string[] + const { currentResources } = this.worldRenderer.resourcesManager + if (!currentResources?.worldBlockProvider) return + + const models = currentResources.worldBlockProvider.getAllResolvedModels0_1({ + name, + properties: props + }, this.isPreflat, possibleIssues, [], [], true) + + // skipping composite blocks + if (models.length !== 1 || !models[0]![0].elements) { + return + } + const elements = models[0]![0]?.elements + if (!elements || (elements.length !== 1 && name !== 'grass_block')) { + return + } + const elem = elements[0] + if (elem.from[0] !== 0 || elem.from[1] !== 0 || elem.from[2] !== 0 || elem.to[0] !== 16 || elem.to[1] !== 16 || elem.to[2] !== 16) { + // not full block + return + } + + const facesMapping = [ + ['front', 'south'], + ['bottom', 'down'], + ['top', 'up'], + ['right', 'east'], + ['left', 'west'], + ['back', 'north'], + ] + + const blockData: InstancedBlockModelData = { + stateId, + rotation: [0, 0, 0, 0, 0, 0], + textureInfos: Array.from({ length: 6 }).fill(null).map(() => ({ u: 0, v: 0, su: 0, sv: 0 })) + } + + for (const [face, { texture, cullface, rotation = 0 }] of Object.entries(elem.faces)) { + const faceIndex = facesMapping.findIndex(x => x.includes(face)) + if (faceIndex === -1) { + throw new Error(`Unknown face ${face}`) + } + + blockData.rotation[faceIndex] = rotation / 90 + if (Math.floor(blockData.rotation[faceIndex]) !== blockData.rotation[faceIndex]) { + throw new Error(`Invalid rotation ${rotation} ${name}`) + } + config.interestedTextureTiles.add(texture.debugName) + + // Store texture info for this face + blockData.textureInfos![faceIndex] = { + u: texture.u, + v: texture.v, + su: texture.su, + sv: texture.sv + } + } + + config.blocksDataModel[stateId] = blockData + config.instanceableBlocks.add(stateId) + config.blockNameToStateIdMap[name] = stateId + + if (mcBlockData) { + blockData.transparent = mcBlockData.transparent + blockData.emitLight = mcBlockData.emitLight + blockData.filterLight = mcBlockData.filterLight + } + } + + prepareInstancedBlocksData () { + if (this.sharedSolidMaterial) { + this.sharedSolidMaterial.dispose() + this.sharedSolidMaterial = null + } + this.sharedSolidMaterial = new THREE.MeshLambertMaterial({ + transparent: true, + // depthWrite: true, + alphaTest: 0.1 + }) + this.sharedSolidMaterial.map = this.worldRenderer.material.map + // this.sharedTransparentMaterial = new THREE.MeshLambertMaterial({ + // transparent: true, + // // depthWrite: false, + // alphaTest: 0.1 + // }) + // this.sharedTransparentMaterial.map = this.worldRenderer.material.map + + const { forceInstancedOnly } = this.worldRenderer.worldRendererConfig + const debugBlocksMap = forceInstancedOnly ? { + 'double_stone_slab': 'stone', + 'stone_slab': 'stone', + 'oak_stairs': 'planks', + 'stone_stairs': 'stone', + 'glass_pane': 'stained_glass', + 'brick_stairs': 'brick_block', + 'stone_brick_stairs': 'stonebrick', + 'nether_brick_stairs': 'nether_brick', + 'double_wooden_slab': 'planks', + 'wooden_slab': 'planks', + 'sandstone_stairs': 'sandstone', + 'cobblestone_wall': 'cobblestone', + 'quartz_stairs': 'quartz_block', + 'stained_glass_pane': 'stained_glass', + 'red_sandstone_stairs': 'red_sandstone', + 'stone_slab2': 'stone_slab', + 'purpur_stairs': 'purpur_block', + 'purpur_slab': 'purpur_block', + } : {} + + const PBlockOriginal = PrismarineBlock(this.worldRenderer.version) + + this.instancedBlocksConfig = { + instanceableBlocks: new Set(), + blocksDataModel: {}, + blockNameToStateIdMap: {}, + interestedTextureTiles: new Set(), + } satisfies InstancedBlocksConfig + + // Add unknown block model + this.prepareInstancedBlock(-1, 'unknown', {}) + + // Handle texture overrides for special blocks + const textureOverrideFullBlocks = { + water: 'water_still', + lava: 'lava_still', + } + + // Process all blocks to find instanceable ones + for (const b of loadedData.blocksArray) { + for (let stateId = b.minStateId; stateId <= b.maxStateId; stateId++) { + const config = this.instancedBlocksConfig + + const mapping = debugBlocksMap[b.name] + const block = PBlockOriginal.fromStateId(mapping && loadedData.blocksByName[mapping] ? loadedData.blocksByName[mapping].defaultState : stateId, 0) + if (this.isPreflat) { + getPreflatBlock(block) + } + + const textureOverride = textureOverrideFullBlocks[block.name] as string | undefined + if (textureOverride) { + const { currentResources } = this.worldRenderer.resourcesManager + if (!currentResources?.worldBlockProvider) continue + const texture = currentResources.worldBlockProvider.getTextureInfo(textureOverride) + if (!texture) { + console.warn('Missing texture override for', block.name) + continue + } + const texIndex = texture.tileIndex + config.blocksDataModel[stateId] = { + stateId, + rotation: [0, 0, 0, 0, 0, 0], + filterLight: b.filterLight, + textureInfos: Array.from({ length: 6 }).fill(null).map(() => ({ + u: texture.u, + v: texture.v, + su: texture.su, + sv: texture.sv + })) + } + config.instanceableBlocks.add(block.stateId) + config.interestedTextureTiles.add(textureOverride) + config.blockNameToStateIdMap[block.name] = stateId + continue + } + + // Check if block is a full cube + if (block.shapes.length === 0 || !block.shapes.every(shape => { + return shape[0] === 0 && shape[1] === 0 && shape[2] === 0 && shape[3] === 1 && shape[4] === 1 && shape[5] === 1 + })) { + continue + } + + this.prepareInstancedBlock(stateId, block.name, block.getProperties(), b, stateId === b.defaultState) + } + } + } + + private getOrCreateColorMaterial (blockName: string): THREE.Material { + const color = this.getBlockColor(blockName) + const materialKey = color + + let material = this.colorMaterials.get(materialKey) + if (!material) { + material = new THREE.MeshBasicMaterial({ + color, + transparent: false + }) + material.name = `instanced_color_${blockName}` + this.colorMaterials.set(materialKey, material) + } + return material + } + + private createBlockMaterial (blockName: string, instancingMode: InstancingMode): THREE.Material { + if (instancingMode === InstancingMode.ColorOnly) { + return this.getOrCreateColorMaterial(blockName) + } else { + return this.sharedSolidMaterial! + } + } + + // Update initializeInstancedMeshes to respect visibility setting + initializeInstancedMeshes () { + if (!this.instancedBlocksConfig) { + console.warn('Instanced blocks config not prepared') + return + } + + // Create InstancedMesh for each instanceable block type + for (const stateId of this.instancedBlocksConfig.instanceableBlocks) { + const blockName = this.stateIdToName.get(stateId) + if (blockName) { + this.initializeInstancedMesh(stateId, blockName, InstancingMode.ColorOnly) + } + } + } + + initializeInstancedMesh (stateId: number, blockName: string, instancingMode: InstancingMode) { + if (this.instancedMeshes.has(stateId)) return // Skip if already exists + + if (!this.instancedBlocksConfig!.blocksDataModel) { + this.prepareInstancedBlock(stateId, blockName, {}) + } + + const blockModelData = this.instancedBlocksConfig!.blocksDataModel[stateId] + const isTransparent = blockModelData?.transparent ?? false + const initialCount = this.getInitialInstanceCount(blockName) + + const geometry = blockModelData ? this.createCustomGeometry(stateId, blockModelData) : this.cubeGeometry + const material = this.createBlockMaterial(blockName, instancingMode) + + const mesh = new THREE.InstancedMesh( + geometry, + material, + initialCount + ) + mesh.name = `instanced_${blockName}` + mesh.frustumCulled = false + mesh.count = 0 + mesh.visible = this._instancedMeshesVisible // Set initial visibility + + // mesh.renderOrder = isTransparent ? 1 : 0 + + this.instancedMeshes.set(stateId, mesh) + // Don't add to scene until actually used + this.totalAllocatedInstances += initialCount + + if (!blockModelData) { + console.warn(`No block model data found for block ${blockName}`) + } + } + + private debugRaycast () { + // get instanced block name + const raycaster = new THREE.Raycaster() + raycaster.setFromCamera(new THREE.Vector2(0, 0), this.worldRenderer.camera) + const intersects = raycaster.intersectObjects(this.worldRenderer.scene.children.filter(child => child.visible)) + for (const intersect of intersects) { + const mesh = intersect.object as THREE.Mesh + if (mesh.name.startsWith('instanced_')) { + console.log(`Instanced block name: ${mesh.name}`) + } + } + } + + private createCubeGeometry (): THREE.BoxGeometry { + // Create a basic cube geometry + // For proper texturing, we would need to modify UV coordinates per block type + // For now, use default BoxGeometry which works with the texture atlas + const geometry = new THREE.BoxGeometry(1, 1, 1) + return geometry + } + + private createCustomGeometry (stateId: number, blockModelData: InstancedBlockModelData): THREE.BufferGeometry { + if (this.USE_APP_GEOMETRY) { + const itemMesh = this.worldRenderer.entities.getItemMesh(stateId === -1 ? { + name: 'unknown' + } : { + blockState: stateId + }, {}) + + return itemMesh?.meshGeometry + } + + // Create custom geometry with specific UV coordinates per face + const geometry = new THREE.BoxGeometry(1, 1, 1) + + // Get UV attribute + const uvAttribute = geometry.getAttribute('uv') as THREE.BufferAttribute + const uvs = uvAttribute.array as Float32Array + + if (!blockModelData.textureInfos) { + console.warn('No texture infos available for block model') + return geometry + } + + // BoxGeometry has 6 faces, each with 4 vertices (8 UV values) + // Three.js BoxGeometry face order: +X, -X, +Y, -Y, +Z, -Z + // Our face mapping: [front, bottom, top, right, left, back] = [south, down, up, east, west, north] + // Map to Three.js indices: [+Z, -Y, +Y, +X, -X, -Z] = [4, 3, 2, 0, 1, 5] + + interface UVVertex { + u: number + v: number + } + + for (let faceIndex = 0; faceIndex < 6; faceIndex++) { + // Map Three.js face index to our face index + let ourFaceIndex: number + switch (faceIndex) { + case 0: ourFaceIndex = 3; break // +X -> right (east) + case 1: ourFaceIndex = 4; break // -X -> left (west) + case 2: ourFaceIndex = 2; break // +Y -> top (up) + case 3: ourFaceIndex = 1; break // -Y -> bottom (down) + case 4: ourFaceIndex = 0; break // +Z -> front (south) + case 5: ourFaceIndex = 5; break // -Z -> back (north) + default: continue + } + + const textureInfo = blockModelData.textureInfos[ourFaceIndex] + const rotation = blockModelData.rotation[ourFaceIndex] + + if (!textureInfo) { + console.warn(`No texture info found for face ${ourFaceIndex}`) + continue + } + + const { u, v, su, sv } = textureInfo + const faceUvStart = faceIndex * 8 + + // Get original UVs for this face + const faceUVs = uvs.slice(faceUvStart, faceUvStart + 8) + + // Apply rotation if needed (0=0°, 1=90°, 2=180°, 3=270°) + // Add base 180° rotation (2) to all faces + const totalRotation = (rotation + 2) % 4 + if (totalRotation > 0) { + // Each vertex has 2 UV coordinates (u,v) + // We need to rotate the 4 vertices as a group + const vertices: UVVertex[] = [] + for (let i = 0; i < 8; i += 2) { + vertices.push({ + u: faceUVs[i], + v: faceUVs[i + 1] + }) + } + + // Rotate vertices + const rotatedVertices: UVVertex[] = [] + for (let i = 0; i < 4; i++) { + const srcIndex = (i + totalRotation) % 4 + rotatedVertices.push(vertices[srcIndex]) + } + + // Write back rotated coordinates + for (let i = 0; i < 4; i++) { + faceUVs[i * 2] = rotatedVertices[i].u + faceUVs[i * 2 + 1] = rotatedVertices[i].v + } + } + + // Apply texture atlas coordinates to the potentially rotated UVs + for (let i = 0; i < 8; i += 2) { + uvs[faceUvStart + i] = u + faceUVs[i] * su + uvs[faceUvStart + i + 1] = v + faceUVs[i + 1] * sv + } + } + + uvAttribute.needsUpdate = true + return geometry + } + + private getBlockColor (blockName: string): number { + // Get color from moreBlockDataGenerated.json + const rgbString = moreBlockData.colors[blockName] + if (rgbString) { + return parseRgbColor(rgbString) + } + + // Debug: Log when color is not found + console.warn(`No color found for block: ${blockName}, using default gray`) + + // Fallback to default gray if color not found + return 0x99_99_99 + } + + handleInstancedBlocksFromWorker (instancedBlocks: MesherGeometryOutput['instancedBlocks'], sectionKey: string, instancingMode: InstancingMode) { + // Initialize section tracking if not exists + if (!this.sectionInstances.has(sectionKey)) { + this.sectionInstances.set(sectionKey, new Map()) + } + const sectionMap = this.sectionInstances.get(sectionKey)! + + // Remove old instances for blocks that are being updated + const previousStateIds = [...sectionMap.keys()] + for (const stateId of previousStateIds) { + const instanceIndices = sectionMap.get(stateId) + if (instanceIndices) { + this.removeInstancesFromBlock(stateId, instanceIndices) + sectionMap.delete(stateId) + } + } + + // Keep track of blocks that were updated this frame + for (const [blockName, blockData] of Object.entries(instancedBlocks)) { + const { stateId, positions, matrices } = blockData + this.stateIdToName.set(stateId, blockName) + + if (this.USE_APP_GEOMETRY) { + this.initializeInstancedMesh(stateId, blockName, instancingMode) + } + + const instanceIndices: number[] = [] + const currentCount = this.blockCounts.get(stateId) || 0 + + // Check if we can add all positions at once + const neededInstances = positions.length + if (!this.canAddMoreInstances(stateId, neededInstances)) { + console.warn(`Cannot add ${neededInstances} instances for block ${blockName} (current: ${currentCount}, max: ${this.maxInstancesPerBlock})`) + continue + } + + const mesh = this.instancedMeshes.get(stateId)! + + // Add new instances for this section using pre-calculated matrices from worker + for (let i = 0; i < positions.length; i++) { + const instanceIndex = currentCount + instanceIndices.length + mesh.setMatrixAt(instanceIndex, new THREE.Matrix4().fromArray(matrices[i])) + instanceIndices.push(instanceIndex) + } + + // Update tracking + if (instanceIndices.length > 0) { + sectionMap.set(stateId, instanceIndices) + const newCount = currentCount + instanceIndices.length + this.blockCounts.set(stateId, newCount) + this.currentTotalInstances += instanceIndices.length + mesh.count = newCount + mesh.instanceMatrix.needsUpdate = true + + // Only add mesh to scene when it's first used + if (newCount === instanceIndices.length) { + this.worldRenderer.scene.add(mesh) + } + this.sceneUsedMeshes.set(blockName, mesh) + } + } + } + + removeSectionInstances (sectionKey: string) { + const sectionMap = this.sectionInstances.get(sectionKey) + if (!sectionMap) return // Section not tracked + + // Remove instances for each block type in this section + for (const [stateId, instanceIndices] of sectionMap) { + this.removeInstancesFromBlock(stateId, instanceIndices) + + // Remove from sceneUsedMeshes if no instances left + const blockName = this.stateIdToName.get(stateId) + if (blockName && (this.blockCounts.get(stateId) || 0) === 0) { + this.sceneUsedMeshes.delete(blockName) + } + } + + // Remove section from tracking + this.sectionInstances.delete(sectionKey) + } + + private removeInstancesFromBlock (stateId: number, indicesToRemove: number[]) { + const mesh = this.instancedMeshes.get(stateId) + if (!mesh || indicesToRemove.length === 0) return + + const currentCount = this.blockCounts.get(stateId) || 0 + const removeSet = new Set(indicesToRemove) + + // Update total instance count + this.currentTotalInstances -= indicesToRemove.length + + // Create mapping from old indices to new indices + const indexMapping = new Map() + let writeIndex = 0 + const tempMatrix = new THREE.Matrix4() + + // Compact the instance matrix by removing gaps + for (let readIndex = 0; readIndex < currentCount; readIndex++) { + if (!removeSet.has(readIndex)) { + indexMapping.set(readIndex, writeIndex) + if (writeIndex !== readIndex) { + mesh.getMatrixAt(readIndex, tempMatrix) + mesh.setMatrixAt(writeIndex, tempMatrix) + } + writeIndex++ + } + } + + // Update count + const newCount = writeIndex + this.blockCounts.set(stateId, newCount) + mesh.count = newCount + mesh.instanceMatrix.needsUpdate = true + + // Update all section tracking to reflect new indices + for (const [sectionKey, sectionMap] of this.sectionInstances) { + const sectionIndices = sectionMap.get(stateId) + if (sectionIndices) { + const updatedIndices = sectionIndices + .map(index => indexMapping.get(index)) + .filter(index => index !== undefined) + + if (updatedIndices.length > 0) { + sectionMap.set(stateId, updatedIndices) + } else { + sectionMap.delete(stateId) + } + } + } + + // Update sceneUsedMeshes if no instances left + if (newCount === 0) { + const blockName = this.stateIdToName.get(stateId) + if (blockName) { + this.sceneUsedMeshes.delete(blockName) + } + } + } + + disposeOldMeshes () { + // Reset total instance count since we're clearing everything + this.currentTotalInstances = 0 + + for (const [stateId, mesh] of this.instancedMeshes) { + if (mesh.material instanceof THREE.Material && mesh.material.name.startsWith('instanced_color_')) { + mesh.material.dispose() + } + mesh.geometry.dispose() + this.instancedMeshes.delete(stateId) + this.worldRenderer.scene.remove(mesh) + } + + // Clear counts + this.blockCounts.clear() + } + + destroy () { + // Clean up resources + for (const [stateId, mesh] of this.instancedMeshes) { + this.worldRenderer.scene.remove(mesh) + mesh.geometry.dispose() + if (mesh.material instanceof THREE.Material) { + mesh.material.dispose() + } + } + + // Clean up materials + if (this.sharedSolidMaterial) { + this.sharedSolidMaterial.dispose() + this.sharedSolidMaterial = null + } + for (const material of this.colorMaterials.values()) { + material.dispose() + } + this.colorMaterials.clear() + + this.instancedMeshes.clear() + this.blockCounts.clear() + this.sectionInstances.clear() + this.stateIdToName.clear() + this.sceneUsedMeshes.clear() + this.cubeGeometry.dispose() + } + + // Add visibility info to stats + getStats () { + let totalInstances = 0 + let activeBlockTypes = 0 + let totalWastedMemory = 0 + + for (const [stateId, mesh] of this.instancedMeshes) { + const allocated = mesh.instanceMatrix.count + const used = mesh.count + totalWastedMemory += (allocated - used) * 64 // 64 bytes per instance (approximate) + + if (used > 0) { + totalInstances += used + activeBlockTypes++ + } + } + + const maxPerBlock = this.maxInstancesPerBlock + const renderDistance = this.worldRenderer.viewDistance + + return { + totalInstances, + activeBlockTypes, + drawCalls: this._instancedMeshesVisible ? activeBlockTypes : 0, + memoryStats: { + totalAllocatedInstances: this.totalAllocatedInstances, + usedInstances: totalInstances, + wastedInstances: this.totalAllocatedInstances - totalInstances, + estimatedMemoryUsage: this.totalAllocatedInstances * 64, + estimatedWastedMemory: totalWastedMemory, + utilizationPercent: ((totalInstances / this.totalAllocatedInstances) * 100).toFixed(1) + '%' + }, + maxInstancesPerBlock: maxPerBlock, + totalInstanceBudget: this.maxTotalInstances, + renderDistance, + instanceUtilization: totalInstances / this.maxTotalInstances, + instancedMeshesVisible: this._instancedMeshesVisible + } + } + + // New method to prepare and initialize everything + prepareAndInitialize () { + console.log('Preparing instanced blocks data...') + this.prepareInstancedBlocksData() + const config = this.instancedBlocksConfig! + console.log(`Found ${config.instanceableBlocks.size} instanceable blocks`) + + this.disposeOldMeshes() + this.initializeInstancedMeshes() + } + + // Method to get the current configuration + getInstancedBlocksConfig (): InstancedBlocksConfig | null { + return this.instancedBlocksConfig + } +} diff --git a/renderer/viewer/three/panorama.ts b/renderer/viewer/three/panorama.ts index 254b980c..930e82d0 100644 --- a/renderer/viewer/three/panorama.ts +++ b/renderer/viewer/three/panorama.ts @@ -204,7 +204,7 @@ export class PanoramaRenderer { } ) if (this.worldRenderer instanceof WorldRendererThree) { - this.scene = this.worldRenderer.scene + this.scene = this.worldRenderer.realScene } void worldView.init(initPos) diff --git a/renderer/viewer/three/threeJsMedia.ts b/renderer/viewer/three/threeJsMedia.ts index 582273d1..92599c8d 100644 --- a/renderer/viewer/three/threeJsMedia.ts +++ b/renderer/viewer/three/threeJsMedia.ts @@ -565,7 +565,7 @@ export class ThreeJsMedia { raycaster.setFromCamera(mouse, camera) // Check intersection with all objects in scene - const intersects = raycaster.intersectObjects(scene.children, true) + const intersects = raycaster.intersectObjects(scene.children.filter(child => child.visible), true) if (intersects.length > 0) { const intersection = intersects[0] const intersectedObject = intersection.object diff --git a/renderer/viewer/three/worldrendererThree.ts b/renderer/viewer/three/worldrendererThree.ts index bc95f06b..c066a1e8 100644 --- a/renderer/viewer/three/worldrendererThree.ts +++ b/renderer/viewer/three/worldrendererThree.ts @@ -1,21 +1,17 @@ import * as THREE from 'three' import { Vec3 } from 'vec3' -import nbt from 'prismarine-nbt' -import PrismarineChatLoader from 'prismarine-chat' import * as tweenJs from '@tweenjs/tween.js' -import { renderSign } from '../sign-renderer' import { DisplayWorldOptions, GraphicsInitOptions } from '../../../src/appViewer' -import { chunkPos, sectionPos } from '../lib/simpleUtils' +import { sectionPos } from '../lib/simpleUtils' import { WorldRendererCommon } from '../lib/worldrendererCommon' +import { WorldDataEmitterWorker } from '../lib/worldDataEmitter' import { addNewStat } from '../lib/ui/newStats' -import { MesherGeometryOutput } from '../lib/mesher/shared' +import { MesherGeometryOutput, InstancingMode } from '../lib/mesher/shared' import { ItemSpecificContextProperties } from '../lib/basePlayerState' import { setBlockPosition } from '../lib/mesher/standaloneRenderer' import { getMyHand } from './hand' import HoldingBlock from './holdingBlock' -import { getMesh } from './entity/EntityMesh' -import { armorModel } from './entity/armorModels' -import { disposeObject, loadThreeJsTextureFromBitmap } from './threeJsUtils' +import { loadThreeJsTextureFromBitmap } from './threeJsUtils' import { CursorBlock } from './world/cursorBlock' import { getItemUv } from './appShared' import { Entities } from './entities' @@ -23,19 +19,23 @@ import { ThreeJsSound } from './threeJsSound' import { CameraShake } from './cameraShake' import { ThreeJsMedia } from './threeJsMedia' import { Fountain } from './threeJsParticles' +import { InstancedRenderer } from './instancedRenderer' +import { ChunkMeshManager } from './chunkMeshManager' type SectionKey = string export class WorldRendererThree extends WorldRendererCommon { outputFormat = 'threeJs' as const - sectionObjects: Record = {} + sectionInstancingMode: Record = {} chunkTextures = new Map() signsCache = new Map() starField: StarField cameraSectionPos: Vec3 = new Vec3(0, 0, 0) - holdingBlock: HoldingBlock - holdingBlockLeft: HoldingBlock - scene = new THREE.Scene() + holdingBlock: HoldingBlock | undefined + holdingBlockLeft: HoldingBlock | undefined + realScene = new THREE.Scene() + scene = new THREE.Group() + templateScene = new THREE.Scene() ambientLight = new THREE.AmbientLight(0xcc_cc_cc) directionalLight = new THREE.DirectionalLight(0xff_ff_ff, 0.5) entities = new Entities(this) @@ -47,9 +47,12 @@ export class WorldRendererThree extends WorldRendererCommon { cameraShake: CameraShake cameraContainer: THREE.Object3D media: ThreeJsMedia + instancedRenderer: InstancedRenderer | undefined + chunkMeshManager: ChunkMeshManager waitingChunksToDisplay = {} as { [chunkKey: string]: SectionKey[] } camera: THREE.PerspectiveCamera renderTimeAvg = 0 + chunkBoxMaterial = new THREE.MeshBasicMaterial({ color: 0x00_00_00, transparent: true, opacity: 0 }) sectionsOffsetsAnimations = {} as { [chunkKey: string]: { time: number, @@ -72,13 +75,14 @@ export class WorldRendererThree extends WorldRendererCommon { private currentPosTween?: tweenJs.Tween private currentRotTween?: tweenJs.Tween<{ pitch: number, yaw: number }> + private readonly worldOffset = new THREE.Vector3() get tilesRendered () { - return Object.values(this.sectionObjects).reduce((acc, obj) => acc + (obj as any).tilesCount, 0) + return this.chunkMeshManager.getTotalTiles() } get blocksRendered () { - return Object.values(this.sectionObjects).reduce((acc, obj) => acc + (obj as any).blocksCount, 0) + return this.chunkMeshManager.getTotalBlocks() } constructor (public renderer: THREE.WebGLRenderer, public initOptions: GraphicsInitOptions, public displayOptions: DisplayWorldOptions) { @@ -94,11 +98,18 @@ export class WorldRendererThree extends WorldRendererCommon { this.addDebugOverlay() this.resetScene() - void this.init() - this.soundSystem = new ThreeJsSound(this) this.cameraShake = new CameraShake(this, this.onRender) this.media = new ThreeJsMedia(this) + this.instancedRenderer = new InstancedRenderer(this) + this.chunkMeshManager = new ChunkMeshManager(this, this.scene, this.material, this.worldSizeParams.worldHeight, this.viewDistance) + + // Enable bypass pooling for debugging if URL param is present + if (new URLSearchParams(location.search).get('bypassMeshPooling') === 'true') { + this.chunkMeshManager.bypassPooling = true + console.log('ChunkMeshManager: Bypassing pooling for debugging') + } + // this.fountain = new Fountain(this.scene, this.scene, { // position: new THREE.Vector3(0, 10, 0), // }) @@ -107,6 +118,14 @@ export class WorldRendererThree extends WorldRendererCommon { this.finishChunk(chunkKey) }) this.worldSwitchActions() + + void this.init() + } + + // Add this method to update world origin + private updateWorldOrigin (pos: THREE.Vector3) { + // this.worldOffset.copy(pos) + // this.scene.position.copy(this.worldOffset).multiplyScalar(-1) } get cameraObject () { @@ -122,6 +141,15 @@ export class WorldRendererThree extends WorldRendererCommon { }) } + override connect (worldView: WorldDataEmitterWorker) { + super.connect(worldView) + + // Add additional renderDistance handling for mesh pool updates + worldView.on('renderDistance', (viewDistance) => { + this.chunkMeshManager.updateViewDistance(viewDistance) + }) + } + updateEntity (e, isPosUpdate = false) { const overrides = { rotation: { @@ -143,33 +171,44 @@ export class WorldRendererThree extends WorldRendererCommon { this.entities.handlePlayerEntity(e) } + resetTemplateScene () { + this.templateScene = new THREE.Scene() + this.templateScene.add(this.ambientLight.clone()) + this.templateScene.add(this.directionalLight.clone()) + } + resetScene () { this.scene.matrixAutoUpdate = false // for perf - this.scene.background = new THREE.Color(this.initOptions.config.sceneBackground) - this.scene.add(this.ambientLight) + this.realScene.background = new THREE.Color(this.initOptions.config.sceneBackground) + this.realScene.add(this.ambientLight) this.directionalLight.position.set(1, 1, 0.5).normalize() this.directionalLight.castShadow = true - this.scene.add(this.directionalLight) + this.realScene.add(this.directionalLight) const size = this.renderer.getSize(new THREE.Vector2()) this.camera = new THREE.PerspectiveCamera(75, size.x / size.y, 0.1, 1000) this.cameraContainer = new THREE.Object3D() this.cameraContainer.add(this.camera) - this.scene.add(this.cameraContainer) + this.realScene.add(this.cameraContainer) + this.realScene.add(this.scene) + + this.resetTemplateScene() } override watchReactivePlayerState () { super.watchReactivePlayerState() this.onReactivePlayerStateUpdated('inWater', (value) => { - this.scene.fog = value ? new THREE.Fog(0x00_00_ff, 0.1, this.playerStateReactive.waterBreathing ? 100 : 20) : null + this.realScene.fog = value ? new THREE.Fog(0x00_00_ff, 0.1, this.playerStateReactive.waterBreathing ? 100 : 20) : null }) this.onReactivePlayerStateUpdated('ambientLight', (value) => { if (!value) return this.ambientLight.intensity = value + this.resetTemplateScene() }) this.onReactivePlayerStateUpdated('directionalLight', (value) => { if (!value) return this.directionalLight.intensity = value + this.resetTemplateScene() }) this.onReactivePlayerStateUpdated('lookingAtBlock', (value) => { this.cursorBlock.setHighlightCursorBlock(value ? new Vec3(value.x, value.y, value.z) : null, value?.shapes) @@ -185,19 +224,34 @@ export class WorldRendererThree extends WorldRendererCommon { }) } + getInstancedBlocksData () { + const config = this.instancedRenderer?.getInstancedBlocksConfig() + if (!config) return undefined + + return { + instanceableBlocks: config.instanceableBlocks, + } + } + override watchReactiveConfig () { super.watchReactiveConfig() this.onReactiveConfigUpdated('showChunkBorders', (value) => { - this.updateShowChunksBorder(value) + this.updateShowChunksBorder() + }) + this.onReactiveConfigUpdated('enableDebugOverlay', (value) => { + if (!value) { + // restore visibility + this.chunkMeshManager.updateSectionsVisibility() + } }) } changeHandSwingingState (isAnimationPlaying: boolean, isLeft = false) { const holdingBlock = isLeft ? this.holdingBlockLeft : this.holdingBlock if (isAnimationPlaying) { - holdingBlock.startSwing() + holdingBlock?.startSwing() } else { - holdingBlock.stopSwing() + holdingBlock?.stopSwing() } } @@ -224,8 +278,12 @@ export class WorldRendererThree extends WorldRendererCommon { oldItemsTexture.dispose() } + // Prepare and initialize instanced renderer with dynamic block detection + this.instancedRenderer?.prepareAndInitialize() + await super.updateAssetsData() this.onAllTexturesLoaded() + if (Object.keys(this.loadedChunks).length > 0) { console.log('rerendering chunks because of texture update') this.rerenderAllChunks() @@ -233,14 +291,18 @@ export class WorldRendererThree extends WorldRendererCommon { } onAllTexturesLoaded () { - this.holdingBlock.ready = true - this.holdingBlock.updateItem() - this.holdingBlockLeft.ready = true - this.holdingBlockLeft.updateItem() + if (this.holdingBlock) { + this.holdingBlock.ready = true + this.holdingBlock.updateItem() + } + if (this.holdingBlockLeft) { + this.holdingBlockLeft.ready = true + this.holdingBlockLeft.updateItem() + } } changeBackgroundColor (color: [number, number, number]): void { - this.scene.background = new THREE.Color(color[0], color[1], color[2]) + this.realScene.background = new THREE.Color(color[0], color[1], color[2]) } timeUpdated (newTime: number): void { @@ -294,12 +356,20 @@ export class WorldRendererThree extends WorldRendererCommon { const formatBigNumber = (num: number) => { return new Intl.NumberFormat('en-US', {}).format(num) } + const instancedStats = this.instancedRenderer?.getStats() let text = '' text += `C: ${formatBigNumber(this.renderer.info.render.calls)} ` text += `TR: ${formatBigNumber(this.renderer.info.render.triangles)} ` text += `TE: ${formatBigNumber(this.renderer.info.memory.textures)} ` text += `F: ${formatBigNumber(this.tilesRendered)} ` - text += `B: ${formatBigNumber(this.blocksRendered)}` + text += `B: ${formatBigNumber(this.blocksRendered)} ` + if (instancedStats) { + text += `I: ${formatBigNumber(instancedStats.totalInstances)}/${instancedStats.activeBlockTypes}t ` + text += `DC: ${formatBigNumber(instancedStats.drawCalls)} ` + } + const poolStats = this.chunkMeshManager.getStats() + const poolMode = this.chunkMeshManager.bypassPooling ? 'BYPASS' : poolStats.hitRate + text += `MP: ${poolStats.activeCount}/${poolStats.poolSize} ${poolMode}` pane.updateText(text) this.backendInfoReport = text } @@ -313,8 +383,8 @@ export class WorldRendererThree extends WorldRendererCommon { const [x, y, z] = key.split(',').map(x => Math.floor(+x / 16)) // sum of distances: x + y + z const chunkDistance = Math.abs(x - this.cameraSectionPos.x) + Math.abs(y - this.cameraSectionPos.y) + Math.abs(z - this.cameraSectionPos.z) - const section = this.sectionObjects[key].children.find(child => child.name === 'mesh')! - section.renderOrder = 500 - chunkDistance + const sectionObject = this.chunkMeshManager.getSectionObject(key)! + sectionObject.mesh!.renderOrder = 500 - chunkDistance } override updateViewerPosition (pos: Vec3): void { @@ -323,10 +393,22 @@ export class WorldRendererThree extends WorldRendererCommon { cameraSectionPositionUpdate () { // eslint-disable-next-line guard-for-in - for (const key in this.sectionObjects) { - const value = this.sectionObjects[key] - if (!value) continue - this.updatePosDataChunk(key) + for (const key in this.sectionInstancingMode) { + const sectionObject = this.chunkMeshManager.getSectionObject(key)! + if (sectionObject) { + this.updatePosDataChunk(key) + } + + if (this.worldRendererConfig.dynamicInstancing) { + const [x, y, z] = key.split(',').map(x => +x) + const pos = new Vec3(x, y, z) + const instancingMode = this.getInstancingMode(pos) + if (instancingMode !== this.sectionInstancingMode[key]) { + // console.log('update section', key, this.sectionInstancingMode[key], '->', instancingMode) + // update section + this.setSectionDirty(pos) + } + } } } @@ -337,123 +419,68 @@ export class WorldRendererThree extends WorldRendererCommon { finishChunk (chunkKey: string) { for (const sectionKey of this.waitingChunksToDisplay[chunkKey] ?? []) { - this.sectionObjects[sectionKey].visible = true + const sectionObject = this.chunkMeshManager.getSectionObject(sectionKey) + if (sectionObject) { + sectionObject.visible = true + } } delete this.waitingChunksToDisplay[chunkKey] } - // debugRecomputedDeletedObjects = 0 handleWorkerMessage (data: { geometry: MesherGeometryOutput, key, type }): void { if (data.type !== 'geometry') return - let object: THREE.Object3D = this.sectionObjects[data.key] - if (object) { - this.scene.remove(object) - disposeObject(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 + const chunkKey = chunkCoords[0] + ',' + chunkCoords[2] - // if (object) { - // this.debugRecomputedDeletedObjects++ - // } + const hasInstancedBlocks = data.geometry.instancedBlocks && Object.keys(data.geometry.instancedBlocks).length > 0 - 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.index = new THREE.BufferAttribute(data.geometry.indices as Uint32Array | Uint16Array, 1) + this.instancedRenderer?.removeSectionInstances(data.key) - 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) - // mesh with static dimensions: 16x16x16 - const staticChunkMesh = new THREE.Mesh(new THREE.BoxGeometry(16, 16, 16), new THREE.MeshBasicMaterial({ color: 0x00_00_00, transparent: true, opacity: 0 })) - staticChunkMesh.position.set(data.geometry.sx, data.geometry.sy, data.geometry.sz) - const boxHelper = new THREE.BoxHelper(staticChunkMesh, 0xff_ff_00) - boxHelper.name = 'helper' - object.add(boxHelper) - object.name = 'chunk'; - (object as any).tilesCount = data.geometry.positions.length / 3 / 4; - (object as any).blocksCount = data.geometry.blocksCount - if (!this.displayOptions.inWorldRenderingConfig.showChunkBorders) { - boxHelper.visible = false + // Handle instanced blocks data from worker + if (hasInstancedBlocks) { + this.instancedRenderer?.handleInstancedBlocksFromWorker(data.geometry.instancedBlocks, data.key, this.getInstancingMode(new Vec3(chunkCoords[0], chunkCoords[1], chunkCoords[2]))) } - // should not compute it once - if (Object.keys(data.geometry.signs).length) { - for (const [posKey, { isWall, isHanging, rotation }] of Object.entries(data.geometry.signs)) { - const signBlockEntity = this.blockEntities[posKey] - if (!signBlockEntity) continue - const [x, y, z] = posKey.split(',') - const sign = this.renderSign(new Vec3(+x, +y, +z), rotation, isWall, isHanging, nbt.simplify(signBlockEntity)) - if (!sign) continue - object.add(sign) - } + + // Check if chunk should be loaded and has geometry + if (!this.loadedChunks[chunkKey] || !data.geometry.positions.length || !this.active) { + // Release any existing section from the pool + this.chunkMeshManager.releaseSection(data.key) + return } - if (Object.keys(data.geometry.heads).length) { - for (const [posKey, { isWall, rotation }] of Object.entries(data.geometry.heads)) { - const headBlockEntity = this.blockEntities[posKey] - if (!headBlockEntity) continue - const [x, y, z] = posKey.split(',') - const head = this.renderHead(new Vec3(+x, +y, +z), rotation, isWall, nbt.simplify(headBlockEntity)) - if (!head) continue - object.add(head) - } + + // Use ChunkMeshManager for optimized mesh handling + const sectionObject = this.chunkMeshManager.updateSection(data.key, data.geometry) + + if (!sectionObject) { + return } - this.sectionObjects[data.key] = object + + + this.updateBoxHelper(data.key) + + // Handle chunk-based rendering if (this.displayOptions.inWorldRenderingConfig._renderByChunks) { - object.visible = false + sectionObject.visible = false const chunkKey = `${chunkCoords[0]},${chunkCoords[2]}` this.waitingChunksToDisplay[chunkKey] ??= [] this.waitingChunksToDisplay[chunkKey].push(data.key) if (this.finishedChunks[chunkKey]) { - // todo it might happen even when it was not an update this.finishChunk(chunkKey) } } this.updatePosDataChunk(data.key) - object.matrixAutoUpdate = false - mesh.onAfterRender = (renderer, scene, camera, geometry, material, group) => { - // mesh.matrixAutoUpdate = false - } - - this.scene.add(object) - } - - getSignTexture (position: Vec3, blockEntity, isHanging, 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, isHanging, 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 } getCameraPosition () { const worldPos = new THREE.Vector3() this.camera.getWorldPosition(worldPos) - return worldPos + // Add world offset to get true world position + return worldPos.add(this.worldOffset) } - getWorldCameraPosition () { + getSectionCameraPosition () { const pos = this.getCameraPosition() return new Vec3( Math.floor(pos.x / 16), @@ -463,7 +490,7 @@ export class WorldRendererThree extends WorldRendererCommon { } updateCameraSectionPos () { - const newSectionPos = this.getWorldCameraPosition() + const newSectionPos = this.getSectionCameraPosition() if (!this.cameraSectionPos.equals(newSectionPos)) { this.cameraSectionPos = newSectionPos this.cameraSectionPositionUpdate() @@ -473,6 +500,17 @@ export class WorldRendererThree extends WorldRendererCommon { setFirstPersonCamera (pos: Vec3 | null, yaw: number, pitch: number) { const yOffset = this.playerStateReactive.eyeHeight + if (pos) { + // Convert Vec3 to THREE.Vector3 + const worldPos = new THREE.Vector3(pos.x, pos.y + yOffset, pos.z) + + // Update world origin before updating camera + this.updateWorldOrigin(worldPos) + + // Keep camera at origin and move world instead + // this.cameraObject.position.set(pos.x, pos.y + yOffset, pos.z) + } + this.updateCamera(pos?.offset(0, yOffset, 0) ?? null, yaw, pitch) this.media.tryIntersectMedia() this.updateCameraSectionPos() @@ -517,7 +555,7 @@ export class WorldRendererThree extends WorldRendererCommon { raycaster.far = distance // Limit raycast distance // Filter to only nearby chunks for performance - const nearbyChunks = Object.values(this.sectionObjects) + const nearbyChunks = Object.values(this.chunkMeshManager.sectionObjects) .filter(obj => obj.name === 'chunk' && obj.visible) .filter(obj => { // Get the mesh child which has the actual geometry @@ -675,7 +713,7 @@ export class WorldRendererThree extends WorldRendererCommon { chunksRenderBelowOverride !== undefined || chunksRenderDistanceOverride !== undefined ) { - for (const [key, object] of Object.entries(this.sectionObjects)) { + for (const [key, object] of Object.entries(this.chunkMeshManager.sectionObjects)) { const [x, y, z] = key.split(',').map(Number) const isVisible = // eslint-disable-next-line no-constant-binary-expression, sonarjs/no-redundant-boolean @@ -687,10 +725,6 @@ export class WorldRendererThree extends WorldRendererCommon { object.visible = isVisible } - } else { - for (const object of Object.values(this.sectionObjects)) { - object.visible = true - } } } @@ -716,7 +750,7 @@ export class WorldRendererThree extends WorldRendererCommon { // eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style const cam = this.cameraGroupVr instanceof THREE.Group ? this.cameraGroupVr.children.find(child => child instanceof THREE.PerspectiveCamera) as THREE.PerspectiveCamera : this.camera - this.renderer.render(this.scene, cam) + this.renderer.render(this.realScene, cam) if ( this.displayOptions.inWorldRenderingConfig.showHand && @@ -725,14 +759,15 @@ export class WorldRendererThree extends WorldRendererCommon { // !this.freeFlyMode && !this.renderer.xr.isPresenting ) { - this.holdingBlock.render(this.camera, this.renderer, this.ambientLight, this.directionalLight) - this.holdingBlockLeft.render(this.camera, this.renderer, this.ambientLight, this.directionalLight) + this.holdingBlock?.render(this.renderer) + this.holdingBlockLeft?.render(this.renderer) } for (const fountain of this.fountains) { - if (this.sectionObjects[fountain.sectionId] && !this.sectionObjects[fountain.sectionId].foutain) { - fountain.createParticles(this.sectionObjects[fountain.sectionId]) - this.sectionObjects[fountain.sectionId].foutain = true + const sectionObject = this.chunkMeshManager.getSectionObject(fountain.sectionId) + if (sectionObject && !sectionObject.fountain) { + fountain.createParticles(sectionObject) + sectionObject.fountain = true } fountain.render() } @@ -742,87 +777,18 @@ export class WorldRendererThree extends WorldRendererCommon { } const end = performance.now() const totalTime = end - start + + if (this.worldRendererConfig.autoLowerRenderDistance) { + // Record render time for performance monitoring + this.chunkMeshManager.recordRenderTime(totalTime) + } + this.renderTimeAvgCount++ this.renderTimeAvg = ((this.renderTimeAvg * (this.renderTimeAvgCount - 1)) + totalTime) / this.renderTimeAvgCount this.renderTimeMax = Math.max(this.renderTimeMax, totalTime) this.currentRenderedFrames++ } - renderHead (position: Vec3, rotation: number, isWall: boolean, blockEntity) { - const textures = blockEntity.SkullOwner?.Properties?.textures[0] - if (!textures) return - - try { - const textureData = JSON.parse(Buffer.from(textures.Value, 'base64').toString()) - let skinUrl = textureData.textures?.SKIN?.url - const { skinTexturesProxy } = this.worldRendererConfig - if (skinTexturesProxy) { - skinUrl = skinUrl?.replace('http://textures.minecraft.net/', skinTexturesProxy) - .replace('https://textures.minecraft.net/', skinTexturesProxy) - } - - const mesh = getMesh(this, skinUrl, armorModel.head) - const group = new THREE.Group() - if (isWall) { - mesh.position.set(0, 0.3125, 0.3125) - } - // move head model down as armor have a different offset than blocks - mesh.position.y -= 23 / 16 - group.add(mesh) - group.position.set(position.x + 0.5, position.y + 0.045, position.z + 0.5) - group.rotation.set( - 0, - -THREE.MathUtils.degToRad(rotation * (isWall ? 90 : 45 / 2)), - 0 - ) - group.scale.set(0.8, 0.8, 0.8) - return group - } catch (err) { - console.error('Error decoding player texture:', err) - } - } - - renderSign (position: Vec3, rotation: number, isWall: boolean, isHanging: boolean, blockEntity) { - const tex = this.getSignTexture(position, blockEntity, isHanging) - - 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 - - const lineHeight = 7 / 16 - const scaleFactor = isHanging ? 1.3 : 1 - mesh.scale.set(1 * scaleFactor, lineHeight * scaleFactor, 1 * scaleFactor) - - const thickness = (isHanging ? 2 : 1.5) / 16 - const wallSpacing = 0.25 / 16 - if (isWall && !isHanging) { - mesh.position.set(0, 0, -0.5 + thickness + wallSpacing + 0.0001) - } else { - mesh.position.set(0, 0, thickness / 2 + 0.0001) - } - - const group = new THREE.Group() - group.rotation.set( - 0, - -THREE.MathUtils.degToRad(rotation * (isWall ? 90 : 45 / 2)), - 0 - ) - group.add(mesh) - const height = (isHanging ? 10 : 8) / 16 - const heightOffset = (isHanging ? 0 : isWall ? 4.333 : 9.333) / 16 - const textPosition = height / 2 + heightOffset - group.position.set(position.x + 0.5, position.y + textPosition, position.z + 0.5) - return group - } lightUpdate (chunkX: number, chunkZ: number) { // set all sections in the chunk dirty @@ -832,26 +798,27 @@ export class WorldRendererThree extends WorldRendererCommon { } rerenderAllChunks () { // todo not clear what to do with loading chunks - for (const key of Object.keys(this.sectionObjects)) { + for (const key of Object.keys(this.chunkMeshManager.sectionObjects)) { const [x, y, z] = key.split(',').map(Number) this.setSectionDirty(new Vec3(x, y, z)) } } - updateShowChunksBorder (value: boolean) { - for (const object of Object.values(this.sectionObjects)) { - for (const child of object.children) { - if (child.name === 'helper') { - child.visible = value - } - } + updateShowChunksBorder () { + for (const key of Object.keys(this.chunkMeshManager.sectionObjects)) { + this.updateBoxHelper(key) } } + updateBoxHelper (key: string) { + const { showChunkBorders } = this.worldRendererConfig + this.chunkMeshManager.updateBoxHelper(key, showChunkBorders, this.chunkBoxMaterial) + } + resetWorld () { super.resetWorld() - for (const mesh of Object.values(this.sectionObjects)) { + for (const mesh of Object.values(this.chunkMeshManager.sectionObjects)) { this.scene.remove(mesh) } @@ -868,7 +835,7 @@ export class WorldRendererThree extends WorldRendererCommon { getLoadedChunksRelative (pos: Vec3, includeY = false) { const [currentX, currentY, currentZ] = sectionPos(pos) - return Object.fromEntries(Object.entries(this.sectionObjects).map(([key, o]) => { + return Object.fromEntries(Object.entries(this.chunkMeshManager.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}` @@ -885,12 +852,13 @@ export class WorldRendererThree extends WorldRendererCommon { } readdChunks () { - for (const key of Object.keys(this.sectionObjects)) { - this.scene.remove(this.sectionObjects[key]) + const { sectionObjects } = this.chunkMeshManager + for (const key of Object.keys(sectionObjects)) { + this.scene.remove(sectionObjects[key]) } setTimeout(() => { - for (const key of Object.keys(this.sectionObjects)) { - this.scene.add(this.sectionObjects[key]) + for (const key of Object.keys(sectionObjects)) { + this.scene.add(sectionObjects[key]) } }, 500) } @@ -902,6 +870,11 @@ export class WorldRendererThree extends WorldRendererCommon { } } + removeCurrentChunk () { + const currentChunk = this.cameraSectionPos + this.removeColumn(currentChunk.x * 16, currentChunk.z * 16) + } + removeColumn (x, z) { super.removeColumn(x, z) @@ -909,19 +882,47 @@ export class WorldRendererThree extends WorldRendererCommon { for (let y = this.worldSizeParams.minY; y < this.worldSizeParams.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) - disposeObject(mesh) - } - delete this.sectionObjects[key] + + // Remove instanced blocks for this section + this.instancedRenderer?.removeSectionInstances(key) + + // Release section from mesh pool (this will also remove from scene) + this.chunkMeshManager.releaseSection(key) } } - setSectionDirty (...args: Parameters) { - const [pos] = args + getInstancingMode (pos: Vec3) { + const { useInstancedRendering, enableSingleColorMode, forceInstancedOnly, dynamicInstancing, dynamicInstancingModeDistance, dynamicColorModeDistance } = this.worldRendererConfig + let instancingMode = InstancingMode.None + + if (useInstancedRendering || enableSingleColorMode) { + instancingMode = enableSingleColorMode + ? InstancingMode.ColorOnly + : forceInstancedOnly + ? InstancingMode.BlockInstancingOnly + : InstancingMode.BlockInstancing + } else if (dynamicInstancing) { + const dx = pos.x / 16 - this.cameraSectionPos.x + const dz = pos.z / 16 - this.cameraSectionPos.z + const distance = Math.floor(Math.hypot(dx, dz)) + // console.log('distance', distance, `${pos.x},${pos.y},${pos.z}`) + if (distance > dynamicColorModeDistance) { + instancingMode = InstancingMode.ColorOnly + } else if (distance > dynamicInstancingModeDistance) { + instancingMode = InstancingMode.BlockInstancingOnly + } + } + + return instancingMode + } + + setSectionDirty (pos: Vec3, value = true) { this.cleanChunkTextures(pos.x, pos.z) // todo don't do this! - super.setSectionDirty(...args) + const instancingMode = this.getInstancingMode(pos) + super.setSectionDirty(pos, value, undefined, instancingMode) + if (value) { + this.sectionInstancingMode[`${pos.x},${pos.y},${pos.z}`] = instancingMode + } } static getRendererInfo (renderer: THREE.WebGLRenderer) { @@ -938,6 +939,8 @@ export class WorldRendererThree extends WorldRendererCommon { } destroy (): void { + this.instancedRenderer?.destroy() + this.chunkMeshManager.dispose() super.destroy() } @@ -950,7 +953,7 @@ export class WorldRendererThree extends WorldRendererCommon { const chunkKey = `${chunkX},${chunkZ}` const sectionKey = `${chunkX},${sectionY},${chunkZ}` - return !!this.finishedChunks[chunkKey] || !!this.sectionObjects[sectionKey] + return !!this.finishedChunks[chunkKey] || !!this.chunkMeshManager.sectionObjects[sectionKey] } updateSectionOffsets () { @@ -988,7 +991,7 @@ export class WorldRendererThree extends WorldRendererCommon { } // Apply the offset to the section object - const section = this.sectionObjects[key] + const section = this.chunkMeshManager.sectionObjects[key] if (section) { section.position.set( anim.currentOffsetX, diff --git a/src/controls.ts b/src/controls.ts index db6a6fc6..6754ffb1 100644 --- a/src/controls.ts +++ b/src/controls.ts @@ -504,7 +504,9 @@ const alwaysPressedHandledCommand = (command: Command) => { lockUrl() } if (command === 'communication.toggleMicrophone') { - toggleMicrophoneMuted?.() + if (typeof toggleMicrophoneMuted === 'function') { + toggleMicrophoneMuted() + } } } diff --git a/src/customChannels.ts b/src/customChannels.ts index 57c057d5..882c46d0 100644 --- a/src/customChannels.ts +++ b/src/customChannels.ts @@ -15,6 +15,28 @@ export default () => { registerMediaChannels() registerSectionAnimationChannels() registeredJeiChannel() + sendTokenChannel() + }) +} + +const sendTokenChannel = () => { + const CHANNEL_NAME = 'minecraft-web-client:token' + bot._client.registerChannel(CHANNEL_NAME, [ + 'container', + [ + { + name: 'token', + type: ['pstring', { countType: 'i16' }] + }, + { + name: 'userId', + type: ['pstring', { countType: 'i16' }] + }, + ] + ], true) + bot._client.writeChannel(CHANNEL_NAME, { + token: new URLSearchParams(window.location.search).get('token') ?? '', + userId: new URLSearchParams(window.location.search).get('userId') ?? '' }) } diff --git a/src/defaultOptions.ts b/src/defaultOptions.ts index 9650ffcd..7a5b62f5 100644 --- a/src/defaultOptions.ts +++ b/src/defaultOptions.ts @@ -43,6 +43,12 @@ export const defaultOptions = { starfieldRendering: true, enabledResourcepack: null as string | null, useVersionsTextures: 'latest', + // Instanced rendering options + useInstancedRendering: false, + autoLowerRenderDistance: false, + forceInstancedOnly: false, + instancedOnlyDistance: 6, + enableSingleColorMode: false, serverResourcePacks: 'prompt' as 'prompt' | 'always' | 'never', showHand: true, viewBobbing: true, diff --git a/src/optionsGuiScheme.tsx b/src/optionsGuiScheme.tsx index b03db37d..84cebb49 100644 --- a/src/optionsGuiScheme.tsx +++ b/src/optionsGuiScheme.tsx @@ -6,7 +6,7 @@ import { versionToNumber } from 'mc-assets/dist/utils' import { gameAdditionalState, miscUiState, openOptionsMenu, showModal } from './globalState' import { AppOptions, getChangedSettings, options, resetOptions } from './optionsStorage' import Button from './react/Button' -import { OptionMeta, OptionSlider } from './react/OptionsItems' +import { OptionButton, OptionMeta, OptionSlider } from './react/OptionsItems' import Slider from './react/Slider' import { getScreenRefreshRate } from './utils' import { setLoadingScreenStatus } from './appStatus' @@ -114,6 +114,52 @@ export const guiOptionsScheme: { text: 'Performance Debug', } }, + { + custom () { + let status = 'OFF' + const { useInstancedRendering, forceInstancedOnly, enableSingleColorMode } = useSnapshot(options) + if (useInstancedRendering) { + status = 'ON' + if (enableSingleColorMode) { + status = 'ON (single color)' + } else if (forceInstancedOnly) { + status = 'ON (force)' + } + } + + return { + // cycle + if (useInstancedRendering) { + if (enableSingleColorMode) { + options.useInstancedRendering = false + options.enableSingleColorMode = false + options.forceInstancedOnly = false + } else if (forceInstancedOnly) { + options.useInstancedRendering = true + options.enableSingleColorMode = true + options.forceInstancedOnly = false + } else { + options.useInstancedRendering = true + options.enableSingleColorMode = false + options.forceInstancedOnly = true + } + } else { + options.useInstancedRendering = true + options.enableSingleColorMode = false + options.forceInstancedOnly = false + } + }} + /> + }, + }, { custom () { const { _renderByChunks } = useSnapshot(options).rendererSharedOptions diff --git a/src/react/RendererDebugMenu.tsx b/src/react/RendererDebugMenu.tsx index f4bf7876..b116bca0 100644 --- a/src/react/RendererDebugMenu.tsx +++ b/src/react/RendererDebugMenu.tsx @@ -16,7 +16,7 @@ const RendererDebugMenu = ({ worldRenderer }: { worldRenderer: WorldRendererComm const { reactiveDebugParams } = worldRenderer const { chunksRenderAboveEnabled, chunksRenderBelowEnabled, chunksRenderDistanceEnabled, chunksRenderAboveOverride, chunksRenderBelowOverride, chunksRenderDistanceOverride, stopRendering, disableEntities } = useSnapshot(reactiveDebugParams) - const { rendererPerfDebugOverlay } = useSnapshot(options) + const { rendererPerfDebugOverlay, useInstancedRendering, forceInstancedOnly, instancedOnlyDistance, enableSingleColorMode } = useSnapshot(options) // Helper to round values to nearest step const roundToStep = (value: number, step: number) => Math.round(value / step) * step @@ -115,5 +115,36 @@ const RendererDebugMenu = ({ worldRenderer }: { worldRenderer: WorldRendererComm /> */} + +
+

Instanced Rendering

+
} diff --git a/src/watchOptions.ts b/src/watchOptions.ts index 478da4fb..c530f831 100644 --- a/src/watchOptions.ts +++ b/src/watchOptions.ts @@ -115,6 +115,18 @@ export const watchOptionsAfterViewerInit = () => { watchValue(options, o => { // appViewer.inWorldRenderingConfig.neighborChunkUpdates = o.neighborChunkUpdates }) + + watchValue(options, o => { + appViewer.inWorldRenderingConfig.autoLowerRenderDistance = o.autoLowerRenderDistance + }) + + // Instanced rendering options + watchValue(options, o => { + appViewer.inWorldRenderingConfig.useInstancedRendering = o.useInstancedRendering + appViewer.inWorldRenderingConfig.forceInstancedOnly = o.forceInstancedOnly + appViewer.inWorldRenderingConfig.instancedOnlyDistance = o.instancedOnlyDistance + appViewer.inWorldRenderingConfig.enableSingleColorMode = o.enableSingleColorMode + }) } export const watchOptionsAfterWorldViewInit = (worldView: WorldDataEmitter) => {