three.js TSL 节点核心基类解析:TempNode 的缓存管理与去重机制
【免费下载链接】three.jsJavaScript 3D Library.项目地址: https://gitcode.com/GitHub_Trending/th/three.js
TempNode 是 three.js 节点材质(Node Material / TSL)体系中最重要的中间层基类之一:它继承自 Node,同时又是 MathNode、OperatorNode、ArrayNode 等大量具体节点类型的公共父类。它的核心使命只有一句话——通过缓存管理,把“被多次引用的节点计算结果”提升为临时变量,从而在生成的着色器代码中杜绝重复计算。读完本文,你将理解 TSL 生成着色器的三次构建流水线、usageCount 计数机制、临时变量的申请与复用逻辑,并能据此写出对生成代码效率有清晰预期的自定义节点。
TempNode 在节点体系中的位置
TSL(Three Shading Language)把材质、光照、后处理等一切着色逻辑抽象为一棵节点树。所有节点的根基类是Node,而Node本身继承自 three.js 的事件分发器EventDispatcher。本文主角TempNode处于继承链的中间层:
EventDispatcher → Node → TempNode → 具体节点从 src/nodes/core/TempNode.js 的实现看,TempNode继承Node后并没有增加复杂的自身状态,而是重写了build()方法并补充了一个判定方法hasDependencies()。文档描述它“作为许多其他节点类型的基类”是字面属实的:在 src/nodes/ 目录中直接extends TempNode的类型横跨多个功能模块,例如:
- 核心类:ArrayNode(对应 TSL 的
array())、AssignNode(赋值节点); - 数学类:MathNode、OperatorNode、BitcastNode;
- 显示/材质类:BumpMapNode、NormalMapNode、ToneMappingNode、ColorSpaceNode、RenderOutputNode、PassNode(后处理通道);
- 其他:FunctionCallNode(函数调用)、VelocityNode(速度访问器)、SubgroupFunctionNode(GPU 计算子组)等。
也就是说,绝大多数“会对子节点做一次运算并产出新值”的节点都选择以 TempNode 为基类,这正是因为它内置了“表达式去重”的通用能力。
为什么需要临时变量:重复计算的代价
在用户书写的节点树中,同一个表达式节点经常会被多处引用。例如一张法线贴图的计算结果既参与漫反射光照,又参与高光反射。如果不做任何处理,生成 GLSL / WGSL 时每个引用点都会内联一次完整表达式,导致:
// 未去重的示意代码(重复计算三遍) vec3 n = normalize( texture( map, uv ).xyz * 2.0 - 1.0 ); // 片段 A 内联 ... vec3 n = normalize( texture( map, uv ).xyz * 2.0 - 1.0 ); // 片段 B 再次内联重复计算不仅拖慢 GPU,还会让代码体积膨胀、可读性下降。TempNode 的思路与编译器常见的“公共子表达式消除”一致:把复杂表达式先赋给一个临时变量,之后所有引用点都直接使用变量名:
// 使用 TempNode 缓存后的示意代码 nodeVar0 = normalize( texture( map, uv ).xyz * 2.0 - 1.0 ); vec3 n = nodeVar0;构造器与属性
构造器
new TempNode( nodeType = null )nodeType是节点的输出类型,比如'float'、'vec3'。它会被透传给基类 Node 的构造器,最终存储在this.nodeType上。默认值为null,表示“类型在构建阶段由实际子节点推导”。TempNode自身并未覆盖nodeType的语义,真正消费它的是Node基类的getNodeType()/generateNodeType()等类型推导逻辑,因此继承 TempNode 的自定义节点只需沿用这套类型约定即可。
注意,TempNode并没有显式定义nodeType属性——从源码看它只是把构造参数原样交给super( nodeType ),可推断属性本身由 Node 基类声明并赋默认值null。这与文档中“nodeType 默认是 null”的说明完全吻合。
静态类型与类型标记
static get type() { return 'TempNode'; }与 three.js 其他对象一致,节点系统通过constructor.type暴露类名,便于序列化(例如 NodeLoader 反序列化 JSON 时按type实例化对应类)。TempNode的type是'TempNode',而继承它的子类各自声明了自己的type。
.isTempNode : boolean(只读)
this.isTempNode = true;该布尔标记在构造时被写死为true,语义与其他 three.js 对象(如isNode、isMesh)一致——它不是用来读的运行时值,而是用来做鸭子类型判断的廉价通道:
function isTempNode( obj ) { return obj !== null && obj !== undefined && obj.isTempNode === true; }相比instanceof,这种标记方式在跨模块引用、压缩混淆后依然可靠。
核心方法 hasDependencies:判定是否“被多次使用”
hasDependencies( builder ) { return builder.getDataFromNode( this ).usageCount > 1; }builder是 NodeBuilder(着色器构建器)实例。getDataFromNode( this )会取出与当前节点关联的内部数据缓存,其中的usageCount字段记录了该节点在整个着色器构建过程中被引用的次数。
关键在于这个计数是谁、在何时累加的。在 Node 基类的 analyze 方法中,每次节点进入“analyze(分析)”构建阶段都会先调用:
const usageCount = builder.increaseUsage( this );而NodeBuilder.increaseUsage()(见 src/nodes/core/NodeBuilder.js)的实现为:
nodeData.usageCount = nodeData.usageCount === undefined ? 1 : nodeData.usageCount + 1;即首次记录为 1,之后每次再被引用都加 1。因此hasDependencies()返回true就等价于“该节点在节点图中被引用了不止一次,存在多个依赖方”。同时NodeBuilder还会区分读引用(readUsageCount)与赋值写上下文中的引用(writeUsageCount),为赋值语义(AssignNode)留出信息。
小结:
hasDependencies(builder)返回值语义 = “该节点相对其他节点是否有多于一个依赖”。文档中“Returns: A flag that indicates if there is more than one dependency to other nodes.”与源码逐字对应。
build() 重写:三次构建流水线中的去重注入
build()是节点的总入口,TempNode重写它来插入缓存逻辑。要理解这段代码,先要明白 Node.build() 划分的三个构建阶段:
- setup:准备节点与子节点,可能创建新节点,返回节点对象;
- analyze:分析节点层级,统计 usageCount(去重依据在此产生);
- generate:真正生成着色器代码字符串。
TempNode只介入第三个阶段'generate':
build( builder, output ) { const buildStage = builder.getBuildStage(); if ( buildStage === 'generate' ) { const type = builder.getVectorType( this.getNodeType( builder, output ) ); const nodeData = builder.getDataFromNode( this ); // 命中缓存:直接格式化返回已生成的临时变量名 if ( nodeData.propertyName !== undefined ) { return builder.format( nodeData.propertyName, type, output ); } // 未命中缓存,且值得缓存 else if ( type !== 'void' && output !== 'void' && this.hasDependencies( builder ) ) { const snippet = super.build( builder, type ); // ① 先生成内联表达式 const nodeVar = builder.getVarFromNode( this, null, type ); // ② 申请临时变量 const propertyName = builder.getPropertyName( nodeVar ); builder.addLineFlowCode( `${propertyName} = ${snippet}`, this ); // ③ 写入赋值流代码 nodeData.snippet = snippet; nodeData.propertyName = propertyName; // ④ 登记缓存 return builder.format( nodeData.propertyName, type, output ); } } return super.build( builder, output ); // 其他阶段或无需缓存时走默认路径 }逐一拆解这段核心逻辑:
① 生成内联表达式。super.build( builder, type )先让父类逻辑按目标类型type生成一串可内联的着色器表达式(snippet)。此时尚未产生任何副作用。
② 申请临时变量。builder.getVarFromNode()在 NodeBuilder 中为节点分配一个NodeVar。自动生成的变量名遵循nodeVar0、nodeVar1… 的递增命名规则(常量场景则用nodeConst0…),其声明会注册进当前 shader 阶段的变量表,最终统一出现在生成的着色器代码中。
③ 写入赋值流代码。builder.addLineFlowCode( 'nodeVar0 = <snippet>', this )生成形如nodeVar0 = ...;的赋值行,并插入到当前代码流(flow code)的合适位置。由于引用点众多,这一步实际由构建器在“流代码”阶段统一输出,确保赋值发生在使用之前。
④ 登记缓存并返回。把生成结果写入nodeData.snippet与nodeData.propertyName后,本次调用返回builder.format( propertyName, type, output )——即引用方拿到的是变量名本身。
后续命中。当该节点第二次、第三次被引用并再次进入 generate 阶段时,第一个分支(propertyName !== undefined)直接命中,返回格式化的变量名,不再重复生成表达式。
边界条件。缓存只对type !== 'void' && output !== 'void'生效:void 类型没有可供复用的“值”,输出为 void(例如纯副作用节点)同样没有提升为临时变量的意义;此时会退回super.build()的默认内联路径。此外,从 Node 基类 build 的流程可知,节点构建会记录buildStages,若某节点在 generate 阶段被直接请求而其父阶段(setup/analyze)尚未执行,构建器会自动补跑,保证usageCount在去重判断前已统计到位。
源码定位与完整阅读路径
本文引用的核心实现集中在一个短文件里,建议对照阅读:
- TempNode 实现源码(约 88 行,含完整的
hasDependencies与build重写); - Node 基类源码(负责 setup/analyze/generate 三阶段调度、
getShared节点共享与analyze中的increaseUsage调用); - NodeBuilder 源码(提供
getDataFromNode、increaseUsage、getVarFromNode、getPropertyName、addLineFlowCode等基础设施); - NodeVar 源码(临时变量数据结构)。
对应的 API 参考文档(与本文档同源生成)位于 Node.html.md、NodeBuilder.html.md、NodeVar.html.md。
派生类中的实际运用:以 ArrayNode 为例
观察继承 TempNode 的类能直观感受“临时变量”机制的适用范围。以 ArrayNode 为例,它把一组节点组合成着色器数组,用户代码通常写成:
const colors = array( [ vec3( 1, 0, 0 ), vec3( 0, 1, 0 ), vec3( 0, 0, 1 ) ] ); const redColor = colors.element( 0 ); // ElementNode 引用 colorsarray()(定义在 ArrayNode.js 的 TSL 工厂函数)内部会new ArrayNode( null, count, values )。当colors被.element()、后续计算多处引用时,它的usageCount会大于 1,于是 TempNode 的build()把整段数组初始化表达式提升为一次赋值,其余引用点只读取变量,避免数组字面量被反复展开。
类似的,MathNode(加减乘除、三角函数等)一旦在某材质中参与多处输出,其计算结果也会被缓存为临时变量——这正是数学表达式节点普遍继承 TempNode 的直接原因。
使用注意事项与扩展建议
- 绝大多数场景无需直接实例化。TSL 用户通过
tsl语法(add(),mul(),normalize()等)间接构建节点树,TempNode的缓存行为自动生效,无需手工干预; - 自定义节点时优先继承 TempNode。若你实现的自定义表达式节点“把若干输入算出一个可复用的值”,继承 TempNode 即可免费获得去重缓存;只需要求这类表达式无副作用且幂等——被提升为临时变量意味着它只会执行一次;
- 与
global/getShared区分。Node 基类还有另一套“节点共享”机制(global标记 +getShared()),面向“同一节点对象在场景中只应声明一次”的场景(如 AttributeNode),而 TempNode 的去重面向“表达式值避免重复计算”。两者关注点不同但可协同; - 类型测试。判断一个对象是否为临时节点可用只读标记
obj.isTempNode === true。
总结
TempNode 用极简的接口(一个类型标记、一个判定方法、一次build()重写)为 three.js TSL 节点系统注入了公共子表达式消除能力:analyze 阶段由NodeBuilder.increaseUsage()累计引用次数,generate 阶段由hasDependencies()判断是否值得缓存,命中后通过getVarFromNode()+addLineFlowCode()把表达式沉淀为命名临时变量并登记到nodeData,使后续所有引用点只读写变量名。理解这条链路,也就掌握了 TSL 生成高效着色器代码的一个关键支柱——无论你是在排查生成代码冗余,还是在编写自己的材质节点,都能精准预判哪些表达式会被“计算一次、复用多次”。
【免费下载链接】three.jsJavaScript 3D Library.项目地址: https://gitcode.com/GitHub_Trending/th/three.js
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考