three.js TSL ExpressionNode 深度解析:用 expression()/return/continue/discard 在节点着色器中注入原生语句
ExpressionNode 是 three.js 节点材质(Node Material / TSL)体系中的基础类之一,用于把一段"原生的着色器代码片段"(snippet)以表达式或语句的形式直接嵌入最终生成的着色器源码中,例如 return、continue、discard 这类流程控制语句。本文以 docs/pages/ExpressionNode.html.md 为核心,结合仓库中 src/nodes/code/ExpressionNode.js 的完整实现以及 TSL 工具函数、Loop 循环、Discard 封装等真实调用链,讲解该类的作用、构造参数、属性、内部生成逻辑与实战用法。读完你不仅能准确使用 expression()、.discard()、Return()、Continue()、Break(),还能理解这些高层 API 最终是如何落到一行原生 shader 代码上的。
ExpressionNode 在节点体系中的定位
ExpressionNode 的继承链为:EventDispatcher → Node → ExpressionNode,它是 three.js 节点系统中直接继承 src/nodes/core/Node.js 的"代码片段"型节点,源码位于 src/nodes/code/ExpressionNode.js(与 CodeNode、FunctionCallNode、FunctionNode 同处 code/ 目录)。
它与同目录下的 CodeNode(见 src/nodes/code/CodeNode.js)有明确分工:
CodeNode表达"一整段原生代码",是FunctionNode(定义 WGSL/GLSL 函数)的基类;ExpressionNode表达"单条内联的原生表达式或语句",粒度更小、更轻量,专用于return、continue、discard、break这类控制流语句,或者textureDimensions(...)这类内联内置调用。
换言之,ExpressionNode 是节点图(Node Graph)通向最终生成着色器文本(WebGL 的 GLSL 或 WebGPU 的 WGSL)的"直通门"——它的 snippet 会被原样写入生成代码。
构造函数:参数与默认值
官方文档给出的构造签名如下,与源码 constructor 完全一致:
new ExpressionNode( snippet = '', nodeType = 'void' )
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
snippet |
string | '' |
要嵌入着色器的原生代码片段(native code snippet) |
nodeType |
string | 'void' |
该表达式声明产生的结果节点类型 |
构造时,nodeType 通过 super( nodeType ) 传递给基类 Node(决定节点的类型元信息),而 snippet 被直接保存在实例属性 this.snippet 上。
两个默认值对应了 ExpressionNode 的两种基本语义:
nodeType为'void'(默认):snippet是一条"不产生值的语句",例如discard、return、continue、break;nodeType为非 void 类型(如'float'、'vec3'、'u32'):snippet是一个"产生值的表达式",可以被嵌入到其他节点的输入位置,例如textureDimensions( map )或字面量6u。
属性说明
.snippet : string
保存将要被写入着色器的原生代码片段,默认值 ''。
这是该类唯一的自有数据成员(其余能力来自基类 Node)。由于片段以纯文本形式保存,使用时需要注意它最终会被插入哪一种着色器语言环境(GLSL 或 WGSL),片段本身必须与渲染后端的着色器语法匹配。
generate() 内部逻辑:void 与取值分支
理解 ExpressionNode 行为的关键在其 generate() 实现。当节点系统在编译着色器时,会为每个节点调用 generate( builder, output ),该实现根据节点类型分两条路径:
generate( builder, output ) {
const type = this.getNodeType( builder );
const snippet = this.snippet;
if ( type === 'void' ) {
// 情况一:作为语句插入当前流程块
builder.addLineFlowCode( snippet, this );
} else {
// 情况二:作为值表达式返回,供上层继续格式化
return builder.format( snippet, type, output );
}
}
情况一(void 语句):通过 builder.addLineFlowCode( snippet, this ) 把片段追加为当前作用域内的一行代码。addLineFlowCode 位于 src/nodes/core/NodeBuilder.js,若当前处于某个条件块(如 If)的上下文中,它还会把语句登记到对应的代码块(nodeBlock),保证 discard、return 出现在正确的大括号作用域内。此时节点不返回值,调用方通常需要把它放进语句栈(TSL 中表现为 .toStack())。
情况二(值表达式):片段被当作表达式返回,并经由 builder.format( snippet, type, output ) 做类型对齐。NodeBuilder.format()(实现见 src/nodes/core/NodeBuilder.js#L3356)负责处理源类型与目标输出类型之间的转换关系(例如向量分量的 swizzle 截取、标量/向量扩展等),确保上游要求 vec3 而 snippet 声明为 vec4 之类的场景也能正确输出可编译代码。
TSL 工厂函数 expression()
类默认导出之外,源文件尾部还导出了对应的 TSL 工厂函数:
export const expression = /*@__PURE__*/ nodeProxy( ExpressionNode ).setParameterLength( 1, 2 );
它由 nodeProxy 生成,允许传 1 或 2 个参数,即:
expression( 'discard' ); // nodeType 缺省为 'void'
expression( snippet, nodeType ); // 指定类型
expression 通过 src/nodes/TSL.js、src/nodes/tsl/TSLBase.js 与 src/nodes/Nodes.js 对外统一导出,因此既可以从 three/tsl 导入 expression,也可以直接实例化 ExpressionNode 类(两种方式等价)。
实战用法一:控制流语句 return / continue / discard / break
ExpressionNode 最常见的用途是承载控制流语句,官方也明确举例 return、continue、discard。仓库在多个高层 TSL API 中复用了它:
discard 与 return(src/nodes/utils/Discard.js):
export const Discard = ( conditional ) =>
( conditional ? select( conditional, expression( 'discard' ) ) : expression( 'discard' ) ).toStack();
export const Return = () => expression( 'return' ).toStack();
addMethodChaining( 'discard', Discard );
Return()直接返回expression( 'return' ).toStack(),即一个 void 的 return 语句;Discard( conditional? )支持可选条件:传条件时用select()把discard语句包进条件块,否则是无条件 discard;.discard()通过addMethodChaining注册为节点方法链,因此任何节点都可写成cond.greaterThanEqual( 1.0 ).discard()这样的链式语法。
continue 与 break(src/nodes/utils/LoopNode.js):
export const Continue = () => expression( 'continue' ).toStack();
export const Break = () => expression( 'break' ).toStack();
在 examples 中有大量真实调用可印证。例如 examples/webgpu_deferred.html 中对未命中场景丢弃片元:
resolveMaterial.colorNode = Fn( () => {
opaqueDepthNode.greaterThanEqual( 1.0 ).discard();
return opaqueOutputNode;
} );
compute 着色器中的提前退出见 examples/webgpu_compute_particles_fluid.html:
If( mass.lessThanEqual( 0 ), () => {
Return();
} );
其余同类佐证还包括 examples/webgpu_volume_fire.html、examples/jsm/tsl/display/GTAONode.js、examples/jsm/tsl/display/SSRNode.js 等。可以看到:所有 .discard()、Return()、Continue()、Break() 调用,底层都是 expression( '...' ) 产生的 ExpressionNode。
实战用法二:带类型的值表达式
除控制流语句外,ExpressionNode 也大量用于生成"带返回值的内联表达式"。此时需要传入第二个参数 nodeType。
循环变量引用:src/nodes/utils/LoopNode.js 在构造循环体时,会把循环参数包成带类型的表达式节点,供循环体内访问循环变量:
const name = ( param.isNode !== true && param.name ) || this.getVarName( i );
const type = ( param.isNode !== true && param.type ) || 'int';
inputs[ name ] = expression( name, type );
WGSL 内置查询:src/renderers/webgpu/nodes/WGSLNodeBuilder.js 用 ExpressionNode 把 WGSL 内置调用包装成可缓存的值节点,例如 textureDimensions( ... )(类型 dimensionType)、textureNumLayers(...)('u32')以及 6u 立方体贴图面数常量,并常以 VarNode 包装。
原子操作(Atomic):src/nodes/gpgpu/AtomicFunctionNode.js 中原子函数片段同样通过 expression( methodSnippet, type ).toConst() 构造,供后续代码生成阶段内联。
编写你自己的表达式/语句
综合以上分析,在使用 TSL 编写自定义逻辑时可按以下模式组合:
import { Fn, If, expression } from 'three/tsl';
// 1) 值表达式:把一段原生表达式当作普通节点使用
const sizeNode = expression( 'textureDimensions( map )', 'vec2' );
// 2) 语句:配合 If/select 实现条件化 discard
material.colorNode = Fn( () => {
uv.y.greaterThan( 0.9 ).discard(); // 隐式:expr + discard 封装
If( depth.greaterThanEqual( 1.0 ), () => { // 显式:if 包裹语句
Return();
} );
return texture( map, uv );
} );
要点归纳:
- void 类型 → 语句:
return/continue/break/discard的节点类型固定为'void',必须以语句(.toStack()或函数体内)形式出现,不能充当上游节点的值输入; - 非 void 类型 → 值:当 snippet 要作为某个输入参与运算时,务必用第二个参数声明正确的节点类型,代码生成阶段的
format()才能正确对齐目标类型; - 语言一致性:snippet 是"原生代码直通",必须与最终着色器语言(WebGL 后端 GLSL / WebGPU 后端 WGSL)匹配;文档(docs/pages/ExpressionNode.html.md)称其为"native code snippet"即此意;
- 优先使用高层封装:日常开发建议使用
expression()工厂以及.discard()、Discard()、Return()、Continue()、Break()这类既有封装,它们内部已经处理了toStack()/条件包装等细节,行为与 three.js 内置着色器保持一致。
总结
ExpressionNode 体量虽小,却是 TSL 节点图连接"高层 JS 语义"与"底层 GLSL/WGSL 文本"的关键一环:return、continue、discard、break 等所有语句型操作,以及带类型的原生内联表达式,最终都汇聚为它的 snippet 字段并由 generate() 注入生成代码。理解其构造参数(snippet、nodeType)、属性、void/非 void 双分支生成逻辑及其与 NodeBuilder.addLineFlowCode / format 的配合,能帮助你在编写自定义 TSL 着色器与 compute 逻辑时准确选择"语句还是值"的表达方式。
如需继续深入,可阅读同目录的 src/nodes/code/CodeNode.js(原生代码段基类)、src/nodes/code/FunctionNode.js(glslFn/wgslFn 函数定义)与 src/nodes/code/FunctionCallNode.js(函数调用),或对照 TSL 综合参考 docs/pages/TSL.html.md 中 Discard、Return 等条目的说明。
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