From 9853674a64daa9951b08f7605751e97b4d9ca20e Mon Sep 17 00:00:00 2001 From: Renaud Rohlinger Date: Sun, 3 Nov 2024 05:25:22 +0900 Subject: [PATCH] SceneOptimizer: Introduce `optimizer.toBatchedMesh()` (#29782) * BatchedMeshUtils: Introduce convertGLTFToBatchedMeshes() * improve debug * cleanup and fix wrong instance count * refactor to optimize original scene instead * fix * keep structure of graph, name and update the original scene instead * remove empty nodes post transform * Refactor to SceneOptimizer --- examples/jsm/utils/SceneOptimizer.js | 401 +++++++++++++++++++++++++++ 1 file changed, 401 insertions(+) create mode 100644 examples/jsm/utils/SceneOptimizer.js diff --git a/examples/jsm/utils/SceneOptimizer.js b/examples/jsm/utils/SceneOptimizer.js new file mode 100644 index 00000000000000..14e56c35a78977 --- /dev/null +++ b/examples/jsm/utils/SceneOptimizer.js @@ -0,0 +1,401 @@ +import * as THREE from 'three'; + +class SceneOptimizer { + + constructor( scene, options = {} ) { + + this.scene = scene; + this.debug = options.debug || false; + + } + + bufferToHash( buffer ) { + + let hash = 0; + if ( buffer.byteLength !== 0 ) { + + let uintArray; + if ( buffer.buffer ) { + + uintArray = new Uint8Array( + buffer.buffer, + buffer.byteOffset, + buffer.byteLength + ); + + } else { + + uintArray = new Uint8Array( buffer ); + + } + + for ( let i = 0; i < buffer.byteLength; i ++ ) { + + const byte = uintArray[ i ]; + hash = ( hash << 5 ) - hash + byte; + hash |= 0; + + } + + } + + return hash; + + } + + getMaterialPropertiesHash( material ) { + + const mapProps = [ + 'alphaMap', + 'aoMap', + 'bumpMap', + 'displacementMap', + 'emissiveMap', + 'envMap', + 'lightMap', + 'metalnessMap', + 'normalMap', + 'roughnessMap', + ]; + + const mapHash = mapProps + .map( ( prop ) => { + + const map = material[ prop ]; + if ( ! map ) return 0; + return `${map.uuid}_${map.offset.x}_${map.offset.y}_${map.repeat.x}_${map.repeat.y}_${map.rotation}`; + + } ) + .join( '|' ); + + const physicalProps = [ + 'transparent', + 'opacity', + 'alphaTest', + 'alphaToCoverage', + 'side', + 'vertexColors', + 'visible', + 'blending', + 'wireframe', + 'flatShading', + 'premultipliedAlpha', + 'dithering', + 'toneMapped', + 'depthTest', + 'depthWrite', + 'metalness', + 'roughness', + 'clearcoat', + 'clearcoatRoughness', + 'sheen', + 'sheenRoughness', + 'transmission', + 'thickness', + 'attenuationDistance', + 'ior', + 'iridescence', + 'iridescenceIOR', + 'iridescenceThicknessRange', + 'reflectivity', + ] + .map( ( prop ) => { + + if ( typeof material[ prop ] === 'undefined' ) return 0; + if ( material[ prop ] === null ) return 0; + return material[ prop ].toString(); + + } ) + .join( '|' ); + + const emissiveHash = material.emissive ? material.emissive.getHexString() : 0; + const attenuationHash = material.attenuationColor + ? material.attenuationColor.getHexString() + : 0; + const sheenColorHash = material.sheenColor + ? material.sheenColor.getHexString() + : 0; + + return [ + material.type, + physicalProps, + mapHash, + emissiveHash, + attenuationHash, + sheenColorHash, + ].join( '_' ); + + } + + getAttributesSignature( geometry ) { + + return Object.keys( geometry.attributes ) + .sort() + .map( ( name ) => { + + const attribute = geometry.attributes[ name ]; + return `${name}_${attribute.itemSize}_${attribute.normalized}`; + + } ) + .join( '|' ); + + } + + getGeometryHash( geometry ) { + + const indexHash = geometry.index + ? this.bufferToHash( geometry.index.array ) + : 'noIndex'; + const positionHash = this.bufferToHash( geometry.attributes.position.array ); + const attributesSignature = this.getAttributesSignature( geometry ); + return `${indexHash}_${positionHash}_${attributesSignature}`; + + } + + getBatchKey( materialProps, attributesSignature ) { + + return `${materialProps}_${attributesSignature}`; + + } + + analyzeModel() { + + const batchGroups = new Map(); + const singleGroups = new Map(); + const uniqueGeometries = new Set(); + + this.scene.updateMatrixWorld( true ); + this.scene.traverse( ( node ) => { + + if ( ! node.isMesh ) return; + + const materialProps = this.getMaterialPropertiesHash( node.material ); + const attributesSignature = this.getAttributesSignature( node.geometry ); + const batchKey = this.getBatchKey( materialProps, attributesSignature ); + const geometryHash = this.getGeometryHash( node.geometry ); + uniqueGeometries.add( geometryHash ); + + if ( ! batchGroups.has( batchKey ) ) { + + batchGroups.set( batchKey, { + meshes: [], + geometryStats: new Map(), + totalInstances: 0, + materialProps: node.material.clone(), + } ); + + } + + const group = batchGroups.get( batchKey ); + group.meshes.push( node ); + group.totalInstances ++; + + if ( ! group.geometryStats.has( geometryHash ) ) { + + group.geometryStats.set( geometryHash, { + count: 0, + vertices: node.geometry.attributes.position.count, + indices: node.geometry.index ? node.geometry.index.count : 0, + geometry: node.geometry, + } ); + + } + + group.geometryStats.get( geometryHash ).count ++; + + } ); + + // Move single instance groups to singleGroups + for ( const [ batchKey, group ] of batchGroups ) { + + if ( group.totalInstances === 1 ) { + + singleGroups.set( batchKey, group ); + batchGroups.delete( batchKey ); + + } + + } + + return { batchGroups, singleGroups, uniqueGeometries: uniqueGeometries.size }; + + } + + createBatchedMeshes( batchGroups ) { + + const meshesToRemove = new Set(); + + for ( const [ , group ] of batchGroups ) { + + const maxGeometries = group.totalInstances; + const maxVertices = Array.from( group.geometryStats.values() ).reduce( + ( sum, stats ) => sum + stats.vertices, + 0 + ); + const maxIndices = Array.from( group.geometryStats.values() ).reduce( + ( sum, stats ) => sum + stats.indices, + 0 + ); + + const batchedMaterial = new THREE.MeshPhysicalMaterial( group.materialProps ); + const batchedMesh = new THREE.BatchedMesh( + maxGeometries, + maxVertices, + maxIndices, + batchedMaterial + ); + + const referenceMesh = group.meshes[ 0 ]; + batchedMesh.name = `${referenceMesh.name}_batch`; + + const geometryIds = new Map(); + const inverseParentMatrix = new THREE.Matrix4(); + + if ( referenceMesh.parent ) { + + referenceMesh.parent.updateWorldMatrix( true, false ); + inverseParentMatrix.copy( referenceMesh.parent.matrixWorld ).invert(); + + } + + for ( const mesh of group.meshes ) { + + const geometryHash = this.getGeometryHash( mesh.geometry ); + + if ( ! geometryIds.has( geometryHash ) ) { + + geometryIds.set( geometryHash, batchedMesh.addGeometry( mesh.geometry ) ); + + } + + const geometryId = geometryIds.get( geometryHash ); + const instanceId = batchedMesh.addInstance( geometryId ); + + const localMatrix = new THREE.Matrix4(); + mesh.updateWorldMatrix( true, false ); + localMatrix.copy( mesh.matrixWorld ); + if ( referenceMesh.parent ) { + + localMatrix.premultiply( inverseParentMatrix ); + + } + + batchedMesh.setMatrixAt( instanceId, localMatrix ); + batchedMesh.setColorAt( instanceId, mesh.material.color ); + + meshesToRemove.add( mesh ); + + } + + if ( referenceMesh.parent ) { + + referenceMesh.parent.add( batchedMesh ); + + } + + } + + return meshesToRemove; + + } + + removeEmptyNodes( object ) { + + const children = [ ...object.children ]; + + for ( const child of children ) { + + this.removeEmptyNodes( child ); + + if ( ( child instanceof THREE.Group || child.constructor === THREE.Object3D ) + && child.children.length === 0 ) { + + object.remove( child ); + + } + + } + + } + + disposeMeshes( meshesToRemove ) { + + meshesToRemove.forEach( ( mesh ) => { + + if ( mesh.parent ) { + + mesh.parent.remove( mesh ); + + } + + if ( mesh.geometry ) mesh.geometry.dispose(); + if ( mesh.material ) { + + if ( Array.isArray( mesh.material ) ) { + + mesh.material.forEach( ( m ) => m.dispose() ); + + } else { + + mesh.material.dispose(); + + } + + } + + } ); + + } + + logDebugInfo( stats ) { + + console.group( 'Scene Optimization Results' ); + console.log( `Original meshes: ${stats.originalMeshes}` ); + console.log( `Batched into: ${stats.batchedMeshes} BatchedMesh` ); + console.log( `Single meshes: ${stats.singleMeshes} Mesh` ); + console.log( `Total draw calls: ${stats.drawCalls}` ); + console.log( `Reduction Ratio: ${stats.reductionRatio}% fewer draw calls` ); + console.groupEnd(); + + } + + toBatchedMesh() { + + const { batchGroups, singleGroups, uniqueGeometries } = this.analyzeModel(); + const meshesToRemove = this.createBatchedMeshes( batchGroups ); + + this.disposeMeshes( meshesToRemove ); + this.removeEmptyNodes( this.scene ); + + if ( this.debug ) { + + const totalOriginalMeshes = meshesToRemove.size + singleGroups.size; + const totalFinalMeshes = batchGroups.size + singleGroups.size; + + const stats = { + originalMeshes: totalOriginalMeshes, + batchedMeshes: batchGroups.size, + singleMeshes: singleGroups.size, + drawCalls: totalFinalMeshes, + uniqueGeometries: uniqueGeometries, + reductionRatio: ( ( 1 - totalFinalMeshes / totalOriginalMeshes ) * 100 ).toFixed( 1 ), + }; + + this.logDebugInfo( stats ); + + } + + return this.scene; + + } + + // Placeholder for future implementation + toInstancingMesh() { + + throw new Error( 'InstancedMesh optimization not implemented yet' ); + + } + +} + +export { SceneOptimizer };