three.js TSL 着色器多输出指南:OutputStructNode 实现原理与 MRT 实战
OutputStructNode 是 three.js TSL(Three Shading Language)节点系统中负责“在着色器程序中声明多个输出”的核心节点。它以结构体的形式把片元着色器的输出组织成一个整体,是 WebGPU 渲染器中 @fragment 入口返回多字段结构体,以及 MRT(多渲染目标)后处理链路的底层基础设施。阅读本文后,你将理解该节点的构造函数与属性、它在 src/nodes/core/OutputStructNode.js 中的完整代码生成流程,并能借助其 TSL 封装 outputStruct 及派生类 MRTNode 写出可复用的多输出着色器代码。
本文对应的官方 API 参考页位于 docs/pages/OutputStructNode.html,可对照阅读。
节点定位与继承关系
OutputStructNode 的继承链为:
EventDispatcher → Node → OutputStructNode
从类定义(src/nodes/core/OutputStructNode.js)可以看到它继承自 Node,并没有重写 setup 等阶段的默认行为,核心职责全部集中在代码生成阶段(generate)。它的静态 type 返回字符串 'OutputStructNode',用于节点系统的类型注册与序列化。
在 TSL 中它还有一个一等公民的工厂函数 outputStruct( ...members ),定义于源码同一文件的末尾(src/nodes/core/OutputStructNode.js):
export const outputStruct = /*@__PURE__*/ nodeProxy( OutputStructNode );
它通过 nodeProxy 生成,凡是引用 src/nodes/TSL.js 的模块都能直接以 outputStruct( a, b, c ) 的形式创建节点。官方的 TSL 速查表在 docs/TSL.md 中将它登记为 “Creates an output struct node for returning multiple values”,而 docs/pages/TSL.html.md 给出了函数签名 .outputStruct( …members : Node ) : OutputStructNode。
构造函数与参数语义
new OutputStructNode( …members : Node )
构造函数接收任意数量的节点作为成员参数(src/nodes/core/OutputStructNode.js):
constructor( ...members ) {
super();
this.members = members;
this.isOutputStructNode = true;
}
- members:一个可变长的参数列表,每个元素都是产生某种类型值的
Node(颜色、法线、深度、自定义 float 等)。传入顺序即最终结构体成员的声明顺序。 - 需要注意:构造时并不校验成员类型,真正的类型解析发生在着色器编译阶段,由
generate()对每个成员调用getNodeType( builder )完成。因此传入的成员应当是"可求值出具体类型"的节点。
与普通节点不同,OutputStructNode 的成员可以拥有异构类型,这正是"结构体输出"与"单值输出"的本质区别——它允许一次编译生成多个不同数据类型的着色器输出。
属性详解
.isOutputStructNode : boolean(只读)
类型测试标志,默认值为 true(src/nodes/core/OutputStructNode.js)。渲染器与构建器在生成片元着色器代码时用它来判断"当前输出是否是多字段结构体输出",例如 WebGL 回退后端在片元着色器中据此决定是否写入 fragColor(详见下文"着色器后端的差异化处理")。
.members : Array.
定义输出内容的一组节点(src/nodes/core/OutputStructNode.js)。它直接引用了构造函数收到的成员参数数组。派生类 MRTNode 会在 setup() 阶段重建该数组(对齐到渲染目标纹理索引),因此这既是构造语义的一部分,也是可被后处理器改写的工作区。
代码生成原理:从成员列表到着色器结构体
OutputStructNode 之所以能输出"结构体",全部秘密都在 generate( builder ) 中(src/nodes/core/OutputStructNode.js)。其流程可分为三段:
第一段:构建成员布局并注册结构体类型(仅首次执行)
if ( nodeData.membersLayout === undefined ) {
const membersLayout = [];
for ( let i = 0; i < members.length; i ++ ) {
const name = 'm' + i; // 成员名固定为 m0、m1、m2……
const type = members[ i ].getNodeType( builder );
membersLayout.push( { name, type, index: i } );
}
nodeData.membersLayout = membersLayout;
nodeData.structType = builder.getOutputStructTypeFromNode( this, nodeData.membersLayout );
}
成员被自动命名为 m0、m1……并按声明次序排列,每个成员的类型通过 Node#getNodeType 实时解析。随后调用构建器的 getOutputStructTypeFromNode(src/nodes/core/NodeBuilder.js)生成结构体类型:
getOutputStructTypeFromNode( node, membersLayout ) {
const structType = this.getStructTypeFromNode( node, membersLayout, 'OutputType', 'fragment' );
structType.output = true;
return structType;
}
可以看到:生成的结构体类型名为 OutputType,作用于 fragment 阶段,并且被显式标记为 structType.output = true,从而让后端的着色器生成器把它识别为片元入口的返回结构体。
第二段:生成每个成员的赋值代码
const propertyName = builder.getOutputStructName();
const structPrefix = propertyName !== '' ? propertyName + '.' : '';
for ( let i = 0; i < members.length; i ++ ) {
const snippet = members[ i ].build( builder, nodeData.membersLayout[ i ].type );
builder.addLineFlowCode( `${ structPrefix }m${ i } = ${ snippet }`, this );
}
每个成员按布局中记录的期望类型完成自身构建(build),随后以 output.mN = <value>; 的形式写入流式代码。此处 propertyName 来自构建器的 getOutputStructName(),它决定了赋值时的前缀:
- 在 WebGPU/WGSL 后端中返回
'output'(src/renderers/webgpu/nodes/WGSLNodeBuilder.js),因此生成的代码形如output.m0 = …; output.m1 = …;,对应 WGSL@fragment函数返回一个名为output的OutputType结构体变量; - 在 WebGL(GLSL)回退后端中返回空字符串(src/renderers/webgl-fallback/nodes/GLSLNodeBuilder.js),此时
structPrefix为空,成员以裸变量方式书写,并交由该后端自己的多输出声明机制处理。
第三段:返回值
return propertyName;
generate() 最终返回结构体变量名(WGSL 下为 'output'),上层片段代码据此引用整个输出结构体。
着色器后端的差异化处理
OutputStructNode 这一抽象在 GLSL 与 WGSL 两种后端下表现不同,可从源码证实:
- WGSL 构建器通过
getOutputStructName()返回'output',并把结构体注册进fragment阶段的 structs 列表,最终形成真正的"多字段结构体返回值",这正是 WebGPU 片元着色器入口的惯用写法。 - GLSL 回退构建器在生成片元主流程时有专门判断(src/renderers/webgl-fallback/nodes/GLSLNodeBuilder.js):
} else if ( shaderStage === 'fragment' ) {
if ( ! node.outputNode.isOutputStructNode ) {
flow += 'fragColor = ';
flow += `${ this.format( flowSlotData.result, ... ) };`;
}
}
即:当主输出节点是一个 OutputStructNode(或其派生类 MRTNode)时,回退后端不再写默认的 fragColor,而是信任该结构体节点自身已展开的多输出赋值语句。这正是文档注释"该节点可被用来在着色器程序中定义多个输出"的落地实现。
与 MRTNode 的关系:多渲染目标的标准实现
OutputStructNode 最主要的实战派生类就是 MRTNode(src/nodes/core/MRTNode.js),它继承 OutputStructNode,把"多字段结构体输出"升级为"多渲染目标"。其注释给出了最典型的用法:
const mrtNode = mrt( {
output: output,
normal: normalView
} );
MRTNode 的 setup()(src/nodes/core/MRTNode.js)展示了它如何复用父类的结构体输出机制:
- 读取当前渲染器绑定的渲染目标及其纹理数组
mrt.textures; - 对字典
outputNodes中的每个键,通过getTextureIndex找到目标纹理索引,未使用过的输出会被直接跳过; - 按索引位置把输出节点转换为对应目标的输出类型,填入数组
members; - 最后调用
super.setup( builder )走 OutputStructNode 的统一流程。
从源码结构看,MRTNode.members 本质上就是 OutputStructNode 的 members——MRT 复用同一套"多输出结构体"生成管线,只是输入形态从参数列表变成了具名字典,并额外维护了每个输出的 blendModes 与 clearColors。
实战场景:后处理 Pass 中的多输出
在真实的后处理示例中,outputStruct/mrt 普遍承担着"一个 Pass 输出多张纹理"的角色。以下文件均可在 examples/jsm/tsl/display/ 下找到相应实现:
- SSAONode.js 注释中演示了
scenePass.setMRT( mrt( { output, normal: normalView } ) ),即用一个场景 Pass 同时产出最终颜色与视图法线; - BloomNode.js 使用 MRT 在一次渲染中分离颜色输出;
- GTAONode.js 与 OITPassNode.js 分别在 AO 计算与顺序无关透明(OIT)中管理多个输出目标;
- PixelationPassNode.js、SSAAPassNode.js 在超采样相关链路中也通过
mrt( outputs )组织中间缓冲。
这些 Pass 一般配合 NodeMaterial/后处理节点的 setMRT() 使用——NodeMaterial 会把传入的 MRT 节点记录为主输出节点,着色器编译后即可一次性写入多个附件,最终由 WebGPU 渲染器的多个颜色附件(或 WebGL 回退的相应扩展)承接。对于需要 GBuffer、AO 法线、深度等多数据流的高阶渲染,这种模式是官方推荐且经过大量示例验证的写法。
小结
OutputStructNode 是 three.js TSL 中"多输出"语义的最小封装:构造函数用可变参数收集异构成员,members 与 isOutputStructNode 两个属性分别承担数据载体与类型判定的职责;编译期它会把成员按 m0、m1… 自动编排为名为 OutputType、标记 output = true 的片元结构体,并按后端差异生成带前缀(WGSL 的 output.)或无前缀(GLSL)的逐成员赋值代码。理解它的代码生成路径,是掌握 WebGPU 片元结构体输出、MRT 多渲染目标,以及阅读官方 TSL 后处理源码的起点。
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 StartedRust0627
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