three
Version:
JavaScript 3D library
995 lines (682 loc) • 30.4 kB
JavaScript
import { DataTexture, FloatType, RGBAFormat, Vector2, Vector3, LightsNode, NodeUpdateType, NodeUtils, PointLightNode, SpotLightNode } from 'three/webgpu';
import {
instancedArray, nodeProxy, int, float, vec3, vec4, ivec2, ivec4, uniform, Break, Loop, positionView,
Fn, If, textureLoad, instanceIndex, screenCoordinate, directPointLight,
renderGroup,
min, max, pow, log, clamp, dot, smoothstep
} from 'three/tsl';
const _vector3 = /*@__PURE__*/ new Vector3();
const _direction = /*@__PURE__*/ new Vector3();
const _targetPosition = /*@__PURE__*/ new Vector3();
const _size = /*@__PURE__*/ new Vector2();
// light data texture rows
const POSITION_ROW = 0; // world position, cutoff distance
const COLOR_ROW = 1; // color * intensity, decay
const SPOT_ROW = 2; // scaled world spot direction, scaled cone cosine
const BOUNDS_ROW = 3; // world bounding sphere center, radius ( negative if unbounded )
const ROWS = 4;
/**
* A custom version of `LightsNode` implementing Forward+ clustered shading:
* the view frustum is subdivided into a 3D grid of clusters (X × Y screen tiles
* times an exponentially-spaced set of Z depth slices), and each cluster holds
* only the point and spot lights whose bounding spheres intersect it. At shading time each fragment
* looks up its cluster and loops over just that cluster's lights. Unlike 2D tiled
* lighting, clustered shading culls lights that share screen pixels but lie at
* different depths — suitable for 3D scenes with real depth complexity.
*
* @augments LightsNode
* @three_import import { clusteredLights } from 'three/addons/tsl/lighting/ClusteredLightsNode.js';
*/
class ClusteredLightsNode extends LightsNode {
static get type() {
return 'ClusteredLightsNode';
}
/**
* Constructs a new clustered lights node.
*
* @param {number} [maxLights=1024] - Maximum number of point and spot lights.
* @param {number} [tileSize=32] - Screen tile size in pixels (cluster XY size).
* @param {number} [zSlices=24] - Number of exponential depth slices.
* @param {number} [maxLightsPerCluster=64] - Per-cluster light-list capacity.
*/
constructor( maxLights = 1024, tileSize = 32, zSlices = 24, maxLightsPerCluster = 64 ) {
super();
/**
* This flag can be used for type testing.
*
* @type {boolean}
* @readonly
* @default true
*/
this.isClusteredLightsNode = true;
this.materialLights = [];
this.clusteredLights = [];
this.maxLights = maxLights;
this.tileSize = tileSize;
this.zSlices = zSlices;
this.maxLightsPerCluster = maxLightsPerCluster;
this._chunksPerCluster = Math.ceil( maxLightsPerCluster / 4 );
this._bufferSize = null;
this._isWebGLBackend = false;
this._needsRebuild = false;
this._invocationsPerCluster = 1;
this._lightIndexes = null;
this._renderer = null;
this._screenClusterIndex = null;
this._compute = null;
const lightsData = new Float32Array( maxLights * 4 * ROWS );
this._lightsTexture = new DataTexture( lightsData, maxLights, ROWS, RGBAFormat, FloatType );
// Per Z-slice light range for Z-culling (CPU-sorted, uploaded each frame)
this._zSliceRangesData = new Float32Array( zSlices * 4 );
this._zSliceRangesTexture = new DataTexture( this._zSliceRangesData, zSlices, 1, RGBAFormat, FloatType );
this._lightViewZ = new Float32Array( maxLights );
this._lightSortOrder = [];
this._lightsCount = uniform( 0, 'int' );
// Render-group uniforms: shared between compute and fragment passes,
// updated manually each frame in updateBefore (compute lacks a camera context).
this._cameraNear = uniform( 0 ).setName( 'clusteredCameraNear' ).setGroup( renderGroup );
this._cameraFar = uniform( 0 ).setName( 'clusteredCameraFar' ).setGroup( renderGroup );
this._cameraViewMatrix = uniform( 'mat4' ).setName( 'clusteredCameraViewMatrix' ).setGroup( renderGroup );
this._cameraProjectionMatrix = uniform( 'mat4' ).setName( 'clusteredCameraProjectionMatrix' ).setGroup( renderGroup );
this._gridDimensions = uniform( new Vector2() );
this.updateBeforeType = NodeUpdateType.RENDER;
}
/**
* Overwrites the default {@link LightsNode#customCacheKey} implementation by
* including the node identity and resource version into the cache key.
*
* @return {number} The custom cache key.
*/
customCacheKey() {
// First use allocates the grid after material cache keys have been computed.
// Keep that allocation stable, but never share bindings between lights nodes.
return NodeUtils.hash( this.id, this.version, super.customCacheKey() );
}
/**
* Updates the light data and the light ranges of the depth slices.
*
* @private
* @param {Camera} camera - The camera.
*/
_updateLightsTexture( camera ) {
const { _lightsTexture: lightsTexture, clusteredLights } = this;
const data = lightsTexture.image.data;
const lineSize = lightsTexture.image.width * 4;
const count = Math.min( clusteredLights.length, this.maxLights );
this._lightsCount.value = count;
// Sort lights by the view-space depth of their bounding spheres for Z-culling
const viewZ = this._lightViewZ;
const order = this._lightSortOrder;
for ( let i = 0; i < count; i ++ ) {
this._getBoundingSphereCenter( clusteredLights[ i ], _vector3 );
_vector3.applyMatrix4( camera.matrixWorldInverse );
viewZ[ i ] = _vector3.z;
order[ i ] = i;
}
order.length = count;
order.sort( ( a, b ) => viewZ[ a ] - viewZ[ b ] );
// Write sorted lights to texture
for ( let i = 0; i < count; i ++ ) {
const light = clusteredLights[ order[ i ] ];
const offset = i * 4;
_vector3.setFromMatrixPosition( light.matrixWorld );
let rowOffset = lineSize * POSITION_ROW + offset;
data[ rowOffset + 0 ] = _vector3.x;
data[ rowOffset + 1 ] = _vector3.y;
data[ rowOffset + 2 ] = _vector3.z;
data[ rowOffset + 3 ] = light.distance;
rowOffset = lineSize * COLOR_ROW + offset;
data[ rowOffset + 0 ] = light.color.r * light.intensity;
data[ rowOffset + 1 ] = light.color.g * light.intensity;
data[ rowOffset + 2 ] = light.color.b * light.intensity;
data[ rowOffset + 3 ] = light.decay;
// The spot attenuation smoothstep( coneCos, penumbraCos, dot( L, direction ) ) is stored
// as smoothstep( 0, 1, dot( L, direction * scale ) - coneCos * scale ) with
// scale = 1 / ( penumbraCos - coneCos ). Point lights store a constant attenuation of 1.
rowOffset = lineSize * SPOT_ROW + offset;
if ( light.isSpotLight === true ) {
const coneCos = Math.cos( light.angle );
const penumbraCos = Math.cos( light.angle * ( 1 - light.penumbra ) );
const scale = 1 / Math.max( penumbraCos - coneCos, 1e-4 );
this._getSpotDirection( light, _direction ).multiplyScalar( scale );
data[ rowOffset + 0 ] = _direction.x;
data[ rowOffset + 1 ] = _direction.y;
data[ rowOffset + 2 ] = _direction.z;
data[ rowOffset + 3 ] = coneCos * scale;
} else {
data[ rowOffset + 0 ] = 0;
data[ rowOffset + 1 ] = 0;
data[ rowOffset + 2 ] = 0;
data[ rowOffset + 3 ] = - 1;
}
this._getBoundingSphereCenter( light, _vector3 );
rowOffset = lineSize * BOUNDS_ROW + offset;
data[ rowOffset + 0 ] = _vector3.x;
data[ rowOffset + 1 ] = _vector3.y;
data[ rowOffset + 2 ] = _vector3.z;
data[ rowOffset + 3 ] = this._getBoundingSphereRadius( light );
}
lightsTexture.needsUpdate = true;
// Compute per Z-slice light ranges
const zRanges = this._zSliceRangesData;
const near = camera.near;
const far = camera.far;
const NZ = this.zSlices;
for ( let z = 0; z < NZ; z ++ ) {
// Exponential Z-slice bounds (view-space, negative values)
const sliceNear = - ( near * Math.pow( far / near, z / NZ ) );
const sliceFar = - ( near * Math.pow( far / near, ( z + 1 ) / NZ ) );
let rangeStart = count;
let rangeEnd = 0;
for ( let i = 0; i < count; i ++ ) {
const vz = viewZ[ order[ i ] ];
const r = data[ lineSize * BOUNDS_ROW + i * 4 + 3 ];
const radius = r >= 0 ? r : far;
// Bounding sphere Z: [vz - radius, vz + radius]
// Slice Z: [sliceFar, sliceNear] (both negative, sliceFar < sliceNear)
if ( vz + radius >= sliceFar && vz - radius <= sliceNear ) {
if ( i < rangeStart ) rangeStart = i;
if ( i + 1 > rangeEnd ) rangeEnd = i + 1;
}
}
if ( rangeStart >= count ) {
rangeStart = 0;
rangeEnd = 0;
}
zRanges[ z * 4 ] = rangeStart;
zRanges[ z * 4 + 1 ] = rangeEnd;
}
this._zSliceRangesTexture.needsUpdate = true;
}
/**
* Returns the normalized world-space direction pointing from the spot light's
* target to the spot light.
*
* @private
* @param {SpotLight} light - The spot light.
* @param {Vector3} target - The target vector.
* @return {Vector3} The direction.
*/
_getSpotDirection( light, target ) {
_targetPosition.setFromMatrixPosition( light.target.matrixWorld );
return target.setFromMatrixPosition( light.matrixWorld ).sub( _targetPosition ).normalize();
}
/**
* Returns the world-space center of the light's bounding sphere. For spot lights,
* this is the center of the smallest sphere enclosing the cone.
*
* @private
* @param {PointLight|SpotLight} light - The light.
* @param {Vector3} target - The target vector.
* @return {Vector3} The bounding sphere center.
*/
_getBoundingSphereCenter( light, target ) {
target.setFromMatrixPosition( light.matrixWorld );
if ( light.isSpotLight === true && light.distance > 0 ) {
const angle = Math.min( light.angle, Math.PI / 2 );
const offset = angle > Math.PI / 4 ? light.distance * Math.cos( angle ) : light.distance / ( 2 * Math.cos( angle ) );
// _getSpotDirection() points away from the cone, so step backwards
target.addScaledVector( this._getSpotDirection( light, _direction ), - offset );
}
return target;
}
/**
* Returns the radius of the light's bounding sphere.
*
* @private
* @param {PointLight|SpotLight} light - The light.
* @return {number} The bounding sphere radius. `-1` if the light has no cutoff distance.
*/
_getBoundingSphereRadius( light ) {
if ( light.distance <= 0 ) return - 1;
if ( light.isSpotLight === true ) {
const angle = Math.min( light.angle, Math.PI / 2 );
return angle > Math.PI / 4 ? light.distance * Math.sin( angle ) : light.distance / ( 2 * Math.cos( angle ) );
}
return light.distance;
}
/**
* Updates the light data and assigns the lights to the clusters.
*
* @param {NodeFrame} frame - A reference to the current node frame.
*/
updateBefore( frame ) {
const { renderer, camera } = frame;
this._initGrid( renderer );
this._updateLightsTexture( camera );
this._cameraNear.value = camera.near;
this._cameraFar.value = camera.far;
this._cameraViewMatrix.value = camera.matrixWorldInverse;
this._cameraProjectionMatrix.value = camera.projectionMatrix;
renderer.compute( this._compute );
}
/**
* Configures this node with an array of lights. Point and spot lights without
* shadows are clustered (except lights with a `colorNode`, spot lights with a
* projected `map` and light types with their own light node, like `IESSpotLight`
* or `ProjectorLight`), all other lights use the default lighting.
*
* @param {Array<Light>} lights - An array of lights.
* @return {ClusteredLightsNode} A reference to this node.
*/
setLights( lights ) {
const { clusteredLights, materialLights } = this;
let materialIndex = 0;
let clusteredIndex = 0;
for ( const light of lights ) {
if ( this._isClusteredLight( light ) ) {
clusteredLights[ clusteredIndex ++ ] = light;
} else {
materialLights[ materialIndex ++ ] = light;
}
}
materialLights.length = materialIndex;
clusteredLights.length = clusteredIndex;
return super.setLights( lights );
}
/**
* Returns `true` if the given light can be clustered.
*
* @private
* @param {Light} light - The light.
* @return {boolean} Whether the light is clustered or not.
*/
_isClusteredLight( light ) {
// Shadows, color nodes (any light) and projected maps (spot lights) are not supported.
if ( light.castShadow === true || light.colorNode ) return false;
// Only lights shaded by the plain point and spot light nodes are clustered. Subclasses
// registered with their own light node (e.g. IESSpotLight, ProjectorLight) keep the
// default lighting, since clustering would shade them as plain spot lights.
if ( light._lightNode === PointLightNode ) return true;
return light._lightNode === SpotLightNode && ! light.map;
}
/**
* Returns the lights that are not clustered.
*
* @return {Array<Light>} The lights using the default lighting.
*/
getBuiltinLights() {
return this.materialLights;
}
/**
* Returns the light index stored at the given slot of the fragment's cluster.
*
* @private
* @param {number|Node<int>} element - The slot.
* @return {Node<int>} The 1-based light index. `0` marks the end of the list.
*/
_getTile( element ) {
element = int( element );
const stride = int( 4 );
const chunkOffset = element.div( stride );
const idx = this._screenClusterIndex.mul( int( this._chunksPerCluster ) ).add( chunkOffset );
return this._lightIndexes.element( idx ).element( element.mod( stride ) );
}
/**
* Returns the number of lights in the fragment's cluster. Useful for debugging.
*
* @param {Node<int>} zSliceNode - The depth slice to inspect. A negative value selects the fragment's own cluster.
* @return {Node<int>} The light count.
*/
getClusterLightCount( zSliceNode ) {
const getCount = Fn( ( [ zSliceNode ] ) => {
const count = int( 0 ).toVar();
const debugClusterIndex = this._screenClusterIndex.toVar();
If( zSliceNode.greaterThanEqual( int( 0 ) ), () => {
const tileSize = int( this.tileSize );
const screenTile = screenCoordinate.div( tileSize ).floor();
const NX = int( this._gridDimensions.x );
const NY = int( this._gridDimensions.y );
debugClusterIndex.assign(
int( screenTile.x )
.add( int( screenTile.y ).mul( NX ) )
.add( zSliceNode.mul( NX.mul( NY ) ) )
);
} );
Loop( this.maxLightsPerCluster, ( { i } ) => {
const element = int( i );
const stride = int( 4 );
const chunkOffset = element.div( stride );
const idx = debugClusterIndex.mul( int( this._chunksPerCluster ) ).add( chunkOffset );
const lightIndex = this._lightIndexes.element( idx ).element( element.mod( stride ) );
If( lightIndex.equal( int( 0 ) ), () => {
Break();
} );
count.addAssign( int( 1 ) );
} );
return count;
} );
return getCount( zSliceNode );
}
/**
* Returns the shading data of the light at the given index.
*
* @private
* @param {number|Node<int>} index - The light index.
* @return {Object} The light's view position, distance, color, decay, view-space spot direction and spot offset.
*/
_getLightData( index ) {
index = int( index );
const dataA = textureLoad( this._lightsTexture, ivec2( index, POSITION_ROW ) );
const dataB = textureLoad( this._lightsTexture, ivec2( index, COLOR_ROW ) );
const dataC = textureLoad( this._lightsTexture, ivec2( index, SPOT_ROW ) );
const viewPosition = this._cameraViewMatrix.mul( vec4( dataA.xyz, 1.0 ) ).xyz;
const distance = dataA.w;
const color = dataB.rgb;
const decay = dataB.w;
const spotDirection = this._cameraViewMatrix.mul( vec4( dataC.xyz, 0.0 ) ).xyz;
const spotOffset = dataC.w;
return {
viewPosition,
distance,
color,
decay,
spotDirection,
spotOffset
};
}
/**
* Returns the culling data of the light at the given index.
*
* @private
* @param {number|Node<int>} index - The light index.
* @return {Object} The view-space center and the radius of the light's bounding sphere. A negative radius marks an unbounded light.
*/
_getLightBounds( index ) {
const data = textureLoad( this._lightsTexture, ivec2( int( index ), BOUNDS_ROW ) );
const center = this._cameraViewMatrix.mul( vec4( data.xyz, 1.0 ) ).xyz;
const radius = data.w;
return { center, radius };
}
/**
* Sets up the default lights and the clustered point and spot lights.
*
* @param {NodeBuilder} builder - A reference to the current node builder.
* @param {Array<LightingNode>} lightNodes - An array of lighting nodes.
*/
setupLights( builder, lightNodes ) {
this._initGrid( builder.renderer );
//
const lightingModel = builder.context.reflectedLight;
lightingModel.directDiffuse.toStack();
lightingModel.directSpecular.toStack();
super.setupLights( builder, lightNodes );
Fn( () => {
Loop( this.maxLightsPerCluster, ( { i } ) => {
const lightIndex = this._getTile( i );
If( lightIndex.equal( int( 0 ) ), () => {
Break();
} );
const { color, decay, viewPosition, distance, spotDirection, spotOffset } = this._getLightData( lightIndex.sub( 1 ) );
const lightVector = viewPosition.sub( positionView );
// spot cone attenuation, always 1 for point lights
const spotAttenuation = smoothstep( 0, 1, dot( lightVector.normalize(), spotDirection ).sub( spotOffset ) ).toVar();
// Early-out: skip full BRDF if fragment is beyond the light's cutoff or outside the spot cone
const inRange = distance.equal( 0 ).or( dot( lightVector, lightVector ).lessThanEqual( distance.mul( distance ) ) );
If( inRange.and( spotAttenuation.greaterThan( 0 ) ), () => {
builder.lightsNode.setupDirectLight( builder, this, directPointLight( {
color: color.mul( spotAttenuation ),
lightVector,
cutoffDistance: distance,
decayExponent: decay
} ) );
} );
} );
}, 'void' )();
}
/**
* Rounds the given size up to a multiple of the tile size.
*
* @private
* @param {number} value - The size.
* @return {number} The fitted size.
*/
_getBufferFitSize( value ) {
const multiple = this.tileSize;
return Math.ceil( value / multiple ) * multiple;
}
/**
* Sets the size of the cluster grid. Must be called outside of rendering. Not required
* with {@link ClusteredLighting}, which fits the grid at the beginning of each render.
*
* @param {number} width - The drawing buffer width.
* @param {number} height - The drawing buffer height.
* @return {ClusteredLightsNode} A reference to this node.
*/
setSize( width, height ) {
this._resize( this._getBufferFitSize( width ), this._getBufferFitSize( height ) );
return this;
}
/**
* Fits the cluster grid to the framebuffer of the render that is about to start. Called by
* {@link ClusteredLighting} before the render objects of the render are prepared. Since each
* render depth has its own node, no pending command buffer uses the resources of this node at
* this point, so a rebuild can free the replaced resources immediately.
*/
updateSize() {
if ( this._renderer !== null ) this._fitToRenderer( this._renderer );
}
/**
* Creates the cluster grid on first use. While rendering, the grid is never rebuilt: the
* replaced resources could still be used by a command buffer that was not submitted yet.
*
* @private
* @param {Renderer} renderer - The renderer.
*/
_initGrid( renderer ) {
this._renderer = renderer;
if ( this._compute === null ) this._fitToRenderer( renderer );
}
/**
* Fits the cluster grid to the framebuffer the renderer is rendering to.
*
* @private
* @param {Renderer} renderer - The renderer.
*/
_fitToRenderer( renderer ) {
// the grid must match the framebuffer being rendered: a render target (e.g. a reflector
// with a resolution scale) can differ from the drawing buffer
const renderTarget = renderer.getRenderTarget();
if ( renderTarget !== null ) {
_size.set( renderTarget.width, renderTarget.height );
} else {
renderer.getDrawingBufferSize( _size );
}
// the compute differs per backend, so a backend change requires a rebuild
const isWebGLBackend = renderer.backend.isWebGLBackend === true;
if ( this._compute !== null && this._isWebGLBackend !== isWebGLBackend ) this._needsRebuild = true;
this._isWebGLBackend = isWebGLBackend;
this._resize( this._getBufferFitSize( _size.width ), this._getBufferFitSize( _size.height ) );
}
/**
* Resizes the cluster grid. The grid dimensions are uniforms, so resizing only updates
* them and the compute count; the compute and the storage are rebuilt only when the
* storage has to grow (or the backend changed).
*
* @private
* @param {number} width - The fitted width.
* @param {number} height - The fitted height.
*/
_resize( width, height ) {
if ( this._needsRebuild === false && this._bufferSize !== null && this._bufferSize.width === width && this._bufferSize.height === height ) return;
const NX = Math.floor( width / this.tileSize );
const NY = Math.floor( height / this.tileSize );
const clusterCount = NX * NY * this.zSlices;
const requiredElements = clusterCount * this._chunksPerCluster;
if ( this._needsRebuild === true || this._compute === null || this._lightIndexes.value.array.length / 4 < requiredElements ) {
this._create( clusterCount );
}
this._gridDimensions.value.set( NX, NY );
this._compute.count = clusterCount * this._invocationsPerCluster;
if ( this._bufferSize === null ) this._bufferSize = new Vector2();
this._bufferSize.set( width, height );
}
/**
* Creates the light index storage, the compute node and the fragment cluster lookup.
* The storage is allocated with headroom so that most resizes do not need a rebuild.
*
* @private
* @param {number} clusterCount - The number of clusters of the current grid.
*/
_create( clusterCount ) {
const { tileSize, zSlices, _chunksPerCluster: chunksPerCluster } = this;
const NZ = zSlices;
// grid dimensions as uniforms: shaders do not depend on the drawing buffer size
const grid = this._gridDimensions;
const NX = int( grid.x );
const NY = int( grid.y );
// Per-cluster light-index storage (ivec4 chunks), grown geometrically.
const requiredElements = clusterCount * chunksPerCluster;
const previousElements = this._lightIndexes !== null ? this._lightIndexes.value.array.length / 4 : 0;
const elements = previousElements === 0 ? Math.ceil( requiredElements * 1.5 ) : Math.max( requiredElements, previousElements * 2 );
// rebuilds only happen outside of rendering (see _initGrid()), so the replaced resources can be freed
if ( this._compute !== null ) {
// Replacing storage requires new material bindings. The initial allocation does not.
this.needsUpdate = true;
this._compute.dispose();
this._lightIndexes.value.dispose();
}
// instanced storage: WebGL computes use gl_InstanceID as instanceIndex; PBO: random access in the fragment stage
const lightIndexes = instancedArray( new Int32Array( elements * 4 ), 'ivec4' ).setPBO( true ).setName( 'lightIndexes' );
// view-space AABB of the given cluster
const getClusterBounds = ( clusterIndex ) => {
// view-space scale factors derived from the projection matrix:
// view_x = ndc_x * (-view_z) / focal_x = ndc_x * (-view_z) * invFocalX
// view_y = ndc_y * (-view_z) / focal_y = ndc_y * (-view_z) * invFocalY
// where focal_x = projMatrix[0][0] and focal_y = projMatrix[1][1].
const invFocalX = float( 1 ).div( this._cameraProjectionMatrix.element( 0 ).element( 0 ) );
const invFocalY = float( 1 ).div( this._cameraProjectionMatrix.element( 1 ).element( 1 ) );
// 3D cluster coordinates
const cx = clusterIndex.mod( NX );
const cy = clusterIndex.div( NX ).mod( NY );
const cz = clusterIndex.div( NX.mul( NY ) );
// NDC X/Y bounds of the cluster.
// Y is flipped: cy=0 is the top screen row (fragment y=0), which is NDC y=+1.
const ndcXmin = float( cx ).mul( 2.0 ).div( grid.x ).sub( 1.0 );
const ndcXmax = float( cx.add( int( 1 ) ) ).mul( 2.0 ).div( grid.x ).sub( 1.0 );
const ndcYmax = float( 1 ).sub( float( cy ).mul( 2.0 ).div( grid.y ) );
const ndcYmin = float( 1 ).sub( float( cy.add( int( 1 ) ) ).mul( 2.0 ).div( grid.y ) );
// View-space Z bounds (negative, exponential slicing)
const farOverNear = this._cameraFar.div( this._cameraNear );
const zNearCluster = this._cameraNear.mul( pow( farOverNear, float( cz ).mul( 1.0 / NZ ) ) ).negate();
const zFarCluster = this._cameraNear.mul( pow( farOverNear, float( cz.add( int( 1 ) ) ).mul( 1.0 / NZ ) ) ).negate();
const scaleNearX = zNearCluster.negate().mul( invFocalX );
const scaleFarX = zFarCluster.negate().mul( invFocalX );
const scaleNearY = zNearCluster.negate().mul( invFocalY );
const scaleFarY = zFarCluster.negate().mul( invFocalY );
const xMinNear = ndcXmin.mul( scaleNearX );
const xMaxNear = ndcXmax.mul( scaleNearX );
const xMinFar = ndcXmin.mul( scaleFarX );
const xMaxFar = ndcXmax.mul( scaleFarX );
const yMinNear = ndcYmin.mul( scaleNearY );
const yMaxNear = ndcYmax.mul( scaleNearY );
const yMinFar = ndcYmin.mul( scaleFarY );
const yMaxFar = ndcYmax.mul( scaleFarY );
// AABB of the 8 view-space corners (tile boundaries can straddle the view axis)
const aabbMinX = min( xMinNear, xMinFar );
const aabbMaxX = max( xMaxNear, xMaxFar );
const aabbMinY = min( yMinNear, yMinFar );
const aabbMaxY = max( yMaxNear, yMaxFar );
const aabbMin = vec3( aabbMinX, aabbMinY, zFarCluster );
const aabbMax = vec3( aabbMaxX, aabbMaxY, zNearCluster );
return { cz, aabbMin, aabbMax };
};
// sphere-AABB intersection in view space
const intersectsCluster = ( lightIdx, aabbMin, aabbMax ) => {
const { center, radius } = this._getLightBounds( lightIdx );
const closest = max( aabbMin, min( center, aabbMax ) );
const diff = center.sub( closest );
const distSq = dot( diff, diff );
return radius.lessThan( 0 ).or( distSq.lessThanEqual( radius.mul( radius ) ) );
};
// Each invocation fills `chunksPerInvocation` ivec4 chunks of one cluster. WebGL computes
// run via transform feedback and can only write their own element, so there each
// invocation fills a single chunk; WebGPU uses one invocation per cluster.
const chunksPerInvocation = this._isWebGLBackend ? 1 : chunksPerCluster;
const invocationsPerCluster = chunksPerCluster / chunksPerInvocation;
this._invocationsPerCluster = invocationsPerCluster;
const compute = Fn( () => {
const clusterIndex = int( instanceIndex.div( invocationsPerCluster ) );
const firstChunk = int( instanceIndex.mod( invocationsPerCluster ) ).mul( chunksPerInvocation );
const firstSlot = firstChunk.mul( 4 );
const lastSlot = firstSlot.add( chunksPerInvocation * 4 );
const { cz, aabbMin, aabbMax } = getClusterBounds( clusterIndex );
const chunks = ivec4( 0 ).toArray( chunksPerInvocation ).toVar();
const index = int( 0 ).toVar();
// Z-culling: only test lights that can reach this cluster's Z-slice
const zRange = textureLoad( this._zSliceRangesTexture, ivec2( cz, 0 ) );
const rangeStart = int( zRange.x );
const rangeEnd = int( zRange.y );
Loop( this.maxLights, ( { i } ) => {
const lightIdx = rangeStart.add( i );
If( index.greaterThanEqual( lastSlot ).or( lightIdx.greaterThanEqual( rangeEnd ) ), () => {
Break();
} );
If( intersectsCluster( lightIdx, aabbMin, aabbMax ), () => {
// slots before firstSlot belong to other invocations of this cluster
If( index.greaterThanEqual( firstSlot ), () => {
const slot = index.sub( firstSlot );
chunks.element( slot.div( 4 ) ).element( slot.mod( 4 ) ).assign( lightIdx.add( int( 1 ) ) );
} );
index.addAssign( int( 1 ) );
} );
} );
const firstElement = clusterIndex.mul( chunksPerCluster ).add( firstChunk );
const writeChunks = () => {
Loop( chunksPerInvocation, ( { i } ) => {
lightIndexes.element( firstElement.add( i ) ).assign( chunks.element( i ) );
} );
};
if ( this._isWebGLBackend === true ) {
// WebGL binds the transform feedback buffers per compute program, and programs are shared by
// identical code. The (always true) capacity and node id constants give each node and storage
// allocation its own program, so a compute never writes to the storage of another one.
If( firstElement.lessThan( int( elements ) ).and( int( this.id ).notEqual( int( - 1 ) ) ), writeChunks );
} else {
writeChunks();
}
} )().compute( clusterCount * invocationsPerCluster ).setName( 'Update Clustered Lights' );
// shading-side: fragment → cluster index
const getScreenClusterIndex = Fn( () => {
const screenTile = screenCoordinate.div( tileSize ).floor();
// view-space depth from positionView (negative in front); take magnitude
const viewDepth = positionView.z.negate();
// exponential Z slice: tz = floor( log(depth/near) / log(far/near) * NZ )
const invLogFarOverNear = float( 1 ).div( log( this._cameraFar.div( this._cameraNear ) ) );
const sliceFloat = log( viewDepth.div( this._cameraNear ) ).mul( invLogFarOverNear ).mul( float( NZ ) );
const zSlice = clamp( sliceFloat.floor(), float( 0 ), float( NZ - 1 ) );
return int( screenTile.x )
.add( int( screenTile.y ).mul( NX ) )
.add( int( zSlice ).mul( NX.mul( NY ) ) );
} );
const screenClusterIndex = getScreenClusterIndex().toVar();
// assigns
this._lightIndexes = lightIndexes;
this._screenClusterIndex = screenClusterIndex;
this._compute = compute;
this._needsRebuild = false;
}
/**
* Frees the GPU resources allocated by this node.
*/
dispose() {
if ( this._compute !== null ) this._compute.dispose();
if ( this._lightIndexes !== null ) this._lightIndexes.value.dispose();
this._lightsTexture.dispose();
this._zSliceRangesTexture.dispose();
super.dispose();
}
}
export default ClusteredLightsNode;
/**
* TSL function that creates a clustered lights node.
*
* @tsl
* @function
* @param {number} [maxLights=1024] - Maximum number of point and spot lights.
* @param {number} [tileSize=32] - Screen tile size in pixels.
* @param {number} [zSlices=24] - Depth slice count.
* @param {number} [maxLightsPerCluster=64] - Per-cluster light-list capacity.
* @return {ClusteredLightsNode} The clustered lights node.
*/
export const clusteredLights = /*@__PURE__*/ nodeProxy( ClusteredLightsNode );