three.js AmbientLightDataNode 详解:动态光照模式下环境光批处理数据节点的原理与用法
本文基于 three.js 官方 API 文档 AmbientLightDataNode 展开,讲解这个"动态光照模式下的环境光批处理数据节点"的继承关系、内部实现(颜色累加、逐帧 uniform 更新、irradiance 注入),以及它在 DynamicLightsNode 与 DynamicLighting 中的调用链,帮助读者理解 three.js 如何做到"增删环境光不触发着色器重编译"。
1. AmbientLightDataNode 是什么
官方文档的定义非常精炼:
Batched data node for ambient lights in dynamic lighting mode.(动态光照模式下,用于环境光的批处理数据节点。)
继承链为:
EventDispatcher → Node → AmbientLightDataNode
它的源码位于 examples/jsm/tsl/lighting/data/AmbientLightDataNode.js,仅 61 行。理解它的最佳方式是把它放进一个具体场景:默认情况下,LightsNode 会为场景中的每盏灯生成一个独立的解析光节点(如 AmbientLightNode)。由于 light 的"种类与数量"会参与着色器缓存键(见 LightsNode.customCacheKey),运行时增删灯光往往导致材质着色器重新编译。而动态光照模式(DynamicLighting)的思路是把支持批处理的光汇总成统一数据:环境光本身没有方向、没有衰减,多盏环境光的作用等价于"颜色×强度"的向量相加,因此只需要**一个固定大小的 uniform(一个 vec3 颜色)**即可表达任意数量的环境光。AmbientLightDataNode 就是承载这个"求和结果"的节点。
2. 源码逐段解析
2.1 构造器:一个 uniform 颜色 + RENDER 级更新
constructor() {
super();
this._color = new Color();
this._lights = [];
this.colorNode = uniform( this._color ).setGroup( renderGroup );
this.updateType = NodeUpdateType.RENDER;
}
几个关键设计:
_color与colorNode:内部持有一个Color实例,并包成uniform节点。setGroup( renderGroup )表示该 uniform 归属于渲染阶段的更新分组。由于 uniform 的大小在着色器里是编译期固定的,这与"光数量可变但着色器不变"的目标完全吻合。updateType = NodeUpdateType.RENDER:声明该节点需要在每一帧渲染前执行update()。这是"动态"二字的底层保障——灯光的color/intensity每帧可能被业务代码修改,而节点只需刷新 CPU 端数据,GPU 着色器纹丝不动。
构造函数没有参数(官方文档中的 new AmbientLightDataNode() 也是无参构造)。这是它与其他几个批处理数据节点(点光、聚光等需要 maxCount 来分配 uniform 数组)的本质区别:环境光无论多少盏,求和后都是一个 vec3,不需要按数量预留数组容量。这一点在 DynamicLightsNode.setupLightsNode 中有明确体现:_lightTypeToMaxProp 里只有 Directional/Point/Spot/Hemisphere 四种光型,没有 AmbientLight,因此代码走到 new DataNodeClass()(无参)分支。
2.2 setLights:绑定一批环境光
setLights( lights ) {
this._lights = lights;
return this;
}
setLights 只做一件事:保存当前帧应参与求和的环境光数组引用,并以 this 结尾支持链式调用。注意它不立即计算颜色——计算被推迟到渲染管线统一调度的 update() 中,这样多个数据节点可以在同一时机、按同一顺序刷新。
2.3 update:逐帧颜色累加
update() {
this._color.setScalar( 0 );
for ( let i = 0; i < this._lights.length; i ++ ) {
const light = this._lights[ i ];
this._color.r += light.color.r * light.intensity;
this._color.g += light.color.g * light.intensity;
this._color.b += light.color.b * light.intensity;
}
}
update 实现 的逻辑是标准的"基色 × 强度"逐通道累加:先把 _color 清零,再对 _lights 中每盏 AmbientLight 做 color * intensity 的向量和。这正好是 PBR/Phong 光照模型中环境项(irradiance)的物理含义——多盏环境光线性叠加等效于单盏"合成环境光"。
2.4 setup:注入 irradiance 上下文
setup( builder ) {
builder.context.irradiance.addAssign( this.colorNode );
}
setup 实现 只有三行:向 builder.context.irradiance 累加赋值为 colorNode。也就是说,当 TSL 构建该节点时,它的求和颜色会被加入当前光照上下文的辐照度项,随后由光照模型(lighting model)统一用于计算间接漫反射分量。从源码结构看,AmbientLightDataNode 并不直接输出最终颜色,而是"向共享上下文贡献一份 irradiance",这与单个 AmbientLightNode 在光照模型中参与 diffuse 计算的职责一致,只是数据来源从"每灯一个节点"变成了"每类型一个节点"。
3. 在 DynamicLightsNode 中的调用链
AmbientLightDataNode 的宿主是 DynamicLightsNode。理解整条链路才能理解文档中 "in dynamic lighting mode" 的确切含义。
3.1 光型到数据节点的映射
const _lightTypeToDataNode = {
AmbientLight: AmbientLightDataNode,
DirectionalLight: DirectionalLightDataNode,
PointLight: PointLightDataNode,
SpotLight: SpotLightDataNode,
HemisphereLight: HemisphereLightDataNode
};
映射表 将 5 种内置解析光映射到 5 个批处理数据节点类。能否进入批处理通道由 canBatchLight 决定:
const canBatchLight = ( light ) => {
return light.isNode !== true &&
light.castShadow !== true &&
isSpecialSpotLight( light ) === false &&
_lightTypeToDataNode[ light.constructor.name ] !== undefined;
};
条件拆开看:不是自定义 Node 光、不投影(castShadow === false)、不是带投影贴图/自定义颜色节点的"特殊聚光灯"、且构造器名在映射表内。标准的 AmbientLight 几乎总是满足全部条件,因此默认走 AmbientLightDataNode 通道;只有当用户显式给环境光设置 castShadow = true 这类异常状态时,才会退回默认的逐灯节点路径。
3.2 数据节点的创建与复用
在 setupLightsNode 中,同类型光先按 id 排序后分组(sortLights 保证顺序稳定),再按类型取出或创建数据节点:
let dataNode = this._dataNodes.get( typeName );
if ( dataNode === undefined ) {
const DataNodeClass = _lightTypeToDataNode[ typeName ];
const maxProp = _lightTypeToMaxProp[ typeName ];
const maxCount = maxProp !== undefined ? this[ maxProp ] : undefined;
dataNode = maxCount !== undefined ? new DataNodeClass( maxCount ) : new DataNodeClass();
this._dataNodes.set( typeName, dataNode );
}
dataNode.setLights( typeLights );
lightNodes.push( dataNode );
对 AmbientLight 而言 maxProp 为 undefined,所以永远是 new AmbientLightDataNode()。注意 _dataNodes 是一个 Map,数据节点创建一次后长期复用,后续帧只调 setLights 更新灯光数组——这正是"不重编译"的机制来源之一。
另外,第 222–231 行 有一个容易被忽略的细节:如果某类光上一帧存在、本帧全部被移除(lightsByType 中没有该类型),代码会执行 dataNode.setLights( [] ) 并仍然把该数据节点推入 lightNodes。此时 update() 累加空数组,_color 保持 0,即环境光贡献自然归零,而着色器结构保持不变。
3.3 缓存键:为什么增删环境光不触发重编译
DynamicLightsNode.customCacheKey 的哈希策略是动态模式的灵魂:
- 可批处理的光:只把光型名(如
AmbientLight)加入类型集合,参与最终哈希——不记录具体数量; - 不可批处理的光(node 光、投影光、特殊聚光灯):逐灯记录
light.id、castShadow、聚光贴图/颜色节点等细粒度数据。
因此,运行时新增/移除一盏 AmbientLight,类型集合不变,customCacheKey 不变,TSL 判定着色器结构未变,不会重新编译;变化的只是 update() 里累加进 uniform 的数值。作为对照,默认 LightsNode.customCacheKey 会把每一盏灯的 id 都压入哈希,灯光数量一变缓存键就变——两套机制的差异即动态光照模式的价值所在。
运行时替换灯光数组的路径是 DynamicLightsNode.setLights → _updateDataNodeLights:按类型重新分组,对每个已存在的数据节点调用 dataNode.setLights( ... )。同时 hasLights 被重写为 super.hasLights || this._dataNodes.size > 0,保证"场景里只剩环境光(数据节点通道)"时,系统依然认为场景有光。
4. 实战:启用动态光照模式
AmbientLightDataNode 一般不直接手动构造,而是通过 DynamicLighting 接入渲染器。DynamicLighting 的构造参数为:
| 参数 | 默认值 | 说明 |
|---|---|---|
maxDirectionalLights |
8 | 批处理的平行光上限(决定 uniform 数组容量) |
maxPointLights |
16 | 批处理的点光上限 |
maxSpotLights |
16 | 批处理的聚光灯上限 |
maxHemisphereLights |
4 | 批处理的半球光上限 |
环境光不受数量上限约束(求和结果恒为单个 vec3)。官方示例 examples/webgpu_lights_dynamic.html 展示了完整用法:
import * as THREE from 'three/webgpu';
import { DynamicLighting } from 'three/addons/lighting/DynamicLighting.js';
const renderer = new THREE.WebGPURenderer( { antialias: true } );
// 一行开启动态光照模式,内部会为每个场景创建 DynamicLightsNode
renderer.lighting = new DynamicLighting();
// 场景中的 AmbientLight 将被 AmbientLightDataNode 批处理
scene.add( new THREE.AmbientLight( 0x404040, 0.5 ) );
该示例场景中摆放了 100 个使用 50 种 PBR 材质的网格,通过 Inspector 面板在运行时反复增删点光与 AmbientLight,官方页面描述为 "Opt-in DynamicLighting avoids shader recompilation when adding/removing supported lights"(选用的 DynamicLighting 在增删受支持灯光时避免着色器重编译)。渲染效果如下:
运行前提:需要 WebGPU 渲染路径(WebGPURenderer + three/webgpu 构建),DynamicLighting 通过 renderer.lighting 属性挂载,内部经 getNode 按场景缓存 DynamicLightsNode 实例(QuadMesh 场景走默认 LightsNode)。
5. 小结:设计要点与适用边界
结合源码可以归纳出 AmbientLightDataNode 的三条设计要点:
- 数据结构恒定:用单个
Coloruniform 表达任意多盏环境光的叠加,着色器里没有"循环 N 盏环境光"的展开,光数量变化不改变着色器结构; - CPU 端逐帧求和:
NodeUpdateType.RENDER+update()每帧重新累加,灯光参数修改零成本生效; - 上下文注入而非独立输出:
setup中向builder.context.irradiance做addAssign,与光照模型的其他间接分量汇合,行为与单个 AmbientLightNode 在渲染结果上保持等价。
适用边界同样清晰:它只服务于 DynamicLighting/DynamicLightsNode 这条动态通道;投影光、自定义 Node 光、带投影贴图的聚光灯会被 canBatchLight 拦截,退回 LightsNode 的逐灯节点路径(该路径下灯光数量变化仍会触发重编译)。如果你需要为每盏环境光保留独立行为(例如各自使用 Node 定义颜色),则应直接使用 AmbientLightNode 或自定义光照节点,而不是这条批处理通道。
参考路径汇总:AmbientLightDataNode 源码、DynamicLightsNode 源码、DynamicLighting 源码、LightsNode 基类、官方示例页面、API 文档页。
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
