Three.js TSL 深度解析:ContextNode 如何管理节点构建上下文,实现局部化着色器行为
ContextNode 是 Three.js 节点着色语言(TSL,Three Shading Language)中的一个核心基础节点,它本身不产生任何着色器代码,而是作为“上下文管理组件”包裹另一个节点,在 NodeBuilder 构建该节点的期间临时修改构建上下文(context)。读完本文,你将理解 ContextNode 的完整 API(构造器、属性、方法)、它在 analyze / setup / generate 构建生命周期中的保存-恢复机制,以及如何通过 context() 在 renderer、material、Pass 或任意节点上挂载上下文数据(典型场景是覆写 getUV() 以替换默认 UV 采样坐标)。
一、ContextNode 是什么:TSL 中的上下文管理组件
根据官方文档 docs/pages/ContextNode.html.md,ContextNode 的继承链为:
EventDispatcher → Node → ContextNode
它的设计定位是:NodeBuilder 在特定上下文中执行节点构建过程(node building process),而 ContextNode 允许你在构建某个节点期间修改这个上下文。官方给出的典型用例是覆写 getUV():
node.context( { getUV: () => customCoord } );
// or
material.contextNode = context( { getUV: () => customCoord } );
// or
renderer.contextNode = context( { getUV: () => customCoord } );
// or
scenePass.contextNode = context( { getUV: () => customCoord } );
这四行示例其实揭示了 ContextNode 的四种挂载位置,也正是它在仓库中被实际使用的四个入口:
- 任意节点的方法链:
node.context( { ... } )—— 由 src/nodes/core/ContextNode.js 末尾的addMethodChaining( 'context', context )注册到所有 Node 上; - 材质:
NodeMaterial构造时默认持有this.contextNode = null(见 src/materials/nodes/NodeMaterial.js); - 渲染器:
Renderer构造时默认持有this.contextNode = context(),即一个空的 ContextNode(见 src/renderers/common/Renderer.js); - Pass 节点:如 src/nodes/display/PassNode.js 同样持有
contextNode属性,用于在场景 Pass 构建期间注入上下文。
理解这四个入口之后,本文其余部分将围绕两个问题展开:上下文数据最终如何被 NodeBuilder 合并与消费;ContextNode 源码中还有哪些内置上下文函数。
二、API 全览:构造器、属性与方法
构造器 new ContextNode( node : Node, value : Object )
构造一个新的 context 节点。源码实现见 src/nodes/core/ContextNode.js#L35-L63:
- node:需要修改上下文的节点。默认是
null。当 node 为null时,ContextNode 只作为纯上下文注入点存在,不包裹任何子节点; - value:被修改的上下文数据(一个普通对象)。默认是
{}。
属性
.isContextNode : boolean (readonly):类型测试标志,默认true。NodeBuilder 在合并 renderer/material 的 contextNode 时会用它做类型校验(见下文 prebuild 部分);.node : Node:需要修改上下文的节点;.value : Object:被修改的上下文数据,默认{}。
方法
ContextNode 重写了 Node 的四个方法,使它在构建系统中表现得像一个“透明代理”——外部(类型推导、作用域解析)看到的始终是它内部包裹的 node:
.generateNodeType( builder : NodeBuilder ) : string:重写自 Node#generateNodeType,返回内部ContextNode#node的类型,保证上下文包装不改变节点类型推导结果(源码 src/nodes/core/ContextNode.js#L82-L86);.getMemberType( builder : NodeBuilder, name : string ) : string:重写自 Node#getMemberType,返回内部ContextNode#node的成员类型,保证成员访问的类型检查穿透上下文包装(源码 src/nodes/core/ContextNode.js#L118-L122);.getScope() : Node:重写自 Node#getScope,返回ContextNode#node的 scope 引用(源码 src/nodes/core/ContextNode.js#L70-L74);.getFlowContextData() : Object:收集当前节点(含其子节点)中所有 ContextNode 的上下文数据,是整条上下文数据流向 NodeBuilder 的关键桥梁。源码实现(src/nodes/core/ContextNode.js#L93-L109):
getFlowContextData() {
const children = [];
this.traverse( ( node ) => {
if ( node.isContextNode === true ) {
children.push( node.value );
}
} );
return Object.assign( {}, ...children );
}
从源码的遍历 + Object.assign 合并方式看:嵌套多层 context() 时,各层的 value 会按遍历顺序依次浅合并,键名相同时后收集到的值会覆盖先收集到的值——这实现了“上下文就近覆盖”的语义。
三、实现原理:addContext / setContext 与构建生命周期
3.1 NodeBuilder 中的上下文机制
NodeBuilder 在构造时初始化上下文,初始值只包含材质引用(src/nodes/core/NodeBuilder.js#L425-L432):
this.context = {
material: this.material
};
围绕它提供了三个方法(src/nodes/core/NodeBuilder.js#L1029-L1060):
setContext( context ):直接替换当前上下文对象;getContext():返回当前上下文对象;addContext( context ):将新上下文浅合并({ ...this.context, ...context })进当前上下文,并返回合并前的旧上下文引用——这个“返回值是旧值”的设计正是 ContextNode 保存-恢复模式的基础。
3.2 ContextNode 的保存-恢复模式
ContextNode 在构建的三个阶段(analyze、setup、generate)中都遵循同一个模式:先用 addContext 注入自己的 value 并拿到旧上下文,构建内部 node,再 setContext 恢复旧上下文。以 setup 为例(src/nodes/core/ContextNode.js#L140-L150):
setup( builder ) {
const previousContext = builder.addContext( this.value );
const node = this.node.build( builder );
builder.setContext( previousContext );
return node;
}
generate 阶段同理(src/nodes/core/ContextNode.js#L152-L162),只是额外返回了代码片段 snippet;analyze 阶段(src/nodes/core/ContextNode.js#L124-L138)还会先用 builder.increaseUsage( this ) 判断使用计数,仅在首次使用时执行上下文切换与构建。
这套“压栈-构建-出栈”的写法保证了上下文的作用域隔离:上下文修改只在内层 node 的构建期间生效,构建结束后自动还原,不会污染兄弟节点或上层构建。这与函数作用域中变量的保存-恢复(save/restore)语义一致,可以推断这正是它被命名为“Context”而非“Wrapper”的原因。
3.3 透明代理:为什么外部看不到 ContextNode 的存在
除了上下文注入,ContextNode 还通过重写 getScope、generateNodeType、getMemberType(见 src/nodes/core/ContextNode.js#L70-L122)把类型与作用域查询全部转发给内部节点。因此,对 TSL 的类型推导系统而言,context( someNode, { ... } ) 与直接使用 someNode 在类型层面是等价的——上下文包装是无副作用的构建期装饰器,不会改变最终生成的着色器结构。
四、上下文数据流:从四个挂载点流入 NodeBuilder
4.1 context() TSL 函数与方法链
context 是创建 ContextNode 的 TSL 函数(src/nodes/core/ContextNode.js#L177-L190):
export const context = ( nodeOrValue = null, value = {} ) => {
let node = nodeOrValue;
if ( node === null || node.isNode !== true ) {
value = node || value;
node = null;
}
return new ContextNode( node, value );
};
它的参数签名是双态的:第一个参数既可以是被包裹的 Node,也可以直接是上下文对象——若传入的不是 Node(通过 isNode 标志判断),就会被当作 value 处理。这正是文档中 context( { getUV: ... } ) 这种“无节点纯上下文”写法的来源。
文件末尾的 addMethodChaining 调用(src/nodes/core/ContextNode.js#L286-L291)把这些函数注册为所有节点实例的方法:context、uniformFlow、setName、label、builtinShadowContext、builtinAOContext。因此 node.context( value ) 等价于 context( node, value )。
4.2 prebuild 阶段的合并顺序:renderer 与 material
NodeBuilder 的 prebuild() 方法在正式构建前,把 renderer 级与 material 级的 contextNode 数据依次合并进上下文(src/nodes/core/NodeBuilder.js#L3111-L3141):
// < renderer.contextNode >
if ( renderer.contextNode.isContextNode === true ) {
this.context = { ...this.context, ...renderer.contextNode.getFlowContextData() };
} else {
error( 'NodeBuilder: "renderer.contextNode" must be an instance of `context()`.' );
}
// < material.contextNode >
if ( material && material.contextNode ) {
if ( material.contextNode.isContextNode === true ) {
this.context = { ...this.context, ...material.contextNode.getFlowContextData() };
} else {
error( 'NodeBuilder: "material.contextNode" must be an instance of `context()`.' );
}
}
从这段代码可以得到两个明确的实现事实:
- 优先级:
renderer.contextNode先合并,material.contextNode后合并。键名冲突时,材质级上下文覆盖渲染器级上下文,材质级上下文最终再被节点树内部context()的保存-恢复覆盖——形成 renderer < material < 节点局部 的三级作用域; - 类型强校验:两个挂载点都必须是
context()创建的 ContextNode 实例,否则 NodeBuilder 会直接报错,这是使用context()函数(而非手写对象)的硬性原因。
另外值得注意的是渲染器侧的一个细节:Renderer 内部会把高精度矩阵写入 this.contextNode.value(modelViewMatrix / modelNormalViewMatrix,见 src/renderers/common/Renderer.js#L1265-L1291)。也就是说,即使你不为 renderer.contextNode 设置任何自定义值,它也在承载渲染器注入的内部数据。
在缓存层面,渲染对象(RenderObject)把 renderer.contextNode 的 id 与 version 纳入缓存键(见 src/renderers/common/RenderObject.js#L951),因此修改 renderer.contextNode 的内容会自动使相关着色器缓存失效并触发重建。
4.3 PassNode 的上下文合并与缓存
场景 Pass(如后处理、XR Pass)拥有自己的 contextNode。src/nodes/display/PassNode.js 的构建逻辑会:
- 暂存
renderer.contextNode; - 把
renderer.contextNode.getFlowContextData()与自身this.contextNode.getFlowContextData()合并,构造一个新的context( { ...renderer, ...pass } )并缓存(以version判断是否需要重建缓存); - 将其赋给
renderer.contextNode,使 Pass 内部所有物体的构建都感知到合并后的上下文; - 构建结束后恢复
renderer.contextNode。
这解释了文档示例中第四种写法 scenePass.contextNode = context( { getUV: ... } ) 的完整机制:Pass 级上下文是经由“临时改写 renderer.contextNode + 构建后恢复”来实现的,与 ContextNode 自身的保存-恢复模式一脉相承。类似的模式也出现在 XR 管理器中,它通过 WeakMap 为每个 XR session 缓存上下文节点(见 src/renderers/common/XRManager.js)。
五、内置上下文函数与典型实战场景
ContextNode.js 除主类外还导出了一组基于 context() 的 TSL 内置函数,它们覆盖了几类最常见的上下文修改需求:
5.1 uniformFlow —— 强制均匀控制流
export const uniformFlow = ( node ) => context( node, { uniformFlow: true } );
(src/nodes/core/ContextNode.js#L200)uniformFlow 把 uniformFlow: true 写入上下文。消费方是条件节点 ConditionalNode:它在生成 if/else 时读取 builder.context.uniformFlow,据此决定条件分支是否必须位于均匀控制流路径上(WebGPU 分支限制等场景)。用法即 someNode.uniformFlow( ... ) 或 uniformFlow( someNode )。
5.2 setName 与 label(已废弃)
export const setName = ( node, name ) => context( node, { nodeName: name } );
export function label( node, name ) {
warn( 'TSL: "label()" has been deprecated. Use "setName()" instead.' );
return setName( node, name );
}
(src/nodes/core/ContextNode.js#L211-L284)二者本质上都是设置上下文的 nodeName 键,用于给生成代码中的节点命名。label() 自 r179 起标记废弃,源码中会发出警告并转发到 setName(),新代码应使用 setName()。
5.3 builtinShadowContext —— 阴影上下文
export function builtinShadowContext( shadowNode, light, node = null ) {
return context( node, {
getShadow: ( { light: shadowLight, shadowColorNode } ) => {
if ( light === shadowLight ) {
return shadowColorNode.mul( shadowNode );
}
return shadowColorNode;
}
} );
}
(src/nodes/core/ContextNode.js#L223-L241)它把 getShadow 注入上下文,逻辑是“当当前分析的光源正是该阴影所属的光源时,才把阴影值乘入阴影色”。消费方是 AnalyticLightNode:光照计算时若 builder.context.getShadow 存在就调用它获取带阴影的阴影色。仓库中 ShadowNode.js#L438-L443 展示了其调用形态(将共享上下文赋给 material.contextNode)。
5.4 builtinAOContext —— 环境光遮蔽上下文
export function builtinAOContext( aoNode, node = null ) {
return context( node, {
getAO: ( inputNode, { material } ) => {
if ( material.transparent === true ) return inputNode;
return inputNode !== null ? inputNode.mul( aoNode ) : aoNode;
}
} );
}
(src/nodes/core/ContextNode.js#L252-L266)规则是:透明材质不应用 AO;非透明材质则将 AO 值乘入输入节点(无输入时直接使用 AO 值)。
5.5 覆写 getUV:最典型的实战场景
getUV 是文档首推的上下文字段,消费方是纹理采样相关节点。TextureNode 在构建时:
if ( ( uvNode === null || builder.context.forceUVContext === true ) && builder.context.getUV ) {
uvNode = builder.context.getUV( this, builder );
}
即:当纹理没有显式 UV 输入(或强制使用 UV 上下文)且上下文中提供了 getUV 时,就用 getUV( textureNode, builder ) 的返回值作为采样坐标。PMREMNode 也遵循同一机制。由此可以得到两类常见玩法:
- 全局替换 UV 来源:
renderer.contextNode = context( { getUV: () => customCoord } ),所有无显式 UV 的纹理采样都改用customCoord; - 局部替换:仓库中 src/nodes/utils/UVUtils.js#L9 提供了
replaceDefaultUV(),它返回的就是形如material.contextNode = ...的上下文赋值,把 UV 替换限定在某个材质范围内。
5.6 getSharedContext:跨材质共享上下文时的过滤
NodeBuilder 还提供 getSharedContext()(src/nodes/core/NodeBuilder.js#L1068-L1079),它复制当前上下文但删除 material、getUV、getOutput、getTextureLevel、getAO、getShadow 这些材质相关键。其 JSDoc 明确说明:渲染器缓存可能把一个材质生成的着色器复用到另一个材质,因此共享上下文必须剥离材质敏感字段。典型使用者是 RTTNode#L153:
this._quadMesh.material.contextNode = context( builder.getSharedContext() );
即 RTT(渲染到纹理)内部四边形使用“去材质化的共享上下文”构建,避免 RTT 子渲染与主场景的材质上下文互相干扰。
六、小结与延伸阅读
ContextNode 的核心价值可以归纳为三点:其一,它把“上下文的修改”表达为一等节点,可嵌入任意 TSL 表达式中局部生效;其二,analyze / setup / generate 三阶段的保存-恢复保证了上下文修改的作用域严格隔离;其三,getFlowContextData() 让嵌套的 context 数据能按顺序合并并流向 renderer/material 两个全局挂载点。
结合仓库源码的延伸阅读路径:
- src/nodes/core/ContextNode.js —— 本文主角的完整实现,含全部内置上下文函数;
- src/nodes/core/NodeBuilder.js ——
setContext/getContext/addContext/getSharedContext与prebuild()的合并逻辑; - src/materials/nodes/NodeMaterial.js ——
contextNode在材质侧的默认值; - src/nodes/display/PassNode.js —— Pass 级上下文的合并、缓存与恢复;
- src/nodes/lighting/ShadowNode.js 与 src/nodes/utils/UVUtils.js —— 内置上下文函数在仓库中的真实调用点;
- docs/pages/ContextNode.html.md、docs/pages/Node.html.md、docs/pages/NodeBuilder.html.md —— 对应的官方 API 文档;
- docs/TSL.md —— TSL 整体用法参考。
需要注意的适用前提:ContextNode 属于 TSL / 节点材质体系,仅在 NodeMaterial、WebGPURenderer/WebGL 节点管线等启用节点系统的渲染路径中生效;其上下文修改发生在构建期(着色器生成时),而非渲染期的每帧状态。
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