From 9ee28ef62f8dfb5b1d806d249cb32c9b5142dbe3 Mon Sep 17 00:00:00 2001 From: Vitaly Turovsky Date: Sun, 20 Jul 2025 08:21:15 +0300 Subject: [PATCH] working geometry pool manager! --- renderer/viewer/lib/worldDataEmitter.ts | 4 +- renderer/viewer/lib/worldrendererCommon.ts | 1 - renderer/viewer/three/chunkMeshManager.ts | 298 ++++++++++++++++++++ renderer/viewer/three/worldrendererThree.ts | 158 ++++++----- 4 files changed, 390 insertions(+), 71 deletions(-) create mode 100644 renderer/viewer/three/chunkMeshManager.ts diff --git a/renderer/viewer/lib/worldDataEmitter.ts b/renderer/viewer/lib/worldDataEmitter.ts index e1ac2f24..c43c9353 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 this.allChunksFinished = true this.allLoadedIn ??= Date.now() - this.initialChunkLoadWasStartedIn! } - this.updateChunksStats() } changeHandSwingingState (isAnimationPlaying: boolean, isLeftHand: boolean): void { } diff --git a/renderer/viewer/three/chunkMeshManager.ts b/renderer/viewer/three/chunkMeshManager.ts new file mode 100644 index 00000000..3038eb9b --- /dev/null +++ b/renderer/viewer/three/chunkMeshManager.ts @@ -0,0 +1,298 @@ +import * as THREE from 'three' +import { MesherGeometryOutput } from '../lib/mesher/shared' + +export interface ChunkMeshPool { + mesh: THREE.Mesh + inUse: boolean + lastUsedTime: number + sectionKey?: string +} + +export class ChunkMeshManager { + private readonly meshPool: ChunkMeshPool[] = [] + private readonly activeSections = new Map() + private poolSize: number + private maxPoolSize: number + private minPoolSize: number + + // Performance tracking + private hits = 0 + private misses = 0 + + // Debug flag to bypass pooling + public bypassPooling = false + + constructor ( + 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.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): THREE.Mesh | null { + // Normal pooling mode + // Check if section already exists + 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}`) + return null + } + + this.activeSections.set(sectionKey, poolEntry) + poolEntry.sectionKey = sectionKey + } + + 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) + + // Use direct index assignment for better performance (like before) + 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( + new THREE.Vector3(-8, -8, -8), + new THREE.Vector3(8, 8, 8) + ) + 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 + + // Store metadata + ;(mesh as any).tilesCount = geometryData.positions.length / 3 / 4 + ;(mesh as any).blocksCount = geometryData.blocksCount + + poolEntry.lastUsedTime = performance.now() + + return mesh + } + + /** + * Release a section and return its mesh to the pool + */ + releaseSection (sectionKey: string): boolean { + 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) + + return true + } + + /** + * 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' + + return { + poolSize: this.poolSize, + activeCount: this.activeSections.size, + freeCount, + hitRate: `${hitRate}%`, + hits: this.hits, + misses: this.misses + } + } + + /** + * 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 + } + } +} diff --git a/renderer/viewer/three/worldrendererThree.ts b/renderer/viewer/three/worldrendererThree.ts index ce21b1de..f3921f45 100644 --- a/renderer/viewer/three/worldrendererThree.ts +++ b/renderer/viewer/three/worldrendererThree.ts @@ -7,6 +7,7 @@ import { renderSign } from '../sign-renderer' import { DisplayWorldOptions, GraphicsInitOptions } from '../../../src/appViewer' import { chunkPos, sectionPos } from '../lib/simpleUtils' import { WorldRendererCommon } from '../lib/worldrendererCommon' +import { WorldDataEmitterWorker } from '../lib/worldDataEmitter' import { addNewStat } from '../lib/ui/newStats' import { MesherGeometryOutput, InstancingMode } from '../lib/mesher/shared' import { ItemSpecificContextProperties } from '../lib/basePlayerState' @@ -25,6 +26,7 @@ import { CameraShake } from './cameraShake' import { ThreeJsMedia } from './threeJsMedia' import { Fountain } from './threeJsParticles' import { InstancedRenderer } from './instancedRenderer' +import { ChunkMeshManager } from './chunkMeshManager' type SectionKey = string @@ -53,6 +55,7 @@ export class WorldRendererThree extends WorldRendererCommon { cameraContainer: THREE.Object3D media: ThreeJsMedia instancedRenderer: InstancedRenderer | undefined + chunkMeshManager: ChunkMeshManager waitingChunksToDisplay = {} as { [chunkKey: string]: SectionKey[] } camera: THREE.PerspectiveCamera renderTimeAvg = 0 @@ -106,6 +109,14 @@ 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) + + // 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), // }) @@ -137,6 +148,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: { @@ -346,8 +366,11 @@ export class WorldRendererThree extends WorldRendererCommon { text += `B: ${formatBigNumber(this.blocksRendered)} ` if (instancedStats) { text += `I: ${formatBigNumber(instancedStats.totalInstances)}/${instancedStats.activeBlockTypes}t ` - text += `DC: ${formatBigNumber(instancedStats.drawCalls)}` + 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 } @@ -361,7 +384,7 @@ 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')! + const section = this.sectionObjects[key].mesh! section.renderOrder = 500 - chunkDistance } @@ -402,7 +425,6 @@ export class WorldRendererThree extends WorldRendererCommon { delete this.waitingChunksToDisplay[chunkKey] } - // debugRecomputedDeletedObjects = 0 handleWorkerMessage (data: { geometry: MesherGeometryOutput, key, type }): void { if (data.type !== 'geometry') return @@ -413,55 +435,74 @@ export class WorldRendererThree extends WorldRendererCommon { this.instancedRenderer?.removeSectionInstances(data.key) - // if (data.key === '48,64,32') { - // console.log('handleWorkerMessage', data.key, this.sectionObjects[data.key], this.sectionInstancingMode[data.key], Object.keys(data.geometry.instancedBlocks).length, data.geometry.positions.length) - // } - // 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]))) } + // 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 + } + + // remvoe object from scene let object = this.sectionObjects[data.key] if (object) { this.scene.remove(object) - disposeObject(object) + // disposeObject(object) delete this.sectionObjects[data.key] } - object = this.sectionObjects[data.key] + // Use ChunkMeshManager for optimized mesh handling + const mesh = this.chunkMeshManager.updateSection(data.key, data.geometry) - if (!this.loadedChunks[chunkKey] || !data.geometry.positions.length || !this.active) return + if (!mesh) { + console.warn(`Failed to get mesh for section ${data.key}`) + return + } - // if (object) { - // this.debugRecomputedDeletedObjects++ + // 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 + const chunkKey = `${chunkCoords[0]},${chunkCoords[2]}` + this.waitingChunksToDisplay[chunkKey] ??= [] + this.waitingChunksToDisplay[chunkKey].push(data.key) + if (this.finishedChunks[chunkKey]) { + this.finishChunk(chunkKey) + } + } + + 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) // } - const geometry = object?.mesh?.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) - const { sx, sy, sz } = data.geometry - - // Set bounding box for the 16x16x16 section - geometry.boundingBox = new THREE.Box3(new THREE.Vector3(-8, -8, -8), new THREE.Vector3(8, 8, 8)) - geometry.boundingSphere = new THREE.Sphere(new THREE.Vector3(0, 0, 0), Math.sqrt(3 * 8 ** 2)) - - const mesh = object?.mesh ?? new THREE.Mesh(geometry, this.material) - mesh.position.set(data.geometry.sx, data.geometry.sy, data.geometry.sz) - mesh.name = 'mesh' - if (!object) { - object = new THREE.Group() - object.add(mesh) - } - (object as any).tilesCount = data.geometry.positions.length / 3 / 4; - (object as any).blocksCount = data.geometry.blocksCount - - // should not compute it once - if (Object.keys(data.geometry.signs).length) { - for (const [posKey, { isWall, isHanging, rotation }] of Object.entries(data.geometry.signs)) { + // 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(',') @@ -470,8 +511,10 @@ export class WorldRendererThree extends WorldRendererCommon { object.add(sign) } } - if (Object.keys(data.geometry.heads).length) { - for (const [posKey, { isWall, rotation }] of Object.entries(data.geometry.heads)) { + + // 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(',') @@ -480,31 +523,6 @@ export class WorldRendererThree extends WorldRendererCommon { object.add(head) } } - - if (!this.sectionObjects[data.key]) { - this.sectionObjects[data.key] = object - this.sectionObjects[data.key].mesh = mesh - this.scene.add(object) - } - - this.updateBoxHelper(data.key) - - if (this.displayOptions.inWorldRenderingConfig._renderByChunks) { - object.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 - } } getSignTexture (position: Vec3, blockEntity, backSide = false) { @@ -1031,10 +1049,13 @@ export class WorldRendererThree extends WorldRendererCommon { // Remove instanced blocks for this section this.instancedRenderer?.removeSectionInstances(key) - const mesh = this.sectionObjects[key] - if (mesh) { - this.scene.remove(mesh) - disposeObject(mesh) + // Release section from mesh pool + this.chunkMeshManager.releaseSection(key) + + const object = this.sectionObjects[key] + if (object) { + this.scene.remove(object) + disposeObject(object) } delete this.sectionObjects[key] } @@ -1089,6 +1110,7 @@ export class WorldRendererThree extends WorldRendererCommon { destroy (): void { this.instancedRenderer?.destroy() + this.chunkMeshManager.dispose() super.destroy() }