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 } }