three.js ClusteredLightsNode:WebGPU 渲染管线中的 Forward+ 集群光照节点
本文基于 three.js 官方文档 ClusteredLightsNode 页面及其源码实现,系统讲解 Forward+ 集群着色(Clustered Shading)在 three.js TSL 节点系统中的落地方式。读完本文,你能理解 ClusteredLightsNode 的四个构造参数(maxLights / tileSize / zSlices / maxLightsPerCluster)各自的含义与默认值,掌握它"每帧上传灯光数据 → 计算着色器划分集群 → 片元只遍历所属集群灯光"的完整工作链路,并能结合 ClusteredLighting 附加组件在 WebGPURenderer 中实际启用集群光照。
1. ClusteredLightsNode 是什么
ClusteredLightsNode 是 LightsNode 的自定义子类(继承链为 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 再查灯光数据(见 getTile 与setupLights中lightIndex.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 行)在每帧渲染前执行三件事:
-
更新灯光数据纹理。
updateLightsTexture( camera )(第 86–187 行)先按视图空间深度排序所有集群灯光(order.sort( ( a, b ) => viewZ[ a ] - viewZ[ b ] )),然后按排序结果写入一个maxLights × 2的Float32DataTexture,每行 2 个vec4:- 第 0 行:世界坐标
xyz+ 灯光distance; - 第 1 行:
color × intensity的 rgb +decay。
排序的意义在于:后续 Z 切片区间在排序数组上是连续的,compute 端只需扫描
[rangeStart, rangeEnd)子区间而非全量灯光。 - 第 0 行:世界坐标
-
计算每个 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 剔除——只测试"深度上能够触及本集群"的灯光。 -
同步相机 uniform 并触发 compute。
camera.near/far、视图矩阵、投影矩阵通过renderGroup组 uniform 在 compute 与片元之间共享(compute 着色器拿不到相机上下文,因此手动逐帧更新,见第 66–72 行注释),随后调用renderer.compute( this._compute )。
4.3 compute 端:一集群一线程,球体-AABB 相交
compute 函数在 create()(第 419–596 行)中构建,以 clusterCount = NX * NY * NZ 个实例运行,每个实例处理一个集群:
-
由实例索引解算 3D 集群坐标:
cx = i mod NX,cy = (i / NX) mod NY,cz = i / (NX*NY); -
计算集群的 NDC 边界(注意屏幕 Y 翻转:
cy = 0对应屏幕顶行,即 NDCy = +1)与视图空间 Z 边界(同样用指数切片公式,值为负); -
求集群的视图空间 AABB:由投影矩阵对角元素
focal_x / focal_y反解出"视图坐标 = NDC × (−view_z) × 1/focal",将近/远两个截面的 8 个角点取 min/max 得到包围盒——因为瓦片边界可能横跨视图轴,直接取角点 min/max 是必要而保守的; -
先清空上一帧的脏数据(
Loop( chunksPerCluster, ... assign( ivec4( 0 ) ) )),保证光数减少时不会出现过期索引; -
读取本 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 行)。它对 reflectedLight 的 directDiffuse/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 网格、每个小球自带一个彩色 PointLight(distance = 9、power = 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 >= maxLightsPerCluster时Return()),若场景灯光密度高可上调,但会线性增加片元端固定循环上界与存储(每集群ceil(N/4)个ivec4)。maxLights:决定了灯光纹理规模与 CPU 端排序/区间扫描的 O(N²) 部分(Z 切片区间计算对每片扫全部灯光),灯数很大时 CPU 侧是潜在瓶颈。
适用限制:
- 仅
WebGPURenderer可用(依赖 compute 着色器); - 只有无阴影的点光源进入集群路径,带阴影点光源与其他灯型回退到常规
LightsNode路径; - 灯光
distance为 0(无限范围)时按相机far平面半径参与相交测试,此类灯几乎无法被剔除,建议显式设置distance; 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:灯光节点生命周期、setupDirectLight、setLights/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 应用,它是把每片元光照成本从"全场景灯光数"压到"本集群灯光数"的核心组件。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00