459 lines
10 KiB
JavaScript
459 lines
10 KiB
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 };
|