首页
/ three.js ClusteredLightsNode:WebGPU 渲染管线中的 Forward+ 集群光照节点

three.js ClusteredLightsNode:WebGPU 渲染管线中的 Forward+ 集群光照节点

2026-09-06 14:23:52作者:苗圣禹Peter

本文基于 three.js 官方文档 ClusteredLightsNode 页面及其源码实现,系统讲解 Forward+ 集群着色(Clustered Shading)在 three.js TSL 节点系统中的落地方式。读完本文,你能理解 ClusteredLightsNode 的四个构造参数(maxLights / tileSize / zSlices / maxLightsPerCluster)各自的含义与默认值,掌握它"每帧上传灯光数据 → 计算着色器划分集群 → 片元只遍历所属集群灯光"的完整工作链路,并能结合 ClusteredLighting 附加组件在 WebGPURenderer 中实际启用集群光照。

1. ClusteredLightsNode 是什么

ClusteredLightsNodeLightsNode 的自定义子类(继承链为 EventDispatcher → Node → LightsNode → ClusteredLightsNode),实现了 Forward+ 集群着色

  • 视锥体被切分为一个 3D 集群网格:屏幕在 X/Y 方向按 tileSize 像素划分屏幕瓦片(screen tiles),Z 方向按指数间隔的深度切片(depth slices)划分;
  • 每个集群只保存"光球与其相交"的聚光(PointLight)——即灯光中心到集群包围盒的距离小于等于灯光 distance 半径的灯;
  • 着色时,每个片元先查询自己所属的集群,然后只遍历该集群的灯光列表求光。

与 2D Tiled Lighting 的关键区别在于:2D 分块只按屏幕像素划分,"共享屏幕像素但处于不同深度"的灯光无法剔除;而集群着色引入深度维度后,可以把"屏幕位置相同、深度很远"的灯光剔除掉。官方文档明确其适用场景是具有真实深度复杂度的 3D 场景(例如大量漂浮点光源覆盖不同深度层)。

从源码注释看(examples/jsm/tsl/lighting/ClusteredLightsNode.js 第 13–20 行),这一设计定位与文档描述完全一致。

2. 导入方式

ClusteredLightsNode 属于 three.js 的 addons(附加组件),必须显式导入,不能从主包直接引入。文档给出的导入方式为:

import { clusteredLights } from 'three/addons/tsl/lighting/ClusteredLightsNode.js';

这里导入的 clusteredLights 是一个 TSL 函数,它通过 nodeProxy(ClusteredLightsNode) 对类进行代理(见源码 examples/jsm/tsl/lighting/ClusteredLightsNode.js 第 613 行),返回 ClusteredLightsNode 实例。该函数也收录在 TSL 函数文档中(见 docs/pages/TSL.html.md 中的 .clusteredLights() 条目),签名如下:

clusteredLights( maxLights : number, tileSize : number, zSlices : number, maxLightsPerCluster : number ) : ClusteredLightsNode

由于该组件依赖 compute 着色器与 WebGPURenderer,实际使用时应搭配 WebGPU 构建入口(three/webgpu)。

3. 构造函数与参数

构造签名为:

new ClusteredLightsNode( maxLights = 1024, tileSize = 32, zSlices = 24, maxLightsPerCluster = 64 )

文档给出的四个参数及默认值:

