three
Version:
JavaScript 3D library
459 lines (324 loc) • 10.3 kB
JavaScript
import * as THREE from 'three';
/**
* This class can be used to optimized scenes by converting
* individual meshes into {@link BatchedMesh}. This component
* is an experimental attempt to implement auto-batching in three.js.
*
* @three_import import { SceneOptimizer } from 'three/addons/utils/SceneOptimizer.js';
*/
class SceneOptimizer {
/**
* Constructs a new scene optimizer.
*
* @param {Scene} scene - The scene to optimize.
* @param {SceneOptimizer~Options} options - The configuration options.
*/
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 = [
'map',
'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 group.materialProps.constructor( group.materialProps );
if ( batchedMaterial.color !== undefined ) {
// Reset color to white, color will be set per instance
batchedMaterial.color.set( 1, 1, 1 );
}
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;
}
/**
* Removes empty nodes from all descendants of the given 3D object.
*
* @param {Object3D} object - The 3D object to process.
*/
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 );
}
}
}
/**
* Removes the given array of meshes from the scene.
*
* @param {Set<Mesh>} meshesToRemove - The meshes to remove.
*/
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();
}
/**
* Performs the auto-baching by identifying groups of meshes in the scene
* that can be represented as a single {@link BatchedMesh}. The method modifies
* the scene by adding instances of `BatchedMesh` and removing the now redundant
* individual meshes.
*
* @return {Scene} The optimized scene.
*/
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;
}
/**
* Performs the auto-instancing by identifying groups of meshes in the scene
* that can be represented as a single {@link InstancedMesh}. The method modifies
* the scene by adding instances of `InstancedMesh` and removing the now redundant
* individual meshes.
*
* This method is not yet implemented.
*
* @abstract
* @return {Scene} The optimized scene.
*/
toInstancingMesh() {
throw new Error( 'InstancedMesh optimization not implemented yet' );
}
}
/**
* Constructor options of `SceneOptimizer`.
*
* @typedef {Object} SceneOptimizer~Options
* @property {boolean} [debug=false] - Whether to enable debug mode or not.
**/
export { SceneOptimizer };