pages235/renderer/viewer/three/chunkMeshManager.ts

743 lines
24 KiB
TypeScript

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<THREE.BufferGeometry, THREE.MeshLambertMaterial>
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<string, ChunkMeshPool>()
readonly sectionObjects: Record<string, SectionObject> = {}
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<THREE.BufferGeometry, THREE.MeshLambertMaterial>
// 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<string, { [pos: string]: THREE.Texture }>()
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
}
}