参数 说明 默认值 源码中的实际作用
maxLights 场景内点光源数量上限 1024 决定灯光数据纹理行数与 compute 端遍历上限;每个灯占 2 行 RGBA float(位置 + 颜色衰减),并预分配 Float32Array( maxLights ) 的视图深度缓存
tileSize 屏幕瓦片边长(像素),即集群 XY 尺寸 32 决定网格列/行数 NX = floor(宽/tileSize)NY = floor(高/tileSize);片元端用 `screenCoordinate / tileSize
zSlices 指数深度切片数量(集群 Z 维数 NZ) 24 tileSize 共同决定集群总数 NX * NY * NZ,即 compute 并行线程数
maxLightsPerCluster 单个集群可容纳的灯光索引上限 64 决定每集群存储槽位数;分片存储时 _chunksPerCluster = ceil(maxLightsPerCluster / 4)ivec4;也是片元端循环上限

几个与源码直接对应的实现细节:

  • 光源索引以 0 作为哨兵值表示"空槽":compute 端写入的是 lightIdx + 1,片元端读出后先减 1 再查灯光数据(见 getTilesetupLightslightIndex.sub( 1 ) 调用);
  • 缓冲尺寸会被 getBufferFitSize 向上取整到 tileSize 的整数倍,保证屏幕能被瓦片完整铺满,窗口大小变化时触发 create() 重建(第 377–417 行);
  • 该节点把 updateBeforeType 设为 NodeUpdateType.RENDER,意味着每帧渲染前都会执行一次完整的数据更新(第 76 行)。

4. 工作原理:三个阶段的完整链路

4.1 灯光分拣:哪些灯进入集群网格

setLights(第 206–232 行)会把传入的灯光分成两类:

if ( light.isPointLight === true && light.castShadow !== true ) {
    clusteredLights[ clusteredIndex ++ ] = light;   // 进入集群光照
} else {
    materialLights[ materialIndex ++ ] = light;     // 走传统内建光照
}

即:只有不产生阴影的 PointLight 参与集群着色;其余所有灯光(平行光、聚光灯、带阴影的点光源等)仍通过 getBuiltinLights() 返回给父类 LightsNode 的常规光照路径(父类实现在 src/nodes/lighting/LightsNode.js)。这一设计从源码结构看是合理的:集群网格按"光球与 AABB 相交"做剔除,与平行光(无衰减、全方向)的数学模型不兼容。

4.2 每帧 CPU 更新:排序、上传与 Z 切片区间

updateBefore( frame )(第 189–204 行)在每帧渲染前执行三件事:

  1. 更新灯光数据纹理updateLightsTexture( camera )(第 86–187 行)先按视图空间深度排序所有集群灯光(order.sort( ( a, b ) => viewZ[ a ] - viewZ[ b ] )),然后按排序结果写入一个 maxLights × 2Float32 DataTexture,每行 2 个 vec4

    • 第 0 行:世界坐标 xyz + 灯光 distance
    • 第 1 行:color × intensity 的 rgb + decay

    排序的意义在于:后续 Z 切片区间在排序数组上是连续的,compute 端只需扫描 [rangeStart, rangeEnd) 子区间而非全量灯光。

  2. 计算每个 Z 切片的灯光区间。对第 z 个切片,其视图空间深度边界按指数公式计算:

    sliceNear = -( near * (far/near)^(z/NZ) )
    sliceFar  = -( near * (far/near)^((z+1)/NZ) )
    

    然后扫描排序后的灯光,凡满足"灯球 Z 区间 [vz - radius, vz + radius] 与切片 Z 区间相交"的灯都计入区间端点(半径取 distance > 0 ? distance : far,即未设置距离的灯视为照射到 far 平面)。结果写入一个 NZ × 1 的区间纹理,供 compute 端做 Z 剔除——只测试"深度上能够触及本集群"的灯光。

  3. 同步相机 uniform 并触发 computecamera.near/far、视图矩阵、投影矩阵通过 renderGroup 组 uniform 在 compute 与片元之间共享(compute 着色器拿不到相机上下文,因此手动逐帧更新,见第 66–72 行注释),随后调用 renderer.compute( this._compute )

4.3 compute 端:一集群一线程,球体-AABB 相交

compute 函数在 create()(第 419–596 行)中构建,以 clusterCount = NX * NY * NZ 个实例运行,每个实例处理一个集群:

  1. 由实例索引解算 3D 集群坐标cx = i mod NXcy = (i / NX) mod NYcz = i / (NX*NY)

  2. 计算集群的 NDC 边界(注意屏幕 Y 翻转:cy = 0 对应屏幕顶行,即 NDC y = +1)与视图空间 Z 边界(同样用指数切片公式,值为负);

  3. 求集群的视图空间 AABB:由投影矩阵对角元素 focal_x / focal_y 反解出"视图坐标 = NDC × (−view_z) × 1/focal",将近/远两个截面的 8 个角点取 min/max 得到包围盒——因为瓦片边界可能横跨视图轴,直接取角点 min/max 是必要而保守的;

  4. 先清空上一帧的脏数据Loop( chunksPerCluster, ... assign( ivec4( 0 ) ) )),保证光数减少时不会出现过期索引;

  5. 读取本 Z 切片的区间,在 [rangeStart, rangeEnd) 内遍历灯光,做视图空间球体-AABB 相交测试

    const closest = max( aabbMin, min( pos, aabbMax ) );
    const distSq = dot( pos.sub( closest ), pos.sub( closest ) );
    // distSq <= distance² 则该灯写入本集群
    

    命中即写入 getClusterSlot( index ).assign( lightIdx + 1 ),写满 maxLightsPerCluster 或区间扫完即 Return()

4.4 片元端:查集群、循环灯光、提前剔除

着色端的入口是 setupLights( builder, lightNodes )(第 330–375 行)。它对 reflectedLightdirectDiffuse/directSpecular 入栈后,执行一个固定上界为 maxLightsPerCluster 的循环:

Loop( this.maxLightsPerCluster, ( { i } ) => {
    const lightIndex = this.getTile( i );          // 读集群第 i 个槽
    If( lightIndex.equal( int( 0 ) ), () => Break()); // 哨兵 0:提前结束
    const { color, decay, viewPosition, distance } = this.getLightData( lightIndex.sub( 1 ) );
    const lightVector = viewPosition.sub( positionView );
    // 早期剔除:超过灯半径的片元跳过完整 BRDF
    If( distance.equal( 0 ).or( dot( lightVector, lightVector ).lessThanEqual( distance.mul( distance ) ) ), () => {
        builder.lightsNode.setupDirectLight( builder, this, directPointLight( {
            color, lightVector, cutoffDistance: distance, decayExponent: decay
        } ) );
    } );
} )

片元到集群的映射由 getScreenClusterIndex()(第 566–582 行)完成:屏幕瓦片坐标 floor( screenCoordinate / tileSize ),加上对视图空间深度的指数切片反解:

zSlice = floor( log( |positionView.z| / near ) / log( far / near ) * NZ )  → clamp 到 [0, NZ-1]
clusterIndex = tileX + tileY * NX + zSlice * (NX * NY)

这与 compute 端的编码方式一一对应,保证片元和集群列表查的是同一份 lightIndexes 数组。

值得注意的性能设计:即使灯光在深度上"够得着"本切片,片元到灯的实际距离仍可能超出 distance,因此片元端用平方距离比较做了一次廉价的 early-out,避免为无效灯执行完整点光 BRDF。

4.5 调试辅助 API

  • getClusterLightCount( zSliceNode )(第 258–305 行):返回指定 Z 切片下"当前片元所属集群的灯光数量"(遇到哨兵 0 提前 Break),可用于可视化集群负载;
  • getTile( element ) / getBlock():按槽位/按 ivec4 块读取集群灯光索引,供自定义扩展复用底层存储。

5. 配合 ClusteredLighting 替换渲染器的光照系统

如果只是想让 WebGPURenderer 整体改用集群光照,官方配套的 ClusteredLighting 附加组件更直接。它继承 Lighting,在 createNode( lights ) 中把场景灯光交给 ClusteredLightsNode,用法(见 docs/pages/ClusteredLighting.html.md):

import { WebGPURenderer } from 'three/webgpu';
import { ClusteredLighting } from 'three/addons/lighting/ClusteredLighting.js';

const renderer = new WebGPURenderer();
const lighting = new ClusteredLighting();   // 或传 ( maxLights, tileSize, zSlices, maxLightsPerCluster )
renderer.lighting = lighting;              // 替换默认光照系统

ClusteredLighting 的构造参数与 ClusteredLightsNode 完全相同(maxLights=1024, tileSize=32, zSlices=24, maxLightsPerCluster=64,见 examples/jsm/lighting/ClusteredLighting.js),其 createNode 由渲染器内部调用,返回带 setLights( lights ) 的节点。

6. 实战示例:webgpu_lights_clustered

仓库提供了完整示例 examples/webgpu_lights_clustered.html,搭建了 30 × 30 网格、每个小球自带一个彩色 PointLightdistance = 9power = 45)并在波浪式上下浮动,直观展示了数百盏点光源下的 Forward+ 效果。示例中有几个值得借鉴的用法:

lighting = new ClusteredLighting();
renderer.lighting = lighting; // set lighting system

以及集群负载热力图的搭建方式——借助 getClusterLightCount 把每个屏幕瓦片在当前所选 Z 切片上的灯光数量映射为蓝→绿→红色阶,叠加到场景 Pass 上:

const lightingNode = lighting.getNode( scene )
    .setSize( window.innerWidth * window.devicePixelRatio, window.innerHeight * window.devicePixelRatio );

const lightCount = lightingNode.getClusterLightCount( debugZSliceNode );
const heatmap = float( lightCount ).div( float( lighting.maxLightsPerCluster ) );
// 蓝→绿→红渐变,再按 slider 强度与场景 Pass 混合

注意示例中 setSize 使用的是"窗口尺寸 × devicePixelRatio":因为集群网格按渲染缓冲尺寸划分,窗口尺寸变化(onWindowResize)后需要重建管线,这也是 updateProgram 内部监听 renderer.getDrawingBufferSize 的原因。

7. 参数调优与适用限制

结合实现,可以给出如下调优方向(均从源码结构推断,无官方基准数据):

  • tileSize:瓦片越大,单集群覆盖的屏幕区域越大、剔除越粗、每集群灯光数越多,片端循环压力上升;瓦片越小,集群总数 NX*NY*NZ 越大,compute 端球-AABB 测试总开销上升。默认 32 是一个平衡值。
  • zSlices:切片越多,深度方向剔除越精细,但每个切片平均分配的灯光区间越窄,compute 扫描的区间越小;默认 24 与指数分布配合可保证近处切片较密(指数间隔 near * (far/near)^(z/NZ) 天然贴近人眼对近处深度更敏感的特性)。
  • maxLightsPerCluster:写满即丢弃超出部分(compute 端 index >= maxLightsPerClusterReturn()),若场景灯光密度高可上调,但会线性增加片元端固定循环上界与存储(每集群 ceil(N/4)ivec4)。
  • maxLights:决定了灯光纹理规模与 CPU 端排序/区间扫描的 O(N²) 部分(Z 切片区间计算对每片扫全部灯光),灯数很大时 CPU 侧是潜在瓶颈。

适用限制:

  1. WebGPURenderer 可用(依赖 compute 着色器);
  2. 只有无阴影的点光源进入集群路径,带阴影点光源与其他灯型回退到常规 LightsNode 路径;
  3. 灯光 distance 为 0(无限范围)时按相机 far 平面半径参与相交测试,此类灯几乎无法被剔除,建议显式设置 distance
  4. far/near 比越大,指数切片在近处的分辨率损失越明显,示例相机采用 near = 1, far = 200 这类适中范围。

8. 关键文件索引

文件 内容
examples/jsm/tsl/lighting/ClusteredLightsNode.js ClusteredLightsNode 完整实现与 clusteredLights TSL 函数(文档页标注的 Source 文件)
examples/jsm/lighting/ClusteredLighting.js 替换 WebGPURenderer 光照系统的 ClusteredLighting 附加组件
src/nodes/lighting/LightsNode.js 父类 LightsNode:灯光节点生命周期、setupDirectLightsetLights/getBuiltinLights
examples/webgpu_lights_clustered.html 官方示例:30×30 点光源网格 + 集群负载热力图调试管线
docs/pages/ClusteredLighting.html.md ClusteredLighting 的文档页

总结:ClusteredLightsNode 把 Forward+ 集群着色做成了 TSL 节点体系里的一等公民——CPU 每帧只负责排序与上传灯光数据、compute 端做"球体-AABB + Z 区间"两级剔除、片元端按哨兵 0 提前终止循环并做半径 early-out。对于灯光数量远超传统 uniform 数组上限、且场景存在真实深度结构的 WebGPU 应用,它是把每片元光照成本从"全场景灯光数"压到"本集群灯光数"的核心组件。

登录后查看全文
热门项目推荐
相关项目推荐