three.js TSL 光照模型基类 LightingModel 深度解析:抽象方法契约、求值生命周期与自定义扩展指南
LightingModel 是 three.js Node 材质体系(TSL/WebGPU Node)中承载「光照模型」的抽象基类。它为直接光、间接光、环境光遮蔽等光照分量定义了一组清晰的抽象方法,并规定了它们在一次光照求值中的先后顺序。阅读完本文,你将理解这些方法何时被调用、各自应实现什么物理项、如何被 NodeMaterial 与 LightsNode 驱动,以及如何基于它编写一个自定义光照模型接入渲染管线。
一、LightingModel 是什么
从 类文档 的定义看,LightingModel 是用于实现光照模型的抽象类。模块内定义了一系列方法,具体的光照模型子类可以有选择地实现这些方法,而它们会在光照求值过程的不同阶段被执行。
在 three.js 中,LightingModel 与一组节点类型共同构成"节点化"的光照求值框架:
LightsNode:代表场景光照,负责管理整个光照求值生命周期;LightingContextNode:在ContextNode基础上注入光照上下文(radiance、irradiance、reflectedLight、ambientOcclusion 等);- 各类
Light节点(如DirectionalLightNode、PointLightNode、SpotLightNode、RectAreaLightNode)负责把传统光源换算成可求值的光照数据; - 具体的
LightingModel子类(BasicLightingModel、PhysicalLightingModel、PhongLightingModel等)则把这些光照数据最终合成为片元的漫反射、镜面反射与出射光。
LightingModel 处于这条链路的最末端——定义"用光照数据怎样算出最终颜色"的策略层。
二、构造函数
new LightingModel()
LightingModel 构造函数不接收任何参数,也不执行任何初始化逻辑,其全部源码可在 src/nodes/core/LightingModel.js 中查看:
class LightingModel {
start( builder ) { /* ... */ }
finish( /*builder*/ ) { }
direct( /*lightData, builder*/ ) { }
directRectArea( /*lightData, builder*/ ) { }
indirect( /*builder*/ ) { }
ambientOcclusion( /*input, stack, builder*/ ) { }
}
可以看到基类中除 start() 外,其余方法默认都是空实现;它们只是以 JSDoc @abstract 标注的抽象方法契约,需要子类覆写来提供实际物理语义。值得强调的是,基类的 start() 并非空实现,它提供了默认编排逻辑(见下文第四节),这也是三个内置模型的 start() 通常会先做准备工作、再以 super.start( builder ) 收尾的原因。
三、六个抽象方法:光照模型的生命周期契约
这些方法分别对应光照求值过程中的不同阶段,先给出全景表,再逐一剖析。
| 方法 | 阶段 | 职责 |
|---|---|---|
.start( builder ) |
求值前 | 设置光照模型自身与上下文数据 |
.direct( lightData, builder ) |
直接光项 | 方向光 / 点光 / 聚光灯的直接光照 |
.directRectArea( lightData, builder ) |
直接光项(面光) | 矩形区域光的直接光照 |
.indirect( builder ) |
间接光项 | 间接光照(IBL、辐照度图、AO 调制等) |
.ambientOcclusion( builder ) |
AO 项 | 环境光遮蔽项,须由光照模型在 indirect 中手动调用 |
.finish( builder ) |
求值后 | 收尾任务,如对出射光的最终修正 |
所有方法都接收 builder 参数,它是对当前 NodeBuilder 的引用;NodeBuilder 的完整定义可参阅 NodeBuilder 文档。
.start( builder )
意图是"为光照模型和上下文数据做准备,供后续求值阶段使用"。在 src/nodes/core/LightingModel.js 中,基类为该方法提供了一套默认编排:
start( builder ) {
// lights ( direct )
builder.lightsNode.setupLights( builder, builder.lightsNode.getLightNodes( builder ) );
// indirect
this.indirect( builder );
}
即默认流程是:
- 通过
lightsNode.setupLights()逐个 build 场景中每个光源对应的LightingNode——这正是触发下方direct/directRectArea求值的起点; - 紧接着调用
this.indirect( builder )求值间接光项。
因此,自定义模型若需要先分配一些中间变量(如 PhysicalLightingModel 为 clearcoat / sheen / iridescence 预留的累加向量),可以覆写 start 并在末尾调用 super.start( builder );若完全不需要直接光驱动,也可以不调用父类实现。
.direct( lightData, builder )
该方法用于实现直接光项,在方向光、点光与聚光灯节点 build 的过程中被执行。lightData 中包含该光源的颜色、方向等数据,builder 为当前节点构建器。
从 src/nodes/lighting/LightsNode.js 可以看到该方法是被光源节点通过 LightsNode 间接触发的:
setupDirectLight( builder, lightNode, lightData ) {
const { lightingModel, reflectedLight } = builder.context;
lightingModel.direct( {
...lightData,
lightNode,
reflectedLight
}, builder );
}
也就是说,direct 实际收到的 lightData 已经由 LightsNode 展开,附带了 lightNode(产生该直接光的光源节点)与 reflectedLight(用于累加输出结果的引用)。例如 PhysicalLightingModel.direct() 在 PhysicalLightingModel.js 中会据此计算 dotNL、BRDF_GGX 高光、clearcoat 分量等,并累加到 reflectedLight.directDiffuse / directSpecular 上。
.directRectArea( lightData, builder )
该方法用于实现矩形区域光(rect area light)的直接光项。区域光不能像理想点光源那样只用一个方向向量近似,通常需要使用 LTC(Linearly Transformed Cosines)矩阵进行面光源 BRDF 积分。对应触发点在 src/nodes/lighting/LightsNode.js:
setupDirectRectAreaLight( builder, lightNode, lightData ) {
const { lightingModel, reflectedLight } = builder.context;
lightingModel.directRectArea( {
...lightData,
lightNode,
reflectedLight
}, builder );
}
PhysicalLightingModel 对它的实现位于 src/nodes/functions/PhysicalLightingModel.js,内部会解构 lightColor、lightPosition、halfWidth、halfHeight、ltc_1、ltc_2 等区域光特有数据完成求值。
.indirect( builder )
该方法用于实现间接光项。间接光包括基于图像的光照(IBL)、辐照度图(irradiance light map)、天空盒环境反射等,并且通常还需要乘以环境光遮蔽。
因为间接项内部往往还要再细分(AO 调制、clearcoat/specular 各自的间接分量),所以框架刻意不替子类规定 indirect 的内部流程,只要求子类自行实现。示例见 src/nodes/functions/PhysicalLightingModel.js,其 indirect() 会累加 IBL 漫反射/镜面反射项,并在内部手动调用 this.ambientOcclusion( builder )(见源码 L749)。这与 BasicLightingModel 只在 indirect 中做"烘焙间接光 × AO × 漫反射颜色"调制(BasicLightingModel.js)形成鲜明对比。
.ambientOcclusion( builder )
该方法专门用于实现环境光遮蔽项。注释与文档均特别强调:与其它方法不同,ambientOcclusion 不会被渲染系统自动调用,必须由光照模型在自己的 indirect 项中手动调用。
PhysicalLightingModel 的 AO 实现位于 src/nodes/functions/PhysicalLightingModel.js,它会读取 builder.context.ambientOcclusion 与 reflectedLight,对间接漫反射、clearcoat 与 sheen 的间接镜面反射分别做遮蔽调制。AO 数值的来源则是 LightingContextNode.js 中以 float( 1 ).toVar( 'ambientOcclusion' ) 初始化的上下文变量——默认 1.0(无遮蔽),随后由材质侧的 AO 贴图节点或 AONode 乘入实际遮蔽值。
.finish( builder )
该方法用于执行收尾任务,例如对出射光(outgoing light)做最终更新。典型用途是在所有直接/间接分量累加完毕后统一处理环境贴图合成或最终的色调微调:
BasicLightingModel.finish()根据material.combine(Multiply / Mix / Add)将环境节点合并进出射光,BasicLightingModel.js;ShadowMaskModel.finish()则用累积得到的 shadow mask 调整片元透明度并把漫反射色赋给出射光,ShadowMaskModel.js。
四、执行时序:一次光照求值中这些方法怎样被驱动
要真正理解这六个方法,需要看清驱动方——LightsNode.setup()。在 src/nodes/lighting/LightsNode.js 中,整个求值流程如下:
- 建立上下文与光照模型:从
builder.context取出lightingModel(由材质经LightingContextNode注入); - start:调用
lightingModel.start( builder )。基类默认实现会触发"所有光源节点 build +indirect";而各光源节点 build 时,AnalyticLightNode会依据光源类型调用builder.lightsNode.setupDirectLight(...)/setupDirectRectAreaLight(...)(见 AnalyticLightNode.js),从而进入lightingModel.direct/directRectArea; - 汇总分量:
LightsNode从context.reflectedLight读出directDiffuse / directSpecular / indirectDiffuse / indirectSpecular,累加得到总漫反射与总镜面反射:totalDiffuse = directDiffuse + indirectDiffuse(若存在 backdrop 还会与 backdrop 混合);totalSpecular = directSpecular + indirectSpecular;outgoingLight = totalDiffuse + totalSpecular;
- finish:调用
lightingModel.finish( builder ),对outgoingLight做最终修正; - 返回
outgoingLight作为节点图光照输出。
其中 LightingContextNode.getContext()(src/nodes/lighting/LightingContextNode.js)负责搭建求值期间共享的上下文对象,结构如下:
{
radiance, // vec3 变量
irradiance, // vec3 变量
iblIrradiance, // vec3 变量
ambientOcclusion, // float 变量,默认 1
reflectedLight: {
directDiffuse, // vec3 变量
directSpecular, // vec3 变量
indirectDiffuse, // vec3 变量
indirectSpecular // vec3 变量
},
backdrop, // 背板(backdrop)节点,默认 null
backdropAlpha, // 背板透明度节点,默认 null
lightingModel, // 在 setup 时从参数或上层上下文解析
}
这也是 direct、indirect 等方法能够直接操作 reflectedLight.directDiffuse 等字段的原因。
把上面的生命周期用一句话串起来就是:start() 打开光照求值并调用 indirect() → 光源节点逐盏触发 direct()/directRectArea() → LightsNode 汇总四路反射分量算出 outgoingLight → finish() 对出射光做最终处理。
五、内置 LightingModel 实现族谱
仓库 src/nodes/functions/ 目录下提供了一批现成的光照模型,对应不同材质风格:
| 光照模型 | 继承 | 用途 / 关联材质 | 参考源码 |
|---|---|---|---|
BasicLightingModel |
LightingModel |
无光照/基础材质,仅烘焙间接光 + AO + 环境贴图,用于 MeshBasicNodeMaterial |
BasicLightingModel.js |
PhongLightingModel |
BasicLightingModel |
Blinn-Phong 高光,用于 MeshPhongNodeMaterial;MeshLambertNodeMaterial 通过 new PhongLightingModel( false ) 关闭高光实现 Lambert |
PhongLightingModel.js |
ToonLightingModel |
LightingModel |
渐变分级光照的卡通渲染,用于 MeshToonNodeMaterial |
ToonLightingModel.js |
PhysicalLightingModel |
LightingModel |
基于物理的 PBR(GGX/多散射能量补偿/clearcoat/sheen/iridescence/anisotropy/transmission/dispersion),用于 MeshStandardNodeMaterial、MeshPhysicalNodeMaterial |
PhysicalLightingModel.js |
SSSLightingModel |
PhysicalLightingModel |
在 PBR 基础上叠加次表面散射,定义于 MeshSSSNodeMaterial |
MeshSSSNodeMaterial.js |
ShadowMaskModel |
LightingModel |
专用于阴影材质:逐光累积 shadow mask 并据此输出透明度,用于 ShadowNodeMaterial |
ShadowMaskModel.js |
VolumetricLightingModel |
LightingModel |
体积光散射/透射(含体绘制射线步进),用于 VolumeNodeMaterial |
VolumetricLightingModel.js |
各模型的实例化与材质绑定关系十分直接:每个 Node 材质的 setupLightingModel() 工厂方法返回对应模型实例,例如 MeshBasicNodeMaterial.js 中 setupLightingModel() 直接返回 new BasicLightingModel();MeshStandardNodeMaterial.js 返回 new PhysicalLightingModel();而 MeshPhysicalNodeMaterial.js 会把 useClearcoat、useSheen、useIridescence、useAnisotropy、useTransmission、useDispersion、useRetroreflection 等开关透传给构造函数。
各具体模型的文档页见 BasicLightingModel 文档、PhongLightingModel 文档、PhysicalLightingModel 文档、ToonLightingModel 文档、ShadowMaskModel 文档,可对照阅读每个实现覆写了哪些抽象方法。
六、模型与材质的接线:NodeMaterial.setupLightingModel
NodeMaterial(src/materials/nodes/NodeMaterial.js)在基类中把 setupLightingModel 声明为抽象接口(源码 L1062-L1066),文档注释明确指出"绝大多数派生材质都应实现该方法,因为它定义了材质的光照模型"。
材质在 build 出射光节点时(src/materials/nodes/NodeMaterial.js)完成装配:
const lightingModel = this.setupLightingModel( builder ) || null;
outgoingLightNode = lightingContext(
lightsNode,
lightingModel,
materialLightings,
backdropNode,
backdropAlphaNode
);
即:调用材质覆写的 setupLightingModel() 取得具体 LightingModel 实例,再把 LightsNode + lightingModel + 材质附加光照节点 一起包进 LightingContextNode。后续 LightsNode.setup() 在执行光照求值时,正是从该上下文里取出 lightingModel 开始生命周期调用的。这与第四节的执行时序闭合起来:材质决定"用什么模型",LightsNode 决定"何时调哪些方法"。
七、实践:编写一个自定义 LightingModel
理解了契约之后,自定义一个光照模型只需三步:新建类继承 LightingModel、按需覆写对应阶段方法、通过自定义 NodeMaterial 的 setupLightingModel() 接入。以下给出一个"只在间接项里叠加 AO 与 IBL 辐照度"的最小骨架(示意,非仓库内代码):
import LightingModel from './three.js/src/nodes/core/LightingModel.js';
class MySimpleLightingModel extends LightingModel {
// 1) 在 start 里完成变量初始化后,交由基类驱动光源求值与 indirect
start( builder ) {
super.start( builder );
}
// 2) 处理方向光/点光/聚光灯直接项
direct( { lightDirection, lightColor, reflectedLight }, builder ) {
const dotNL = /* 法线与光线方向点积 */;
reflectedLight.directDiffuse.addAssign( /* 漫反射贡献 */ );
}
// 3) 间接项内手动调用 AO
indirect( builder ) {
const { ambientOcclusion, irradiance, reflectedLight } = builder.context;
reflectedLight.indirectDiffuse.assign( irradiance );
reflectedLight.indirectDiffuse.mulAssign( ambientOcclusion );
reflectedLight.indirectDiffuse.mulAssign( /* 漫反射颜色 */ );
this.ambientOcclusion( builder ); // 必须手动调用
}
// 4) AO 实现
ambientOcclusion( builder ) {
const { ambientOcclusion, reflectedLight } = builder.context;
reflectedLight.indirectDiffuse.mulAssign( ambientOcclusion );
}
// 5) 收尾
finish( { context } ) {
// 例如对 context.outgoingLight 做最后处理
}
}
再将材质子类的 setupLightingModel() 覆写为返回 new MySimpleLightingModel(),即可让该材质的所有片元走自定义光照模型。接入后可在运行时通过切换不同的 LightingModel 实例,在同一个节点管线内体验完全不同的着色风格——这正是 TSL 光照模型解耦设计的价值所在。
八、小结
LightingModel是 src/nodes/core/LightingModel.js 中定义的抽象基类,通过start/direct/directRectArea/indirect/ambientOcclusion/finish六个方法划分光照求值生命周期;- 求值顺序由 LightsNode 驱动,光源节点(如 AnalyticLightNode)触发
direct/directRectArea,AO 必须由模型自身在indirect内手动调用; - 上下文数据(反射光分量、AO、backdrop 等)由 LightingContextNode 统一提供;
- 仓库内置 Basic / Phong / Toon / Physical / SSS / ShadowMask / Volumetric 等多种实现,材质通过
NodeMaterial.setupLightingModel()选择其一,开发者也可轻松注入自定义模型。
如需查阅官方 API 条目,可访问 LightingModel 官方文档页;进一步学习上下文与光照节点体系,可参考 LightingContextNode 文档 与 LightsNode 文档。
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 StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00