diff --git a/experiments/three.ts b/experiments/three.ts index 9b158dec..63e08d9e 100644 --- a/experiments/three.ts +++ b/experiments/three.ts @@ -1,5 +1,5 @@ import * as THREE from 'three' -import { loadThreeJsTextureFromBitmap } from '../renderer/viewer/lib/utils/skins' +import globalTexture from 'mc-assets/dist/blocksAtlasLegacy.png' // Create scene, camera and renderer const scene = new THREE.Scene() @@ -9,53 +9,193 @@ 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) +// Create shared material that will be used by all blocks +const sharedMaterial = new THREE.MeshLambertMaterial({ + vertexColors: true, + transparent: true, + alphaTest: 0.1 +}) -// 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) +function createCustomGeometry(textureInfo: { u: number, v: number, su: number, sv: number }): THREE.BufferGeometry { + // Create custom geometry with specific UV coordinates for this block type + 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 + + console.log('Original UVs:', Array.from(uvs)) + console.log('Texture info:', textureInfo) + + // BoxGeometry has 6 faces, each with 2 triangles (4 vertices), so 24 UV pairs total + // Apply the same texture to all faces for simplicity + for (let i = 0; i < uvs.length; i += 2) { + const u = uvs[i] + const v = uvs[i + 1] + + // Map from 0-1 to the specific texture region in the atlas + uvs[i] = textureInfo.u + u * textureInfo.su + uvs[i + 1] = textureInfo.v + v * textureInfo.sv + } + + console.log('Modified UVs:', Array.from(uvs)) + uvAttribute.needsUpdate = true + return geometry +} + +let currentInstancedMesh: THREE.InstancedMesh | null = null +let currentRefCube: THREE.Mesh | null = null + +async function createInstancedBlock() { + try { + // Clean up previous meshes if they exist + if (currentInstancedMesh) { + scene.remove(currentInstancedMesh) + currentInstancedMesh.geometry.dispose() + } + if (currentRefCube) { + scene.remove(currentRefCube) + currentRefCube.geometry.dispose() + } + + // Load the blocks atlas texture + const textureLoader = new THREE.TextureLoader() + const texture = await new Promise((resolve, reject) => { + textureLoader.load( + globalTexture, + resolve, + undefined, + reject + ) + }) + + // 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 (first 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 custom geometry with proper UV mapping + const geometry = createCustomGeometry(textureInfo) + + // Create instanced mesh using shared material + currentInstancedMesh = new THREE.InstancedMesh(geometry, sharedMaterial, 1) + const matrix = new THREE.Matrix4() + matrix.setPosition(0.5, 0.5, 0.5) // Offset by +0.5 on each axis + currentInstancedMesh.setMatrixAt(0, matrix) + currentInstancedMesh.count = 1 + currentInstancedMesh.instanceMatrix.needsUpdate = true + scene.add(currentInstancedMesh) + + // Reference non-instanced cube using same material + currentRefCube = new THREE.Mesh(geometry, sharedMaterial) + currentRefCube.position.set(2.5, 0.5, 0.5) // Offset by +0.5 on each axis + scene.add(currentRefCube) + + console.log('Instanced block created successfully') + + } catch (error) { + console.error('Error creating instanced block:', error) + + // Fallback: create a colored cube + const geometry = new THREE.BoxGeometry(1, 1, 1) + const material = new THREE.MeshLambertMaterial({ color: 0xff0000 }) + currentRefCube = new THREE.Mesh(geometry, material) + scene.add(currentRefCube) + console.log('Created fallback colored cube') + } } -// Create the textured box -createTexturedBox() +// Create the instanced block +createInstancedBlock() -// 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 cube + 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/worldrendererCommon.ts b/renderer/viewer/lib/worldrendererCommon.ts index 2ac7af86..7db0acc3 100644 --- a/renderer/viewer/lib/worldrendererCommon.ts +++ b/renderer/viewer/lib/worldrendererCommon.ts @@ -65,6 +65,7 @@ export const defaultWorldRendererConfig = { dynamicColorModeDistance: 1, // chunks beyond this distance use color mode only instancedOnlyDistance: 6, // chunks beyond this distance use instancing only enableSingleColorMode: false, // ultra-performance mode with solid colors + autoLowerRenderDistance: false, } export type WorldRendererConfig = typeof defaultWorldRendererConfig diff --git a/renderer/viewer/three/chunkMeshManager.ts b/renderer/viewer/three/chunkMeshManager.ts index 3038eb9b..a5ac58a6 100644 --- a/renderer/viewer/three/chunkMeshManager.ts +++ b/renderer/viewer/three/chunkMeshManager.ts @@ -1,5 +1,14 @@ +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 @@ -8,12 +17,25 @@ export interface ChunkMeshPool { 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 @@ -22,14 +44,30 @@ export class ChunkMeshManager { // 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.Scene, public material: THREE.Material, public worldHeight: number, viewDistance = 3, ) { this.updateViewDistance(viewDistance) - - console.log(`ChunkMeshManager: Initializing with pool size ${this.poolSize} (min: ${this.minPoolSize}, max: ${this.maxPoolSize})`) + this.signHeadsRenderer = new SignHeadsRenderer(worldRenderer) this.initializePool() } @@ -57,13 +95,16 @@ export class ChunkMeshManager { /** * Update or create a section with new geometry data */ - updateSection (sectionKey: string, geometryData: MesherGeometryOutput): THREE.Mesh | null { - // Normal pooling mode - // Check if section already exists - let poolEntry = this.activeSections.get(sectionKey) + 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) { - // Get mesh from pool poolEntry = this.acquireMesh() if (!poolEntry) { console.warn(`ChunkMeshManager: No available mesh in pool for section ${sectionKey}`) @@ -75,23 +116,22 @@ export class ChunkMeshManager { } const { mesh } = poolEntry - const { geometry } = mesh // Update geometry attributes efficiently - this.updateGeometryAttribute(geometry, 'position', geometryData.positions, 3) - this.updateGeometryAttribute(geometry, 'normal', geometryData.normals, 3) - this.updateGeometryAttribute(geometry, 'color', geometryData.colors, 3) - this.updateGeometryAttribute(geometry, 'uv', geometryData.uvs, 2) + 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) - geometry.index = new THREE.BufferAttribute(geometryData.indices as Uint32Array | Uint16Array, 1) + mesh.geometry.index = new THREE.BufferAttribute(geometryData.indices as Uint32Array | Uint16Array, 1) // Set bounding box and sphere for the 16x16x16 section - geometry.boundingBox = new THREE.Box3( + mesh.geometry.boundingBox = new THREE.Box3( new THREE.Vector3(-8, -8, -8), new THREE.Vector3(8, 8, 8) ) - geometry.boundingSphere = new THREE.Sphere( + mesh.geometry.boundingSphere = new THREE.Sphere( new THREE.Vector3(0, 0, 0), Math.sqrt(3 * 8 ** 2) ) @@ -100,20 +140,81 @@ export class ChunkMeshManager { mesh.position.set(geometryData.sx, geometryData.sy, geometryData.sz) mesh.updateMatrix() mesh.visible = true - - // Store metadata - ;(mesh as any).tilesCount = geometryData.positions.length / 3 / 4 - ;(mesh as any).blocksCount = geometryData.blocksCount + mesh.name = 'mesh' poolEntry.lastUsedTime = performance.now() - return mesh + // 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 + + // 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 + } + + // 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 @@ -130,9 +231,43 @@ export class ChunkMeshManager { 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 */ @@ -174,6 +309,7 @@ export class ChunkMeshManager { 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, @@ -181,7 +317,87 @@ export class ChunkMeshManager { freeCount, hitRate: `${hitRate}%`, hits: this.hits, - misses: this.misses + 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`, + } } } @@ -295,4 +511,229 @@ export class ChunkMeshManager { 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) + + 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, 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, 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/worldrendererThree.ts b/renderer/viewer/three/worldrendererThree.ts index f3921f45..24d09f6f 100644 --- a/renderer/viewer/three/worldrendererThree.ts +++ b/renderer/viewer/three/worldrendererThree.ts @@ -1,11 +1,8 @@ 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' @@ -15,8 +12,6 @@ import { getMyHand } from '../lib/hand' import { setBlockPosition } from '../lib/mesher/standaloneRenderer' import { loadThreeJsTextureFromBitmap } from '../lib/utils/skins' import HoldingBlock from './holdingBlock' -import { getMesh } from './entity/EntityMesh' -import { armorModel } from './entity/armorModels' import { disposeObject } from './threeJsUtils' import { CursorBlock } from './world/cursorBlock' import { getItemUv } from './appShared' @@ -32,7 +27,6 @@ type SectionKey = string export class WorldRendererThree extends WorldRendererCommon { outputFormat = 'threeJs' as const - sectionObjects: Record }> = {} sectionInstancingMode: Record = {} chunkTextures = new Map() signsCache = new Map() @@ -85,11 +79,11 @@ export class WorldRendererThree extends WorldRendererCommon { 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) { @@ -109,7 +103,7 @@ export class WorldRendererThree extends WorldRendererCommon { this.cameraShake = new CameraShake(this, this.onRender) this.media = new ThreeJsMedia(this) this.instancedRenderer = new InstancedRenderer(this) - this.chunkMeshManager = new ChunkMeshManager(this.material, this.worldSizeParams.worldHeight, this.viewDistance) + this.chunkMeshManager = new ChunkMeshManager(this, this.realScene, 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') { @@ -245,6 +239,12 @@ export class WorldRendererThree extends WorldRendererCommon { this.onReactiveConfigUpdated('showChunkBorders', (value) => { this.updateShowChunksBorder() }) + this.onReactiveConfigUpdated('enableDebugOverlay', (value) => { + if (!value) { + // restore visibility + this.chunkMeshManager.updateSectionsVisibility() + } + }) } changeHandSwingingState (isAnimationPlaying: boolean, isLeft = false) { @@ -384,8 +384,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].mesh! - section.renderOrder = 500 - chunkDistance + const sectionObject = this.chunkMeshManager.getSectionObject(key)! + sectionObject.renderOrder = 500 - chunkDistance } override updateViewerPosition (pos: Vec3): void { @@ -395,8 +395,8 @@ export class WorldRendererThree extends WorldRendererCommon { cameraSectionPositionUpdate () { // eslint-disable-next-line guard-for-in for (const key in this.sectionInstancingMode) { - const object = this.sectionObjects[key] - if (object) { + const sectionObject = this.chunkMeshManager.getSectionObject(key)! + if (sectionObject) { this.updatePosDataChunk(key) } @@ -420,7 +420,10 @@ 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] } @@ -447,39 +450,19 @@ export class WorldRendererThree extends WorldRendererCommon { return } - // remvoe object from scene - let object = this.sectionObjects[data.key] - if (object) { - this.scene.remove(object) - // disposeObject(object) - delete this.sectionObjects[data.key] - } - // Use ChunkMeshManager for optimized mesh handling - const mesh = this.chunkMeshManager.updateSection(data.key, data.geometry) + const sectionObject = this.chunkMeshManager.updateSection(data.key, data.geometry) - if (!mesh) { - console.warn(`Failed to get mesh for section ${data.key}`) + if (!sectionObject) { return } - // Create or update the section object container - object = new THREE.Group() - this.sectionObjects[data.key] = object - this.scene.add(object) - - // Add the pooled mesh to the container - object.add(mesh) - this.sectionObjects[data.key].mesh = mesh as THREE.Mesh - - // Handle signs and heads (these are added to the container, not the pooled mesh) - this.addSignsAndHeads(object as THREE.Group, data.geometry) 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) @@ -489,62 +472,6 @@ export class WorldRendererThree extends WorldRendererCommon { } this.updatePosDataChunk(data.key) - object.matrixAutoUpdate = false - } - - private addSignsAndHeads (object: THREE.Group, geometry: MesherGeometryOutput) { - // Clear existing signs and heads - // const childrenToRemove = object.children.filter(child => child.name !== 'mesh' && child.name !== 'helper') - // for (const child of childrenToRemove) { - // object.remove(child) - // disposeObject(child) - // } - - // Add signs - if (Object.keys(geometry.signs).length) { - for (const [posKey, { isWall, isHanging, rotation }] of Object.entries(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) - } - } - - // Add heads - if (Object.keys(geometry.heads).length) { - for (const [posKey, { isWall, rotation }] of Object.entries(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) - } - } - } - - getSignTexture (position: Vec3, blockEntity, backSide = false) { - const chunk = chunkPos(position) - let textures = this.chunkTextures.get(`${chunk[0]},${chunk[1]}`) - if (!textures) { - textures = {} - this.chunkTextures.set(`${chunk[0]},${chunk[1]}`, textures) - } - const texturekey = `${position.x},${position.y},${position.z}` - // todo investigate bug and remove this so don't need to clean in section dirty - if (textures[texturekey]) return textures[texturekey] - - const PrismarineChat = PrismarineChatLoader(this.version) - const canvas = renderSign(blockEntity, PrismarineChat) - if (!canvas) return - const tex = new THREE.Texture(canvas) - tex.magFilter = THREE.NearestFilter - tex.minFilter = THREE.NearestFilter - tex.needsUpdate = true - textures[texturekey] = tex - return tex } getCameraPosition () { @@ -629,7 +556,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 @@ -787,7 +714,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 @@ -799,10 +726,6 @@ export class WorldRendererThree extends WorldRendererCommon { object.visible = isVisible } - } else { - for (const object of Object.values(this.sectionObjects)) { - object.visible = true - } } } @@ -842,9 +765,10 @@ export class WorldRendererThree extends WorldRendererCommon { } 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() } @@ -854,87 +778,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) - - 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 @@ -944,45 +799,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 () { - for (const key of Object.keys(this.sectionObjects)) { + for (const key of Object.keys(this.chunkMeshManager.sectionObjects)) { this.updateBoxHelper(key) } } updateBoxHelper (key: string) { const { showChunkBorders } = this.worldRendererConfig - const section = this.sectionObjects[key] - if (!section) return - if (showChunkBorders) { - if (!section.boxHelper) { - // mesh with static dimensions: 16x16x16 - const staticChunkMesh = new THREE.Mesh(new THREE.BoxGeometry(16, 16, 16), this.chunkBoxMaterial) - staticChunkMesh.position.set(section.mesh!.position.x, section.mesh!.position.y, section.mesh!.position.z) - const boxHelper = new THREE.BoxHelper(staticChunkMesh, 0xff_ff_00) - boxHelper.name = 'helper' - // boxHelper.geometry.boundingSphere = section.mesh!.geometry.boundingSphere - section.add(boxHelper) - section.name = 'chunk' - section.boxHelper = boxHelper - } - - section.boxHelper.visible = true - } else if (section.boxHelper) { - section.boxHelper.visible = false - } + 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) } @@ -999,7 +836,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}` @@ -1016,12 +853,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) } @@ -1049,15 +887,8 @@ export class WorldRendererThree extends WorldRendererCommon { // Remove instanced blocks for this section this.instancedRenderer?.removeSectionInstances(key) - // Release section from mesh pool + // Release section from mesh pool (this will also remove from scene) this.chunkMeshManager.releaseSection(key) - - const object = this.sectionObjects[key] - if (object) { - this.scene.remove(object) - disposeObject(object) - } - delete this.sectionObjects[key] } } @@ -1123,7 +954,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 () { @@ -1161,7 +992,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 9430f9c1..34876664 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/defaultOptions.ts b/src/defaultOptions.ts index be5851b2..df2ff4fd 100644 --- a/src/defaultOptions.ts +++ b/src/defaultOptions.ts @@ -44,6 +44,7 @@ export const defaultOptions = { useVersionsTextures: 'latest', // Instanced rendering options useInstancedRendering: false, + autoLowerRenderDistance: false, forceInstancedOnly: false, instancedOnlyDistance: 6, enableSingleColorMode: false, diff --git a/src/watchOptions.ts b/src/watchOptions.ts index 8d1e46b9..c530f831 100644 --- a/src/watchOptions.ts +++ b/src/watchOptions.ts @@ -116,6 +116,10 @@ export const watchOptionsAfterViewerInit = () => { // appViewer.inWorldRenderingConfig.neighborChunkUpdates = o.neighborChunkUpdates }) + watchValue(options, o => { + appViewer.inWorldRenderingConfig.autoLowerRenderDistance = o.autoLowerRenderDistance + }) + // Instanced rendering options watchValue(options, o => { appViewer.inWorldRenderingConfig.useInstancedRendering = o.useInstancedRendering