three.js TSL 深度解析:AnalyticLightNode 解析光节点基类与阴影、逐帧更新机制
在 three.js 的节点着色语言(TSL,Three Shading Language)体系中,AnalyticLightNode 是所有“解析光”(方向光、点光、聚光灯、矩形区域光等可计算光照方向的真实光源)节点的统一基类。它负责把场景中的 Light 对象桥接到节点系统:逐帧同步颜色与强度、在构建期把光照方向与衰减数据注册进当前 LightingModel,并自动接管阴影的接入与释放。读完本文,你将理解 TSL 光照管线中“光源对象 → 光节点 → 光照模型 → 输出节点”的完整链路,以及阴影节点如何被挂载到光颜色上参与最终计算。
继承链与职责定位
AnalyticLightNode 的完整继承链为:
EventDispatcher → Node → LightingNode → AnalyticLightNode
- LightingNode.js 是最底层的抽象,构造函数中调用
super('vec3')声明输出类型并设置只读标志isLightingNode,本身没有任何行为逻辑; - AnalyticLightNode.js 在其之上补齐了真实光源才需要的能力:颜色 uniform、阴影接入、逐帧 uniform 更新。
需要区分的是,“解析光”特指能够求出“从当前像素指向光源方向”这类方向向量的光源。从源码目录结构看,src/nodes/lighting/ 下的 DirectionalLightNode、PointLightNode、SpotLightNode、RectAreaLightNode、IESSpotLightNode、ProjectorLightNode 都继承自 AnalyticLightNode;而 AmbientLightNode、HemisphereLightNode、LightProbeNode 这类环境/间接光节点则直接继承 LightingNode,不走阴影与方向向量逻辑。
构造函数与核心属性
构造函数签名与文档一致:
new AnalyticLightNode( light = null )
结合 src/nodes/lighting/AnalyticLightNode.js 的构造函数,各属性含义如下:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
.light |
Light |
null |
引用的光源对象,后续 getLightVector、getHash、update 都依赖它 |
.color |
Color |
new Color() |
光的颜色值,每帧由 light.color * light.intensity 覆盖 |
.colorNode |
Node |
— | 光颜色的节点。若光源自身已设置 colorNode 则直接复用;否则基于 .color 创建 uniform 节点 |
.baseColorNode |
Node |
null |
保存 colorNode 的原始引用;启用阴影后 .colorNode 会被替换为“颜色 × 阴影”的组合节点,baseColorNode 用于在阴影关闭或释放时还原 |
.shadowNode |
ShadowNode |
null |
表示该光阴影的节点,由 setupShadowNode() 创建 |
.shadowColorNode |
Node |
null |
表示阴影调制后的最终光颜色节点 |
.isAnalyticLightNode |
boolean(只读) |
true |
类型测试标志 |
.updateType |
string |
'frame' |
覆写自 LightingNode,因为解析光节点需要逐帧更新 |
其中 colorNode 的初始化逻辑值得注意(见 AnalyticLightNode.js#L54):
this.colorNode = ( light && light.colorNode ) || uniform( this.color ).setGroup( renderGroup );
这意味着如果用户在光源上直接挂载了一个 TSL 颜色节点(例如程序化颜色或投射纹理),光节点会尊重用户节点;否则回退为标准的 uniform 节点,并归入 renderGroup 更新组,保证 uniform 数据按渲染帧同步。
updateType 被覆写为 NodeUpdateType.FRAME(即字符串 'frame')。四种更新频率定义在 src/nodes/core/constants.js#L20-L25:NONE(不更新)、FRAME(每帧更新)、RENDER(每次 render 调用更新,比 frame 更细)、OBJECT(每个使用该节点的 Object3D 更新一次)。解析光选择 frame,是因为光颜色/强度、位置等属性通常按动画帧变化,逐帧同步一次 uniform 即可。
此外还有一个未在文档属性表中列出、但对渲染缓存至关重要的方法 getHash():它直接返回 this.light.uuid(见 AnalyticLightNode.js#L148-L152)。这个哈希会参与 LightsNode.js 的 getHash(),用于判断同一组光源的着色器是否可以直接复用缓存。
setup:把光注册进光照模型
文档特别强调了一点:光照节点在 setup 中不返回输出节点,这与大多数普通 Node 不同。光照节点的核心职责是配置当前 LightingModel 并调用其接口方法。setup(builder) 的完整流程(见 AnalyticLightNode.js#L255-L290)可以拆解为四步:
setup( builder ) {
this.colorNode = this.baseColorNode || this.colorNode; // 1. 每帧还原基础颜色节点
if ( this.light.castShadow ) {
if ( builder.object.receiveShadow ) {
this.setupShadow( builder ); // 2. 条件接入阴影
}
} else if ( this.shadowNode !== null ) {
this.shadowNode.dispose(); // 2'. 光不再投射阴影时主动释放
this.shadowNode = null;
this.shadowColorNode = null;
}
const directLightData = this.setupDirect( builder ); // 3. 抽象方法:子类给出颜色与方向
const directRectAreaLightData = this.setupDirectRectArea( builder );
if ( directLightData ) {
builder.lightsNode.setupDirectLight( builder, this, directLightData );
}
if ( directRectAreaLightData ) {
builder.lightsNode.setupDirectRectAreaLight( builder, this, directRectAreaLightData );
}
}
- 还原颜色节点:阴影接入会改写
colorNode,所以每次 setup 开头先取回baseColorNode(若存在),保证基础状态干净; - 阴影条件接入:只有当
light.castShadow === true且当前构建对象builder.object.receiveShadow为真时才调用setupShadow(builder);反之如果上一帧还存在阴影节点,则立即dispose()释放——阴影资源的生命周期完全由光节点的setup自动管理; - 抽象的直接光照数据:
setupDirect(builder)与setupDirectRectArea(builder)在基类中是空实现(标记@abstract),必须由具体光类覆写,返回{ lightColor, lightDirection }或矩形区域光数据; - 交给 LightsNode 注册:拿到数据后调用
builder.lightsNode.setupDirectLight(...),最终触发LightingModel.direct(...)完成 PBR 等模型的具体光照累加(见 LightsNode.js#L303-L332)。
阴影管线:setupShadow、setupShadowNode 与 disposeShadow
setupShadow(builder) 是阴影与光照计算的汇合点(见 AnalyticLightNode.js#L204-L246),其内部行为:
- 首先检查
renderer.shadowMap.enabled === false则直接返回——全局关闭阴影时不做任何阴影构建; - 若尚未创建
shadowColorNode,则优先使用光源自定义的light.shadow.shadowNode(经nodeObject()包装),否则调用this.setupShadowNode()创建默认节点; - 关键一行:
this.shadowColorNode = this.colorNode.mul( shadowNode )。也就是说阴影结果被乘进光的颜色节点,之后整个光照模型对该光的任何使用(漫反射、高光、环境反射等)都自动带上阴影衰减,无需逐处修改; - 记录
this.baseColorNode = this.colorNode,保证setupShadow的幂等(第二次进入不会把“颜色×阴影”再乘一次阴影); - 若构建上下文提供了
builder.context.getShadow钩子,则用它覆盖默认的阴影颜色节点,这为自定义光照系统(如体积光模型)提供了扩展点。
setupShadowNode() 的默认实现直接返回 TSL 函数 shadow( this.light ) 创建的 ShadowNode。该方法是刻意为子类保留的工厂方法——例如 PointLightNode.js#L83-L87 覆写为 pointShadow( this.light ),以便点光使用立方体贴图阴影。ShadowNode 内部完成了阴影贴图渲染目标创建、PCF/VSM 滤波选择、shadowCoord 计算与 updateBefore 中的阴影贴图按需重渲染等完整逻辑(见 ShadowNode.js#L595-L631 与 ShadowNode.js#L792-L826)。
资源回收由两条路径共同保证:
- 光被销毁时:构造函数中如果检测到
light.shadow,会向light注册一个'dispose'监听器,触发disposeShadow();dispose()中会先移除该监听器(见 AnalyticLightNode.js#L99-L123); - 阴影被关闭时:
setup()中castShadow为假且存在旧shadowNode时主动释放。
disposeShadow() 本身(见 AnalyticLightNode.js#L128-L146)依次释放 shadowNode、清空 shadowColorNode,并把 colorNode 还原为 baseColorNode,恢复为无阴影状态。
getLightVector:视图空间中的光照方向
getLightVector(builder) 返回一个 vec3 节点,语义是“从当前像素的视图空间位置指向光源视图空间位置的方向向量”:
getLightVector( builder ) {
return lightViewPosition( this.light ).sub( builder.context.positionView || positionView );
}
见 AnalyticLightNode.js#L161-L165。这里 lightViewPosition( this.light ) 是 src/nodes/accessors/Lights.js 提供的 TSL 访问器;builder.context.positionView 允许特定构建上下文注入不同的观察位置,缺省时回退到标准 positionView 节点。子类正是基于这个向量计算距离与方向:例如 PointLightNode.setupDirect 先取 lightVector.normalize() 作为方向、.length() 作为距离,再经 getDistanceAttenuation 计算物理衰减(见 PointLightNode.js#L89-L98);SpotLightNode.setupDirect 则用它与目标方向的点积求出锥角余弦,配合 smoothstep 生成 penumbra 平滑衰减(见 SpotLightNode.js#L119-L164)。
逐帧更新:update(frame) 与具体光类的扩展
基类的 update(frame) 只有一行核心逻辑(见 AnalyticLightNode.js#L299-L305):
update( /*frame*/ ) {
const { light } = this;
this.color.copy( light.color ).multiplyScalar( light.intensity );
}
即每帧把 light.color × light.intensity 写入 this.color,进而经由 uniform 节点进入着色器。具体光类在此基础上覆写 update 补充各自特有的 uniform,例如:
- PointLightNode.js#L67-L76:同步
cutoffDistanceNode(来自light.distance)与decayExponentNode(来自light.decay); - SpotLightNode.js#L73-L85:额外同步
coneCosNode、penumbraCosNode,注意二者都预先转换为余弦值,着色器侧因此只需一次比较/插值即可判定锥角范围。
这种“基类管通用颜色/强度、子类管特有参数”的拆分,配合 updateType = 'frame',使得光源参数在动画中逐帧平滑更新而无需手动刷新着色器。
与 LightsNode 的协作:节点如何被创建与缓存
AnalyticLightNode 并不是用户手动创建的——它由 LightsNode.js 的 setupLightsNode(builder)(见 LightsNode.js#L236-L294)自动实例化:
- 将材质声明的光(
materialLightings)与场景内置光(getBuiltinLights())合并后按id排序,保证构建顺序稳定; - 对每个非节点化光源,通过
builder.renderer.library.getLightNodeClass( light.constructor )从 NodeLibrary.js 查询对应的光节点类(内部维护Light.constructor → AnalyticLightNode.constructor的映射,见 NodeLibrary.js#L128-L141),查不到则警告并跳过; - 用模块级
WeakMap(_lightsNodeRef)按光源对象缓存已创建的光节点实例,同一光源在多个材质间共享同一个节点; - 复用判断时通过
getLightNodeById匹配,而该函数正是依赖lightNode.isAnalyticLightNode标志做类型测试(见 LightsNode.js#L48-L62)——这也是文档中isAnalyticLightNode属性存在的直接原因。
LightsNode.customCacheKey()(见 LightsNode.js#L147-L175)进一步把每盏灯的 id 与 castShadow 状态编入着色器缓存键,聚光灯还会纳入 map 与 colorNode 的哈希。从源码结构看,这意味着光源集合或其阴影状态发生变化时,TSL 会触发相应着色器重新生成;而仅改变光的颜色、强度、位置这类由 uniform 承载的参数,则只走 update 的逐帧同步,不产生重编译。
具体子类一览
以下光节点均以 AnalyticLightNode 为基类,源码全部位于 src/nodes/lighting/:
| 节点类 | 来源文件 | 覆写的抽象方法 | 要点 |
|---|---|---|---|
DirectionalLightNode |
DirectionalLightNode.js | setupDirect |
方向取 lightTargetDirection( light ),无距离衰减,适合平行光 |
PointLightNode |
PointLightNode.js | setupDirect、setupShadowNode |
距离衰减 + 立方体阴影 pointShadow |
SpotLightNode |
SpotLightNode.js | setupDirect |
锥角/半影 smoothstep 衰减 + 距离衰减,支持 light.map 或自定义 light.colorNode 投射 |
RectAreaLightNode |
RectAreaLightNode.js | setupDirectRectArea |
矩形区域光,走 lightingModel.directRectArea 分支 |
IESSpotLightNode |
IESSpotLightNode.js | setupDirect |
在聚光灯基础上叠加 IES 配光曲线采样 |
ProjectorLightNode |
ProjectorLightNode.js | setupDirect |
投影光,复用点光的距离衰减结构 |
其中 DirectionalLightNode.setupDirect(见 DirectionalLightNode.js#L28-L35)是最简实现:
setupDirect() {
const lightColor = this.colorNode;
const lightDirection = lightTargetDirection( this.light );
return { lightDirection, lightColor };
}
返回的 { lightColor, lightDirection } 正是基类 setup 中传给 builder.lightsNode.setupDirectLight 的“直接光数据”,随后由当前 LightingModel(如 PBR 模型)完成 BRDF 累加并汇入 totalDiffuseNode / totalSpecularNode(见 LightsNode.js#L373-L436 中 LightsNode.setup 对 lightingModel.start / finish 的调用)。
小结与延伸阅读
AnalyticLightNode的价值在于把“光源参数同步(update)”“光照方向与衰减数据生产(setupDirect/setupDirectRectArea)”“阴影自动接入与释放(setupShadow/disposeShadow)”三件事收敛到统一基类,子类只需补齐各自特有的 uniform 与方向计算;- 理解它的最佳切入点是
setup与setupShadow两个方法,前者是光照管线注册入口,后者展示了阴影如何以“颜色 × 阴影”的方式透明地融入整个光照计算; - 相关文档页:AnalyticLightNode、LightingNode、LightingModel、Node#setup;
- 源码入口:src/nodes/lighting/AnalyticLightNode.js、src/nodes/lighting/LightsNode.js、src/nodes/lighting/ShadowNode.js、src/renderers/common/nodes/NodeLibrary.js;
- 示例工程中 examples/webgpu_shadowmap.html 演示了阴影贴图渲染效果,其背后正是本文所述的“光节点 → ShadowNode → shadowMap”链路在工作。
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