news 2026/9/7 18:56:46

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
three.js ClusteredLightsNode:WebGPU 渲染管线中的 Forward+ 集群光照节点

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

【免费下载链接】three.jsJavaScript 3D Library.项目地址: https://gitcode.com/GitHub_Trending/th/three.js

本文基于 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 | floor定位屏幕瓦片
zSlices指数深度切片数量(集群 Z 维数 NZ)24tileSize共同决定集群总数NX * NY * NZ,即 compute 并行线程数
maxLightsPerCluster单个集群可容纳的灯光索引上限64决定每集群存储槽位数;分片存储时_chunksPerCluster = ceil(maxLightsPerCluster / 4)ivec4;也是片元端循环上限

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

  • 光源索引以0作为哨兵值表示"空槽":compute 端写入的是lightIdx + 1,片元端读出后先减 1 再查灯光数据(见 getTile 与setupLightslightIndex.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 × 2Float32DataTexture,每行 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对应屏幕顶行,即 NDCy = +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.jsClusteredLightsNode完整实现与clusteredLightsTSL 函数(文档页标注的 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.mdClusteredLighting的文档页

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

【免费下载链接】three.jsJavaScript 3D Library.项目地址: https://gitcode.com/GitHub_Trending/th/three.js

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/7 18:56:41

Zynq xc7z020与复旦微FM25F32 QSPI Flash烧写配置实战指南

简介&#xff1a;针对Xilinx公司Zynq-7000系列现场可编程门阵列在国产化替代中&#xff0c;开发环境无法识别复旦微电子Nor型QSPI闪存FM25F32的问题&#xff0c;这套基于XC7Z020器件的验证工程给出了完整解决思路。作者利用自编烧写测试程序&#xff0c;绕过开发套件自带工具的…

作者头像 李华
网站建设 2026/9/7 18:56:23

Hive与TimescaleDB整合实践:时序数据的冷热分离与同步链路设计

1. 为什么时序数据场景不能只用一种数据库1.1 我最初遇到的真实困境去年我做了一个典型的工业物联网数据平台&#xff0c;设备端每5秒上报一次运行状态&#xff0c;包括温度、振动、电流、电压、产量计数等指标。单台设备一天产生的记录量大概在1.7万条左右&#xff0c;几百台设…

作者头像 李华
网站建设 2026/9/7 18:55:30

基于Spring Boot的小区业主物业公共收益管理系统实战解析

在Java后端这个方向里&#xff0c;毕设项目的选题其实挺有讲究。做得太简单&#xff0c;答辩的时候讲不出东西&#xff1b;做得太复杂&#xff0c;自己又扛不住开发周期。今天聊的这个“基于Spring Boot的小区业主物业公共收益管理系统”&#xff0c;属于最典型的中等体量实战项…

作者头像 李华
网站建设 2026/9/7 18:53:45

python的图论工业场景模拟第九十七篇:设备协同最大团识与固化单元发现,任务:找完全互连最大节点集合组固化生产单位,图建模说明:无向图,边=协同加工能力,核心点:find_cliques

设备协同最大团识别与固化单元发现&#xff1a;找完全互连最大节点集合&#xff0c;固化生产单元"某柔性制造车间&#xff0c;12 台加工中心之间有些能协同作业&#xff08;比如 CNC1 和 CNC3 可以组合加工复杂零件&#xff09;&#xff0c;有些不行。工艺工程师想找出哪些…

作者头像 李华