three
Version:
JavaScript 3D library
5,572 lines • 190 kB
JavaScript
import {
StandardDrawingPipeline,
IndexNode,
RenderTarget,
DepthTexture,
Scene,
StorageBufferAttribute,
IndirectStorageBufferAttribute,
BufferGeometry,
Float32BufferAttribute,
Mesh,
Sphere,
Vector2,
Vector3,
Vector4,
Matrix4,
FrontSide,
BackSide,
NoBlending,
VSMShadowMap,
NodeMaterial,
MeshBasicNodeMaterial
} from 'three/webgpu';
import {
Fn, If, Loop, vec2, vec3, vec4, uvec4, int, uint, float, max, min, clamp, ceil, log2, select, dot, bool, distance, length, normalize, textureLoad, ivec2, mat4, sqrt,
storage, uniform, uniformArray, instanceIndex, vertexIndex, varying, context as contextNode, overrideNodes, Return,
modelWorldMatrix, modelWorldMatrixInverse, modelNormalMatrix, modelPosition, modelViewPosition, mat3,
positionLocal, positionPrevious, normalLocal, transformNormal, velocity, materialColor,
cameraViewMatrix, atomicAdd, atomicStore, atomicLoad, workgroupId, localId, floatBitsToUint, workgroupArray, workgroupBarrier, packHalf2x16, unpackHalf2x16
} from 'three/tsl';
import { createMeshlets, ready as clusterProcessingReady, MESHLET_SIZE, MAX_LODS } from '../utils/MeshletGeometryUtils.js';
// the offsets of the objects are stored as 32-bit floats in the batch table, which represent integers up to 2^24
const MAX_INSTANCES = 1 << 24;
const DEFAULT_MAX_BINDING_SIZE = 134217728;
// the growth of the buffers of the culling when it is created again
const CULLING_GROWTH = 1.5;
// the growth of the capacity of a batch when its objects exceed it
const BATCH_GROWTH = 1.25;
// the clusters of the visible objects are allocated every frame, the budget is a number of clusters per object
// and at least a minimum number of clusters. the clusters exceeding the budget are not drawn
const CLUSTERS_PER_OBJECT = 8;
const MIN_CLUSTER_BUDGET = 1 << 22;
// the work queue of a batch holds the visible clusters of its objects, a budget of clusters per object and at least
// the clusters of a number of objects. the counters of the culling are read every interval of projections, the
// budgets exceeded in the last frame grow by the growth of the culling
const QUEUE_ITEMS_PER_OBJECT = 4;
const QUEUE_MIN_OBJECTS = 64;
const COUNTER_READBACK_INTERVAL = 30;
// the occlusion culling is skipped while it culls less than this fraction of the visible clusters, the depth of its
// first phase would cost more than it saves. it's measured again in the last projections before each read of the counters
const OCCLUSION_MIN_CULLED = 0.3;
const OCCLUSION_PROBE_PROJECTIONS = 3;
const MAX_WORK_ITEMS = 1 << 20;
const MAX_WORKGROUPS = 65535;
// the culling of all batches of a render list: one thread per cluster of the visible objects, the parameters of the
// batches are rows of a table
const WORKGROUP_SIZE = 64;
// the rows of a batch in the table of the batches: [ object offset, object count, queue offset, queue capacity ],
// [ data offset, -, visibility offset, clusters per object ] and [ -, -, levels of detail, flags ]
const BATCH_STRIDE = 3;
// the rows of a geometry in the table of the geometries: [ center of the bounding sphere, radius ], [ levels of detail,
// first triangle in the geometry pool, first cluster, clusters of the most detailed level ] and the levels of detail
// [ first triangle, triangles, first cluster, error ]. the batches of a material draw objects of several geometries
const GEOMETRY_LOD_ROW = 2;
const GEOMETRY_STRIDE = GEOMETRY_LOD_ROW + MAX_LODS;
// the record of a slot stores its batch and its geometry
const GEOMETRY_SHIFT = 16;
const BATCH_MASK = ( 1 << GEOMETRY_SHIFT ) - 1;
const MAX_GEOMETRIES = BATCH_MASK;
// the selected level of an object, or CULLED. objects occluded as a whole and objects beyond the switch distance of
// the far material are flagged
const CULLED = 255;
const LOD_MASK = 255;
const OCCLUDED = 256;
const FAR = 512;
// the flags of a batch
const FLAG_CONE = 1;
const FLAG_FAR = 2;
const MAX_UNUSED_PROJECTIONS = 60;
// the shared attribute of the vertex pulling geometries, the number of vertices limits the hardware draws of the visibility buffer drawing pipeline
const PULLED_VERTICES = 65536 * 3;
// node material properties which change the geometry, the coverage or the output of the draw
const UNSUPPORTED_NODES = [
'vertexNode', 'geometryNode', 'depthNode', 'fragmentNode', 'outputNode',
'contextNode', 'backdropNode', 'backdropAlphaNode', 'alphaTestNode', 'castShadowNode', 'maskShadowNode'
];
const _size = /*@__PURE__*/ new Vector2();
const _vector4 = /*@__PURE__*/ new Vector4();
const _cameraPosition = /*@__PURE__*/ new Vector3();
// the distance range of objects which are not levels of LOD objects
const NO_LEVEL_DISTANCE = 3.4e38;
const NO_LEVEL_NEAR = Math.fround( - NO_LEVEL_DISTANCE );
const NO_LEVEL_FAR = Math.fround( NO_LEVEL_DISTANCE );
const _objectWorld = /*@__PURE__*/ new Float32Array( 16 );
const IDENTITY_MATRIX = /*@__PURE__*/ new Float32Array( [ 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1 ] );
const EMPTY_HISTORY = { transform: [], object: [] };
const EMPTY_NODES = [];
// the slots written by an upload: slots closer than the gap are uploaded in one range, too many ranges in one range
const RANGE_GAP = 32;
const MAX_UPDATE_RANGES = 64;
// the instance index of the draws, a distinct node from the instance index of materials, which is
// replaced by the index of the drawn instance of an instanced mesh
const drawInstanceIndex = /*@__PURE__*/ new IndexNode( IndexNode.INSTANCE );
// returns the components of a vec4 node used by an attribute of the given item size
const swizzle = ( node, itemSize ) => itemSize === 1 ? node.x : itemSize === 2 ? node.xy : itemSize === 3 ? node.xyz : node;
let _pulledPositionAttribute = null;
// hashes an id into a bright color
const hashColor = /*@__PURE__*/ Fn( ( [ id ] ) => {
const hash = uint( id ).mul( uint( 747796405 ) ).add( uint( 289559509 ) ).toVar();
hash.assign( hash.shiftRight( 16 ).bitXor( hash ).mul( uint( 277803737 ) ) );
hash.assign( hash.shiftRight( 16 ).bitXor( hash ) );
const r = float( hash.bitAnd( uint( 255 ) ) ).div( 255.0 );
const g = float( hash.shiftRight( 8 ).bitAnd( uint( 255 ) ) ).div( 255.0 );
const b = float( hash.shiftRight( 16 ).bitAnd( uint( 255 ) ) ).div( 255.0 );
return vec4( r.mul( 0.8 ).add( 0.2 ), g.mul( 0.8 ).add( 0.2 ), b.mul( 0.8 ).add( 0.2 ), 1.0 );
} );
/**
* Creates an unlit material which colors each cluster of each object differently.
*
* @private
* @param {Node<uint>} cluster - The cluster index.
* @param {Node<uint>} instance - The object index in the batch.
* @return {MeshBasicNodeMaterial} The debug material.
*/
function createDebugMaterial( cluster, instance ) {
const material = new MeshBasicNodeMaterial();
material.colorNode = hashColor( cluster.add( instance.mul( 7919 ) ) );
material.toneMapped = false;
material.fog = false;
return material;
}
/**
* Links a draw material property to its source. Assigning a pipeline-specific override detaches
* the property without changing the source material.
*
* @private
* @param {Material} material - The draw material.
* @param {Material} source - The source material.
* @param {string} key - The property name.
*/
function linkMaterialProperty( material, source, key ) {
Object.defineProperty( material, key, {
configurable: true,
enumerable: true,
get() {
return source[ key ];
},
set( value ) {
Object.defineProperty( this, key, { configurable: true, enumerable: true, writable: true, value } );
}
} );
}
/**
* Creates the node material that draws the merged objects. The material keeps its own identity,
* listeners and pipeline overrides, while reading live properties from the source material.
*
* @private
* @param {Material} source - The material of the merged objects.
* @param {Renderer} renderer - The renderer.
* @return {NodeMaterial} The node material.
*/
function createDrawMaterial( source, renderer ) {
const NodeMaterialClass = renderer.library.getMaterialNodeClass( source.type );
const material = source.isNodeMaterial === true ? source.clone() : new NodeMaterialClass();
for ( const key in source ) {
if ( key === '_listeners' || key === 'uuid' || key === 'version' || key === 'type' ) continue;
linkMaterialProperty( material, source, key );
}
return material;
}
/**
* Creates the material of a depth-only draw of objects with an alpha-tested material: the alpha of the
* material is computed like the alpha of the diffuse color, so the same fragments are discarded.
*
* @private
* @param {Material} source - The alpha-tested material.
* @return {MeshBasicNodeMaterial} The material.
*/
function createAlphaDepthMaterial( source ) {
const material = new MeshBasicNodeMaterial();
for ( const key of [ 'color', 'map', 'alphaMap', 'opacity', 'opacityNode', 'colorNode', 'alphaTest' ] ) {
if ( source[ key ] !== undefined ) linkMaterialProperty( material, source, key );
}
return material;
}
/**
* Returns `true` if objects with the given material can be drawn by the GPU-driven pipeline. The material
* must be opaque or alpha-tested and its nodes must not change the geometry, the coverage or the output of the draw.
*
* @private
* @param {Material} material - The material.
* @param {Renderer} renderer - The renderer.
* @return {boolean} Whether the material is supported or not.
*/
function isSupportedMaterial( material, renderer ) {
if ( material.isNodeMaterial !== true && renderer.library.getMaterialNodeClass( material.type ) === null ) return false;
if ( material.transparent === true || material.transmission > 0 || material.alphaHash === true || material.alphaToCoverage === true ) return false;
if ( material.side !== FrontSide || material.vertexColors === true || material.flatShading === true || material.wireframe === true ) return false;
if ( material.displacementMap ) return false;
if ( material.clippingPlanes !== null && material.clippingPlanes.length > 0 ) return false;
if ( material.depthTest === false || material.depthWrite === false || material.colorWrite === false || material.polygonOffset === true ) return false;
for ( const key in material ) {
const value = material[ key ];
if ( value !== null && typeof value === 'object' && value.isTexture === true && value.channel !== 0 ) return false;
}
if ( material.isNodeMaterial === true ) {
for ( const key of UNSUPPORTED_NODES ) {
if ( material[ key ] !== null && material[ key ] !== undefined ) return false;
}
}
return true;
}
/**
* Returns the accessors of matrices stored as four vec4 columns in a storage buffer of vec4 elements.
*
* @private
* @param {StorageBufferNode} buffer - The storage buffer.
* @param {number} offset - The element of the first matrix.
* @return {{element: function(Node<uint>): Node<mat4>, assign: function(Node<uint>, Node<mat4>): void}} The accessors.
*/
function createMatrices( buffer, offset ) {
const column = ( index, i ) => buffer.element( uint( index ).mul( 4 ).add( offset + i ) );
return {
element: ( index ) => mat4( column( index, 0 ), column( index, 1 ), column( index, 2 ), column( index, 3 ) ),
assign: ( index, matrix ) => {
for ( let i = 0; i < 4; i ++ ) column( index, i ).assign( matrix.element( i ) );
}
};
}
/**
* Returns the bounding sphere of the given geometry, computed if missing.
*
* @private
* @param {BufferGeometry} geometry - The geometry.
* @return {Sphere} The bounding sphere.
*/
function getBoundingSphere( geometry ) {
if ( geometry.boundingSphere === null ) geometry.computeBoundingSphere();
return geometry.boundingSphere;
}
/**
* Returns an attribute function for a context which provides attributes. Contexts must return the same node for the
* same attribute, so the node of each attribute is created once.
*
* @private
* @param {function(string, NodeBuilder): ?Node} getAttribute - Creates the node of an attribute, `null` if not provided.
* @return {function(string, NodeBuilder): ?Node} The attribute function of the context.
*/
function cacheAttributes( getAttribute ) {
const nodes = new Map();
return ( name, builder ) => {
if ( nodes.has( name ) === false ) nodes.set( name, getAttribute( name, builder ) );
return nodes.get( name );
};
}
/**
* Returns `true` if the given material has a position node, which deforms the positions of the objects.
*
* @private
* @param {Material} material - The material.
* @return {boolean} Whether the material deforms the positions or not.
*/
function isDeformedMaterial( material ) {
return material.isNodeMaterial === true && material.positionNode !== null && material.positionNode !== undefined;
}
// the margin of positions whose deformation can't be evaluated on the GPU, the objects are never culled
const UNBOUNDED_MARGIN = 1e20;
// the unit normals of a regular tetrahedron, their sum is zero
const TETRAHEDRON_NORMALS = [[ 1, 1, 1 ], [ 1, - 1, - 1 ], [ - 1, 1, - 1 ], [ - 1, - 1, 1 ]].map( ( v ) => vec3( ...v.map( ( c ) => c / Math.sqrt( 3 ) ) ) );
// rounds half floats up, their relative precision is 2^-11
const HALF_ROUND_UP = 1.001;
/**
* Evaluates a position node on the GPU and returns the affine transform which approximates it on the bounding sphere
* of the geometry: the node is evaluated at the center and at both ends of each axis, the central differences give the
* linear part. Affine deformations like translations, rotations and scales, of any distance, are represented exactly.
* The second differences measure the curvature of other deformations, they are returned as the margin of the bounds.
*
* The node is evaluated with the position of the geometry and the position transformed by the instance matrix, like
* the regular instancing of node materials. The normal is unknown at these positions: if the node reads it, the node
* is evaluated with the four unit normals of a regular tetrahedron per position. Their mean is the position, their
* spread bounds the displacement of any unit normal for nodes which are linear in the normal, like a displacement
* along the normal. Nodes which read other attributes or the vertex index can't be evaluated at arbitrary positions:
* the unbounded uniform is set while the node is built, and the objects are not culled.
*
* @private
* @param {Node<vec3>} positionNode - The position node of the material.
* @param {Node<mat4>} instanceMatrix - The instance matrix, identity for objects which are not instanced meshes.
* @param {Node<uint>} instance - The instance index of the material.
* @param {Node<vec4>} bounds - The bounding sphere of the geometry.
* @param {{unbounded: UniformNode<float>, normalSamples: UniformNode<int>}} state - Set while the node is built: whether
* the node can't be evaluated at arbitrary positions, and the number of normals per position.
* @return {{matrix: Node<mat4>, margin: Node<float>}} The affine transform in the space of the geometry and the margin.
*/
function createDeformation( positionNode, instanceMatrix, instance, bounds, { unbounded, normalSamples } ) {
const center = bounds.xyz.toVar();
const radius = max( bounds.w, 1e-6 ).toVar();
const point = vec3( 0 ).toVar();
const normal = vec3( 0, 1, 0 ).toVar();
const unsupported = ( value ) => () => {
unbounded.value = 1;
return value;
};
const sampleNormal = () => {
normalSamples.value = TETRAHEDRON_NORMALS.length;
return normal;
};
const getAttribute = cacheAttributes( ( name ) => {
if ( name === 'position' ) return point;
if ( name === 'normal' ) return sampleNormal();
return unsupported( vec4( 0 ) )();
} );
const deformed = contextNode( positionNode.overrideNodes( [
[ positionLocal, instanceMatrix.mul( vec4( point, 1.0 ) ).xyz ],
[ normalLocal, sampleNormal ],
[ instanceIndex, instance ],
[ vertexIndex, unsupported( uint( 0 ) ) ]
] ), { getAttribute } );
// the center, then the negative and the positive end of the x, y and z axes, the node is built once in the loops
const samples = [ vec3( 0 ).toVar(), vec3( 0 ).toVar(), vec3( 0 ).toVar(), vec3( 0 ).toVar(), vec3( 0 ).toVar(), vec3( 0 ).toVar(), vec3( 0 ).toVar() ];
const normalMargin = float( 0 ).toVar();
Loop( { start: 0, end: 7, type: 'int' }, ( { i } ) => {
const axis = i.sub( 1 ).div( 2 );
const direction = select( i.sub( 1 ).mod( 2 ).equal( 0 ), float( - 1 ), float( 1 ) );
const offset = vec3( select( axis.equal( 0 ), direction, 0.0 ), select( axis.equal( 1 ), direction, 0.0 ), select( axis.equal( 2 ), direction, 0.0 ) );
point.assign( center.add( select( i.equal( 0 ), vec3( 0 ), offset ).mul( radius ) ) );
// the positions of the normals relative to the first one, which keeps the sums precise far from the origin
const first = vec3( 0 ).toVar();
const sum = vec3( 0 ).toVar();
const sumSquared = float( 0 ).toVar();
Loop( { start: 0, end: normalSamples, type: 'int', name: 'n' }, ( { n } ) => {
normal.assign( select( n.equal( 1 ), TETRAHEDRON_NORMALS[ 1 ], select( n.equal( 2 ), TETRAHEDRON_NORMALS[ 2 ], select( n.equal( 3 ), TETRAHEDRON_NORMALS[ 3 ], TETRAHEDRON_NORMALS[ 0 ] ) ) ) );
const value = vec3( deformed ).toVar();
If( n.equal( 0 ), () => {
first.assign( value );
} );
const delta = value.sub( first );
sum.addAssign( delta );
sumSquared.addAssign( delta.dot( delta ) );
} );
// the mean is the position, the spread of the tetrahedron normals is 4/3 of the squared norm of the linear part
const count = float( normalSamples );
const mean = sum.div( count );
const value = first.add( mean ).toVar();
normalMargin.assign( max( normalMargin, sqrt( max( sumSquared.sub( mean.dot( mean ).mul( count ) ), 0.0 ).mul( 0.75 ) ) ) );
let branch = If( i.equal( 0 ), () => {
samples[ 0 ].assign( value );
} );
for ( let s = 1; s < 7; s ++ ) {
branch = branch.ElseIf( i.equal( s ), () => {
samples[ s ].assign( value );
} );
}
} );
const [ origin, negX, posX, negY, posY, negZ, posZ ] = samples;
const axisX = posX.sub( negX ).div( radius.mul( 2 ) );
const axisY = posY.sub( negY ).div( radius.mul( 2 ) );
const axisZ = posZ.sub( negZ ).div( radius.mul( 2 ) );
const translation = origin.sub( axisX.mul( center.x ) ).sub( axisY.mul( center.y ) ).sub( axisZ.mul( center.z ) );
const curvature = max( length( posX.add( negX ).mul( 0.5 ).sub( origin ) ), max( length( posY.add( negY ).mul( 0.5 ).sub( origin ) ), length( posZ.add( negZ ).mul( 0.5 ).sub( origin ) ) ) );
return {
matrix: mat4( vec4( axisX, 0 ), vec4( axisY, 0 ), vec4( axisZ, 0 ), vec4( translation, 1 ) ),
margin: select( unbounded.greaterThan( 0 ), float( UNBOUNDED_MARGIN ), curvature.add( normalMargin ) )
};
}
/**
* Returns the key of an entry of objects sharing geometry and material.
*
* @private
* @param {boolean} receiveShadow - Whether the objects receive shadows.
* @param {boolean} instanced - Whether the objects are instanced meshes.
* @param {?Material} farMaterial - The material beyond the switch distance of merged levels.
* @return {string} The key.
*/
function getEntryKey( receiveShadow, instanced, instanceColor, farMaterial ) {
return ( receiveShadow ? 1 : 0 ) + ( instanced ? 2 : 0 ) + ( instanceColor ? 4 : 0 ) + ( farMaterial !== null ? ':' + farMaterial.id : '' );
}
/**
* Returns `true` if the given object can be drawn by the GPU-driven pipeline. The instances of instanced
* meshes are drawn as objects of the batch.
*
* @private
* @param {Object3D} object - The 3D object.
* @param {BufferGeometry} geometry - The geometry.
* @param {?Object} group - The geometry group.
* @param {boolean} [allowEmpty=false] - Whether empty instanced meshes can be prepared.
* @return {boolean} Whether the object is supported or not.
*/
function isSupportedObject( object, geometry, group, allowEmpty = false ) {
if ( object.isMesh !== true || object.isSkinnedMesh === true || object.isBatchedMesh === true ) return false;
if ( object.occlusionTest === true ) return false;
if ( Array.isArray( object.material ) && group === null ) return false;
if ( group !== null ) {
const count = geometry.index !== null ? geometry.index.count : geometry.attributes.position?.count ?? 0;
if ( Number.isInteger( group.start ) === false || group.start < 0 || count - group.start < 3 || group.count < 3 || ( Number.isFinite( group.count ) === false && group.count !== Infinity ) ) return false;
}
if ( object.isInstancedMesh === true ) {
// the instances are drawn as objects of the batch. instance matrices and colors written on the GPU are not supported
if ( object.instanceMatrix.isStorageInstancedBufferAttribute === true || object.instanceColor?.isStorageInstancedBufferAttribute === true || object.morphTexture !== null || ( object.count === 0 && allowEmpty === false ) ) return false;
} else if ( object.count > 1 ) {
return false;
}
if ( object.morphTargetInfluences !== undefined && object.morphTargetInfluences.length > 0 ) return false;
if ( object.onBeforeRender !== Mesh.prototype.onBeforeRender || object.onAfterRender !== Mesh.prototype.onAfterRender ) return false;
// the rasterizer culls back faces by winding, mirrored objects would flip it
if ( object.matrixWorld.determinant() <= 0 ) return false;
const { position, normal } = geometry.attributes;
if ( position === undefined || normal === undefined || position.itemSize !== 3 ) return false;
if ( geometry.drawRange.start !== 0 || geometry.drawRange.count !== Infinity ) return false;
if ( Object.keys( geometry.morphAttributes ).length > 0 ) return false;
return true;
}
// the maximum number of levels of the hierarchical depth, enough for 65536 pixels
const MAX_HZB_LEVELS = 16;
/**
* Creates the occlusion data of a view: the depth of the clusters visible in the last frame and its
* hierarchical depth. Each level stores the maximum depth of 2x2 texels of the previous level, the first
* level covers 2x2 pixels. The levels are stored in one storage buffer.
*
* @private
* @param {number} width - The width of the view in pixels.
* @param {number} height - The height of the view in pixels.
* @return {Object} The occlusion data.
*/
function createOcclusion( width, height ) {
const depthTexture = new DepthTexture( width, height );
const target = new RenderTarget( width, height, { depthTexture, count: 0 } );
const levels = [];
let levelWidth = width;
let levelHeight = height;
let size = 0;
do {
levelWidth = Math.ceil( levelWidth / 2 );
levelHeight = Math.ceil( levelHeight / 2 );
levels.push( { offset: size, width: levelWidth, height: levelHeight } );
size += levelWidth * levelHeight;
} while ( levelWidth > 1 || levelHeight > 1 );
const hzbAttribute = new StorageBufferAttribute( new Float32Array( size ), 1 );
const hzbWrite = storage( hzbAttribute, 'float', size );
// the texels at the border cover the remaining pixels, so odd sizes stay conservative
const computeNodes = levels.map( ( level, i ) => {
const source = i > 0 ? levels[ i - 1 ] : { offset: 0, width, height };
return Fn( () => {
If( instanceIndex.lessThan( level.width * level.height ), () => {
const x = instanceIndex.mod( level.width );
const y = instanceIndex.div( level.width );
const depth = float( 0 ).toVar();
for ( let j = 0; j < 4; j ++ ) {
const sourceX = min( x.mul( 2 ).add( j & 1 ), uint( source.width - 1 ) );
const sourceY = min( y.mul( 2 ).add( j >> 1 ), uint( source.height - 1 ) );
const value = i === 0 ? textureLoad( depthTexture, ivec2( sourceX, sourceY ) ).x : hzbWrite.element( sourceY.mul( source.width ).add( sourceX ).add( source.offset ) );
depth.assign( max( depth, value ) );
}
hzbWrite.element( instanceIndex.add( level.offset ) ).assign( depth );
} );
} )().compute( level.width * level.height ).setName( 'MeshletDrawingPipeline HZB' );
} );
const levelData = [];
for ( let i = 0; i < MAX_HZB_LEVELS; i ++ ) {
const level = levels[ Math.min( i, levels.length - 1 ) ];
levelData.push( new Vector4( level.offset, level.width, level.height, 0 ) );
}
return {
target,
scene: new Scene(),
hzb: storage( hzbAttribute, 'float', size ).toReadOnly(),
hzbLevels: uniformArray( levelData, 'vec4' ),
hzbLevelCount: uniform( levels.length, 'uint' ),
hzbSize: uniform( new Vector2( width, height ) ),
computeNodes,
dispose() {
for ( const computeNode of computeNodes ) computeNode.dispose();
target.dispose();
depthTexture.dispose();
hzbAttribute.dispose();
}
};
}
/**
* The culling of all batches of one render list, in one set of compute dispatches. The objects, the clusters of
* their geometries, the work queues and the indirect draw arguments of all batches are stored in shared buffers,
* each batch owns a range of them. The parameters of the batches are stored in a table, so all batches share the
* compute shaders:
*
* - one thread per object culls the object, selects its level of detail and allocates the clusters of the level,
* - the clusters are dispatched indirectly, one thread per cluster culls the cluster and appends it to the work
* queue of its batch,
* - one thread per queue writes the indirect draw arguments of the batches.
*
* Occlusion culling adds the draw of the clusters visible in the last frame into the depth of the occlusion
* view, one thread per object tests its bounding sphere and the clusters are tested in a second phase.
*
* @private
*/
class MeshletCulling {
/**
* Constructs a new culling.
*
* @param {Object} capacity - The capacity of the shared buffers.
* @param {number} capacity.batches - The maximum number of batches.
* @param {number} capacity.objects - The maximum number of objects of all batches.
* @param {number} capacity.queue - The maximum number of work items of all batches.
* @param {number} capacity.clusters - The maximum number of clusters of the visible objects of all batches in a frame.
* @param {number} capacity.visibility - The maximum number of clusters of all objects, for the occlusion culling.
* @param {number} capacity.chunks - The maximum number of clusters of all geometries.
* @param {Object} parameters - The configuration parameter.
* @param {boolean} parameters.shadow - Whether the culling draws the shadow casters of a shadow pass.
* @param {?Object} parameters.occlusion - The occlusion data of the view, if the clusters are occlusion culled.
* @param {?Function} parameters.boundsNode - Returns the bounding sphere of the deformed positions.
* @param {?Object} parameters.view - The view of the render list.
*/
constructor( capacity, { shadow, occlusion, boundsNode, view } ) {
this.capacity = capacity;
this.shadow = shadow;
this.occlusion = occlusion;
this.boundsNode = boundsNode;
this.view = view;
this.batches = [];
this.attributes = [];
// the allocated ranges of the batches, see add()
this.objectEnd = 0;
this.dataEnd = 0;
this.queueEnd = 0;
this.visibilityEnd = 0;
this.chunkEnd = 0;
this.batchEnd = 0;
this.geometryEnd = 0;
this.geometryIndices = new WeakMap();
// the world matrices are followed by the matrices of the previous frame, for the velocity of temporal effects
const history = shadow === false;
this.history = history;
// the values depending on the capacity: the offset of the previous matrices, the offset of the table, the offset of
// the arguments of the occlusion prepass and the maximum number of clusters of the visible objects
this.historyOffset = uniform( 0, 'uint' );
this.tableOffset = uniform( 0, 'uint' );
this.geometryOffset = uniform( 0, 'uint' );
this.prepassArgsBase = uniform( 0, 'uint' );
this.occludedCounter = uniform( 0, 'uint' );
this.maxClusters = uniform( 0, 'uint' );
// the buffers are allocated for the capacity and replaced by resize(), the nodes reading them and the batches stay valid
this.allocate( capacity );
// the data of the objects: the world matrix, the bounding sphere ( w: the maximum scale, negative if the scale is
// not uniform ), the level of LOD objects ( the position of the LOD object, the distance range and the distance of
// the far material ) and the view projection of the world matrix. the batches of the groups of the same objects
// share the data of the objects, each batch has its own slots of the culling
const instanceWorld = storage( this.worldAttribute, 'mat4' ).toReadOnly();
const instanceBounds = storage( this.boundsAttribute, 'vec4' ).toReadOnly();
const instanceLevel = storage( this.levelAttribute, 'vec4' ).toReadOnly();
const instanceMvp = storage( this.mvpAttribute, 'mat4' );
// the world matrices and bounds written on the GPU, by the batches of instanced meshes
this.worldWrite = storage( this.worldAttribute, 'mat4' );
this.boundsWrite = storage( this.boundsAttribute, 'vec4' );
// the record of each slot: its first cluster, its level ( LOD_MASK, CULLED if the object is not visible ) with
// the OCCLUDED and FAR flags and its batch, its scale and the batch of the slot, written by add()
const instanceRecord = storage( this.recordAttribute, 'uvec4' );
this.instanceWorld = instanceWorld;
this.instanceBounds = instanceBounds;
this.instanceMvp = instanceMvp;
// the clusters of the geometries: bounding sphere and normal cone, followed by the batch table.
// sharing their storage buffer keeps the table independent of the uniform binding size without
// adding a storage binding to the cluster culling ( eight bindings with occlusion culling )
const chunkPool = storage( this.chunkAttribute, 'vec4' ).toReadOnly();
// the parameters of the batches, see add()
const row = ( batch, index ) => chunkPool.element( batch.mul( BATCH_STRIDE ).add( index ).add( this.tableOffset ) );
const rowBits = ( batch, index ) => floatBitsToUint( row( batch, index ) );
// the parameters of the geometries, see addGeometry()
const geometryRow = ( geometry, index ) => chunkPool.element( geometry.mul( GEOMETRY_STRIDE ).add( index ).add( this.geometryOffset ) );
const geometryRowBits = ( geometry, index ) => floatBitsToUint( geometryRow( geometry, index ) );
// the counters: the clusters, then the near and the far queue of each batch and the clusters culled by the occlusion
const counters = storage( this.countersAttribute, 'uint' ).toAtomic();
const countersRead = storage( this.countersAttribute, 'uint' ).toReadOnly();
this.countersRead = countersRead;
// the work queues of the batches — one item is a cluster of a visible object
const workQueue = storage( this.queueAttribute, 'uvec4' );
this.workQueue = workQueue;
// the object of each cluster of the visible objects
const clusterOwner = storage( this.clusterOwnerAttribute, 'uint' );
const maxClusters = this.maxClusters;
this.clusterDispatchAttribute = new IndirectStorageBufferAttribute( new Uint32Array( [ 0, 1, 1, 0 ] ), 4 );
const clusterDispatchAttribute = this.clusterDispatchAttribute;
const clusterDispatch = storage( clusterDispatchAttribute, 'uint', 4 );
// the indirect draw arguments of the queues of the batches, followed by the arguments of the occlusion prepass
const args = storage( this.argsAttribute, 'uint' );
// the visibility of the clusters of all objects in the last frame, for the occlusion culling
const clusterVisibility = occlusion !== null ? storage( this.visibilityAttribute, 'uint' ) : null;
this.bufferNodes = [
[ instanceWorld, 'worldAttribute' ], [ this.worldWrite, 'worldAttribute' ], [ instanceBounds, 'boundsAttribute' ], [ this.boundsWrite, 'boundsAttribute' ],
[ instanceLevel, 'levelAttribute' ], [ instanceMvp, 'mvpAttribute' ], [ instanceRecord, 'recordAttribute' ], [ chunkPool, 'chunkAttribute' ],
[ counters, 'countersAttribute' ], [ countersRead, 'countersAttribute' ], [ workQueue, 'queueAttribute' ], [ clusterOwner, 'clusterOwnerAttribute' ],
[ args, 'argsAttribute' ]
];
if ( clusterVisibility !== null ) this.bufferNodes.push( [ clusterVisibility, 'visibilityAttribute' ] );
// the view
this.projScreenMatrix = uniform( new Matrix4() );
this.frustumPlanes = uniformArray( [ new Vector4(), new Vector4(), new Vector4(), new Vector4(), new Vector4(), new Vector4() ], 'vec4' );
this.cameraPosition = uniform( new Vector3() );
this.cameraZoom = uniform( 1.0 );
this.viewSize = uniform( new Vector2() );
// the view selecting the level of detail, orthographic views select it independent of the distance
this.lodPosition = uniform( new Vector3() );
this.lodScale = uniform( 1.0 );
this.lodPerspective = uniform( 1.0 );
this.lodThreshold = uniform( 1.0 );
// the radius of objects on the screen in pixels below which they are not drawn, or drawn with their simplest level
this.minScreenRadius = uniform( 0.0 );
this.coarseScreenRadius = uniform( 0.0 );
const { projScreenMatrix, frustumPlanes, cameraPosition, cameraZoom, lodPosition, lodScale, lodPerspective, lodThreshold, minScreenRadius, coarseScreenRadius } = this;
// the bounding sphere of the drawn positions: the positions can be deformed by the render call, like a curved
// world, the culling tests the deformed bounds. the level of detail is selected by the real bounds
const deformBounds = ( center, radius ) => boundsNode !== null ? vec4( boundsNode( vec4( center, radius ), cameraPosition ) ).toVar() : vec4( center, radius );
const isOccluded = occlusion !== null ? createOcclusionTest( occlusion ) : null;
const computeReset = Fn( () => {
atomicStore( counters.element( instanceIndex ), uint( 0 ) );
} )().compute( this.countersAttribute.count ).setName( 'MeshletDrawingPipeline Reset' );
this.computeReset = computeReset;
// one thread per object: culling, level of detail and the clusters of the level. the clusters of a workgroup
// are allocated with one atomic operation, the visible objects would contend for the counter
this.objectEndNode = uniform( 0, 'uint' );
const objectEnd = this.objectEndNode;
const workgroupClusters = workgroupArray( 'uint', 1 ).toAtomic();
const workgroupBase = workgroupArray( 'uint', 1 );
const computeCull = Fn( () => {
const slot = instanceIndex;
const slotData = instanceRecord.element( slot ).w.toVar();
const batch = slotData.bitAnd( BATCH_MASK ).toVar();
const geometryIndex = slotData.shiftRight( GEOMETRY_SHIFT ).toVar();
const batchObjects = rowBits( batch, 0 );
const local = slot.sub( uint( batchObjects.x ) );
const lodLevel = uint( CULLED ).toVar();
const clusterCount = uint( 0 ).toVar();
const scaleBits = uint( 0 ).toVar();
// the scale of the object and the margin of its deformed positions for the cluster culling, as half floats rounded
// up. the binding of the bounds would exceed the storage buffers of the cluster culling with occlusion culling
If( slot.lessThan( objectEnd ).and( local.lessThan( uint( batchObjects.y ) ) ), () => {
const batchGeometry = row( batch, 2 );
const flags = uint( batchGeometry.w );
const geometryBounds = geometryRow( geometryIndex, 0 );
const geometryLevels = geometryRowBits( geometryIndex, 1 );
// the transform, the scale and the level of the object, shared by the batches of the groups of the object
const data = rowBits( batch, 1 ).x.add( local ).toVar();
const dataFar = instanceLevel.element( data.mul( 2 ).add( 1 ) );
const matrixWorld = instanceWorld.element( data ).toVar();
// the scale of the object and the margin of positions deformed by its position node, see MeshletBatch
const bounds = instanceBounds.element( data );
const center = matrixWorld.mul( vec4( geometryBounds.xyz, 1.0 ) ).xyz.toVar();
const scale = bounds.w.abs();
const radius = scale.mul( geometryBounds.w ).add( bounds.x );
const cullBounds = deformBounds( center, radius );
const visible = bool( true ).toVar();
Loop( { start: 0, end: 6 }, ( { i } ) => {
const plane = frustumPlanes.element( i );
If( dot( plane.xyz, cullBounds.xyz ).add( plane.w ).lessThan( cullBounds.w.negate() ), () => {
visible.assign( false );
} );
} );
// the level of a LOD object is selected by the distance of the camera to the LOD object, like LOD.update()
const levelData = instanceLevel.element( data.mul( 2 ) );
const levelDistance = distance( cameraPosition, levelData.xyz ).div( cameraZoom ).toVar();
If( levelDistance.lessThan( levelData.w ).or( levelDistance.greaterThanEqual( dataFar.x ) ), () => {
visible.assign( false );
} );
// screen-space projected error: pixelError = errorWorld / dist * cotHalfFov * screenHeight / 2,
// orthographic views: pixelError = errorWorld * 2 / frustumHeight * screenHeight / 2
const lodDistance = select( lodPerspective.greaterThan( 0.5 ), max( 0.01, distance( lodPosition, center ) ), float( 1.0 ) );
const pixelFactor = lodScale.div( lodDistance ).toVar();
// the radius of the object on the screen in pixels, the objects smaller than the minimum are not drawn
const screenRadius = scale.mul( geometryBounds.w ).mul( pixelFactor ).toVar();
If( screenRadius.lessThan( minScreenRadius ), () => {
visible.assign( false );
} );
If( visible, () => {
// the errors grow with the level, so the last level within the threshold is the simplest acceptable one
const lodCount = min( geometryLevels.x, uint( batchGeometry.z ) ).toVar();
lodLevel.assign( 0 );
Loop( { name: 'lod', type: 'uint', start: uint( 1 ), end: lodCount, condition: '<' }, ( { lod } ) => {
If( geometryRow( geometryIndex, lod.add( GEOMETRY_LOD_ROW ) ).w.mul( scale ).mul( pixelFactor ).lessThanEqual( lodThreshold ), () => {
lodLevel.assign( lod );
} );
} );
// the objects smaller than the coarse radius use their simplest level
If( screenRadius.lessThan( coarseScreenRadius ), () => {
lodLevel.assign( lodCount.sub( 1 ) );
} );
instanceMvp.element( data ).assign( projScreenMatrix.mul( matrixWorld ) );
// the clusters of the level
clusterCount.assign( uint( geometryRow( geometryIndex, lodLevel.add( GEOMETRY_LOD_ROW ) ).y ).div( MESHLET_SIZE ) );
// the far material beyond the switch distance
If( flags.bitAnd( FLAG_FAR ).notEqual( uint( 0 ) ).and( levelDistance.greaterThanEqual( dataFar.y ) ), () => {
lodLevel.assign( lodLevel.bitOr( FAR ) );
} );
scaleBits.assign( packHalf2x16( vec2( bounds.w, bounds.x ).mul( HALF_ROUND_UP ) ) );
} );
} );
// the offset of the clusters of the object in the workgroup, then one atomic operation per workgroup
const localOffset = atomicAdd( workgroupClusters.element( uint( 0 ) ), clusterCount ).toVar();
workgroupBarrier();
If( localId.x.equal( uint( 0 ) ), () => {
workgroupBase.element( uint( 0 ) ).assign( atomicAdd( counters.element( 0 ), atomicLoad( workgroupClusters.element( uint( 0 ) ) ) ) );
} );
workgroupBarrier();
const firstCluster = workgroupBase.element( uint( 0 ) ).add( localOffset ).toVar();
If( slot.lessThan( objectEnd ), () => {
instanceRecord.element( slot ).assign( uvec4( firstCluster, lodLevel.bitOr( batch.shiftLeft( 16 ) ), scaleBits, slotData ) );
} );
Loop( { name: 'cluster', type: 'uint', start: uint( 0 ), end: clusterCount, condition: '<' }, ( { cluster } ) => {
const index = firstCluster.add( cluster );
If( index.lessThan( maxClusters ), () => {
clusterOwner.element( index ).assign( slot );
} );
} );
} )().compute( capacity.objects ).setName( 'MeshletDrawingPipeline Cull' );
// the dispatch of one thread per cluster, in rows of MAX_WORKGROUPS workgroups
const computeClusterArgs = Fn( () => {
const workgroups = min( countersRead.element( 0 ), maxClusters ).add( WORKGROUP_SIZE - 1 ).div( WORKGROUP_SIZE );
clusterDispatch.element( 0 ).assign( min( workgroups, uint( MAX_WORKGROUPS ) ) );
clusterDispatch.element( 1 ).assign( workgroups.add( MAX_WORKGROUPS - 1 ).div( MAX_WORKGROUPS ) );
clusterDispatch.element( 2 ).assign( uint( 1 ) );
} )().compute( 1 ).setName( 'MeshletDrawingPipeline Cluster Args' );
// occlusion culling in two phases: the first phase draws the clusters visible in the last frame, which
// are rendered into the depth of the occlusion view. the second phase tests all clusters against the
// hierarchical depth of this depth and adds the newly visible clusters. a cluster stores its level of
// detail + 1 if it was visible, the clusters of another level are tested in the second phase
// one thread per cluster of the visible objects
const createClusterCull = ( phase ) => Fn( () => {
const clusterIndex = workgroupId.y.mul( MAX_WORKGROUPS ).add( workgroupId.x ).mul( WORKGROUP_SIZE ).add( localId.x ).toVar();
If( clusterIndex.greaterThanEqual( min( atomicLoad( counters.element( 0 ) ), maxClusters ) ), () => {
Return();
} );
const slot = clusterOwner.element( clusterIndex ).toVar();
const record = instanceRecord.element( slot ).toVar();
const chunk = clusterIndex.sub( record.x ).toVar();
const lodLevel = record.y.bitAnd( LOD_MASK );
const far = record.y.bitAnd( FAR ).notEqual( uint( 0 ) );
const batch = record.y.shiftRight( 16 ).toVar();
const geometryIndex = record.w.shiftRight( GEOMETRY_SHIFT ).toVar();
const objectBounds = unpackHalf2x16( record.z );
const objectScale = objectBounds.x;
const batchObjects = rowBits( batch, 0 );
const batchQueues = rowBits( batch, 1 );
const batchGeometry = row( batch, 2 );
const geometryLevels = geometryRowBits( geometryIndex, 1 );
const lodData = geometryRow( geometryIndex, lodLevel.add( GEOMETRY_LOD_ROW ) );
const local = slot.sub( uint( batchObjects.x ) );
const maxChunks = uint( batchQueues.w );
const visibilityIndex = uint( batchQueues.z ).add( local.mul( maxChunks ) ).add( chunk );
// the first triangle of the level in the geometry pool, the draws read the triangles of all geometries
const lodTriangleStart = geometryLevels.y.add( uint( lodData.x ) );
const lodNumTriangles = uint( lodData.y );
const chunkIndex = geometryLevels.z.add( uint( lodData.z ) ).add( chunk );
If( chunk.lessThan( lodNumTriangles.div( MESHLET_SIZE ) ), () => {
// the clusters of objects occluded as a whole are not visible, without testing each of them
if ( phase === 'second' ) {
If( record.y.bitAnd( OCCLUDED ).notEqual( uint( 0 ) ), () => {
If( clusterVisibility.element( visibilityIndex ).notEqual( lodLevel.add( 1 ) ), () => {
atomicAdd( counters.element( this.occludedCounter ), 1 );
} );
clusterVisibility.element( visibilityIndex ).assign( uint( 0 ) );
Return();
} );
}
const matrixWorld = instanceWorld.element( uint( batchQueues.x ).add( local ) );
const chunkBounds = chunkPool.element( chunkIndex.mul( 2 ) );
const chunkCenter = matrixWorld.mul( vec4( chunkBounds.xyz, 1.0 ) ).xyz.toVar();
const chunkRadius = chunkBounds.w.mul( objectScale.abs() ).add( objectBounds.y ).toVar();
const chunkCullBounds = deformBounds( chunkCenter, chunkRadius );
const chunkVisible = bool( true ).toVar();
// cone culling: all triangles of the cluster face away from the camera. the normal cone is
// transformed by the world matrix, which requires a uniform scale ( positive object scale ).
// shadow passes draw the back faces, deformed positions change the facing
if ( shadow === false && boundsNode === null ) {
If( objectScale.greaterThan( 0.0 ).and( uint( batchGeometry.w ).bitAnd( FLAG_CONE ).notEqual( uint( 0 ) ) ), () => {
const cone = chunkPool.element( chunkIndex.mul( 2 ).add( 1 ) );
const coneAxis = normalize( matrixWorld.mul( vec4( cone.xyz, 0.0 ) ).xyz );
const toCluster = chunkCenter.sub( cameraPosition );
If( dot( toCluster, coneAxis ).greaterThanEqual( cone.w.mul( length( toCluster ) ).add( chunkRadius ) ), () => {
chunkVisible.assign( false );
} );
} );
}
Loop( { name: 'plane', start: 0, end: 6 }, ( { plane: planeIndex } ) => {
const plane = frustumPlanes.element( planeIndex );
If( dot( plane.xyz, chunkCullBounds.xyz ).add( plane.w ).lessThan( chunkCullBounds.w.negate() ), () => {
chunkVisible.assign( false );
} );
} );
// the near or the far queue of the batch
const enqueue = () => {
const queue = select( far, uint( 1 ), uint( 0 ) );
const queueCapacity = uint( batchObjects.w );
const item = atomicAdd( counters.element( batch.mul( 2 ).add( 1 ).add( queue ) ), 1 );
If( item.lessThan( queueCapacity ), () => {
workQueue.element( uint( batchObjects.z ).add( queue.mul( queueCapacity ) ).add( item ) ).assign( uvec4( local, lodTriangleStart, lodNumTriangles, chunk ) );
} );
};
If( chunkVisible, () => {
if ( phase === 'all' ) {
enqueue();
} else {
const visibilityValue = lodLevel.add( 1 );
const wasVisible = clusterVisibility.element( visibilityIndex ).equal( visibilityValue ).toVar();
if ( phase === 'first' ) {
If( wasVisible, enqueue );
} else {
const occluded = isOccluded( projScreenMatrix, chunkCullBounds );
If( occluded.not().and( wasVisible.not() ), enqueue );
// the clusters which would be drawn without the occlusion culling
If( occluded.and( wasVisible.not() ), () => {
atomicAdd( counters.element( this.occludedCounter ), 1 );
} );
clusterVisibility.element( visibilityIndex ).assign( select( occluded, uint( 0 ), visibilityValue ) );
}
}
} );
} );
} )().compute( clusterDispatchAttribute, [ WORKGROUP_SIZE ] ).setName( 'MeshletDrawingPipeline Cluster Cull' );
// one thread per queue writes the indirect draw arguments
const createArgs = ( prepass, name ) => Fn( () => {
const batch = instanceIndex.div( 2 );
const queueCapacity = rowBits( batch, 0 ).w;
const base = prepass === true ? instanceIndex.mul( 4 ).add( this.prepassArgsBase ) : instanceIndex.mul( 4 );
args.element( base ).assign( uint( MESHLET_SIZE * 3 ) );
args.element( base.add( 1 ) ).assign( min( countersRead.element( instanceIndex.add( 1 ) ), queueCapacity ) );
args.element( base.add( 2 ) ).assign( uint( 0 ) );
args.element( base.add( 3 ) ).assign( uint( 0 ) );
} )().compute( capacity.batches * 2 ).setName( name );
const computeDrawArgs = createArgs( false, 'MeshletDrawingPipeline Draw Args' );
if ( occlusion === null ) {
this.cullNodes = [ computeReset, computeCull, computeClusterArgs, createClusterCull( 'all' ) ];
this.computeNodes = [ computeDrawArgs ];
this.skipOcclusionNodes = [];
} else {
// one thread per object tests its bounding sphere, so the clusters of objects occluded as a whole skip their tests
const computeObjectOcclusion = Fn( () => {
const record = instanceRecord.element( instanceIndex ).toVar();
If( record.y.bitAnd( LOD_MASK ).notEqual( uint( CULLED ) ), () => {
const batch = record.y.shiftRight( 16 ).toVar();
const geometryBounds = geometryRow( record.w.shiftRight( GEOMETRY_SHIFT ), 0 );
const data = rowBits( batch, 1 ).x.add( instanceIndex.sub( rowBits( batch, 0 ).x ) ).toVar();
const bounds = instanceBounds.element( data );
const center = instanceWorld.element( data ).mul( vec4( geometryBounds.xyz, 1.0 ) ).xyz;
const radius = bounds.w.abs().mul( geometryBounds.w ).add( bounds.x );
If( isOccluded( projScreenMatrix, deformBounds( center, radius ) ), () => {
instanceRecord.element( instanceIndex ).assign( uvec4( record.x, record.y.bitOr( OCCLUDED ), record.z, record.w ) );
} );
} );
} )().compute( capacity.objects ).setName( 'MeshletDrawingPipeline Object Occlusion' );
this.cullNodes = [ computeReset, computeCull, computeClusterArgs, createClusterCull( 'first' ), createArgs( true, 'MeshletDrawingPipeline Occlusion Args' ) ];
this.computeNodes = [ computeObjectOcclusion, createClusterCull( 'second' ), computeDrawArgs ];
// the culling without the occlusion, while it culls few clusters
this.skipOcclusionNodes = [ computeReset, computeCull, computeClusterArgs, createClusterCull( 'all' ), computeDrawArgs ];
this.objectCountNodes = [ computeObjectOcclusion ];
}
this.computeCull = computeCull;
this.argsNodes = this.cullNodes.concat( this.computeNodes ).filter( ( node ) => node.name === 'MeshletDrawingPipeline Draw Args' || node.name === 'MeshletDrawingPipeline Occlusion Args' );
}
/**
* Allocates the buffers for the given capacity and sets them to the nodes reading them.
*
* @param {Object} capacity - The capacity of the shared buffers, see the constructor.
*/
allocate( capacity ) {
for ( const attribute of this.attributes ) attribute.dispose();
const attributes = [];
const create = ( array, itemSize, Type = StorageBufferAttribute ) => {
const attribute = new Type( array, itemSize );
attributes.push( attribute );
return attribute;
};
const { objects, data } = capacity;
// the buffers written only by the GPU are created from views of one buffer of zeros, the CPU doesn't keep the
// initial data of each buffer. the views must not be written
const zeroBuffer = new ArrayBuffer( Math.max( data * 16, capacity.queue * 4, capacity.clusters, capacity.visibility, capacity.batches * 16 + 2 ) * 4 );
const zeros = ( Type, length ) => new Type( zeroBuffer, 0, length );
this.capacity = capacity;
this.attributes = attributes;
this.worldArray = new Float32Array( data * 16 * ( this.history === true ? 2 : 1 ) );
this.boundsArray = new Float32Array( data * 4 );
this.levelArray = new Float32Array( data * 8 );
this.recordArray = new Uint32Array( objects * 4 );
this.chunkArray = new Float32Array( capacity.chunks * 8 + capacity.batches * BATCH_STRIDE * 4 + capacity.geometries * GEOMETRY_STRIDE * 4 );
this.tableArray = this.chunkArray.subarray( capacity.chunks * 8, capacity.chunks * 8 + capacity.batches * BATCH_STRIDE * 4 );
this.geometryArray = this.chunkArray.subarray( capacity.chunks * 8 + capacity.batches * BATCH_STRIDE * 4 );
// the offsets, counts and capacities of the table are stored as the bits of integers, floats would only represent
// the integers up to 2^24
this.tableBits = new Uint32Array( this.tableArray.buffer, this.tableArray.byteOffset, this.tableArray.length );
this.geometryBits = new Uint32Array( this.geometryArray.buffer, this.geometryArray.byteOffset, this.geometryArray.length );
this.worldAttribute = create( this.worldArray, 16 );
this.boundsAttribute = create( this.boundsArray, 4 );
this.levelAttribute = create( this.levelArray, 4 );
this.mvpAttribute = create( zeros( Float32Array, data * 16 ), 16 );
this.recordAttribute = create( this.recordArray, 4 );
this.chunkAttribute = create( this.chunkArray, 4 );
this.countersAttribute = create( zeros( Uint32Array, 2 + capacity.batches * 2 ), 1 );
this.queueAttribute = create( zeros( Uint32Array, capacity.queue * 4 ), 4 );
this.clusterOwnerAttribute = create( zeros( Uint32Array, Math.max( capacity.clusters, 1 ) ), 1 );
this.argsAttribute = create( zeros( Uint32Array, capacity.batches * 2 * 4 * 2 ), 4, IndirectStorageBufferAttribute );
this.visibilityAttribute = this.occlusion !== null ? create( zeros( Uint32Array, Math.max( capacity.visibility, 1 ) ), 1 ) : null;
this.prepassArgsOffset = capacity.batches * 2 * 4 * 4;
this.historyOffset.value = data;
this.tableOffset.value = capacity.chunks * 2;
this.geometryOffset.value = capacity.chunks * 2 + capacity.batches * BATCH_STRIDE;
this.prepassArgsBase.value = this.prepassArgsOffset / 4;
this.occludedCounter.value = 1 + capacity.batches * 2;
this.maxClusters.value = capacity.clusters;
if ( this.bufferNodes !== undefined ) {
for ( const [ node, key ] of this.bufferNodes ) {
node.value = this[ key ];
node.bufferCount = this[ key ].count;
}
this.computeReset.count = this.countersAttribute.count;
}
}
/**
* Replaces the buffers with buffers of the given capacity. The batches are removed and must be added again,
* their nodes and materials stay valid.
*
* @param {Object} capacity - The capacity of the shared buffers, see the constructor.
*/
resize( capacity ) {
this.allocate( capacity );
this.objectEnd = 0;
this.dataEnd = 0;
this.queueEnd = 0;
this.visibilityEnd = 0;
this.chunkEnd = 0;
this.batchEnd = 0;
this.geometryEnd = 0;
this.geometryIndices = new WeakMap();
this.batches.length = 0;
this.updateCounts();
}
/**
* Allocates the ranges of a batch in the shared buffers and writes its parameters into the table.
* Returns `false` if the buffers are full.
*
* @param {MeshletBatch} batch - The batch.
* @return {boolean} Whether the batch was added.
*/
add( batch ) {
const { capacity: objects, maxChunks } = batch;
const capacity = this.capacity;
const queueCount = batch.farMaterial !== null ? 2 : 1;
const queueCapacity = getQueueCapacity( objects, maxChunks, batch.queueBudget );
const visibility = this.occlusion !== null ? objects * maxChunks : 0;
const data = batch.source !== null ? 0 : objects;
if ( this.batchEnd + 1 > capacity.batches || this.objectEnd + objects > capacity.objects || this.dataEnd + data > capacity.data || this.queueEnd + queueCapacity * queueCount > capacity.queue ||
this.visibilityEnd + visibility > capacity.visibility ) {
return false;
}
const index = this.batchEnd ++;
batch.index = index;
batch.objectOffset = this.objectEnd;
batch.queueOffset = this.queueEnd;
batch.queueCapacity = queueCapacity;
// the objects of a batch sharing the data of another batch, the groups of the same objects, read the
// transforms, the bounds and the levels from the range of the other batch
batch.dataOffset = batch.source !== null ? batch.source.dataOffset : this.dataEnd;
// the table, see BATCH_STRIDE. the first and the second rows are integers
const values = this.tableArray;
const bits = this.tableBits;
const base = index * BATCH_STRIDE * 4;
bits.set( [ this.objectEnd, 0, this.queueEnd, queueCapacity ], base );
bits.set( [ batch.dataOffset, 0, this.visibilityEnd, maxChunks ], base + 4 );
values.set( [ 0, 0, batch.lodCount, ( batch.cone === true ? FLAG_CONE : 0 ) | ( batch.farMaterial !== null ? FLAG_FAR : 0 ) ], base + 8 );
// the batch of each slot of the range
const recordArray = this.recordArray;
for ( let i = this.objectEnd; i < this.objectEnd + objects; i ++ ) recordArray[ i * 4 + 3 ] = index;
this.recordAttribute.addUpdateRange( this.objectEnd * 4, objects * 4 );
this.recordAttribute.needsUpdate = true;
this.objectEnd += objects;
this.dataEnd += data;
this.queueEnd += queueCapacity * queueCount;
this.visibilityEnd += visibility;
this.batches.push( batch );
this.updateCounts();
return true;
}
/**
* Removes a batch, its ranges are reused after a rebuild of the culling.
*
* @param {MeshletBatch} batch - The batch.
*/
remove( batch ) {
const index = this.batches.indexOf( batch );
if ( index === - 1 ) return;
this.batches.splice( index, 1 );
this.setObjectCount( batch, 0 );
}
/**
* Returns the index of the given geometry in the table of the geometries, or `-1` if it was not added.
*
* @param {Object} clusters - The cluster data of the geometry.
* @return {number} The index.
*/
getGeometry( clusters ) {
const index = this.geometryIndices.get( clusters );
return index !== undefined ? index : - 1;
}
/**
* Adds the clusters and the levels of detail of a geometry to the table of the geometries, see GEOMETRY_STRIDE.
* Returns `-1` if the buffers are full.
*
* @param {Object} clusters - The cluster data of the geometry.
* @param {number} triangleBase - The first triangle of the geometry in the geometry pool.
* @return {number} The index of the geometry.
*/
addGeometry( clusters, triangleBase ) {
let index = this.geometryIndices.get( clusters );
if ( index !== undefined ) return index;
const capacity = this.capacity;
if ( this.geometryEnd + 1 > capacity.geometries || this.chunkEnd + clusters.chunkCount > capacity.chunks ) return - 1;
// the clusters of the geometry
const chunkOffset = this.chunkEnd;
this.chunkArray.set( clusters.chunkArray, chunkOffset * 8 );
this.chunkAttribute.addUpdateRange( chunkOffset * 8, clusters.chunkCount * 8 );
this.chunkEnd += clusters.chunkCount;
// the rows of the geometry, the second row are integers
index = this.geometryEnd ++;
const base = index * GEOMETRY_STRIDE * 4;
const { center, radius } = clusters.boundingSphere;
this.geometryArray.set( [ center.x, center.y, center.z, radius ], base );
this.geometryBits.set( [ clusters.lodCount, triangleBase, chunkOffset, clusters.maxChunks ], base + 4 );
for ( let i = 0; i < MAX_LODS; i ++ ) {
const lod = clusters.lodOffsetValues[ i ];
const offset = base + ( GEOMETRY_LOD_ROW + i ) * 4;
if ( lod !== undefined ) lod.toArray( this.geometryArray, offset );
else this.geometryArray.fill( 0, offset, offset + 4 );
}
this.chunkAttribute.addUpdateRange( this.geometryArray.byteOffset / 4 + base, GEOMETRY_STRIDE * 4 );
this.chunkAttribute.needsUpdate = true;
this.geometryIndices.set( clusters, index );
return index;
}
/**
* Sets the number of objects of a batch drawn in this projection.
*
* @param {MeshletBatch} batch - The batch.
* @param {number} count - The number of objects.
*/
setObjectCount( batch, count ) {
this.tableBits[ batch.index * BATCH_STRIDE * 4 + 1 ] = count;
}
/**
* Uploads the batch table after the object counts have been updated.
* Only the table range changes each frame; the cluster bounds remain in the same buffer.
*/
updateTable() {
this.chunkAttribute.addUpdateRange( this.capacity.chunks * 8, this.tableArray.length );
this.chunkAttribute.needsUpdate = true;
}
/**
* Limits the dispatches to the allocated objects and batches.
*/
updateCounts() {
this.computeCull.count = Math.max( this.objectEnd, 1 );
this.objectEndNode.value = this.objectEnd;
if ( this.objectCountNodes !== undefined ) {
for ( const node of this.objectCountNodes ) node.count = Math.max( this.objectEnd, 1 );
}
for ( const node of this.argsNodes ) node.count = Math.max( this.batchEnd * 2, 1 );
}
/**
* Updates the view of the culling.
*
* @param {Camera} camera - The camera.
* @param {Matrix4} projScreenMatrix - The view projection matrix.
* @param {Frustum} frustum - The camera frustum.
* @param {number} width - The width of the view in pixels.
* @param {number} height - The height of the view in pixels.
* @param {Object} lodView - The view selecting the level of detail.
*/
updateView( camera, projScreenMatrix, frustum, width, height, lodView ) {
this.projScreenMatrix.value.copy( projScreenMatrix );
this.cameraPosition.value.setFromMatrixPosition( camera.matrixWorld );
this.cameraZoom.value = camera.zoom;
this.viewSize.value.set( width, height );
this.lodPosition.value.copy( lodView.position );
this.lodScale.value = lodView.scale;
this.lodPerspective.value = lodView.perspective;
this.lodThreshold.value = lodView.threshold;
this.minScreenRadius.value = lodView.minScreenRadius;
this.coarseScreenRadius.value = lodView.coarseScreenRadius;
const planes = frustum.planes;
for ( let i = 0; i < 6; i ++ ) {
this.frustumPlanes.array[ i ].set( planes[ i ].normal.x, planes[ i ].normal.y, planes[ i ].normal.z, planes[ i ].constant );
}
}
/**
* Frees the GPU-related resources of the culling.
*/
dispose() {
for ( const computeNode of new Set( [ ...this.cullNodes, ...this.computeNodes, ...this.skipOcclusionNodes ] ) ) computeNode.dispose();
for ( const attribute of this.attributes ) attribute.dispose();
this.clusterDispatchAttribute.dispose();
}
}
/**
* Returns a function testing a bounding sphere against the hierarchical depth of the occlusion view.
*
* @private
* @param {Object} occlusion - The occlusion data of the view.
* @return {Function} Returns true if the sphere is behind the hierarchical depth, the matrix projects the space of the sphere.
*/
function createOcclusionTest( occlusion ) {
const { hzb, hzbLevels, hzbLevelCount, hzbSize } = occlusion;
return ( mvp, sphere ) => {
const minX = float( 1e9 ).toVar(), minY = float( 1e9 ).toVar(), minZ = float( 1e9 ).toVar();
const maxX = float( - 1e9 ).toVar(), maxY = float( - 1e9 ).toVar();
const behind = bool( false ).toVar();
// the corners of the box around the sphere bound its projection
for ( let i = 0; i < 8; i ++ ) {
const corner = vec3( i & 1 ? 1 : - 1, i & 2 ? 1 : - 1, i & 4 ? 1 : - 1 ).mul( sphere.w ).add( sphere.xyz );
const clip = mvp.mul( vec4( corner, 1.0 ) ).toVar();
If( clip.w.lessThanEqual( 0.0 ), () => {
behind.assign( true );
} ).Else( () => {
const ndc = clip.xyz.div( clip.w );
minX.assign( min( minX, ndc.x ) );
minY.assign( min( minY, ndc.y ) );
minZ.assign( min( minZ, ndc.z ) );
maxX.assign( max( maxX, ndc.x ) );
maxY.assign( max( maxY, ndc.y ) );
} );
}
const occluded = bool( false ).toVar();
// spheres crossing the camera plane are visible
If( behind.not(), () => {
// pixel rectangle, the framebuffer origin is the top left corner
const width = hzbSize.x;
const height = hzbSize.y;
const x0 = int( clamp( minX.mul( 0.5 ).add( 0.5 ).mul( width ), 0.0, width.sub( 1.0 ) ) );
const x1 = int( clamp( maxX.mul( 0.5 ).add( 0.5 ).mul( width ), 0.0, width.sub( 1.0 ) ) );
const y0 = int( clamp( float( 0.5 ).sub( maxY.mul( 0.5 ) ).mul( height ), 0.0, height.sub( 1.0 ) ) );
const y1 = int( clamp( float( 0.5 ).sub( minY.mul( 0.5 ) ).mul( height ), 0.0, height.sub( 1.0 ) ) );
// the level whose texels cover 2^( level + 1 ) pixels, so the rectangle overlaps at most 2x2 texels
const span = max( x1.sub( x0 ), y1.sub( y0 ) ).add( 1 );
const level = clamp( int( ceil( log2( float( span ) ) ) ).sub( 1 ), int( 0 ), int( hzbLevelCount ).sub( 1 ) );
const shift = uint( level.add( 1 ) );
const levelData = hzbLevels.element( level );
const offset = uint( levelData.x );
const levelWidth = uint( levelData.y );
const tx0 = uint( x0 ).shiftRight( shift );
const tx1 = uint( x1 ).shiftRight( shift );
const ty0 = uint( y0 ).shiftRight( shift );
const ty1 = uint( y1 ).shiftRight( shift );
const depth = max(
max( hzb.element( offset.add( ty0.mul( levelWidth ) ).add( tx0 ) ), hzb.element( offset.add( ty0.mul( levelWidth ) ).add( tx1 ) ) ),
max( hzb.element( offset.add( ty1.mul( levelWidth ) ).add( tx0 ) ), hzb.element( offset.add( ty1.mul( levelWidth ) ).add( tx1 ) ) )
);
occluded.assign( minZ.greaterThan( depth ) );
} );
return occluded;
};
}
/**
* Returns `true` if the 16 values of the array at the given offset equal the given values stored as 32-bit floats.
*
* @private
* @param {Float32Array} array - The array.
* @param {number} offset - The offset in the array.
* @param {ArrayLike<number>} values - The values.
* @param {number} [valuesOffset=0] - The offset in the values.
* @return {boolean} Whether the values are equal.
*/
function equalsMatrix( array, offset, values, valuesOffset = 0 ) {
for ( let i = 0; i < 16; i ++ ) {
if ( array[ offset + i ] !== Math.fround( values[ valuesOffset + i ] ) ) return false;
}
return true;
}
/**
* Returns `true` if the 16 elements of the given matrices are equal.
*
* @private
* @param {ArrayLike<number>} a - The elements of the first matrix.
* @param {ArrayLike<number>} b - The elements of the second matrix.
* @return {boolean} Whether the elements are equal.
*/
function equalsElements( a, b ) {
for ( let i = 0; i < 16; i ++ ) {
if ( a[ i ] !== b[ i ] ) return false;
}
return true;
}
/**
* The slots written by an upload, marked in ascending order. Slots closer than a gap are merged into one
* range, each range is one upload.
*
* @private
*/
class SlotRanges {
constructor() {
this.ranges = [];
this.start = - 1;
this.end = - 1;
}
/**
* Marks a slot as written. The slots must be marked in ascending order.
*
* @param {number} slot - The slot.
*/
mark( slot ) {
if ( this.end !== - 1 && slot <= this.end + RANGE_GAP ) {
this.end = slot + 1;
return;
}
if ( this.end !== - 1 ) this.ranges.push( [ this.start, this.end ] );
this.start = slot;
this.end = slot + 1;
}
/**
* Returns the ranges of the written slots. Too many ranges are merged into one range.
*
* @return {Array<Array<number>>} The ranges, the first slot and the slot after the last slot.
*/
finish() {
const ranges = this.ranges;
if ( this.end !== - 1 ) ranges.push( [ this.start, this.end ] );
this.start = this.end = - 1;
if ( ranges.length > MAX_UPDATE_RANGES ) ranges.splice( 0, ranges.length, [ ranges[ 0 ][ 0 ], ranges[ ranges.length - 1 ][ 1 ] ] );
return ranges;
}
}
/**
* Writes the maximum scale of an object into the bounds of the culling, negative if the scale is not uniform.
* The culling transforms the center of the bounding sphere of the geometry of each slot.
*
* @private
* @param {Float32Array} array - The bounds of the culling.
* @param {number} index - The slot of the object.
* @param {ArrayLike<number>} e - The elements of the world matrix.
*/
function writeBounds( array, index, e ) {
const scaleX = Math.sqrt( e[ 0 ] * e[ 0 ] + e[ 1 ] * e[ 1 ] + e[ 2 ] * e[ 2 ] );
const scaleY = Math.sqrt( e[ 4 ] * e[ 4 ] + e[ 5 ] * e[ 5 ] + e[ 6 ] * e[ 6 ] );
const scaleZ = Math.sqrt( e[ 8 ] * e[ 8 ] + e[ 9 ] * e[ 9 ] + e[ 10 ] * e[ 10 ] );
const maxScale = Math.max( scaleX, scaleY, scaleZ );
const uniformScale = maxScale - Math.min( scaleX, scaleY, scaleZ ) <= maxScale * 1e-4;
array[ index * 4 + 3 ] = uniformScale ? maxScale : - maxScale;
}
/**
* Writes the distance range of an object into the levels of the culling, if it changed. The batch of the slot
* is not changed.
*
* @private
* @param {Float32Array} array - The levels of the culling.
* @param {number} index - The slot of the object.
* @param {?Object} level - The distance range, or `null` if the object is not a level of a LOD object.
* @return {boolean} Whether the values changed.
*/
function writeLevel( array, index, level ) {
const i = index * 8;
if ( level === null ) {
if ( array[ i + 3 ] === NO_LEVEL_NEAR && array[ i + 4 ] === NO_LEVEL_FAR && array[ i + 5 ] === NO_LEVEL_FAR ) return false;
array[ i + 3 ] = NO_LEVEL_NEAR;
array[ i + 4 ] = NO_LEVEL_FAR;
array[ i + 5 ] = NO_LEVEL_FAR;
return true;
}
const x = Math.fround( level.position.x );
const y = Math.fround( level.position.y );
const z = Math.fround( level.position.z );
const near = Math.fround( level.near );
const far = Math.fround( level.far );
const switchDistance = Math.fround( level.switchDistance );
if ( array[ i ] === x && array[ i + 1 ] === y && array[ i + 2 ] === z && array[ i + 3 ] === near && array[ i + 4 ] === far && array[ i + 5 ] === switchDistance ) return false;
array[ i ] = x;
array[ i + 1 ] = y;
array[ i + 2 ] = z;
array[ i + 3 ] = near;
array[ i + 4 ] = far;
array[ i + 5 ] = switchDistance;
return true;
}
/**
* The vertices and the triangles of the geometries drawn by the GPU-driven pipeline, in one storage buffer each,
* so the objects of several geometries sharing a material are drawn by one draw. The vertex data of a geometry
* is interleaved ( see createMeshlets() ), the triangles store the address of the first element of each vertex.
* The buffers grow when geometries are added, the nodes reading them stay valid.
*
* @private
*/
class MeshletGeometryPool {
constructor() {
this.vertexArray = new Float32Array( 1024 * 4 );
this.indexArray = new Uint32Array( 1024 * 3 );
this.vertexEnd = 0;
this.indexEnd = 0;
this.vertexAttribute = new StorageBufferAttribute( this.vertexArray, 4 );
this.indexAttribute = new StorageBufferAttribute( this.indexArray, 1 );
this.vertexNode = storage( this.vertexAttribute, 'vec4' ).toReadOnly();
this.indexNode = storage( this.indexAttribute, 'uint' ).toReadOnly();
// the geometries in the pool and the elements of the removed geometries, which are reclaimed by compact()
this.geometries = new Map();
this.wasted = 0;
// changes when the geometries move, the tables of the geometries of the cullings are built again
this.version = 0;
}
/**
* Adds the vertices and the triangles of the given geometry, if not added yet.
*
* @param {Object} clusters - The cluster data of the geometry.
* @return {number} The first triangle of the geometry in the pool.
*/
add( clusters ) {
const geometry = this.geometries.get( clusters );
if ( geometry !== undefined ) return geometry.triangleBase;
const { vertexArray, indexArray, stride } = clusters;
const vertexBase = this.vertexEnd;
const indexBase = this.indexEnd;
this.reserve( vertexBase + vertexArray.length / 4, indexBase + indexArray.length );
this.vertexArray.set( vertexArray, vertexBase * 4 );
for ( let i = 0; i < indexArray.length; i ++ ) this.indexArray[ indexBase + i ] = vertexBase + indexArray[ i ] * stride;
this.vertexAttribute.addUpdateRange( vertexBase * 4, vertexArray.length );
this.vertexAttribute.needsUpdate = true;
this.indexAttribute.addUpdateRange( indexBase, indexArray.length );
this.indexAttribute.needsUpdate = true;
this.vertexEnd += vertexArray.length / 4;
this.indexEnd += indexArray.length;
const triangleBase = indexBase / 3;
this.geometries.set( clusters, { triangleBase, size: vertexArray.length + indexArray.length } );
return triangleBase;
}
/**
* Removes the given geometry, like a disposed geometry. Its elements are reclaimed by update().
*
* @param {Object} clusters - The cluster data of the geometry.
*/
remove( clusters ) {
const geometry = this.geometries.get( clusters );
if ( geometry === undefined ) return;
this.geometries.delete( clusters );
this.wasted += geometry.size;
}
/**
* Reclaims the elements of the removed geometries once they exceed half of the elements of the pool, so
* removing many geometries moves the geometries once.
*/
update() {
if ( this.wasted > 0 && this.wasted > ( this.vertexEnd * 4 + this.indexEnd ) / 2 ) this.compact();
}
/**
* Moves the geometries of the pool into new buffers, without the elements of the removed geometries.
*/
compact() {
const geometries = [ ...this.geometries.keys() ];
const vertices = geometries.reduce( ( sum, clusters ) => sum + clusters.vertexArray.length / 4, 0 );
const indices = geometries.reduce( ( sum, clusters ) => sum + clusters.indexArray.length, 0 );
this.dispose();
this.vertexArray = new Float32Array( Math.max( Math.ceil( vertices * 1.25 ), 1024 ) * 4 );
this.indexArray = new Uint32Array( Math.max( Math.ceil( indices * 1.25 ), 1024 * 3 ) );
this.vertexAttribute = new StorageBufferAttribute( this.vertexArray, 4 );
this.indexAttribute = new StorageBufferAttribute( this.indexArray, 1 );
this.vertexNode.value = this.vertexAttribute;
this.vertexNode.bufferCount = this.vertexAttribute.count;
this.indexNode.value = this.indexAttribute;
this.indexNode.bufferCount = this.indexAttribute.count;
this.vertexEnd = 0;
this.indexEnd = 0;
this.wasted = 0;
this.geometries.clear();
for ( const clusters of geometries ) this.add( clusters );
this.version ++;
}
/**
* Grows the buffers to hold the given numbers of vertex elements and indices.
*
* @param {number} vertices - The number of vec4 elements of the vertices.
* @param {number} indices - The number of indices.
*/
reserve( vertices, indices ) {
if ( vertices * 4 > this.vertexArray.length ) {
const array = new Float32Array( Math.max( vertices * 4, Math.ceil( this.vertexArray.length * 1.5 / 4 ) * 4 ) );
array.set( this.vertexArray.subarray( 0, this.vertexEnd * 4 ) );
this.vertexAttribute.dispose();
this.vertexArray = array;
this.vertexAttribute = new StorageBufferAttribute( array, 4 );
this.vertexNode.value = this.vertexAttribute;
this.vertexNode.bufferCount = this.vertexAttribute.count;
}
if ( indices > this.indexArray.length ) {
const array = new Uint32Array( Math.max( indices, Math.ceil( this.indexArray.length * 1.5 ) ) );
array.set( this.indexArray.subarray( 0, this.indexEnd ) );
this.indexAttribute.dispose();
this.indexArray = array;
this.indexAttribute = new StorageBufferAttribute( array, 1 );
this.indexNode.value = this.indexAttribute;
this.indexNode.bufferCount = this.indexAttribute.count;
}
}
/**
* Frees the GPU-related resources of the pool.
*/
dispose() {
this.vertexAttribute.dispose();
this.indexAttribute.dispose();
}
}
/**
* All objects of one render list sharing geometry and material, drawn by one indirect draw of their visible
* clusters through the hardware rasterizer. The objects are culled by the {@link MeshletCulling} of the render
* list, the batch owns a range of its buffers. Subclasses can replace the draw by overriding `setupDraw()`.
*
* @private
*/
class MeshletBatch {
/**
* Constructs a new batch.
*
* @param {Object} clusters - The cluster data of the geometry.
* @param {Material} material - The material of the objects.
* @param {boolean} receiveShadow - Whether the objects receive shadows.
* @param {number} capacity - The maximum number of objects.
* @param {Renderer} renderer - The renderer.
* @param {MeshletCulling} culling - The culling of the render list.
* @param {Object} [parameters] - The configuration parameter.
* @param {boolean} [parameters.debug=false] - Whether the clusters are shown instead of the material.
* @param {number} [parameters.lodCount=clusters.lodCount] - The number of levels of detail used.
* @param {?Object} [parameters.view=null] - The view of the render list, if the draw needs one.
* @param {boolean} [parameters.depthPrepass=false] - Whether the depth of the clusters is drawn before the material.
* @param {boolean} [parameters.instanced=false] - Whether the objects are instanced meshes, whose instances are drawn as objects.
* @param {boolean} [parameters.instanceColor=false] - Whether the instanced meshes have instance colors.
* @param {?Material} [parameters.farMaterial=null] - The material of the objects beyond their switch distance, the levels of
* LOD objects sharing the geometry. Shadow casters ignore it.
* @param {?MeshletBatch} [parameters.source=null] - A batch of the same culling and capacity drawing the same objects, like
* another group of the objects. The transforms, bounds and instance data of the objects are read from this batch, which
* uploads them, instead of being stored again.
* @param {number} [parameters.queueBudget=0] - The minimum number of work items of a queue of the batch, see getQueueCapacity().
*/
constructor( clusters, material, receiveShadow, capacity, renderer, culling, { debug = false, lodCount = MAX_LODS, view = null, depthPrepass = false, instanced = false, instanceColor = false, farMaterial = null, source = null, queueBudget = 0, maxChunks = clusters.maxChunks, pool = null } = {} ) {
const shadow = culling.shadow;
// position nodes can move the vertices out of the bounds of the clusters, they also change the facing. their
// bounds are computed on the GPU, like the bounds of instanced meshes, see createDeformation()
const deformed = isDeformedMaterial( material );
const gpuTransform = instanced === true || deformed === true;
this.source = source !== null && source.added === true && source.culling === culling && source.capacity === capacity && source.gpuTransform === gpuTransform && source.instanceColor === instanceColor ? source : null;
this.clusters = clusters;
this.material = material;
this.materialVersion = material.version;
this.farMaterial = shadow === false ? farMaterial : null;
this.farMaterialVersion = farMaterial !== null ? farMaterial.version : - 1;
this.debug = debug;
this.shadow = shadow;
this.culling = culling;
this.occlusion = culling.occlusion;
this.depthPrepass = depthPrepass;
this.instanced = instanced;
this.instanceColor = instanceColor;
this.deformed = deformed;
this.gpuTransform = gpuTransform;
this.capacity = capacity;
this.lodCount = lodCount;
this.queueBudget = queueBudget;
this.maxChunks = maxChunks;
this.pool = pool;
this.count = 0;
this.view = view;
this.cone = shadow === false && deformed === false;
this.attributes = [];
this.ownedGeometries = [];
this.indirectGeometries = [];
this.meshes = [];
this.prepassMeshes = [];
this.computeNodes = [];
// the compute nodes dispatched before the culling when the objects changed, see getComputeNodesBefore()
this.computeNodesBefore = [];
this.transformNode = null;
this.transformFrames = 0;
this.unbounded = null;
this.transformedCount = 0;
this.added = culling.add( this );
if ( this.added === false ) return;
const createAttribute = ( array, itemSize, Type = StorageBufferAttribute ) => {
const attribute = new Type( array, itemSize );
this.attributes.push( attribute );
return attribute;
};
// the vertices of the geometries of the batch, read from the geometry pool. the geometries share the layout
// of the vertex data, the triangles store the address of the first element of each vertex
const layout = clusters.attributeLayout;
const vertexElement = ( address, offset ) => pool.vertexNode.element( address.add( offset ) );
const position = ( address ) => vertexElement( address, 0 ).xyz;
const normal = ( address ) => vertexElement( address, 1 ).xyz;
const vertexUv = ( address ) => vec2( vertexElement( address, 0 ).w, vertexElement( address, 1 ).w );
const hasAttribute = ( name ) => layout.has( name );
const attribute = ( name, address ) => swizzle( vertexElement( address, layout.get( name ).offset ), layout.get( name ).itemSize );
const indexBuffer = pool.indexNode;
const history = culling.history;
this.history = history;
// the range of the batch in the buffers of the culling, updated when the batch is added to the resized culling
const dataOffset = uniform( this.dataOffset, 'uint' );
const queueOffset = uniform( this.queueOffset, 'uint' );
const counterOffset = uniform( 1 + this.index * 2, 'uint' );
const maxWorkItems = uniform( this.queueCapacity, 'uint' );
this.rangeNodes = { dataOffset, queueOffset, counterOffset, maxWorkItems };
const instanceWorld = {
element: ( index ) => culling.instanceWorld.element( dataOffset.add( index ) ),
previous: ( index ) => culling.instanceWorld.element( culling.historyOffset.add( dataOffset ).add( index ) )
};
const instanceMvp = { element: ( index ) => culling.instanceMvp.element( dataOffset.add( index ) ) };
const workQueue = { element: ( index ) => culling.workQueue.element( queueOffset.add( index ) ) };
const workQueueCountRead = { element: ( queue ) => culling.countersRead.element( counterOffset.add( queue ) ) };
// instanced meshes: the world matrix of the mesh and the matrix of the instance, like the regular instancing
// of node materials, and per instance its index, the instanced attributes of the geometry and the instance color. the objects whose
// material has a position node are stored like instances with an identity instance matrix
let objectWorld = null;
let instanceLocal = null;
let instanceData = null;
this.instanceDataStride = 1 + clusters.instancedAttributes.length + ( instanceColor === true ? 1 : 0 ) + ( deformed === true ? 1 : 0 );
const worldCapacity = history === true ? capacity * 2 : capacity;
if ( gpuTransform === true && this.source !== null ) {
( { objectWorld, instanceLocal, instanceData } = this.source.instanceNodes );
} else if ( gpuTransform === true ) {
const stride = this.instanceDataStride;
// one buffer stores the world matrices of the meshes, the matrices of the instances and the data of the instances,
// a matrix is four vec4 columns. one binding keeps the draws and the software rasterizer within the storage
// buffer limit of a shader stage
const instanceArray = new Float32Array( worldCapacity * 32 + capacity * stride * 4 );
this.objectWorldArray = instanceArray.subarray( 0, worldCapacity * 16 );
this.instanceLocalArray = instanceArray.subarray( worldCapacity * 16, worldCapacity * 32 );
this.instanceDataArray = instanceArray.subarray( worldCapacity * 32 );
this.instanceAttribute = createAttribute( instanceArray, 4 );
this.instanceLayout = { local: worldCapacity * 16, data: worldCapacity * 32 };
const instanceRead = storage( this.instanceAttribute, 'vec4', instanceArray.length / 4 ).toReadOnly();
const instanceWrite = storage( this.instanceAttribute, 'vec4', instanceArray.length / 4 );
objectWorld = createMatrices( instanceRead, 0 );
instanceLocal = createMatrices( instanceRead, worldCapacity * 4 );
instanceData = { element: ( index ) => instanceRead.element( uint( index ).add( worldCapacity * 8 ) ) };
// only the matrices of the instances and of the meshes are uploaded. the world matrices and the bounds of the
// instances for the culling, and the previous matrices, are computed on the GPU. the matrices of the last
// computation are kept on the GPU, they become the previous matrices of the next computation
const objectWorldWrite = createMatrices( instanceWrite, 0 );
const instanceLocalWrite = createMatrices( instanceWrite, worldCapacity * 4 );
const instanceDataWrite = { element: ( index ) => instanceWrite.element( uint( index ).add( worldCapacity * 8 ) ) };
const cullingWorld = culling.worldWrite;
const cullingBounds = culling.boundsWrite;
const lastObjectWorld = history === true ? storage( createAttribute( new Float32Array( capacity * 16 ), 16 ), 'mat4', capacity ) : null;
const lastInstanceLocal = history === true ? storage( createAttribute( new Float32Array( capacity * 16 ), 16 ), 'mat4', capacity ) : null;
const transformCount = uniform( 0, 'uint' );
const transformPreviousCount = uniform( 0, 'uint' );
this.transformCount = transformCount;
this.transformPreviousCount = transformPreviousCount;
// the bounds of positions deformed by the position node are found by evaluating the node, see createDeformation()
const deformation = deformed === true ? { unbounded: uniform( 0 ), normalSamples: uniform( 1, 'int' ) } : null;
this.unbounded = deformed === true ? deformation.unbounded : null;
this.transformNode = Fn( () => {
// the instance index of the material is replaced by the index of the instance in the position node
const index = drawInstanceIndex;
If( index.lessThan( transformCount ), () => {
const local = instanceLocalWrite.element( index ).toVar();
const object = objectWorldWrite.element( index ).toVar();
if ( history === true ) {
// added instances didn't move
const previous = index.add( capacity );
If( index.lessThan( transformPreviousCount ), () => {
instanceLocalWrite.assign( previous, lastInstanceLocal.element( index ) );
objectWorldWrite.assign( previous, lastObjectWorld.element( index ) );
} ).Else( () => {
instanceLocalWrite.assign( previous, local );
objectWorldWrite.assign( previous, object );
} );
lastInstanceLocal.element( index ).assign( local );
lastObjectWorld.element( index ).assign( object );
}
const slot = dataOffset.add( index );
let world, margin = float( 0 );
if ( deformed === true ) {
// the index of the instance, then the bounding sphere of the geometry is the last element of the instance data
const instance = uint( instanceDataWrite.element( index.mul( stride ) ).x ).toVar();
const bounds = instanceDataWrite.element( index.mul( stride ).add( stride - 1 ) );
const fit = createDeformation( material.positionNode, local, instance, bounds, deformation );
world = object.mul( fit.matrix ).toVar();
// the margin in world units
const objectScale = max( length( object.mul( vec4( 1, 0, 0, 0 ) ).xyz ), max( length( object.mul( vec4( 0, 1, 0, 0 ) ).xyz ), length( object.mul( vec4( 0, 0, 1, 0 ) ).xyz ) ) );
margin = fit.margin.mul( objectScale );
} else {
world = object.mul( local ).toVar();
}
cullingWorld.element( slot ).assign( world );
// the maximum scale, negative if the scale is not uniform. the culling transforms the center of the geometry
const scaleX = length( world.mul( vec4( 1, 0, 0, 0 ) ).xyz );
const scaleY = length( world.mul( vec4( 0, 1, 0, 0 ) ).xyz );
const scaleZ = length( world.mul( vec4( 0, 0, 1, 0 ) ).xyz );
const maxScale = max( scaleX, max( scaleY, scaleZ ) ).toVar();
const minScale = min( scaleX, min( scaleY, scaleZ ) );
cullingBounds.element( slot ).assign( vec4( margin, 0, 0, select( maxScale.sub( minScale ).lessThanEqual( maxScale.mul( 1e-4 ) ), maxScale, maxScale.negate() ) ) );
} );
} )().compute( capacity ).setName( 'MeshletDrawingPipeline Instances' );
this.computeNodesBefore.push( this.transformNode );
}
this.instanceNodes = { objectWorld, instanceLocal, instanceData };
// the slots written in the last upload, their previous matrices are updated in the next frame
this.historyRanges = EMPTY_HISTORY;
// the state of the objects of instanced meshes in the last upload, see update()
this.objectStates = [];
// the geometry of the objects in their slots, see writeGeometries()
this.geometryStates = [];
// one draw instance per cluster of 64 triangles, the far queue follows the near queue
const createSource = ( queue ) => {
const workItem = workQueue.element( queue === 0 ? drawInstanceIndex : drawInstanceIndex.add( maxWorkItems.mul( queue ) ) );
return {
instance: workItem.x,
triangle: workItem.y.add( workItem.w.mul( MESHLET_SIZE ) ).add( vertexIndex.div( 3 ) ),
corner: vertexIndex.mod( 3 )
};
};
const clusterSource = createSource( 0 );
const farClusterSource = this.farMaterial !== null ? createSource( 1 ) : null;
const shared = {
createAttribute, clusters, material, renderer, receiveShadow, debug, shadow, depthPrepass, capacity,
position, normal, vertexUv, hasAttribute, attribute, indexBuffer, instanceWorld, instanceMvp, instanced: gpuTransform, objectWorld, instanceLocal, instanceData,
workQueue, workQueueCountRead, maxWorkItems, projScreenMatrix: culling.projScreenMatrix, viewSize: culling.viewSize,
clusterSource, farClusterSource
};
this.setupDraw( shared );
// the clusters of the first phase, drawn into the depth of the occlusion view
if ( this.occlusion !== null ) {
this.prepassMeshes.push( this.createDepthMesh( culling.argsAttribute, clusterSource, shared, FrontSide, this.getArgsOffset( 0, true ) ) );
if ( farClusterSource !== null ) this.prepassMeshes.push( this.createDepthMesh( culling.argsAttribute, farClusterSource, { ...shared, material: this.farMaterial }, FrontSide, this.getArgsOffset( 1, true ) ) );
}
}
/**
* Adds the batch to its resized culling, see {@link MeshletCulling#resize}. The nodes and materials of the batch
* stay valid: the range of the batch and its indirect draws are updated, and the data of the objects is written
* again in the next update.
*
* @return {boolean} Whether the batch was added.
*/
relocate() {
const culling = this.culling;
this.added = culling.add( this );
if ( this.added === false ) return false;
const { dataOffset, queueOffset, counterOffset, maxWorkItems } = this.rangeNodes;
dataOffset.value = this.dataOffset;
queueOffset.value = this.queueOffset;
counterOffset.value = 1 + this.index * 2;
maxWorkItems.value = this.queueCapacity;
for ( const { geometry, queue, prepass } of this.indirectGeometries ) geometry.setIndirect( culling.argsAttribute, this.getArgsOffset( queue, prepass ) );
// the buffers of the culling are new: the objects are compared with empty values and written again, the world
// matrices and bounds of instances are computed again. the previous matrices of the batch are kept
this.count = 0;
this.objectStates.length = 0;
this.geometryStates.length = 0;
this.historyRanges = EMPTY_HISTORY;
this.uploadedVersion = undefined;
if ( this.transformNode !== null ) this.transformFrames = this.history === true ? 2 : 1;
return true;
}
/**
* Returns the offset in bytes of the indirect draw arguments of a queue of the batch.
*
* @param {number} queue - The queue, 0 for the material and 1 for the far material.
* @param {boolean} [prepass=false] - Whether the arguments of the occlusion prepass are returned.
* @return {number} The offset in bytes.
*/
getArgsOffset( queue, prepass = false ) {
return ( this.index * 2 + queue ) * 16 + ( prepass === true ? this.culling.prepassArgsOffset : 0 );
}
/**
* Creates the draw of the visible clusters: one draw instance per cluster of 64 triangles, drawn by the
* hardware rasterizer through vertex pulling. Adds the meshes of the draw.
*
* @param {Object} shared - The nodes and resources of the batch.
*/
setupDraw( shared ) {
const { clusterSource, farClusterSource } = shared;
// one draw per queue: the material, and the far material beyond the switch distance
const queues = [ { source: clusterSource, material: shared.material } ];
if ( farClusterSource !== null ) queues.push( { source: farClusterSource, material: this.farMaterial } );
for ( let queue = 0; queue < queues.length; queue ++ ) {
const mesh = this.createPulledMesh( this.culling.argsAttribute, queues[ queue ].source, { ...shared, material: queues[ queue ].material }, this.getArgsOffset( queue ) );
this.meshes.push( mesh );
if ( mesh.depthMesh !== undefined ) this.meshes.push( mesh.depthMesh );
if ( queue === 0 ) this.clusterMesh = mesh;
}
}
/**
* Creates the geometry of a mesh drawing pulled vertices with the given indirect draw arguments.
*
* @param {IndirectStorageBufferAttribute} drawAttribute - The indirect draw arguments.
* @param {number} [drawOffset=0] - The offset in bytes of the arguments.
* @return {BufferGeometry} The geometry.
*/
createPulledGeometry( drawAttribute, drawOffset = 0 ) {
if ( _pulledPositionAttribute === null ) _pulledPositionAttribute = new Float32BufferAttribute( new Float32Array( PULLED_VERTICES * 3 ), 3 );
const geometry = new BufferGeometry();
geometry.setAttribute( 'position', _pulledPositionAttribute );
// the normals and the tangents are provided by the context, the attributes enable the features based on them like the
// geometry roughness or the tangents of normal maps, which are derived from the screen space without the attribute
geometry.setAttribute( 'normal', _pulledPositionAttribute );
if ( this.clusters.attributeLayout.has( 'tangent' ) ) geometry.setAttribute( 'tangent', _pulledPositionAttribute );
geometry.setIndirect( drawAttribute, drawOffset );
geometry.boundingSphere = new Sphere( new Vector3(), Infinity );
this.ownedGeometries.push( geometry );
// the arguments of the culling move when the batch is added to the resized culling, see relocate()
const culling = this.culling;
if ( drawAttribute === culling.argsAttribute ) {
const prepass = drawOffset >= culling.prepassArgsOffset;
const queue = ( drawOffset - ( prepass === true ? culling.prepassArgsOffset : 0 ) ) / 16 - this.index * 2;
this.indirectGeometries.push( { geometry, queue, prepass } );
}
return geometry;
}
/**
* Returns a context which feeds pulled vertices into the regular vertex stage of node materials: the
* attributes of the geometry are read from the cluster data and the model matrices from the objects of
* the batch. The nodes of the material, including position nodes, run as for the regular meshes. The instances
* of instanced meshes are transformed like the regular instancing of node materials, before the position node.
*
* @param {Object} source - The object, triangle and corner nodes of the current vertex.
* @param {Object} shared - The nodes and resources of the batch.
* @return {{context: ContextNode, positionNode: ?Node<vec3>, colorNode: ?Node<vec3>}} The context node, the position node and the instance color of the materials.
*/
createPulledContext( source, shared ) {
const { clusters, material, position, normal, vertexUv, hasAttribute, attribute, indexBuffer, instanceWorld, instanced, objectWorld, instanceLocal, instanceData, capacity } = shared;
const vertex = indexBuffer.element( source.triangle.mul( 3 ).add( source.corner ) );
// the object of the vertex, the fragments read it through a flat varying
const instance = varying( source.instance, 'vMeshletInstance' );
let matrixWorld = instanceWorld.element( instance );
let previousMatrixWorld = this.history === true ? instanceWorld.previous( instance ) : matrixWorld;
let instanceIndexNode = uint( 0 );
let positionNode = material.positionNode || null;
if ( instanced === true ) {
const stride = this.instanceDataStride;
const instanceMatrix = instanceLocal.element( instance );
const previousInstanceMatrix = this.history === true ? instanceLocal.element( instance.add( capacity ) ) : instanceMatrix;
const userPositionNode = positionNode;
matrixWorld = objectWorld.element( instance );
previousMatrixWorld = this.history === true ? objectWorld.element( instance.add( capacity ) ) : matrixWorld;
instanceIndexNode = uint( instanceData.element( instance.mul( stride ) ).x );
positionNode = Fn( ( builder ) => {
positionLocal.assign( instanceMatrix.mul( positionLocal ).xyz );
if ( builder.hasGeometryAttribute( 'normal' ) ) normalLocal.assign( transformNormal( normalLocal, instanceMatrix ) );
if ( builder.needsPreviousData() ) positionPrevious.assign( previousInstanceMatrix.mul( positionPrevious ).xyz );
return userPositionNode !== null ? userPositionNode : positionLocal;
} )();
}
const worldPosition = matrixWorld.mul( vec4( 0.0, 0.0, 0.0, 1.0 ) ).xyz;
const instancedAttributes = clusters.instancedAttributes;
const attributes = {
position: position( vertex ),
normal: normal( vertex ),
uv: vertexUv( vertex )
};
const getAttribute = cacheAttributes( ( name ) => {
let node = attributes[ name ];
if ( node === undefined && hasAttribute( name ) ) node = attribute( name, vertex );
// the instanced attributes of instanced meshes
const index = instancedAttributes.findIndex( ( attribute ) => attribute.name === name );
if ( node === undefined && instanced === true && index !== - 1 ) {
node = swizzle( instanceData.element( instance.mul( this.instanceDataStride ).add( 1 + index ) ), instancedAttributes[ index ].itemSize );
}
// pulled in the vertex stage, interpolated if read by the fragment stage
return node !== undefined ? varying( node ) : null;
} );
// the color of the instance follows the instanced attributes
const colorNode = this.instanceColor === true ? varying( instanceData.element( instance.mul( this.instanceDataStride ).add( 1 + instancedAttributes.length ) ).xyz ) : null;
const context = overrideNodes( [
[ instanceIndex, instanceIndexNode ],
[ modelWorldMatrix, matrixWorld ],
[ modelWorldMatrixInverse, matrixWorld.inverse() ],
[ modelNormalMatrix, mat3( matrixWorld ).inverse().transpose() ],
[ modelPosition, worldPosition ],
[ modelViewPosition, cameraViewMatrix.mul( vec4( worldPosition, 1.0 ) ).xyz ],
[ velocity.previousModelWorldMatrix, previousMatrixWorld ]
], contextNode( { getAttribute } ) );
return { context, positionNode, colorNode };
}
/**
* Creates a mesh which only draws the depth of pulled triangles, for shadow passes and the occlusion view.
* The position node of the material is applied.
*
* @param {IndirectStorageBufferAttribute} drawAttribute - The indirect draw arguments.
* @param {Object} source - The object, triangle and corner nodes of the current vertex.
* @param {Object} shared - The nodes and resources of the batch.
* @param {number} side - The side of the triangles to draw.
* @param {number} [drawOffset=0] - The offset in bytes of the indirect draw arguments.
* @return {Mesh} The mesh.
*/
createDepthMesh( drawAttribute, source, shared, side, drawOffset = 0 ) {
const pulled = this.createPulledContext( source, shared );
// alpha-tested materials discard the same fragments in the depth of shadows and of the occlusion view
let material;
if ( shared.material.alphaTest > 0 ) {
material = createAlphaDepthMaterial( shared.material );
} else {
material = new NodeMaterial();
material.colorNode = vec4( 0, 0, 0, 1 );
}
material.positionNode = pulled.positionNode;
material.maskNode = shared.material.maskNode || null;
material.contextNode = pulled.context;
material.blending = NoBlending;
material.fog = false;
material.side = side;
// the shadow material of the renderer would replace the pulled context
material.allowOverride = false;
const mesh = new Mesh( this.createPulledGeometry( drawAttribute, drawOffset ), material );
mesh.frustumCulled = false;
return mesh;
}
/**
* Creates a mesh which draws triangles through the hardware rasterizer by vertex pulling. The source
* provides the object, the triangle and the corner of the current vertex.
*
* @param {IndirectStorageBufferAttribute} drawAttribute - The indirect draw arguments.
* @param {Object} source - The object, triangle and corner nodes of the current vertex.
* @param {Object} shared - The nodes and resources of the batch.
* @param {number} [drawOffset=0] - The offset in bytes of the indirect draw arguments.
* @return {Mesh} The mesh.
*/
createPulledMesh( drawAttribute, source, shared, drawOffset = 0 ) {
const { material, renderer, receiveShadow, debug, shadow, depthPrepass } = shared;
if ( shadow === true ) {
// the shadow pass draws the back faces like the shadow material of the renderer
const side = ( material.shadowSide !== null && material.shadowSide !== undefined ) ? material.shadowSide : BackSide;
const mesh = this.createDepthMesh( drawAttribute, source, shared, side, drawOffset );
mesh.castShadow = true;
return mesh;
}
const geometry = this.createPulledGeometry( drawAttribute, drawOffset );
const pulled = this.createPulledContext( source, shared );
let drawMaterial;
if ( debug === true ) {
// clusters are stored as blocks of 64 triangles, so the cluster of a triangle is its index divided by 64
drawMaterial = createDebugMaterial( varying( source.triangle.div( MESHLET_SIZE ) ), varying( source.instance ) );
} else {
drawMaterial = createDrawMaterial( material, renderer );
// the instance color multiplies the color of the material, like NodeMaterial.setupDiffuseColor() for instanced meshes
if ( pulled.colorNode !== null ) drawMaterial.colorNode = vec4( pulled.colorNode, 1 ).mul( drawMaterial.colorNode ? vec4( drawMaterial.colorNode ) : materialColor );
}
drawMaterial.positionNode = pulled.positionNode;
drawMaterial.maskNode = material.maskNode || null;
drawMaterial.contextNode = depthPrepass ? contextNode( pulled.context, { invariant: true } ) : pulled.context;
const mesh = new Mesh( geometry, drawMaterial );
mesh.frustumCulled = false;
mesh.receiveShadow = receiveShadow;
if ( depthPrepass === true ) {
// the depth is drawn first with the same vertex nodes, so the material only shades the visible surface
const depthMaterial = material.alphaTest > 0 ? createAlphaDepthMaterial( material ) : new NodeMaterial();
depthMaterial.colorWrite = false;
depthMaterial.fog = false;
depthMaterial.positionNode = pulled.positionNode;
depthMaterial.maskNode = material.maskNode || null;
depthMaterial.contextNode = contextNode( pulled.context, { invariant: true } );
const depthMesh = new Mesh( geometry, depthMaterial );
depthMesh.frustumCulled = false;
depthMesh.renderOrder = - 1;
drawMaterial.depthWrite = false;
mesh.depthMesh = depthMesh;
}
return mesh;
}
/**
* Writes the transforms of the objects into the range of the batch in the buffers of the culling. The values
* are compared with the values of the last upload, only the slots which changed are uploaded. Batches sharing
* the objects of another batch only update their number of objects, the other batch writes the values.
*
* @param {Array<Object3D>} objects - The objects of the batch.
* @param {MeshletDrawingPipeline} pipeline - The pipeline.
* @param {boolean} [upload=true] - Whether the transforms of the objects can have changed and must be compared.
* @param {?Array<?Object>} [levels=null] - The distance ranges of the objects which are levels of LOD objects.
* @param {?Array<number>} [geometries=null] - The indices of the geometries of the objects in the culling.
*/
update( objects, pipeline, upload = true, levels = null, geometries = null ) {
const culling = this.culling;
if ( geometries !== null ) this.writeGeometries( objects, geometries );
let count = objects.length;
if ( this.gpuTransform === true ) {
count = 0;
for ( let i = 0, l = objects.length; i < l; i ++ ) count += objects[ i ].count;
}
if ( this.source === null && this.gpuTransform === true ) {
// the world matrices, the bounds and the previous matrices of the instances are computed on the GPU while
// the instances change, and once more to update the previous matrices, see getComputeNodesBefore()
const ranges = upload === true ? this.write( objects, levels ) : EMPTY_HISTORY;
if ( ranges.transform.length > 0 || ranges.object.length > 0 || count !== this.count ) this.transformFrames = this.history === true ? 2 : 1;
} else if ( this.source === null ) {
// the slots written in the last upload moved since the last frame: their matrices of the last frame become
// the previous matrices. the previous matrices of all other slots are their current matrices
for ( const [ start, end ] of this.historyRanges.transform ) this.copyHistory( start, end );
this.historyRanges = upload === true ? this.write( objects, levels ) : EMPTY_HISTORY;
// added objects didn't move
this.copyHistory( this.count, count );
}
this.count = count;
culling.setObjectCount( this, count );
}
/**
* Writes the geometry of each slot into the records of the culling, with the batch of the slot. Only the
* objects which changed their slots or their geometry are written.
*
* @param {Array<Object3D>} objects - The objects of the batch.
* @param {Array<number>} geometries - The indices of the geometries of the objects in the culling.
*/
writeGeometries( objects, geometries ) {
const culling = this.culling;
const recordArray = culling.recordArray;
const offset = this.objectOffset;
const states = this.geometryStates;
const ranges = new SlotRanges();
states.length = Math.min( states.length, objects.length );
let slot = 0;
for ( let o = 0; o < objects.length; o ++ ) {
const count = this.gpuTransform === true ? objects[ o ].count : 1;
const value = ( this.index | ( geometries[ o ] << GEOMETRY_SHIFT ) ) >>> 0;
let state = states[ o ];
if ( state === undefined ) state = states[ o ] = { slot: - 1, count: 0, value: - 1 };
if ( state.slot !== slot || state.count !== count || state.value !== value ) {
for ( let i = slot; i < slot + count; i ++ ) {
const index = ( offset + i ) * 4 + 3;
if ( recordArray[ index ] !== value ) {
recordArray[ index ] = value;
ranges.mark( i );
}
}
state.slot = slot;
state.count = count;
state.value = value;
}
slot += count;
}
const attribute = culling.recordAttribute;
const written = ranges.finish();
for ( const [ from, to ] of written ) attribute.addUpdateRange( ( offset + from ) * 4, ( to - from ) * 4 );
if ( written.length > 0 ) attribute.needsUpdate = true;
}
/**
* Writes the transforms, bounds, levels and instance data of the objects which changed since the last upload
* and adds the update ranges of the written slots, per buffer.
*
* @param {Array<Object3D>} objects - The objects of the batch.
* @param {?Array<?Object>} levels - The distance ranges of the objects which are levels of LOD objects.
* @return {{transform: Array<Array<number>>, object: Array<Array<number>>}} The ranges of the slots whose matrices were written:
* the world matrices of the objects or the matrices of the instances, and the world matrices of instanced meshes.
*/
write( objects, levels ) {
const culling = this.culling;
const offset = this.dataOffset;
const { worldArray, boundsArray, levelArray } = culling;
const transform = new SlotRanges();
const object = new SlotRanges();
const data = new SlotRanges();
const level = new SlotRanges();
if ( this.gpuTransform === true ) {
const { objectWorldArray, instanceLocalArray, instanceDataArray, instanceDataStride: stride, objectStates } = this;
const instancedAttributes = this.clusters.instancedAttributes;
// the state of an object is only valid while the object keeps its slots
objectStates.length = Math.min( objectStates.length, objects.length );
let slot = 0;
for ( let o = 0; o < objects.length; o ++ ) {
const mesh = objects[ o ];
const range = levels !== null ? levels[ o ] : null;
const instanceCount = mesh.count;
const matrixWorld = mesh.matrixWorld;
const attributes = instancedAttributes.map( ( { name } ) => mesh.geometry.attributes[ name ] );
// the values of 32-bit float attributes which are not interleaved or normalized are read from their arrays
const direct = attributes.map( ( attribute ) => attribute.isInterleavedBufferAttribute !== true && attribute.normalized !== true && attribute.array instanceof Float32Array );
// the objects which are not instanced meshes have one instance with an identity matrix
const instanceMatrix = mesh.isInstancedMesh === true ? mesh.instanceMatrix : null;
const instanceColor = this.instanceColor === true ? mesh.instanceColor : null;
let version = instanceMatrix !== null ? instanceMatrix.version : 0;
for ( let a = 0; a < attributes.length; a ++ ) version += attributes[ a ].version;
if ( instanceColor !== null ) version += instanceColor.version;
let state = objectStates[ o ];
if ( state === undefined ) state = objectStates[ o ] = { object: null, slot: - 1, count: 0, version: - 1, level: null, matrix: new Float64Array( 16 ), instanceMatrix: null, instanceColor: null, attributes: [] };
// Attribute versions belong to their buffers: replacing a buffer can leave the summed version unchanged.
const sameWorld = state.object === mesh && state.slot === slot && equalsElements( state.matrix, matrixWorld.elements );
const sameAttributes = state.instanceMatrix === instanceMatrix && state.instanceColor === instanceColor &&
state.attributes.length === attributes.length && state.attributes.every( ( attribute, i ) => attribute === attributes[ i ] );
if ( sameWorld === true && sameAttributes === true && state.count === instanceCount && state.version === version && range === null && state.level === null ) {
slot += instanceCount;
continue;
}
// the slots of the instances written with this transform in the last upload keep it
const keptWorld = sameWorld === true ? Math.min( state.count, instanceCount ) : 0;
const instanceArray = instanceMatrix !== null ? instanceMatrix.array : IDENTITY_MATRIX;
const float32 = instanceArray instanceof Float32Array;
_objectWorld.set( matrixWorld.elements );
state.object = mesh;
state.slot = slot;
state.count = instanceCount;
state.version = version;
state.level = range;
state.matrix.set( matrixWorld.elements );
state.instanceMatrix = instanceMatrix;
state.instanceColor = instanceColor;
state.attributes = attributes;
for ( let i = 0; i < instanceCount; i ++, slot ++ ) {
const local = slot * 16;
const source = i * 16;
let same = true;
if ( float32 === true ) {
for ( let j = 0; j < 16; j ++ ) {
if ( instanceLocalArray[ local + j ] !== instanceArray[ source + j ] ) {
same = false;
break;
}
}
} else {
same = equalsMatrix( instanceLocalArray, local, instanceArray, source );
}
if ( same === false ) {
for ( let j = 0; j < 16; j ++ ) instanceLocalArray[ local + j ] = instanceArray[ source + j ];
transform.mark( slot );
}
if ( i >= keptWorld && equalsMatrix( objectWorldArray, slot * 16, _objectWorld ) === false ) {
objectWorldArray.set( _objectWorld, slot * 16 );
object.mark( slot );
}
// the index of the instance and the instanced attributes of the geometry
const index = slot * stride * 4;
let dataChanged = false;
if ( instanceDataArray[ index ] !== i ) {
instanceDataArray[ index ] = i;
dataChanged = true;
}
for ( let a = 0; a < attributes.length; a ++ ) {
const attribute = attributes[ a ];
const itemSize = attribute.itemSize;
for ( let c = 0; c < itemSize; c ++ ) {
const value = direct[ a ] === true ? attribute.array[ i * itemSize + c ] : Math.fround( attribute.getComponent( i, c ) );
const component = index + ( 1 + a ) * 4 + c;
if ( instanceDataArray[ component ] !== value ) {
instanceDataArray[ component ] = value;
dataChanged = true;
}
}
}
// the color of the instance, multiplied with the color of the material like the regular instancing
if ( this.instanceColor === true ) {
const colors = mesh.instanceColor;
const component = index + ( 1 + attributes.length ) * 4;
for ( let c = 0; c < 3; c ++ ) {
const value = Math.fround( colors.getComponent( i, c ) );
if ( instanceDataArray[ component + c ] !== value ) {
instanceDataArray[ component + c ] = value;
dataChanged = true;
}
}
}
// the bounding sphere of the geometry, where the position node is evaluated
if ( this.deformed === true ) {
const sphere = getBoundingSphere( mesh.geometry );
const component = index + ( stride - 1 ) * 4;
if ( instanceDataArray[ component ] !== Math.fround( sphere.center.x ) || instanceDataArray[ component + 1 ] !== Math.fround( sphere.center.y ) ||
instanceDataArray[ component + 2 ] !== Math.fround( sphere.center.z ) || instanceDataArray[ component + 3 ] !== Math.fround( sphere.radius ) ) {
instanceDataArray.set( [ sphere.center.x, sphere.center.y, sphere.center.z, sphere.radius ], component );
dataChanged = true;
}
}
if ( dataChanged === true ) data.mark( slot );
// the distance range of the object, shared by its instances
if ( writeLevel( levelArray, offset + slot, range ) === true ) level.mark( slot );
}
}
} else {
for ( let o = 0; o < objects.length; o ++ ) {
const elements = objects[ o ].matrixWorld.elements;
const index = offset + o;
if ( equalsMatrix( worldArray, index * 16, elements ) === false ) {
worldArray.set( elements, index * 16 );
writeBounds( boundsArray, index, elements );
transform.mark( o );
}
if ( writeLevel( levelArray, index, levels !== null ? levels[ o ] : null ) === true ) level.mark( o );
}
}
// the update ranges of the buffers
const upload = ( attribute, ranges, start, itemSize, base = 0 ) => {
for ( const [ from, to ] of ranges ) attribute.addUpdateRange( base + ( start + from ) * itemSize, ( to - from ) * itemSize );
if ( ranges.length > 0 ) attribute.needsUpdate = true;
};
const transformRanges = transform.finish();
const objectRanges = object.finish();
upload( culling.levelAttribute, level.finish(), offset, 8 );
if ( this.gpuTransform === true ) {
// the world matrices and the bounds of the culling are computed on the GPU, they must not be uploaded
const layout = this.instanceLayout;
upload( this.instanceAttribute, transformRanges, 0, 16, layout.local );
upload( this.instanceAttribute, objectRanges, 0, 16 );
upload( this.instanceAttribute, data.finish(), 0, this.instanceDataStride * 4, layout.data );
} else {
upload( culling.worldAttribute, transformRanges, offset, 16 );
upload( culling.boundsAttribute, transformRanges, offset, 4 );
}
return { transform: transformRanges, object: objectRanges };
}
/**
* Copies the current world matrices of the given slots to their previous matrices, for the velocity of temporal
* effects. The previous matrices of instanced meshes are computed on the GPU.
*
* @param {number} from - The first slot.
* @param {number} to - The slot after the last slot.
*/
copyHistory( from, to ) {
if ( this.history !== true || this.gpuTransform === true || to <= from ) return;
const culling = this.culling;
const attribute = culling.worldAttribute;
const start = this.dataOffset * 16;
const previous = ( culling.capacity.data + this.dataOffset ) * 16;
attribute.array.copyWithin( previous + from * 16, start + from * 16, start + to * 16 );
attribute.addUpdateRange( previous + from * 16, ( to - from ) * 16 );
attribute.needsUpdate = true;
}
/**
* Returns the compute nodes to dispatch before the culling in this frame: the world matrices and bounds of
* the instances, computed while their matrices change and once more to update their previous matrices.
*
* @param {boolean} [compile=false] - Whether all nodes are returned to be compiled, without dispatching them.
* @return {Array<ComputeNode>} The compute nodes.
*/
getComputeNodesBefore( compile = false ) {
if ( compile === true ) return this.computeNodesBefore;
// the position node can change every frame, the deformed bounds are computed every frame
if ( this.transformNode === null || ( this.transformFrames === 0 && this.deformed === false ) ) return EMPTY_NODES;
if ( this.transformFrames > 0 ) this.transformFrames --;
this.transformNode.count = Math.max( this.count, 1 );
this.transformCount.value = this.count;
this.transformPreviousCount.value = this.transformedCount;
this.transformedCount = this.count;
return this.computeNodesBefore;
}
/**
* Frees the GPU-related resources of the batch.
*/
dispose() {
this.culling.remove( this );
for ( const computeNode of this.computeNodesBefore ) computeNode.dispose();
for ( const computeNode of this.computeNodes ) computeNode.dispose();
for ( const mesh of this.meshes ) mesh.material.dispose();
for ( const mesh of this.prepassMeshes ) mesh.material.dispose();
for ( const geometry of this.ownedGeometries ) geometry.dispose();
for ( const attribute of this.attributes ) attribute.dispose();
}
}
/**
* A GPU-driven drawing pipeline that renders compatible meshes as meshlets. Geometries are processed once into
* clusters (meshlets) of 64 triangles with a chain of simplified levels of detail. Every frame, compute
* shaders select the level of detail per object and cull the objects and clusters against the frustum,
* and the visible clusters of all objects sharing geometry and material are drawn in one indirect draw
* through the hardware rasterizer.
*
* The node graph of the material is evaluated on the pulled vertices (position, normal, model matrices and
* the attributes of the geometry), so custom nodes, lights, shadows and the environment work as usual.
* Supported are opaque and alpha-tested meshes using front side rendering whose material nodes don't change the geometry,
* the coverage or the output of the draw. All other objects, and all objects in render calls using a custom
* render object function like shadow passes, are drawn by the regular pipeline. Objects are drawn by the
* regular pipeline until their geometry is processed.
* Objects affected by clipping planes use the regular pipeline, including their depth-only passes.
* Material properties are read from the source material, so uniform changes do not require rebuilding the
* batches. Meshlets are regenerated when geometry attributes or indices are replaced or marked `needsUpdate`.
* Meshes with an array of materials are processed per geometry group, with separate meshlets and levels of
* detail for each index range. Compatible groups use the GPU-driven pipeline while the other groups use
* the regular pipeline. Meshlets of a group are shared by all meshes using the same geometry and range. The
* batches of the groups of the same objects share the transforms and instance data of the objects, which are
* uploaded once.
*
* The transforms of the objects are compared with the last upload, only the objects and instances which changed
* are uploaded. Objects exceeding {@link MeshletDrawingPipeline#maxInstances} are drawn by the regular pipeline: static
* bundle groups and the objects drawn by the GPU-driven pipeline in the last frames have precedence.
*
* Positions deformed by the context of the render call, like a curved world in the context of a pass, are culled
* by the bounds of the deformed positions which the context provides with a `getBoundingSphere( sphere, cameraPosition )`
* function: it receives the bounding sphere in world space ( `vec4` center and radius ) and returns the bounding
* sphere of the deformed positions. Cone culling is disabled, shadow passes use the real positions.
*
* ```js
* scenePass.contextNode = overrideNodes( [ [ modelViewProjection, curvedProjection ] ], context( {
* getBoundingSphere: ( sphere, cameraPosition ) => curvedSphere( sphere, cameraPosition )
* } ) );
* ```
*
* MRT outputs of the render call and of the materials are evaluated like for the regular meshes. The matrices
* of the objects in the previous frame are kept on the GPU, so the velocity of temporal effects like TRAA
* includes the motion of the objects and the instances.
*
* Objects are culled on the GPU, but the CPU still traverses the scene and uploads the transforms every frame.
* The content of a static {@link BundleGroup} is kept on the GPU instead: the group is captured once, the
* transforms of its meshes are uploaded once and the group is not traversed again until `needsUpdate` is set.
* Changes inside of the group, like transforms or added objects, require `needsUpdate` as with render bundles.
*
* ```js
* renderer.drawingPipeline = new MeshletDrawingPipeline();
*
* const city = new BundleGroup();
* city.add( ...buildings );
* scene.add( city );
* ```
*
* This class is experimental and its interface might change.
*
* @augments StandardDrawingPipeline
*/
class MeshletDrawingPipeline extends StandardDrawingPipeline {
/**
* Constructs a new meshlet drawing pipeline.
*
* @param {Object} [parameters] - The configuration parameter.
* @param {number} [parameters.lodThreshold=1] - The projected error in pixels below which a simpler level of detail is selected.
* @param {number} [parameters.shadowLodThreshold=1] - The projected error in shadow map texels below which a simpler level of detail is selected for shadow casters.
* @param {number} [parameters.minScreenRadius=0] - The radius in pixels below which objects are not drawn, `0` draws all objects.
* @param {number} [parameters.coarseScreenRadius=0] - The radius in pixels below which objects are drawn with their simplest level of detail.
* @param {boolean} [parameters.debug=false] - Whether the clusters should be shown with a flat color each instead of the material.
* @param {boolean} [parameters.occlusionCulling=true] - Whether the clusters hidden by other clusters should be culled.
* @param {boolean} [parameters.depthPrepass=false] - Whether the depth of the clusters should be drawn before their material.
*/
constructor( { lodThreshold = 1, shadowLodThreshold = 1, minScreenRadius = 0, coarseScreenRadius = 0, debug = false, occlusionCulling = true, depthPrepass = false } = {} ) {
super();
/**
* This flag can be used for type testing.
*
* @type {boolean}
* @readonly
* @default true
*/
this.isMeshletDrawingPipeline = true;
/**
* The projected error in pixels below which a simpler level of detail is selected.
*
* @type {number}
* @default 1
*/
this.lodThreshold = lodThreshold;
/**
* The projected error in shadow map texels below which a simpler level of detail is selected for the
* shadow casters. The level of detail of shadow passes doesn't depend on the camera, so shadow maps
* which are not updated every frame stay valid when the camera moves.
*
* @type {number}
* @default 1
*/
this.shadowLodThreshold = shadowLodThreshold;
/**
* The radius of the bounding sphere of objects on the screen in pixels, or in shadow map texels for shadow
* casters, below which they are not drawn. Small objects contribute few pixels, culling them saves the work of
* their clusters. `0` draws all objects.
*
* @type {number}
* @default 0
*/
this.minScreenRadius = minScreenRadius;
/**
* The radius of the bounding sphere of objects on the screen in pixels, or in shadow map texels for shadow
* casters, below which they are drawn with their simplest level of detail, independent of the projected
* error. `0` selects the level by the projected error only.
*
* @type {number}
* @default 0
*/
this.coarseScreenRadius = coarseScreenRadius;
/**
* The clusters and triangles drawn by the GPU-driven pipeline in the main view, read from the counters of the
* culling without waiting for the GPU. The values are updated every interval of frames, the triangles include the degenerate triangles which pad the clusters.
*
* @type {{clusters: number, triangles: number}}
* @readonly
*/
this.info = { clusters: 0, triangles: 0 };
/**
* Whether the clusters (meshlets) of the objects drawn by the GPU-driven pipeline should be shown
* with a flat color each, instead of shading them with their material. Each object uses different
* colors, and the clusters of all levels of detail are distinct.
*
* @type {boolean}
* @default false
*/
this.debug = debug;
/**
* Whether the clusters hidden by other clusters of the GPU-driven pipeline should be culled. The clusters
* visible in the last frame are rendered into a depth buffer first, the other clusters are tested
* against its hierarchical depth. Shadow passes are not occlusion culled.
*
* @type {boolean}
* @default true
*/
this.occlusionCulling = occlusionCulling;
/**
* Whether the depth of the clusters drawn by the hardware rasterizer should be drawn before their material,
* so the material is only evaluated once per pixel. Reduces the overdraw of expensive materials at the cost
* of drawing the clusters twice.
*
* @type {boolean}
* @default false
*/
this.depthPrepass = depthPrepass;
this._occlusionPipeline = new StandardDrawingPipeline();
this._boundsNode = null;
this._ready = false;
this._meshletsEnabled = false;
this._camera = null;
this._depth = 0;
this._gpuProjectionId = 0;
this._clusters = new WeakMap();
this._geometryStates = new WeakMap();
this._checkedGeometries = new WeakSet();
this._geometryPool = new MeshletGeometryPool();
this._records = new Set();
// the meshlets of disposed geometries are removed, they are processed again if the geometry is drawn again
this._onGeometryDispose = ( event ) => this._disposeGeometry( event.target );
this._groupClusters = new WeakMap();
this._materials = new WeakMap();
this._pendingGeometry = null;
this._waitingGeometry = null;
this._renderListBatches = new WeakMap();
this._capture = null;
this._lodRange = null;
this._lodRanges = new WeakMap();
this._meshletsSupported = false;
this._shadowPass = false;
this._sceneProjected = false;
this._lodView = { position: new Vector3(), scale: 1, perspective: 1, threshold: 1, minScreenRadius: 0, coarseScreenRadius: 0 };
clusterProcessingReady.then( () => {
this._ready = true;
} );
}
/**
* The maximum number of objects drawn by the GPU-driven pipeline per render list. The instances of instanced
* meshes count as objects, once per geometry group. Limited by the maximum size of a storage buffer binding:
* the world matrices of the objects and of the last frame use 128 bytes per object.
*
* @type {number}
* @readonly
*/
get maxInstances() {
return Math.min( Math.floor( this._getMaxBindingSize() / 128 ), MAX_INSTANCES );
}
/**
* Returns the maximum size in bytes of a storage buffer binding of the device.
*
* @private
* @return {number} The maximum size in bytes.
*/
_getMaxBindingSize() {
const limits = this.renderer !== null && this.renderer !== undefined ? this.renderer.backend.device?.limits : undefined;
return limits !== undefined ? Math.min( limits.maxStorageBufferBindingSize, limits.maxBufferSize ) : DEFAULT_MAX_BINDING_SIZE;
}
/**
* Prepares the meshlets and geometry LODs before `Renderer.compileAsync()` projects
* the scene. Shared geometries are processed once, including meshes outside the frustum.
* Batches and their render and compute pipelines are then prepared by the renderer.
*
* @async
* @param {Object3D} scene - The scene or object to precompile.
* @param {Camera} camera - The camera.
* @return {Promise} Resolves when the meshlets are ready.
*/
async prepareAsync( scene, camera ) {
if ( this.renderer.backend.isWebGPUBackend !== true ) return;
await clusterProcessingReady;
this._ready = true;
this._checkedGeometries = new WeakSet();
if ( scene.matrixWorldAutoUpdate === true ) scene.updateMatrixWorld();
const geometries = new Map();
const collect = ( object, material, group ) => {
if ( isSupportedObject( object, object.geometry, group, true ) === false || this._isSupportedMaterial( material ) === false ) return;
let groups = geometries.get( object.geometry );
if ( groups === undefined ) geometries.set( object.geometry, groups = new Map() );
groups.set( group !== null ? `${ group.start }:${ group.count }` : null, group );
};
scene.traverse( object => {
if ( object.isMesh !== true || object.layers.test( camera.layers ) === false ) return;
if ( Array.isArray( object.material ) ) {
for ( const group of object.geometry.groups ) {
const material = object.material[ group.materialIndex ];
if ( material ) collect( object, material, group );
}
} else {
collect( object, object.material, null );
}
} );
for ( const [ geometry, groups ] of geometries ) {
for ( const group of groups.values() ) {
if ( this._hasClusters( geometry, group ) ) continue;
this._getClusters( geometry, group, true );
// Yield between submeshes without leaving the renderer in precompilation state.
await new Promise( resolve => setTimeout( resolve, 0 ) );
}
}
}
begin( camera, renderContext ) {
super.begin( camera, renderContext );
this._checkedGeometries = new WeakSet();
// the vertices of disposed geometries are reclaimed before the render call
this._geometryPool.update();
const renderer = this.renderer;
const renderTarget = renderer.getRenderTarget();
this._meshletsSupported = this._ready === true &&
renderer.backend.isWebGPUBackend === true &&
renderer.logarithmicDepthBuffer !== true &&
renderer.reversedDepthBuffer !== true &&
renderer.localClippingEnabled !== true &&
( renderer.clippingPlanes === undefined || renderer.clippingPlanes.length === 0 ) &&
camera.isArrayCamera !== true &&
( renderTarget === null || ( renderTarget.isCubeRenderTarget !== true && renderTarget.depth <= 1 ) );
// render calls with a custom render object function are only supported for shadow passes, see project()
this._meshletsEnabled = this._meshletsSupported === true && renderer.getRenderObjectFunction() === null && camera.isPerspectiveCamera === true;
this._shadowPass = false;
this._sceneProjected = false;
// positions deformed by the context of the render call, like a curved world in the context of a pass, provide
// the bounding sphere of the deformed positions for the culling
const getBoundingSphere = renderer.contextNode.getFlowContextData().getBoundingSphere;
this._boundsNode = typeof getBoundingSphere === 'function' ? getBoundingSphere : null;
this._camera = camera;
this._gpuProjectionId ++;
}
beginRenderList( renderList ) {
super.beginRenderList( renderList );
this._depth ++;
}
project( object, camera, groupOrder, renderList, clippingContext ) {
if ( this._sceneProjected === false && object.isScene === true ) {
this._sceneProjected = true;
// shadow passes draw the shadow casters with the shadow material of the scene and a custom render object function
const renderer = this.renderer;
const overrideMaterial = object.overrideMaterial;
if ( this._meshletsSupported === true && renderer.getRenderObjectFunction() !== null &&
overrideMaterial !== null && overrideMaterial.isShadowPassMaterial === true && renderer.shadowMap.type !== VSMShadowMap &&
( camera.isPerspectiveCamera === true || camera.isOrthographicCamera === true ) ) {
this._meshletsEnabled = true;
this._shadowPass = true;
}
}
// the levels of LOD objects are selected per object on the GPU
if ( object.isLOD === true && this._meshletsEnabled === true && object.levels.length > 0 ) {
if ( object.visible === false ) return;
this._projectLOD( object, camera, groupOrder, renderList, clippingContext );
return;
}
if ( object.isBundleGroup === true ) {
// static bundle groups keep the supported meshes on the GPU and the other objects in the render bundle
if ( this._capture === null && object.static === true && this._meshletsEnabled === true && this._depth === 1 && this.renderer.backend.beginBundle !== undefined ) {
if ( object.visible === false ) return;
this._projectStaticGroup( object, camera, groupOrder, renderList, clippingContext );
return;
}
// bundle groups inside of a captured group are part of it. non-static bundle groups are projected like groups,
// a render bundle would draw their meshes without the GPU-driven pipeline
if ( this._capture !== null || ( object.static !== true && this._meshletsEnabled === true && this._depth === 1 ) ) {
if ( object.visible === false ) return;
if ( object.layers.test( camera.layers ) ) groupOrder = object.renderOrder;
const children = object.children;
for ( let i = 0, l = children.length; i < l; i ++ ) {
this.project( children[ i ], camera, groupOrder, renderList, clippingContext );
}
return;
}
}
super.project( object, camera, groupOrder, renderList, clippingContext );
}
/**
* Projects a LOD object. Instead of selecting a level on the CPU, all levels are projected with the distance
* range of the level: the objects of the GPU-driven pipeline are culled outside of it on the GPU, the other
* objects are culled by the CPU. The distance is measured like {@link LOD#update}, without hysteresis.
*
* Two consecutive levels which are the same geometry with different materials are drawn as one object: the
* object selects the material of the second level beyond its distance, without a second batch.
*
* @private
* @param {LOD} lod - The LOD object.
* @param {Camera} camera - The camera.
* @param {number} groupOrder - The group order.
* @param {RenderList} renderList - The render list.
* @param {ClippingContext} clippingContext - The clipping context.
*/
_projectLOD( lod, camera, groupOrder, renderList, clippingContext ) {
const levels = lod.levels;
let ranges = this._lodRanges.get( lod );
if ( ranges === undefined ) {
ranges = [];
this._lodRanges.set( lod, ranges );
}
const previousRange = this._lodRange;
for ( let i = 0; i < levels.length; i ++ ) {
let range = ranges[ i ];
if ( range === undefined ) {
range = ranges[ i ] = { position: new Vector3(), near: 0, far: 0, switchDistance: NO_LEVEL_DISTANCE, farMaterial: null };
}
const level = levels[ i ].object;
const next = i < levels.length - 1 ? levels[ i + 1 ].object : null;
const merged = next !== null && this._canMergeLevels( level, next );
const last = merged === true ? i + 1 : i;
range.position.setFromMatrixPosition( lod.matrixWorld );
range.near = i === 0 ? - NO_LEVEL_DISTANCE : levels[ i ].distance;
range.far = last < levels.length - 1 ? levels[ last + 1 ].distance : NO_LEVEL_DISTANCE;
range.switchDistance = merged === true ? levels[ i + 1 ].distance : NO_LEVEL_DISTANCE;
range.farMaterial = merged === true ? next.material : null;
// the visibility set by previous updates of the LOD object is ignored
const visible = level.visible;
level.visible = true;
this._lodRange = range;
this.project( level, camera, groupOrder, renderList, clippingContext );
level.visible = visible;
i = last;
}
this._lodRange = previousRange;
// children which are not levels
const children = lod.children;
for ( let i = 0, l = children.length; i < l; i ++ ) {
const child = children[ i ];
if ( levels.some( ( level ) => level.object === child ) === false ) this.project( child, camera, groupOrder, renderList, clippingContext );
}
}
/**
* Returns `true` if two consecutive levels of a LOD object can be drawn as one object of the GPU-driven pipeline,
* which selects the material per object: the same geometry and transform with another supported material.
*
* @private
* @param {Object3D} level - The level.
* @param {Object3D} next - The next level.
* @return {boolean} Whether the levels can be merged.
*/
_canMergeLevels( level, next ) {
return level.isMesh === true && next.isMesh === true && level.isInstancedMesh !== true && next.isInstancedMesh !== true &&
level.geometry === next.geometry && level.material !== next.material && Array.isArray( next.material ) === false &&
level.receiveShadow === next.receiveShadow && level.castShadow === next.castShadow && next.children.length === 0 &&
level.layers.mask === next.layers.mask && level.matrixWorld.equals( next.matrixWorld ) &&
this._isMeshlet( level, level.geometry, level.material, null ) === true && this._isMeshlet( next, next.geometry, next.material, null ) === true;
}
/**
* Returns `true` if the camera is inside of the given distance range of a level of a LOD object.
*
* @private
* @param {?Object} range - The distance range, or `null` if the object is not a level.
* @param {Camera} camera - The camera.
* @return {boolean} Whether the level is selected or not.
*/
_isLevelSelected( range, camera ) {
if ( range === null ) return true;
const levelDistance = _cameraPosition.setFromMatrixPosition( camera.matrixWorld ).distanceTo( range.position ) / camera.zoom;
return levelDistance >= range.near && levelDistance < range.far;
}
isCulled( object, camera ) {
// objects of the GPU-driven pipeline are culled on the GPU, per object and per cluster. the objects
// captured from a static bundle group are culled every frame
if ( Array.isArray( object.material ) ) {
for ( const group of object.geometry.groups ) {
const material = object.material[ group.materialIndex ];
if ( material && material.visible && this._isMeshlet( object, object.geometry, material, group ) && ( this._capture !== null || this._hasClusters( object.geometry, group ) ) ) return false;
}
} else if ( this._isMeshlet( object, object.geometry, object.material, null ) === true && ( this._capture !== null || this._hasClusters( object.geometry, null ) ) ) {
return false;
}
return super.isCulled( object, camera );
}
pushRenderItem( renderList, object, geometry, material, groupOrder, z, group, clippingContext ) {
// Clipping also affects depth-only passes. Keep clipped objects in the regular pipeline so they cannot
// share a batch with another clipping context or occlude objects through their clipped fragments.
const hasClipping = clippingContext.unionPlanes.length > 0 || clippingContext.intersectionPlanes.length > 0;
if ( hasClipping === false && this._capture !== null && this._captureRenderItem( object, geometry, material, groupOrder, z, group, clippingContext ) === true ) {
return;
}
// levels of LOD objects drawn by the regular pipeline in a captured group are selected every frame, outside of the render bundle
if ( this._capture !== null && this._lodRange !== null ) {
this._capture.data.items.push( { object, geometry, material, groupOrder, group, clippingContext, range: this._lodRange } );
return;
}
if ( hasClipping === false && this._capture === null && this._isMeshlet( object, geometry, material, group ) === true ) {
const clusters = this._getClusters( geometry, group );
if ( clusters !== null ) {
// the instances of instanced meshes are drawn as objects. the entries exceeding the maximum number of
// instances of the render list are drawn by the regular pipeline, see _drawBatches()
const record = this._getRecord( renderList );
const entry = this._getEntry( record, clusters, material, object.receiveShadow, object.isInstancedMesh === true, object.isInstancedMesh === true && object.instanceColor !== null, this._getFarMaterial() );
if ( entry.objects.length === 0 ) {
entry.groupOrder = groupOrder;
entry.z = z;
entry.clippingContext = clippingContext;
}
entry.objects.push( object );
entry.objectClusters.push( clusters );
entry.groups.push( group );
entry.levels.push( this._lodRange );
entry.count += object.isInstancedMesh === true ? object.count : 1;
return;
}
}
// levels of LOD objects drawn by the regular pipeline are selected by the CPU
if ( this._isLevelSelected( this._lodRange, this._camera ) === false ) return;
if ( Array.isArray( object.material ) && super.isCulled( object, this._camera ) ) return;
super.pushRenderItem( renderList, object, geometry, material, groupOrder, z, group, clippingContext );
}
/**
* Draws a static bundle group. When the render bundle of the group is recorded, the supported meshes are
* captured into the GPU-driven pipeline: they get fixed slots in its buffers and their transforms are
* uploaded once. The other objects are recorded into the render bundle as usual.
*
* @private
* @param {BundleGroup} bundleGroup - The bundle group.
* @param {Camera} camera - The camera.
* @param {number} groupOrder - The group order.
* @param {RenderList} renderList - The render list.
* @param {ClippingContext} clippingContext - The clipping context.
*/
_projectStaticGroup( bundleGroup, camera, groupOrder, renderList, clippingContext ) {
const renderer = this.renderer;
const record = this._getRecord( renderList );
let data = record.staticGroups.get( bundleGroup );
let capture = false;
if ( data === undefined ) {
data = {
captureId: 0,
pending: false,
entries: [],
lookup: new Map(),
items: [],
instanceCount: 0,
projectionId: - 1,
unusedProjections: 0
};
record.staticGroups.set( bundleGroup, data );
// the render bundle might be recorded without the meshes of the GPU-driven pipeline
capture = true;
}
// capture again while geometries of the group are processed, one per projection
if ( data.pending === true ) capture = true;
const renderBundle = renderer._bundles.get( bundleGroup, camera, renderer._currentRenderContext );
const bundleNeedsUpdate = renderer._bundleNeedsUpdate( bundleGroup, renderer.backend.get( renderBundle ) );
if ( capture === true || bundleNeedsUpdate === true ) {
// the objects leaving the GPU-driven pipeline are recorded into the render bundle
if ( bundleNeedsUpdate === false ) bundleGroup.needsUpdate = true;
data.pending = false;
data.captureId ++;
data.instanceCount = 0;
data.items.length = 0;
for ( const entry of data.entries ) {
entry.objects.length = 0;
entry.objectClusters.length = 0;
entry.groups.length = 0;
entry.levels.length = 0;
entry.count = 0;
entry.version = data.captureId;
}
this._capture = { data, clippingContext };
}
super.project( bundleGroup, camera, groupOrder, renderList, clippingContext );
this._capture = null;
for ( const item of data.items ) {
if ( this._isLevelSelected( item.range, camera ) === true ) this._pushCapturedItem( renderList, camera, item );
}
// static bundle groups have precedence, the entries of the other objects use the remaining instances
if ( record.staticInstanceCount + data.instanceCount <= this.maxInstances ) {
data.projectionId = this._gpuProjectionId;
record.staticInstanceCount += data.instanceCount;
} else {
// the GPU-driven pipeline of the render list is full
for ( const entry of data.entries ) this._pushEntryItems( renderList, entry );
}
}
/**
* Captures a render item of the static bundle group being recorded into the GPU-driven pipeline,
* if supported.
*
* @private
* @param {Object3D} object - The 3D object.
* @param {BufferGeometry} geometry - The geometry.
* @param {Material} material - The material.
* @param {number} groupOrder - The group order.
* @param {number} z - The depth of the object.
* @param {?Object} group - The geometry group.
* @param {ClippingContext} clippingContext - The clipping context.
* @return {boolean} Whether the render item was captured, otherwise it is recorded into the render bundle.
*/
_captureRenderItem( object, geometry, material, groupOrder, z, group, clippingContext ) {
const { data } = this._capture;
// objects inside of clipping groups are clipped by the regular pipeline
if ( clippingContext !== this._capture.clippingContext || this._isMeshlet( object, geometry, material, group ) === false ) return false;
const clusters = this._getClusters( geometry, group );
if ( clusters === null ) {
data.pending = true;
return false;
}
const count = object.isInstancedMesh === true ? object.count : 1;
const entry = this._getStaticEntry( data, clusters, material, object.receiveShadow, object.isInstancedMesh === true, object.isInstancedMesh === true && object.instanceColor !== null, this._getFarMaterial() );
if ( entry.objects.length === 0 ) {
entry.groupOrder = groupOrder;
entry.z = z;
entry.clippingContext = clippingContext;
}
entry.objects.push( object );
entry.objectClusters.push( clusters );
entry.groups.push( group );
entry.levels.push( this._lodRange );
entry.count += count;
data.instanceCount += count;
return true;
}
/**
* Pushes a captured render item which can not be drawn by the GPU-driven pipeline, culled by the CPU.
*
* @private
* @param {RenderList} renderList - The render list.
* @param {Camera} camera - The camera.
* @param {Object} item - The captured render item.
*/
_pushCapturedItem( renderList, camera, item ) {
const { object, geometry, material, groupOrder, group, clippingContext } = item;
if ( super.isCulled( object, camera ) === true ) return;
if ( this.renderer.sortObjects === true ) {
if ( object.isSprite === true ) {
_vector4.setFromMatrixPosition( object.matrixWorld );
} else {
if ( geometry.boundingSphere === null ) geometry.computeBoundingSphere();
_vector4.copy( geometry.boundingSphere.center ).applyMatrix4( object.matrixWorld );
}
_vector4.w = 1;
_vector4.applyMatrix4( this._projScreenMatrix );
}
super.pushRenderItem( renderList, object, geometry, material, groupOrder, _vector4.z, group, clippingContext );
}
/**
* Pushes the objects of an entry which is not drawn by the GPU-driven pipeline as render items of the
* regular pipeline, culled by the CPU.
*
* @private
* @param {RenderList} renderList - The render list.
* @param {Object} entry - The entry.
*/
_pushEntryItems( renderList, entry ) {
const camera = this._camera;
for ( let i = 0; i < entry.objects.length; i ++ ) {
const object = entry.objects[ i ];
if ( this._isLevelSelected( entry.levels[ i ], camera ) === false ) continue;
this._pushCapturedItem( renderList, camera, { object, geometry: object.geometry, material: entry.material, groupOrder: entry.groupOrder, group: entry.groups[ i ], clippingContext: entry.clippingContext } );
}
}
/**
* Returns the entry of a static bundle group collecting the objects of the given geometry, material and
* shadow state. The entries keep their batches between captures.
*
* @private
* @param {Object} data - The data of the static bundle group.
* @param {Object} clusters - The cluster data of the geometry.
* @param {Material} material - The material.
* @param {boolean} receiveShadow - Whether the objects receive shadows.
* @param {boolean} instanced - Whether the objects are instanced meshes.
* @param {boolean} instanceColor - Whether the instanced meshes have instance colors.
* @param {?Material} farMaterial - The material beyond the switch distance of merged levels.
* @return {Object} The entry.
*/
_getStaticEntry( data, clusters, material, receiveShadow, instanced, instanceColor, farMaterial ) {
let byClusters = data.lookup.get( material );
if ( byClusters === undefined ) {
byClusters = new Map();
data.lookup.set( material, byClusters );
}
// the geometries of a batch key share the batch, see _getBatchKey()
const batchKey = this._getBatchKey( clusters );
let byShadow = byClusters.get( batchKey );
if ( byShadow === undefined ) {
byShadow = new Map();
byClusters.set( batchKey, byShadow );
}
const key = getEntryKey( receiveShadow, instanced, instanceColor, farMaterial );
let entry = byShadow.get( key );
if ( entry === undefined ) {
entry = {
clusters,
batchKey,
material,
farMaterial,
receiveShadow,
instanced,
instanceColor,
key,
objects: [],
objectClusters: [],
groups: [],
levels: [],
count: 0,
groupOrder: 0,
z: 0,
clippingContext: null,
source: null,
queueBudget: Infinity, // all clusters until measured, so a single render like a shadow map isn't incomplete
queueMeasured: false,
maxChunks: 0,
geometryIndices: null,
batch: null,
version: data.captureId
};
byShadow.set( key, entry );
data.entries.push( entry );
}
return entry;
}
/**
* Returns `true` if the given object can be drawn by the GPU-driven pipeline in the current render call.
* Render lists of render bundles are recorded once, so only the objects of the top level render list
* and the objects captured from static bundle groups are drawn by the GPU-driven pipeline, which runs
* every frame.
*
* @private
* @param {Object3D} object - The 3D object.
* @param {BufferGeometry} geometry - The geometry.
* @param {Material|Array<Material>} material - The material.
* @param {?Object} group - The geometry group.
* @return {boolean} Whether the object can be drawn by the GPU-driven pipeline or not.
*/
_isMeshlet( object, geometry, material, group ) {
return this._meshletsEnabled === true && ( this._depth === 1 || this._capture !== null ) && ( this._shadowPass === false || object.castShadow === true ) && isSupportedObject( object, geometry, group ) && this._isSupportedMaterial( material );
}
/**
* Returns `true` if objects with the given material can be drawn by the GPU-driven pipeline.
* The result is cached per material version.
*
* @private
* @param {Material} material - The material.
* @return {boolean} Whether the material is supported or not.
*/
_isSupportedMaterial( material ) {
let cache = this._materials.get( material );
if ( cache === undefined || cache.version !== material.version ) {
cache = { version: material.version, supported: isSupportedMaterial( material, this.renderer ) };
this._materials.set( material, cache );
}
return cache.supported;
}
finishRenderList( renderList ) {
this._depth --;
const record = this._renderListBatches.get( renderList );
if ( record !== undefined ) this._drawBatches( renderList, record );
super.finishRenderList( renderList );
}
/**
* Prepares the view of the given render list, for pipelines using screen-space resources.
*
* @param {Object} record - The record of the render list.
* @param {number} width - The width of the view in pixels.
* @param {number} height - The height of the view in pixels.
* @return {?Object} The view, or `null` if the draw doesn't need one.
*/
updateView( record, width, height ) {
const occlusion = this.occlusionCulling === true && this._shadowPass === false;
let view = record.view;
if ( view !== null && ( view.width !== width || view.height !== height || ( view.occlusion !== null ) !== occlusion ) ) {
view.dispose();
view = record.view = null;
}
if ( view === null ) view = record.view = this.createView( width, height, occlusion );
return view;
}
/**
* Creates the view of a render list.
*
* @param {number} width - The width of the view in pixels.
* @param {number} height - The height of the view in pixels.
* @param {boolean} occlusion - Whether the clusters are occlusion culled.
* @param {boolean} shadow - Whether the culling draws a shadow map.
* @param {number} [clusterBudget=0] - The measured number of clusters of the visible objects.
* @return {?Object} The view, or `null` if the draw doesn't need one.
*/
createView( width, height, occlusion ) {
if ( occlusion === false ) return null;
const view = {
width,
height,
occlusion: createOcclusion( width, height ),
computeClear: null,
dispose() {
view.occlusion.dispose();
}
};
return view;
}
/**
* Creates the batch drawing the objects of the given entry.
*
* @param {Object} entry - The objects sharing geometry, material and shadow state.
* @param {number} capacity - The maximum number of objects.
* @param {?Object} view - The view of the render list.
* @param {MeshletCulling} culling - The culling of the render list.
* @return {MeshletBatch} The batch.
*/
createBatch( entry, capacity, view, culling ) {
return new MeshletBatch( entry.clusters, entry.material, entry.receiveShadow, capacity, this.renderer, culling, {
debug: this.debug,
depthPrepass: this._usesDepthPrepass(),
instanced: entry.instanced,
instanceColor: entry.instanceColor,
farMaterial: entry.farMaterial,
source: entry.source !== null ? entry.source.batch : null,
queueBudget: entry.queueBudget,
maxChunks: entry.maxChunks,
pool: this._geometryPool,
view
} );
}
/**
* Returns `true` if the batches of the current render call draw the depth of the clusters before their material.
*
* @private
* @return {boolean} Whether the depth prepass is used or not.
*/
_usesDepthPrepass() {
return this.depthPrepass === true && this._shadowPass === false;
}
/**
* Runs the GPU-driven pipeline of the objects collected in this projection and adds the meshes of
* their batches to the render list. Batches are created and rebuilt here, once the view is known,
* and disposed if they were not used for several projections.
*
* @private
* @param {RenderList} renderList - The render list.
* @param {Object} record - The objects of the render list.
*/
_drawBatches( renderList, record ) {
const renderer = this.renderer;
const renderTarget = renderer.getRenderTarget();
if ( renderTarget !== null ) _size.set( renderTarget.width, renderTarget.height );
else renderer.getDrawingBufferSize( _size );
const width = Math.floor( _size.x );
const height = Math.floor( _size.y );
// the level of detail is selected by the projected error in pixels of the view. shadow passes select it
// by the texels of the shadow map, independent of the camera, so cached shadow maps stay valid
const camera = this._camera;
const lodView = this._lodView;
lodView.position.setFromMatrixPosition( camera.matrixWorld );
lodView.scale = camera.projectionMatrix.elements[ 5 ] * height / 2;
lodView.perspective = camera.isPerspectiveCamera === true ? 1 : 0;
lodView.threshold = this._shadowPass === true ? this.shadowLodThreshold : this.lodThreshold;
lodView.minScreenRadius = this.minScreenRadius;
lodView.coarseScreenRadius = this.coarseScreenRadius;
const view = this.updateView( record, width, height );
const occlusion = view !== null ? view.occlusion : null;
const boundsNode = this._shadowPass === true ? null : this._boundsNode;
// the culling of the render list is created again with its batches if the view changed
if ( record.culling !== null && ( record.culling.view !== view || record.culling.occlusion !== occlusion || record.culling.boundsNode !== boundsNode ) ) {
this._disposeCulling( record );
}
// the entries collected in this projection
const dynamicEntries = [];
const staticEntries = [];
for ( let i = record.entries.length - 1; i >= 0; i -- ) {
const entry = record.entries[ i ];
if ( entry.projectionId !== this._gpuProjectionId ) {
if ( ++ entry.unusedProjections >= MAX_UNUSED_PROJECTIONS ) {
if ( entry.batch !== null ) entry.batch.dispose();
record.entries.splice( i, 1 );
// the maps of the lookup are removed with their last entry, so disposed materials can be collected
const byKey = record.lookup.get( entry.material );
const byShadow = byKey.get( entry.batchKey );
byShadow.delete( entry.key );
if ( byShadow.size === 0 ) byKey.delete( entry.batchKey );
if ( byKey.size === 0 ) record.lookup.delete( entry.material );
}
continue;
}
dynamicEntries.push( entry );
}
// static bundle groups
for ( const [ bundleGroup, data ] of record.staticGroups ) {
if ( data.projectionId !== this._gpuProjectionId ) {
if ( ++ data.unusedProjections >= MAX_UNUSED_PROJECTIONS ) {
for ( const entry of data.entries ) {
if ( entry.batch !== null ) entry.batch.dispose();
}
record.staticGroups.delete( bundleGroup );
}
continue;
}
data.unusedProjections = 0;
for ( const entry of data.entries ) {
if ( entry.objects.length > 0 ) staticEntries.push( entry );
}
}
if ( record.entries.length === 0 && record.staticGroups.size === 0 ) {
this._disposeCulling( record );
if ( record.view !== null ) {
record.view.dispose();
record.view = null;
}
}
// the entries of the same objects, like the groups of meshes with multiple materials, share the data of the
// objects. the first entry of a set writes it
const dynamicSets = getEntrySets( dynamicEntries );
const staticSets = getEntrySets( staticEntries );
// the instances not used by static bundle groups. the sets which have batches are drawn first, so the entries
// of the GPU-driven pipeline don't change if the objects exceed the maximum number of instances. the other
// entries are drawn by the regular pipeline and keep their batches for a while
let available = this.maxInstances - record.staticInstanceCount;
dynamicSets.sort( ( a, b ) => ( b[ 0 ].batch !== null ? 1 : 0 ) - ( a[ 0 ].batch !== null ? 1 : 0 ) );
const active = [];
for ( const set of dynamicSets ) {
let count = 0;
for ( const entry of set ) count += entry.count;
if ( count <= available ) {
available -= count;
for ( const entry of set ) {
entry.unusedProjections = 0;
active.push( entry );
}
continue;
}
for ( const entry of set ) {
if ( entry.batch !== null && ++ entry.unusedProjections >= MAX_UNUSED_PROJECTIONS ) {
entry.batch.dispose();
entry.batch = null;
}
this._pushEntryItems( renderList, entry );
}
}
for ( const set of staticSets ) active.push( ...set );
if ( active.length === 0 ) return;
// the batches: created again if the objects exceed the capacity, the material, the debug mode or the batch sharing the
// objects changed. a batch which doesn't fit into the buffers of the culling creates the culling again, sized for all
// batches. batches sharing the objects of another batch are created after it, with its capacity
const getSource = ( entry ) => entry.source !== null ? entry.source.batch : null;
const needsBatch = ( entry ) => {
const batch = entry.batch;
return batch === null || batch.added === false || batch.culling !== record.culling || entry.count > batch.capacity || batch.depthPrepass !== this._usesDepthPrepass() ||
batch.materialVersion !== entry.material.version || ( batch.farMaterial !== null && batch.farMaterialVersion !== batch.farMaterial.version ) || batch.debug !== this.debug ||
batch.source !== getSource( entry ) || ( entry.source !== null && batch.capacity !== entry.source.batch.capacity );
};
const getCapacity = ( entry ) => Math.min( Math.max( entry.batch !== null ? entry.batch.capacity : 64, Math.ceil( entry.count * BATCH_GROWTH / 64 ) * 64 ), this.maxInstances );
// the clusters per object of a batch: the most detailed level of the geometries of its objects
for ( const entry of active ) {
let maxChunks = 0;
for ( const clusters of entry.objectClusters ) maxChunks = Math.max( maxChunks, clusters.maxChunks );
entry.maxChunks = maxChunks;
}
// while geometries wait to be processed, one per projection, the culling and the batches are not created again for
// each processed geometry. the entries which don't fit into them are drawn by the regular pipeline, the culling
// is sized once for all entries when the last geometry is processed
if ( this._waitingGeometry === this._gpuProjectionId ) {
const culling = record.culling;
const resize = culling === null || record.needsResize === true || record.poolVersion !== this._geometryPool.version;
const waiting = new Set();
for ( const entry of active ) {
const batch = entry.batch;
if ( resize === true || needsBatch( entry ) === true || entry.maxChunks > batch.maxChunks || ( entry.source !== null && waiting.has( entry.source ) ) ||
entry.objectClusters.some( ( clusters ) => culling.getGeometry( clusters ) === - 1 ) ) {
waiting.add( entry );
}
}
for ( let i = active.length - 1; i >= 0; i -- ) {
if ( waiting.has( active[ i ] ) === false ) continue;
this._pushEntryItems( renderList, active[ i ] );
active.splice( i, 1 );
}
if ( active.length === 0 ) return;
}
// the culling is resized if the visible clusters exceeded a budget in the last read of its counters
let rebuild = record.culling === null || record.needsResize === true || record.poolVersion !== this._geometryPool.version;
record.needsResize = false;
// the culling is resized for geometries which don't fit into its table, or objects with more clusters than a batch
if ( rebuild === false ) {
rebuild = this._addGeometries( record.culling, active ) === false || active.some( ( entry ) => entry.batch !== null && entry.maxChunks > entry.batch.maxChunks );
}
if ( rebuild === false ) {
for ( const entry of active ) {
if ( needsBatch( entry ) === false ) continue;
const capacity = entry.source !== null ? entry.source.batch.capacity : getCapacity( entry );
if ( entry.batch !== null ) entry.batch.dispose();
entry.batch = this.createBatch( entry, capacity, view, record.culling );
if ( entry.batch.added === false ) {
rebuild = true;
break;
}
}
}
if ( rebuild === true ) {
// the culling is resized for all batches. the batches which can be kept are added to the resized culling, so
// their materials and pipelines stay valid. a batch sharing the objects of another batch is kept with it
const capacities = new Map();
const kept = new Set();
for ( const entry of active ) {
if ( record.culling !== null && needsBatch( entry ) === false && ( entry.source === null || kept.has( entry.source ) ) ) {
kept.add( entry );
capacities.set( entry, entry.batch.capacity );
entry.batch.maxChunks = Math.max( entry.batch.maxChunks, entry.maxChunks );
} else {
capacities.set( entry, entry.source !== null ? capacities.get( entry.source ) : getCapacity( entry ) );
}
}
const capacity = this._getCullingCapacity( active, active.map( ( entry ) => capacities.get( entry ) ), occlusion !== null, this._shadowPass, record.clusterBudget );
if ( record.culling === null ) {
record.culling = new MeshletCulling( capacity, { shadow: this._shadowPass, occlusion, boundsNode, view } );
} else {
this._disposeBatches( record, kept );
record.culling.resize( capacity );
}
this._addGeometries( record.culling, active );
record.poolVersion = this._geometryPool.version;
// the budgets of the new buffers are checked after a few projections, the occlusion culling needs the visible
// clusters of a frame. without it they are checked in this projection, so a render which isn't repeated like
// a static shadow map is measured too
record.readbackProjections = COUNTER_READBACK_INTERVAL - ( occlusion !== null ? 2 : 1 );
for ( const entry of active ) {
if ( kept.has( entry ) === true ) {
entry.batch.queueBudget = entry.queueBudget;
if ( entry.batch.relocate() === true ) continue;
}
if ( entry.batch !== null ) entry.batch.dispose();
entry.batch = this.createBatch( entry, capacities.get( entry ), view, record.culling );
}
}
const culling = record.culling;
culling.updateView( camera, this._projScreenMatrix, this._frustum, width, height, lodView );
// the objects of the batches not drawn in this projection are not culled
for ( const batch of culling.batches ) culling.setObjectCount( batch, 0 );
const compile = renderer._compilationPromises !== null;
const batchComputeNodes = [];
const computeNodesBefore = [];
let instanceOffset = 0;
for ( const entry of active ) {
const batch = entry.batch;
// batches drawing into the view own a slice of its instance index range
if ( batch.instanceOffset !== undefined ) {
batch.instanceOffset.value = instanceOffset;
instanceOffset += entry.count;
}
// the transforms of static bundle groups are uploaded once per capture
const upload = entry.version === undefined || batch.uploadedVersion !== entry.version;
// the geometry of each object, its index in the table of the culling
const geometryIndices = entry.geometryIndices || ( entry.geometryIndices = [] );
geometryIndices.length = entry.objectClusters.length;
for ( let i = 0; i < geometryIndices.length; i ++ ) geometryIndices[ i ] = culling.getGeometry( entry.objectClusters[ i ] );
batch.update( entry.objects, this, upload, entry.levels, geometryIndices );
batch.uploadedVersion = entry.version;
computeNodesBefore.push( ...batch.getComputeNodesBefore( compile ) );
batchComputeNodes.push( ...batch.computeNodes );
}
culling.updateTable();
const computeNodes = [];
// the occlusion culling is skipped while it culls few clusters, except in the projections measuring it again.
// a measure needs the visible clusters of the previous projections in the same buffers
const occlusionActive = occlusion !== null && ( record.occlusionSkipped === false || record.readbackProjections >= COUNTER_READBACK_INTERVAL - OCCLUSION_PROBE_PROJECTIONS );
record.occlusionProjections = occlusionActive === true && record.occlusionVisibility === culling.visibilityAttribute ? record.occlusionProjections + 1 : 0;
record.occlusionVisibility = culling.visibilityAttribute;
if ( occlusion !== null ) computeNodes.push( ...occlusion.computeNodes );
if ( view !== null && view.computeClear !== null ) computeNodes.push( view.computeClear );
computeNodes.push( ...culling.computeNodes, ...batchComputeNodes );
// the world matrices and bounds of instances which changed are computed before the culling
const cullNodes = computeNodesBefore.length > 0 ? [ ...computeNodesBefore, ...culling.cullNodes ] : culling.cullNodes;
if ( compile === true ) {
// Compile all compute stages asynchronously, without dispatching the culling.
const skipNodes = occlusion !== null ? culling.skipOcclusionNodes : [];
renderer._compilationPromises.push( { computeNodes: [ ...cullNodes, ...computeNodes, ...skipNodes ] } );
if ( occlusion !== null ) this._renderOcclusionDepth( occlusion, active );
} else if ( occlusionActive === true ) {
// first phase, the depth of the clusters visible in the last frame and the second phase
renderer.compute( cullNodes );
this._renderOcclusionDepth( occlusion, active );
renderer.compute( computeNodes );
} else if ( occlusion !== null ) {
renderer.compute( [ ...computeNodesBefore, ...culling.skipOcclusionNodes, ...( view.computeClear !== null ? [ view.computeClear ] : [] ), ...batchComputeNodes ] );
} else {
// one compute call for all batches of the render list
renderer.compute( [ ...cullNodes, ...computeNodes ] );
}
if ( compile === false ) this._readCounters( record, active, record.occlusionProjections >= OCCLUSION_PROBE_PROJECTIONS );
for ( const entry of active ) {
const batch = entry.batch;
// the resolve of the visibility buffer draws one box per object
if ( batch.resolveMesh !== undefined ) batch.resolveMesh.count = entry.count;
for ( const mesh of batch.meshes ) {
super.pushRenderItem( renderList, mesh, mesh.geometry, mesh.material, entry.groupOrder, entry.z, null, entry.clippingContext );
}
}
}
/**
* Reads the counters of the culling of a render list every interval of projections, without waiting for the
* GPU. The visible clusters of the last frame which exceeded the budget of the clusters or the queue of a batch
* were not drawn: the budgets grow and the culling is resized in the next projection. The first read of a budget
* calibrates its estimate.
*
* @private
* @param {Object} record - The record of the render list.
* @param {Array<Object>} entries - The entries drawn in this projection.
* @param {boolean} occlusionMeasured - Whether the occlusion culling of this projection is measured.
*/
_readCounters( record, entries, occlusionMeasured ) {
if ( record.readback !== null || ++ record.readbackProjections < COUNTER_READBACK_INTERVAL ) return;
record.readbackProjections = 0;
const culling = record.culling;
const attribute = culling.countersAttribute;
const batches = entries.map( ( entry ) => [ entry, entry.batch, entry.batch.index, entry.batch.queueCapacity ] );
record.readback = this.renderer.getArrayBufferAsync( attribute ).then( ( buffer ) => {
record.readback = null;
// the counters of a culling which was resized or disposed since are not valid anymore
if ( record.culling !== culling || culling.countersAttribute !== attribute ) return;
const counters = new Uint32Array( buffer );
let exceeded = false;
let shrink = false;
let drawn = 0;
if ( counters[ 0 ] > culling.capacity.clusters ) {
record.clusterBudget = Math.ceil( counters[ 0 ] * CULLING_GROWTH );
exceeded = true;
}
for ( const [ entry, batch, index, queueCapacity ] of batches ) {
const visible = Math.max( counters[ 1 + index * 2 ], batch.farMaterial !== null ? counters[ 2 + index * 2 ] : 0 );
drawn += Math.min( counters[ 1 + index * 2 ], queueCapacity ) + ( batch.farMaterial !== null ? Math.min( counters[ 2 + index * 2 ], queueCapacity ) : 0 );
if ( visible > queueCapacity ) {
entry.queueBudget = Math.ceil( visible * CULLING_GROWTH );
exceeded = true;
} else if ( entry.queueMeasured === false ) {
// the first measure replaces the budget of all clusters, the buffers shrink if much larger
entry.queueBudget = Math.ceil( visible * CULLING_GROWTH );
if ( queueCapacity > getQueueCapacity( batch.capacity, batch.maxChunks, entry.queueBudget ) * 4 ) shrink = true;
}
entry.queueMeasured = true;
}
// the statistics of the main view
if ( culling.shadow === false ) {
this.info.clusters = drawn;
this.info.triangles = drawn * MESHLET_SIZE;
}
if ( occlusionMeasured === true ) {
const occluded = counters[ culling.occludedCounter.value ];
record.occlusionSkipped = occluded < ( occluded + drawn ) * OCCLUSION_MIN_CULLED;
}
if ( exceeded === true ) {
record.needsResize = true;
} else if ( shrink === true ) {
this._shrinkCulling( record, batches.map( ( [ entry ] ) => entry ) );
}
} ).catch( () => {
record.readback = null;
} );
}
/**
* Shrinks the buffers of a culling to the measured budgets of the given entries, right after the read of the
* counters, so a render which isn't repeated like a static shadow map doesn't keep the larger buffers. The
* batches are added to the resized culling like in a rebuild. If one doesn't fit, the culling is rebuilt in
* its next projection.
*
* @private
* @param {Object} record - The record of the render list.
* @param {Array<Object>} entries - The entries drawn in the measured projection.
*/
_shrinkCulling( record, entries ) {
const culling = record.culling;
if ( entries.some( ( entry ) => entry.batch === null || entry.batch.culling !== culling ) ) {
record.needsResize = true;
return;
}
const capacity = this._getCullingCapacity( entries, entries.map( ( entry ) => entry.batch.capacity ), culling.occlusion !== null, culling.shadow, record.clusterBudget );
this._disposeBatches( record, new Set( entries ) );
culling.resize( capacity );
if ( this._addGeometries( culling, entries ) === false ) record.needsResize = true;
record.poolVersion = this._geometryPool.version;
for ( const entry of entries ) {
entry.batch.queueBudget = entry.queueBudget;
if ( entry.batch.relocate() === false ) record.needsResize = true;
}
}
/**
* Returns the capacity of the buffers of a culling for the given entries, with room for more objects and batches.
*
* @private
* @param {Array<Object>} entries - The entries.
* @param {Array<number>} capacities - The capacities of the batches of the entries.
* @param {boolean} occlusion - Whether the clusters are occlusion culled.
* @param {boolean} shadow - Whether the culling draws a shadow map.
* @param {number} [clusterBudget=0] - The measured number of clusters of the visible objects.
* @return {Object} The capacity.
*/
_getCullingCapacity( entries, capacities, occlusion, shadow, clusterBudget = 0 ) {
const capacity = { batches: 0, objects: 0, data: 0, queue: 0, clusters: 0, visibility: 0, chunks: 0, geometries: 0 };
const geometries = new Set();
for ( let i = 0; i < entries.length; i ++ ) {
const { farMaterial, source, batch } = entries[ i ];
const objects = capacities[ i ];
const queueCount = farMaterial !== null && shadow === false ? 2 : 1;
const maxChunks = Math.max( entries[ i ].maxChunks, batch !== null ? batch.maxChunks : 0 );
capacity.batches ++;
capacity.objects += objects;
capacity.data += source !== null ? 0 : objects;
capacity.queue += getQueueCapacity( objects, maxChunks, entries[ i ].queueBudget ) * queueCount;
capacity.clusters += objects * maxChunks;
capacity.visibility += occlusion === true ? objects * maxChunks : 0;
for ( const clusters of entries[ i ].objectClusters ) {
if ( geometries.has( clusters ) === false ) {
geometries.add( clusters );
capacity.chunks += clusters.chunkCount;
}
}
}
capacity.geometries = Math.min( Math.max( geometries.size * 2, 64 ), MAX_GEOMETRIES );
// room for more batches and objects without creating the culling again, the capacities of the batches have
// room for more objects as well. the buffers are limited by the maximum size of a storage buffer binding
const maxBindingSize = this._getMaxBindingSize();
const grow = ( value, minimum, bytes ) => Math.min( Math.max( Math.ceil( value * CULLING_GROWTH ), minimum ), Math.floor( maxBindingSize / bytes ) );
capacity.batches = Math.max( capacity.batches * 2, 16 );
capacity.objects = grow( capacity.objects, 1024, 16 );
capacity.data = grow( capacity.data, 1024, shadow === true ? 64 : 128 );
capacity.queue = grow( capacity.queue, 1024, 16 );
capacity.clusters = grow( Math.max( Math.min( capacity.clusters, Math.max( capacity.objects * CLUSTERS_PER_OBJECT, MIN_CLUSTER_BUDGET ) ), clusterBudget ), 1024, 4 );
capacity.visibility = grow( capacity.visibility, 0, 4 );
capacity.chunks = Math.max( capacity.chunks * 2, 1024 );
return capacity;
}
/**
* Disposes the batches of a render list, except the given entries.
*
* @private
* @param {Object} record - The record of the render list.
* @param {Set<Object>} kept - The entries keeping their batches.
*/
_disposeBatches( record, kept ) {
const dispose = ( entry ) => {
if ( entry.batch !== null && kept.has( entry ) === false ) {
entry.batch.dispose();
entry.batch = null;
}
};
for ( const entry of record.entries ) dispose( entry );
for ( const data of record.staticGroups.values() ) {
for ( const entry of data.entries ) dispose( entry );
}
}
/**
* Disposes the culling of a render list and its batches.
*
* @private
* @param {Object} record - The record of the render list.
*/
_disposeCulling( record ) {
if ( record.culling === null ) return;
const dispose = ( entry ) => {
if ( entry.batch !== null ) {
entry.batch.dispose();
entry.batch = null;
}
};
for ( const entry of record.entries ) dispose( entry );
for ( const data of record.staticGroups.values() ) {
for ( const entry of data.entries ) dispose( entry );
}
record.culling.dispose();
record.culling = null;
}
/**
* Renders the depth of the clusters of the first phase into the depth of the occlusion view.
*
* @private
* @param {Object} occlusion - The occlusion data of the view.
* @param {Array<Object>} entries - The entries drawn in this projection.
*/
_renderOcclusionDepth( occlusion, entries ) {
const renderer = this.renderer;
const scene = occlusion.scene;
scene.children.length = 0;
for ( const entry of entries ) scene.children.push( ...entry.batch.prepassMeshes );
if ( renderer._compilationPromises !== null ) {
const previousRenderContext = renderer._currentRenderContext;
const previousRenderTarget = renderer.getRenderTarget();
const previousActiveCubeFace = renderer.getActiveCubeFace();
const previousActiveMipmapLevel = renderer.getActiveMipmapLevel();
const previousMRT = renderer.getMRT();
const target = occlusion.target;
const renderContext = renderer._renderContexts.get( target, null, renderer._callDepth + 1, this._camera );
renderer._textures.updateRenderTarget( target );
const targetData = renderer._textures.get( target );
renderContext.textures = targetData.textures;
renderContext.depthTexture = targetData.depthTexture;
renderContext.depth = target.depthBuffer;
renderContext.stencil = target.stencilBuffer;
renderContext.renderTarget = target;
renderContext.width = target.width;
renderContext.height = target.height;
renderContext.clippingContext = previousRenderContext.clippingContext;
try {
renderer._currentRenderContext = renderContext;
renderer.setRenderTarget( target );
renderer.setMRT( null );
const renderList = renderer._renderLists.get( scene, this._camera, renderer.lighting );
this._occlusionPipeline.renderer = renderer;
this._occlusionPipeline.begin( this._camera, renderContext );
this._occlusionPipeline.beginRenderList( renderList );
this._occlusionPipeline.project( scene, this._camera, 0, renderList, renderContext.clippingContext );
this._occlusionPipeline.finishRenderList( renderList );
renderer._renderObjects( renderList.opaque, this._camera, scene, renderList.lightsNode );
} finally {
renderer._currentRenderContext = previousRenderContext;
renderer.setRenderTarget( previousRenderTarget, previousActiveCubeFace, previousActiveMipmapLevel );
renderer.setMRT( previousMRT );
scene.children.length = 0;
}
return;
}
const currentPipeline = renderer.drawingPipeline;
const currentRenderTarget = renderer.getRenderTarget();
const currentActiveCubeFace = renderer.getActiveCubeFace();
const currentActiveMipmapLevel = renderer.getActiveMipmapLevel();
const currentAutoClear = renderer.autoClear;
const currentAutoClearDepth = renderer.autoClearDepth;
const currentMRT = renderer.getMRT();
// the meshes are drawn as they are, without the GPU-driven pipeline
renderer.drawingPipeline = this._occlusionPipeline;
renderer.setMRT( null );
renderer.autoClear = true;
renderer.autoClearDepth = true;
renderer.setRenderTarget( occlusion.target );
renderer.render( scene, this._camera );
renderer.setRenderTarget( currentRenderTarget, currentActiveCubeFace, currentActiveMipmapLevel );
renderer.autoClear = currentAutoClear;
renderer.autoClearDepth = currentAutoClearDepth;
renderer.setMRT( currentMRT );
renderer.drawingPipeline = currentPipeline;
scene.children.length = 0;
}
/**
* Returns the record of the given render list for the current projection.
*
* @private
* @param {RenderList} renderList - The render list.
* @return {Object} The record.
*/
_getRecord( renderList ) {
let record = this._renderListBatches.get( renderList );
if ( record === undefined ) {
record = { entries: [], lookup: new Map(), staticGroups: new Map(), view: null, culling: null, projectionId: - 1, staticInstanceCount: 0, poolVersion: - 1, clusterBudget: 0, needsResize: false, readback: null, readbackProjections: 0, occlusionSkipped: false, occlusionProjections: 0, occlusionVisibility: null };
this._renderListBatches.set( renderList, record );
this._records.add( new WeakRef( record ) );
}
if ( record.projectionId !== this._gpuProjectionId ) {
record.projectionId = this._gpuProjectionId;
record.staticInstanceCount = 0;
}
return record;
}
/**
* Returns the entry collecting the objects of the given geometry, material and shadow state
* for the current projection.
*
* @private
* @param {Object} record - The record of the render list.
* @param {Object} clusters - The cluster data of the geometry.
* @param {Material} material - The material.
* @param {boolean} receiveShadow - Whether the objects receive shadows.
* @param {boolean} instanced - Whether the objects are instanced meshes.
* @param {boolean} instanceColor - Whether the instanced meshes have instance colors.
* @param {?Material} farMaterial - The material beyond the switch distance of merged levels.
* @return {Object} The entry.
*/
_getEntry( record, clusters, material, receiveShadow, instanced, instanceColor, farMaterial ) {
let byClusters = record.lookup.get( material );
if ( byClusters === undefined ) {
byClusters = new Map();
record.lookup.set( material, byClusters );
}
// the geometries of a batch key share the batch, see _getBatchKey()
const batchKey = this._getBatchKey( clusters );
let byShadow = byClusters.get( batchKey );
if ( byShadow === undefined ) {
byShadow = new Map();
byClusters.set( batchKey, byShadow );
}
const key = getEntryKey( receiveShadow, instanced, instanceColor, farMaterial );
let entry = byShadow.get( key );
if ( entry === undefined ) {
entry = {
clusters,
batchKey,
material,
farMaterial,
receiveShadow,
instanced,
instanceColor,
key,
objects: [],
objectClusters: [],
groups: [],
levels: [],
count: 0,
projectionId: - 1,
unusedProjections: 0,
groupOrder: 0,
z: 0,
clippingContext: null,
source: null,
queueBudget: Infinity, // all clusters until measured, so a single render like a shadow map isn't incomplete
queueMeasured: false,
maxChunks: 0,
geometryIndices: null,
batch: null
};
byShadow.set( key, entry );
record.entries.push( entry );
}
if ( entry.projectionId !== this._gpuProjectionId ) {
entry.projectionId = this._gpuProjectionId;
entry.objects.length = 0;
entry.objectClusters.length = 0;
entry.groups.length = 0;
entry.levels.length = 0;
entry.count = 0;
}
return entry;
}
/**
* Removes the meshlets of a disposed geometry and its groups, and their vertices from the geometry pool.
*
* @private
* @param {BufferGeometry} geometry - The geometry.
*/
_disposeGeometry( geometry ) {
geometry.removeEventListener( 'dispose', this._onGeometryDispose );
const clusters = this._clusters.get( geometry );
if ( clusters !== undefined ) this._geometryPool.remove( clusters );
const groups = this._groupClusters.get( geometry );
if ( groups !== undefined ) {
for ( const groupClusters of groups.values() ) this._geometryPool.remove( groupClusters );
}
this._clusters.delete( geometry );
this._groupClusters.delete( geometry );
this._geometryStates.delete( geometry );
}
/**
* Frees the GPU-related resources of the pipeline: the cullings, batches and views of all render lists and the
* geometry pool. Called by {@link Renderer#dispose}. The pipeline can be used again, the resources are created again.
*/
dispose() {
for ( const reference of this._records ) {
const record = reference.deref();
if ( record === undefined ) continue;
this._disposeCulling( record );
for ( const entry of record.entries ) {
if ( entry.batch !== null ) entry.batch.dispose();
}
if ( record.view !== null ) record.view.dispose();
}
this._records.clear();
this._renderListBatches = new WeakMap();
this._geometryPool.dispose();
this._geometryPool = new MeshletGeometryPool();
super.dispose();
}
/**
* Returns the key of the batch drawing objects of the given geometry: the geometries with the same layout of
* the vertex data share the batches of a material, their vertices are read from the geometry pool.
*
* @param {Object} clusters - The cluster data of the geometry.
* @return {any} The key.
*/
_getBatchKey( clusters ) {
return clusters.layoutKey;
}
/**
* Adds the geometries of the given entries to the table of the culling and to the geometry pool.
*
* @private
* @param {MeshletCulling} culling - The culling.
* @param {Array<Object>} entries - The entries.
* @return {boolean} Whether all geometries were added, `false` if the culling is full.
*/
_addGeometries( culling, entries ) {
for ( const entry of entries ) {
for ( const clusters of entry.objectClusters ) {
if ( culling.getGeometry( clusters ) !== - 1 ) continue;
if ( culling.addGeometry( clusters, this._geometryPool.add( clusters ) ) === - 1 ) return false;
}
}
return true;
}
/**
* Returns the far material of the level being projected, if it is merged with the next level.
*
* @private
* @return {?Material} The far material.
*/
_getFarMaterial() {
return this._lodRange !== null ? this._lodRange.farMaterial : null;
}
/**
* Returns the cluster data of the given geometry, or `null` if it is not available yet.
* At most one geometry is processed per projection to avoid stalls.
*
* @private
* @param {BufferGeometry} geometry - The geometry.
* @param {?Object} [group=null] - The material group, or the whole geometry.
* @param {boolean} [prepare=false] - Whether preprocessing may bypass the per-projection limit.
* @return {?Object} The cluster data.
*/
_getClusters( geometry, group = null, prepare = false ) {
this._updateGeometry( geometry );
let cache = this._clusters;
let key = geometry;
if ( group !== null ) {
cache = this._groupClusters.get( geometry );
if ( cache === undefined ) this._groupClusters.set( geometry, cache = new Map() );
key = `${ group.start }:${ group.count }`;
}
let clusters = cache.get( key );
if ( clusters === undefined ) {
if ( prepare === false && this._pendingGeometry === this._gpuProjectionId ) {
this._waitingGeometry = this._gpuProjectionId;
return null;
}
this._pendingGeometry = this._gpuProjectionId;
clusters = createMeshlets( geometry, group );
cache.set( key, clusters );
if ( geometry.hasEventListener( 'dispose', this._onGeometryDispose ) === false ) geometry.addEventListener( 'dispose', this._onGeometryDispose );
}
return clusters;
}
/**
* Returns whether meshlets of the geometry or material group have been prepared.
*
* @private
* @param {BufferGeometry} geometry - The geometry.
* @param {?Object} group - The material group.
* @return {boolean} Whether the meshlets are cached.
*/
_hasClusters( geometry, group ) {
this._updateGeometry( geometry );
return group === null ? this._clusters.has( geometry ) : this._groupClusters.get( geometry )?.has( `${ group.start }:${ group.count }` ) === true;
}
/**
* Invalidates meshlets when source attributes or indices change. Shared geometries are checked once
* per projection. Instance attribute values are uploaded separately and do not change the meshlets.
*
* @private
* @param {BufferGeometry} geometry - The geometry.
*/
_updateGeometry( geometry ) {
if ( this._checkedGeometries.has( geometry ) ) return;
this._checkedGeometries.add( geometry );
const state = [];
for ( const [ name, attribute ] of [[ 'index', geometry.index ], ...Object.entries( geometry.attributes ) ] ) {
state.push( name, attribute );
if ( attribute === null ) continue;
const buffer = attribute.isInterleavedBufferAttribute === true ? attribute.data : attribute;
const version = attribute.isInstancedBufferAttribute === true ? 0 : buffer.version;
state.push( buffer, version, buffer.array, attribute.count, attribute.itemSize, attribute.normalized, attribute.offset, buffer.stride );
}
const previous = this._geometryStates.get( geometry );
if ( previous !== undefined && ( previous.length !== state.length || previous.some( ( value, i ) => value !== state[ i ] ) ) ) {
this._disposeGeometry( geometry );
// The bounds stored in the meshlets must describe the updated vertex positions as well.
geometry.computeBoundingSphere();
geometry.computeBoundingBox();
}
this._geometryStates.set( geometry, state );
}
}
/**
* Returns the number of work items of a queue of a batch: the visible clusters of its objects within a budget of
* clusters per object and at least the clusters of a number of objects, or the given budget if larger.
*
* @private
* @param {number} objects - The capacity of the batch.
* @param {number} maxChunks - The clusters of the most detailed level of the geometry.
* @param {number} [budget=0] - The minimum number of work items, after the visible clusters exceeded the queue.
* @return {number} The number of work items.
*/
function getQueueCapacity( objects, maxChunks, budget = 0 ) {
return Math.min( objects * maxChunks, MAX_WORK_ITEMS, Math.max( objects * QUEUE_ITEMS_PER_OBJECT, maxChunks * QUEUE_MIN_OBJECTS, budget ) );
}
/**
* Groups the given entries by their objects: the entries of a geometry drawing the same objects, like the groups
* of meshes with multiple materials, form a set. The first entry of a set is the source of the other entries,
* which read the data of the objects from its batch.
*
* @private
* @param {Array<Object>} entries - The entries.
* @return {Array<Array<Object>>} The sets of entries.
*/
function getEntrySets( entries ) {
const sets = [];
const firstObjects = new Map();
for ( const entry of entries ) {
const firstObject = entry.objects[ 0 ];
let candidates = firstObjects.get( firstObject );
if ( candidates === undefined ) firstObjects.set( firstObject, candidates = [] );
const set = candidates.find( ( candidate ) => isSameObjects( candidate[ 0 ], entry ) );
if ( set !== undefined ) {
entry.source = set[ 0 ];
set.push( entry );
} else {
entry.source = null;
candidates.push( [ entry ] );
sets.push( candidates[ candidates.length - 1 ] );
}
}
return sets;
}
/**
* Returns `true` if the given entries draw the same objects, in the same order and with the same levels.
*
* @private
* @param {Object} a - The first entry.
* @param {Object} b - The second entry.
* @return {boolean} Whether the objects are the same.
*/
function isSameObjects( a, b ) {
// the world matrices of objects with a position node are deformed per material, they are not shared
if ( a.instanced !== b.instanced || a.count !== b.count || a.objects.length !== b.objects.length ) return false;
if ( isDeformedMaterial( a.material ) || isDeformedMaterial( b.material ) ) return false;
for ( let i = 0; i < a.objects.length; i ++ ) {
if ( a.objects[ i ] !== b.objects[ i ] || a.levels[ i ] !== b.levels[ i ] ) return false;
}
return true;
}
export { MeshletDrawingPipeline, MeshletBatch, cacheAttributes, createDebugMaterial, createDrawMaterial, createOcclusion };