three.js DynamicLighting 指南:将解析式灯光批量打包,彻底消除灯光数量变动引发的材质重编译
导读
本指南围绕 three.js WebGPU 渲染管线中的附加组件(Addon)DynamicLighting 展开。它解决的是 WebGPU 渲染器在运行时增删解析式(analytic)灯光时反复重编译着色器、导致画面卡顿的典型痛点:通过把平行光、点光、聚光、半球光批量收集到统一的 uniform 数组与循环中,让灯光数量的变化不再触发材质重编译。读完本文,你将掌握如何引入并启用该能力、正确配置各类型灯光的上限参数,并理解其底层“按类型分批 + uniform 数组 + TSL 循环”的实现机制与适用边界。
DynamicLighting 是什么
在默认情况下,three.js WebGPU 渲染管线的光照节点(LightsNode)会把场景中的灯光逐个转换为对应的灯光节点,而材质编译时的缓存键(cache key)与灯光节点集合强相关。这意味着只要场景中灯光的数量或类型组合发生改变(例如动态添加/删除一盏点光),就可能导致材质触发一次着色器重编译(shader recompilation),在运行时表现为明显的顿挫。
DynamicLighting 正是针对这一场景提供的一种自定义光照实现。按照官方 API 文档(docs/pages/DynamicLighting.html.md)与源码注释的准确表述,它:
把受支持的解析式灯光批量收集进 uniform 数组,使灯光数量变化时不再触发材质重编译。
类继承关系为 DynamicLighting → Lighting,其代码骨架可以在 examples/jsm/lighting/DynamicLighting.js 中看到——它继承自渲染器公共模块中的 Lighting,后者负责为 “scene + camera + lighting” 组合管理每个场景对应的灯光节点。
它的典型应用场景包括:实时编辑工具中反复增删灯光、周期性刷新灯光数量的大规模演示场景、以及希望场景中灯光频繁进出却保持渲染流畅的项目。
引入与最小使用
DynamicLighting 属于显式引入的附加组件,不在 three.js 核心构建中导出,需要按 Addon 方式导入:
import { DynamicLighting } from 'three/addons/lighting/DynamicLighting.js';
在使用层面,只需两步:构造实例并把实例赋给 WebGPU 渲染器的 lighting 属性:
const renderer = new THREE.WebGPURenderer();
// 启用动态光照,并把点光批量上限提升到 64 盏
const lighting = new DynamicLighting( { maxPointLights: 64 } );
renderer.lighting = lighting;
渲染器在初始化时默认创建了标准 Lighting 实例(见 src/renderers/common/Renderer.js 中 this.lighting = new Lighting() 一行),直接在创建渲染器之后覆盖该属性即可完成启用。
一个注意点:由于光线最终会影响材质的着色代码,切换动态模式与普通模式需要在渲染器尚未完成该材质的着色器编译前进行。官方演示 examples/webgpu_lights_dynamic.html 中提供了更稳妥的处理:切换 dynamic mode 时显式调用 renderer.dispose()、移除旧的 canvas,再重新 createRenderer()(见其中的 params.dynamic 开关回调),从而确保以全新状态重建渲染器。
构造参数详解
构造函数签名:
new DynamicLighting( options : Object )
options 是动态光照配置对象,默认值为 {},未提供的字段会与内置默认值合并(合并逻辑在 DynamicLighting.js 的构造函数中,通过展开运算符与默认值对象合并实现)。
四类批量灯光的上限参数如下表:
| 参数 | 作用 | 默认值 | 类型含义 |
|---|---|---|---|
maxDirectionalLights |
可批量打包的平行光最大数量 | 8 |
超过上限的平行光会被忽略并告警 |
maxPointLights |
可批量打包的点光最大数量 | 16 |
超过上限的点光会被忽略并告警 |
maxSpotLights |
可批量打包的聚光最大数量 | 16 |
超过上限的聚光会被忽略并告警 |
maxHemisphereLights |
可批量打包的半球光最大数量 | 4 |
超过上限的半球光会被忽略并告警 |
这四个上限直接决定着色器中 uniform 数组的编译期固定长度——它们是材质着色器缓存键的一部分,因此必须把上限配置为一个“足以覆盖你场景峰值灯数、又不至于过度膨胀”的数值。上限越高,预先分配的 uniform 槽位越多,着色器常量的负载也越大;上限过低则可能让多余的灯被静默丢弃。
当同一类型实际灯光数超过配置上限时,相关数据节点会在运行时向控制台输出形如下方的警告(见 PointLightDataNode.js):
THREE.PointLightDataNode: 30 lights exceed the configured max of 16. Excess lights are ignored.
注意:以上配置上限只影响批量打包的灯光。不属于批量范围的特殊灯光(如投射阴影的灯、节点灯光等)会走默认的“每灯一节点”路径,不占用这些上限额度。
核心方法
DynamicLighting 覆盖了基类 Lighting 的两个方法,均与 LightsNode(灯光节点)的创建与获取相关。
.createNode( lights : Array. ) : DynamicLightsNode
为给定的灯光数组创建新的动态灯光节点,返回类型为 DynamicLightsNode。
lights:要绑定到该节点上的灯光数组。- 覆写了 Lighting#createNode。
源码实现实际上是把配置(this.options)整体透传给 DynamicLightsNode 构造器并绑定灯光:
createNode( lights = [] ) {
return new DynamicLightsNode( this.options ).setLights( lights );
}
.getNode( scene : Scene ) : LightsNode
返回给定场景对应的灯光节点。
scene:目标场景对象。- 覆写了 Lighting#getNode。
源码实现使用 WeakMap 为每个场景缓存节点,避免重复创建(DynamicLighting.js):
getNode( scene ) {
if ( scene.isQuadMesh ) return _defaultLights; // 四边面等渲染对象直接返回默认节点
let node = this._nodes.get( scene );
if ( node === undefined ) {
node = this.createNode();
this._nodes.set( scene, node );
}
return node;
}
从源码可以看出,若传入的是 QuadMesh 这类渲染对象,则直接复用模块级的共享 LightsNode 默认节点(这一点对应基类中“忽略可渲染对象”的设计)。
底层原理:DynamicLightsNode 如何批量打包
真正承担“批量打包”职责的是 examples/jsm/tsl/lighting/DynamicLightsNode.js。它继承自 three.js 核心的 LightsNode(见 src/nodes/lighting/LightsNode.js),并重写了节点创建逻辑。
分批规则:哪些灯可以进 uniform 数组
DynamicLightsNode 内部通过 canBatchLight( light ) 判定灯光是否走批量路径。从源码(DynamicLightsNode.js)可以提炼出以下约束,满足全部条件的灯光才会被批量打包:
- 不是“节点灯光”(
light.isNode !== true); - 不投射阴影(
light.castShadow !== true)——因为阴影需要独立的 shadow map 与 shadow node,无法并入简单的 uniform 循环; - 不是“特殊聚光”(即带投影贴图
map或自定义colorNode的聚光灯); - 类型命中内置映射表
_lightTypeToDataNode,目前支持的解析式灯光类型为:AmbientLight→ AmbientLightDataNode.jsDirectionalLight→ DirectionalLightDataNode.jsPointLight→ PointLightDataNode.jsSpotLight→ SpotLightDataNode.jsHemisphereLight→ HemisphereLightDataNode.js
不满足条件的灯光(如投射阴影的灯、节点灯光、带投影纹理的聚光、以及表中未收录的类型)会回退到默认的每灯一个 light node 路径(getOrCreateLightNode 从渲染器的 node library 中取对应灯光节点类)。因此文档与注释中所说的“受支持的解析式灯光”实质上指上述批量子集。
另外,setupLightsNode 开头会对灯光按 id 排序(sortLights),保证同一场景内灯光顺序稳定,进而让着色器生成结果稳定。
按类型分组 → 一个类型一个数据节点
setupLightsNode( builder ) 的核心流程(DynamicLightsNode.js):
- 遍历灯光,把可批量灯光按
constructor.name分组存入lightsByType; - 不可批量灯光照旧生成独立 light node;
- 对每种类型,从缓存
_dataNodes(Map)中取出(没有则新建)对应的 Data 节点,并传入该类型当前灯光数组; - 若某种类型当前场景中已没有灯,则向该数据节点传入空数组
[]并仍然保留该节点——这保证着色器中的 uniform 数组与循环代码恒定存在,正是“灯光数量变化不重编译”的关键之一。
在缓存键方面,DynamicLightsNode.customCacheKey() 对批量灯光仅记录“类型集合”,而对不可批量灯光记录其 light.id 与 shadow 状态等细节。这意味着只要灯光类型集合不变,新增或删除批量灯不会改动缓存键,着色器便可复用;而不可批量灯(如阴影灯)的任何增减仍会精确反映到缓存键中,可能触发重编译。这与类的整体设计意图完全一致。
数据节点:预分配 uniform 数组 + 每帧上传
以点光为例,PointLightDataNode.js 在构造时按 maxCount 一次性预分配固定大小的槽位:
colors:Color数组,保存color × intensity;positionsAndCutoff:Vector4数组(xyz 为灯光在视图空间的位置,w 为衰减截止距离light.distance);decays:Vector4数组(x 存衰减指数decay);- 外加一个
int类型的 count uniform 记录当前实际灯光数。
这些 uniform 均被标记为 renderGroup,并以 uniformArray / uniform 形式接入 TSL 节点。节点的 updateType = NodeUpdateType.RENDER,即每个渲染帧都会回调 update( { camera } ),将灯光的当前颜色、视图空间位置、距离、衰减等实时写入槽位——因此灯光被移动、变色、调强度时只涉及 uniform 数据刷新,同样不触发重编译。
setup() 阶段则以 TSL 的 Loop( this.countNode, ... ) 在着色器里对当前实际灯数做循环,对每盏灯执行距离衰减计算,并调用 lightingModel.direct(...) 把漫反射与镜面反射分量累加进 dynDiffuse / dynSpecular,最后统一写回 reflectedLight。平行光数据节点 DirectionalLightDataNode.js 的处理类似,只是方向由 light.position → light.target 相减并在视图空间变换得到,且没有距离衰减项。
值得一提的是,该目录下还导出了一个等价 TSL 函数:
import { dynamicLights } from 'three/addons/tsl/lighting/DynamicLightsNode.js';
const node = dynamicLights( { maxPointLights: 64 } ); // 等价于 new DynamicLightsNode(...)
便于在纯 TSL 节点构建流程中直接使用(见 DynamicLightsNode.js)。
官方演示示例拆解
配套的官方示例 examples/webgpu_lights_dynamic.html 是理解该能力的最佳“实验台”,其场景为 100 个网格、50 种独立 PBR 材质(前 50 个网格各用唯一材质,后 50 个网格复用),配以深色地面与中心高反光金属球。
示例默认以两盏点光开场,并通过 renderer.lighting = new DynamicLighting(); 启用动态模式。Inspector 面板提供了四个可控参数:
- dynamic mode:布尔开关。关闭时会
dispose()渲染器并重建为默认光照,用于与动态模式做渲染性能/重编译对比; - auto-add lights:开启后每 500ms 自动向场景新增一盏随机颜色与轨道的点光(
setInterval驱动addLight()); - add light / remove light / remove all lights:手动逐盏添加、移除(或清空)点光,供直观观察“增删灯”瞬间的流畅度差异;
- point lights:实时显示当前场景点光数量。
所有点光还带有小球可视化,并在动画循环中沿各自的圆形轨道运动、高度上下浮动(见 animate() 中对 light.userData 轨道参数的使用),可同时验证“动态移动的灯不触发重编译”。
注意事项与边界
综合源码与官方文档,使用 DynamicLighting 时有以下几点需要明确:
- 适用范围限定于 WebGPU 渲染器。
DynamicLighting.js与数据节点文件均从three/webgpu导入核心类型,仅对 WebGPURenderer 有意义;在 WebGL 渲染器上lighting属性不存在此用法。 - 上限即“预留容量”。四个 max 参数决定编译期 uniform 数组大小,应结合场景峰值灯数配置;超出部分会被忽略,并在控制台打印警告。
- 并非所有灯都被批量。投射阴影的灯、节点灯光、带投影贴图/自定义 colorNode 的聚光灯会回退到逐灯节点路径,相关变动仍可能触发重编译。因此“不重编译”的保证仅针对受支持的批量灯光。
- 切换模式需重建渲染器。为避免材质基于已失效的缓存状态被复用,切换 dynamic 开关时应按官方示例先
renderer.dispose()再重建渲染器。 - 每类型数据节点会常驻。即使某一类型暂时没有任何灯,对应数据节点仍保留并上传空数组,这是换取“数量稳定不变”的必要设计,也是着色器轻微增加的来源。
延伸阅读
- API 文档:docs/pages/DynamicLighting.html.md
- 实现源码:examples/jsm/lighting/DynamicLighting.js、examples/jsm/tsl/lighting/DynamicLightsNode.js
- 数据节点目录:examples/jsm/tsl/lighting/data(内含五类灯光的批量实现)
- 基类与接入点:src/renderers/common/Lighting.js、src/renderers/common/Renderer.js、核心 src/nodes/lighting/LightsNode.js
- 运行示例:examples/webgpu_lights_dynamic.html
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