首页
/ Three.js TSL 深度解析:ContextNode 如何管理节点构建上下文,实现局部化着色器行为

Three.js TSL 深度解析:ContextNode 如何管理节点构建上下文,实现局部化着色器行为

2026-09-06 15:58:05作者:郜逊炳

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 的四种挂载位置,也正是它在仓库中被实际使用的四个入口:

  1. 任意节点的方法链node.context( { ... } ) —— 由 src/nodes/core/ContextNode.js 末尾的 addMethodChaining( 'context', context ) 注册到所有 Node 上;
  2. 材质NodeMaterial 构造时默认持有 this.contextNode = null(见 src/materials/nodes/NodeMaterial.js);
  3. 渲染器Renderer 构造时默认持有 this.contextNode = context(),即一个空的 ContextNode(见 src/renderers/common/Renderer.js);
  4. 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

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 在构建的三个阶段(analyzesetupgenerate)中都遵循同一个模式:先用 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),只是额外返回了代码片段 snippetanalyze 阶段(src/nodes/core/ContextNode.js#L124-L138)还会先用 builder.increaseUsage( this ) 判断使用计数,仅在首次使用时执行上下文切换与构建。

这套“压栈-构建-出栈”的写法保证了上下文的作用域隔离:上下文修改只在内层 node 的构建期间生效,构建结束后自动还原,不会污染兄弟节点或上层构建。这与函数作用域中变量的保存-恢复(save/restore)语义一致,可以推断这正是它被命名为“Context”而非“Wrapper”的原因。

3.3 透明代理:为什么外部看不到 ContextNode 的存在

除了上下文注入,ContextNode 还通过重写 getScopegenerateNodeTypegetMemberType(见 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)把这些函数注册为所有节点实例的方法:contextuniformFlowsetNamelabelbuiltinShadowContextbuiltinAOContext。因此 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()`.' );
	}
}

从这段代码可以得到两个明确的实现事实:

  1. 优先级renderer.contextNode 先合并,material.contextNode 后合并。键名冲突时,材质级上下文覆盖渲染器级上下文,材质级上下文最终再被节点树内部 context() 的保存-恢复覆盖——形成 renderer < material < 节点局部 的三级作用域;
  2. 类型强校验:两个挂载点都必须是 context() 创建的 ContextNode 实例,否则 NodeBuilder 会直接报错,这是使用 context() 函数(而非手写对象)的硬性原因。

另外值得注意的是渲染器侧的一个细节:Renderer 内部会把高精度矩阵写入 this.contextNode.valuemodelViewMatrix / modelNormalViewMatrix,见 src/renderers/common/Renderer.js#L1265-L1291)。也就是说,即使你不为 renderer.contextNode 设置任何自定义值,它也在承载渲染器注入的内部数据。

在缓存层面,渲染对象(RenderObject)把 renderer.contextNodeidversion 纳入缓存键(见 src/renderers/common/RenderObject.js#L951),因此修改 renderer.contextNode 的内容会自动使相关着色器缓存失效并触发重建。

4.3 PassNode 的上下文合并与缓存

场景 Pass(如后处理、XR Pass)拥有自己的 contextNodesrc/nodes/display/PassNode.js 的构建逻辑会:

  1. 暂存 renderer.contextNode
  2. renderer.contextNode.getFlowContextData() 与自身 this.contextNode.getFlowContextData() 合并,构造一个新的 context( { ...renderer, ...pass } ) 并缓存(以 version 判断是否需要重建缓存);
  3. 将其赋给 renderer.contextNode,使 Pass 内部所有物体的构建都感知到合并后的上下文;
  4. 构建结束后恢复 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#L200uniformFlowuniformFlow: 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),它复制当前上下文但删除 materialgetUVgetOutputgetTextureLevelgetAOgetShadow 这些材质相关键。其 JSDoc 明确说明:渲染器缓存可能把一个材质生成的着色器复用到另一个材质,因此共享上下文必须剥离材质敏感字段。典型使用者是 RTTNode#L153

this._quadMesh.material.contextNode = context( builder.getSharedContext() );

即 RTT(渲染到纹理)内部四边形使用“去材质化的共享上下文”构建,避免 RTT 子渲染与主场景的材质上下文互相干扰。

六、小结与延伸阅读

ContextNode 的核心价值可以归纳为三点:其一,它把“上下文的修改”表达为一等节点,可嵌入任意 TSL 表达式中局部生效;其二,analyze / setup / generate 三阶段的保存-恢复保证了上下文修改的作用域严格隔离;其三,getFlowContextData() 让嵌套的 context 数据能按顺序合并并流向 renderer/material 两个全局挂载点。

结合仓库源码的延伸阅读路径:

需要注意的适用前提:ContextNode 属于 TSL / 节点材质体系,仅在 NodeMaterial、WebGPURenderer/WebGL 节点管线等启用节点系统的渲染路径中生效;其上下文修改发生在构建期(着色器生成时),而非渲染期的每帧状态。

登录后查看全文
热门项目推荐
相关项目推荐