three.js 节点材质中的 FunctionNode 详解:用 wgslFn / glslFn 注入原生着色器函数
导读
在 three.js 基于 Node(TSL,Three Shading Language)的节点材质体系中,绝大多数效果都可以用 Fn()、texture()、add() 等内建 TSL 语法声明式地组合出来。但某些算法(如复杂的噪声、Voronoi 纹理、Blender 移植的节点算法)直接用 TSL 组合器表达既笨重又难以阅读。FunctionNode 正是为解决这类问题而设计的——它允许开发者把一段原生着色器代码(WGSL 或 GLSL)直接包装成可被节点材质调用的函数节点。阅读完本文,你将掌握 wgslFn / glslFn 两个便捷函数的使用方法、includes 依赖注入机制、函数签名解析原理,以及如何把自建着色器函数挂接到 material.colorNode 等材质属性上。
本文内容基于 docs/pages/FunctionNode.html.md 文档,并结合仓库源码(src/nodes/code/FunctionNode.js、CodeNode.js、FunctionCallNode.js)与真实示例展开。本文所描述的能力同时适用于 WebGL 与 WebGPU 渲染后端。
什么是 FunctionNode
FunctionNode 表示一个原生着色器函数。它的类继承链为:
EventDispatcher → Node → CodeNode → FunctionNode
即它继承自 CodeNode,而 CodeNode 本身就是"原生代码段"节点的基类——CodeNode 持有 code(原生代码字符串)、includes(依赖节点数组)与 language(语言标识)三个核心属性,并将其序列化/反序列化(见 src/nodes/code/CodeNode.js 中的 serialize/deserialize)。FunctionNode 在 CodeNode 之上进一步把代码段提升为可解析、可调用、可推断签名的函数。
它的典型用途是"用原生着色器语言实现节点材质的某些方面":
- 需要精确控制 GPU 端计算过程、使用原生内建函数或算法移植代码时;
- 代码结构存在循环、多次函数互相调用、三层嵌套遍历等,用 TSL 组合表达繁琐时;
- 团队已有大量现成 GLSL/WGSL 函数代码,希望零改写地接入 TSL 材质时。
整个 FunctionNode 在 src/nodes/code/FunctionNode.js 中实现,并通过 src/nodes/Nodes.js(export { default as FunctionNode })与 src/nodes/TSL.js(export * from './code/FunctionNode.js')对外暴露,因此可以从 three 或 three/tsl 中直接导入。
两个预定义 TSL 便捷函数
按文档与源码说明,官方为 FunctionNode 提供了两个预定义的 TSL 函数,避免开发者手动 new FunctionNode(...):
| 函数 | 说明 | 适用后端 |
|---|---|---|
wgslFn( code, includes? ) |
创建一个 WGSL 语言的原生函数节点 | WebGPU 渲染后端(WGSL 着色器) |
glslFn( code, includes? ) |
创建一个 GLSL 语言的原生函数节点 | WebGL 渲染后端(GLSL 着色器) |
它们的源码位于 src/nodes/code/FunctionNode.js:
const nativeFn = ( code, includes = [], language = '' ) => {
const functionNode = new FunctionNode( code, includes, language );
const fn = ( ...params ) => functionNode.call( ...params );
return nodeProxyConstructor( fn, functionNode );
};
export const glslFn = ( code, includes ) => nativeFn( code, includes, 'glsl' );
export const wgslFn = ( code, includes ) => nativeFn( code, includes, 'wgsl' );
可以看到两者只是 nativeFn 的语法糖:内部创建 FunctionNode 实例并预设 language,随后用 nodeProxyConstructor 把节点包装成可像普通函数一样调用的 TSL 代理对象——这正是 wgslFn( '...' )( { color: texture( map ) } ) 这种二次调用写法能够成立的原因。
基础用法:带 include 依赖的函数定义
原文档给出的核心示例展示了一个函数依赖另一个函数(即 includes 注入)的写法:
const desaturateWGSLFn = wgslFn( `
fn desaturate( color:vec3<f32> ) -> vec3<f32> {
let lum = vec3<f32>( 0.299, 0.587, 0.114 );
return vec3<f32>( dot( lum, color ) );
}`
);
const someWGSLFn = wgslFn( `
fn someFn( color:vec3<f32> ) -> vec3<f32> {
return desaturate( color );
}
`, [ desaturateWGSLFn ] );
material.colorNode = someWGSLFn( { color: texture( map ) } );
拆解这个例子,可以得到三条关键经验:
- 第一个参数是原生代码字符串,需要写完整的 WGSL/GLSL 函数定义(含函数名、参数列表、返回类型与函数体)。
- 第二个参数是依赖数组(includes)。
someFn的函数体内部调用了desaturate,因此desaturateWGSLFn必须作为 include 传入,否则生成的着色器里找不到被调用函数的声明。CodeNode.generate()在生成代码时会先逐个include.build( builder, 'void' ),把依赖函数先编译进着色器(见 src/nodes/code/CodeNode.js)。 - 返回值是一个可调用的节点代理。把
{ color: texture( map ) }以具名参数对象的形式传入,参数名必须与 WGSL 函数签名中的参数名一致(此处为color),实参可以是任意 TSL 节点表达式。结果可直接赋给material.colorNode参与整个材质的光照与颜色管线。
构造器与参数说明
new FunctionNode( code : string, includes : Array.<Node>, language : 'js' | 'wgsl' | 'glsl' )
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
code |
string |
'' |
原生着色器函数代码字符串 |
includes |
Array.<Node> |
[] |
依赖节点数组(通常是其他 FunctionNode/代码节点) |
language |
'js' | 'wgsl' | 'glsl' |
'' |
代码语言标识 |
源码中的构造器实现(src/nodes/code/FunctionNode.js)直接把这些参数透传给父类 CodeNode:
constructor( code = '', includes = [], language = '' ) {
super( code, includes, language );
}
因此以下两种写法等价:
// 方式一:手动构造
const fnNode = new FunctionNode( 'fn foo() -> f32 { return 1.0; }', [], 'wgsl' );
// 方式二:使用预定义便捷函数(推荐)
import { wgslFn } from 'three/tsl';
const fnNode = wgslFn( 'fn foo() -> f32 { return 1.0; }' );
方法逐一解析
.generateNodeType( builder ) : string
generateNodeType( builder ) {
return this.getNodeFunction( builder ).type;
}
返回该函数的返回类型,实现见 src/nodes/code/FunctionNode.js。即解析后函数签名的返回类型(如 vec3<f32> / float 等)会成为该节点在 TSL 图中的类型。它覆写了父类 CodeNode 的同名钩子。
.getInputs( builder ) : Array.
getInputs( builder ) {
return this.getNodeFunction( builder ).inputs;
}
返回解析出的函数输入参数列表(数组元素为 NodeFunctionInput,含参数名与类型),实现见 src/nodes/code/FunctionNode.js。它服务于后续的函数调用生成——调用时实参就是按这些输入逐一映射的。
.getMemberType( builder, name ) : string
getMemberType( builder, name ) {
const type = this.getNodeType( builder );
const structType = builder.getStructTypeNode( type );
return structType.getMemberType( builder, name );
}
当函数返回的是结构体类型时,通过 builder.getStructTypeNode() 找到对应的 struct 类型节点,再查询其成员类型(见 src/nodes/code/FunctionNode.js)。这使得返回结构体的原生函数也能以 .member 的方式在 TSL 中继续取用字段。同样覆写自 CodeNode。
.getNodeFunction( builder ) : NodeFunction
getNodeFunction( builder ) {
const nodeData = builder.getDataFromNode( this );
let nodeFunction = nodeData.nodeFunction;
if ( nodeFunction === undefined ) {
nodeFunction = builder.parser.parseFunction( this.code );
nodeData.nodeFunction = nodeFunction;
}
return nodeFunction;
}
这是整个类最核心的方法(src/nodes/code/FunctionNode.js):借助当前 NodeBuilder 携带的 parser 解析函数代码,并把解析结果按节点缓存在 nodeData.nodeFunction 中,避免同一函数被重复解析。解析器输出的 NodeFunction 内含函数名、返回类型与输入列表,后续的类型推导与调用生成全部依赖它。
.generate( builder, output )
FunctionNode 还覆写了 generate() 钩子(见 src/nodes/code/FunctionNode.js),其生成逻辑要点如下:
- 先调用父类
CodeNode.generate保证 includes 依赖先被编译; - 取得解析出的函数名与类型;
- 通过
builder.registerDeclaration()把函数声明注册一次(以nodeData.declarationRegistered做幂等标记),保证同一函数在最终着色器中只声明一遍; - 若输出目标是
'property'则返回函数名(供引用函数地址),否则返回格式化的函数调用表达式,如desaturate( color )。
函数调用是如何生成的:FunctionCallNode
当你执行 someWGSLFn( { color: texture( map ) } ) 时,实际触发的是 FunctionNode 代理上的 call() 逻辑。官方把"一次函数调用"建模为独立的 FunctionCallNode,src/nodes/code/FunctionCallNode.js 的注释明确指出:开发者通常不会直接接触它,因为 wgslFn / glslFn 已经封装了这一层逻辑。
FunctionCallNode.generate()(见 src/nodes/code/FunctionCallNode.js)负责把 TSL 实参还原成原生函数调用的参数串,其行为要点:
- 具名对象参数:
{ color: texture( map ) }会按解析出的inputs里的参数名逐一查找并传入; - 数组参数:按位置传入;实参少于形参时自动补
float( 0 ),多于形参时报错'TSL: The number of provided parameters exceeds the expected number of inputs in Fn().'; - 缺失具名参数时报错
'TSL: Input 'xxx' not found in Fn().'; pointer类型参数(如 WGSL 的var指针语义)会生成&前缀实参;- 最终拼接为
函数名( 实参, ... )的原生函数调用字符串。
这也意味着 include 依赖函数同样可以参与调用——desaturate 本身并不需要单独被调用,它作为依赖被声明进着色器后,由外层函数的函数体直接引用。
签名解析与后端:parseFunction 从何而来
getNodeFunction() 中使用的 builder.parser 由渲染后端决定,而不是由你自由选择:
- WebGL 后端:
GLSLNodeBuilder在构造时注入new GLSLNodeParser()(见 src/renderers/webgl-fallback/nodes/GLSLNodeBuilder.js); - WebGPU 后端:
WGSLNodeBuilder在构造时注入new WGSLNodeParser()(见 src/renderers/webgpu/nodes/WGSLNodeBuilder.js)。
两者的 parseFunction( source ) 实现分别位于 src/nodes/parsers/GLSLNodeParser.js 与 src/renderers/webgpu/nodes/WGSLNodeParser.js,共同抽象基类为 src/nodes/core/NodeParser.js。它们从函数源码中解析出函数名、参数(名称 + 类型)与返回类型。
由此可以得到一条重要的实践结论:wgslFn 中的代码应以 WGSL 语法书写(如 vec3<f32>、-> 返回箭头),glslFn 中的代码应以 GLSL 语法书写(如 vec3、float),并与实际的渲染后端匹配。若后端与语言不匹配,语法解析或最终编译都可能失败。
实战示例:从 Blender 移植的 WGSL Voronoi 函数
仓库中的 examples/jsm/materials/WoodNodeMaterial.js 是一个把 Blender 算法移植成 TSL 材质并大量使用 wgslFn 的真实样例(在该文件示例中通过 import * as TSL from 'three/tsl' 引入 TSL API)。其内部定义了依赖哈希函数的 Voronoi 函数:
const voronoi3d = TSL.wgslFn( `
fn voronoi3d(x: vec3<f32>, smoothness: f32, randomness: f32) -> f32
{
let p = floor(x);
let f = fract(x);
var res = 0.0;
var totalWeight = 0.0;
for (var k = -1; k <= 1; k++)
{
for (var j = -1; j <= 1; j++)
{
for (var i = -1; i <= 1; i++)
{
let b = vec3<f32>(f32(i), f32(j), f32(k));
let hashOffset = hash3d(p + b) * randomness;
let r = b - f + hashOffset;
let d = length(r);
...
}
}
}
...
return smoothstep(0.0, 1.0, res);
}
`, [ hash3d ] ); // hash3d 同样由 wgslFn 创建,作为 include 依赖注入
这个例子体现了 FunctionNode 的典型价值:
- 复杂算法(多层嵌套循环、
var局部变量)可以原封不动地以原生 WGSL 书写,可读性与可移植性(从 Blender/其他项目迁移)都更好; - 依赖链清晰:
voronoi3d依赖hash3d,通过 includes 数组显式声明依赖关系; - 定义完成后,
voronoi3d( position, smoothness, randomness )这类带实参的调用可以被当作普通 TSL 节点组合进整棵材质图。
完整可运行的最小示例
综合以上内容,给出一个自包含的 WGSL 最小示例,把原生函数接入材质颜色通道:
import * as THREE from 'three';
import { wgslFn, texture, uv, vec3 } from 'three/tsl';
// 1. 定义原生 WGSL 函数
const grayscaleFn = wgslFn( `
fn grayscale( color: vec3<f32> ) -> vec3<f32> {
let lum = vec3<f32>( 0.299, 0.587, 0.114 );
return vec3<f32>( dot( lum, color ) );
}
` );
// 2. 读取一张贴图,构造节点材质
const map = new THREE.TextureLoader().load( 'textures/uv_grid_opengl.jpg' );
const material = new THREE.MeshNodeMaterial();
material.colorNode = grayscaleFn( { color: texture( map ) } );
对应的 WebGL/GLSL 版本只需把 wgslFn 换成 glslFn,并把函数体改写为 GLSL 语法(如 float luminance = dot(vec3(0.299, 0.587, 0.114), color); return vec3(luminance);),同时确保场景使用 WebGL 后端渲染。
使用建议与注意事项
- 函数体必须自包含:除了通过
includes声明的依赖函数与着色器语言自带的内建函数之外,不要引用未声明的外部函数或全局,否则生成的着色器无法通过编译。 - 命名冲突:注册进同一个着色器的函数名应保持唯一,避免与外层
Fn()生成的辅助函数重名。 - 语言与后端匹配:
wgslFn/glslFn的选择应与实际渲染后端一致(参见上文解析器选择机制)。在不确定后端的情况下,可以先判断renderer.isWebGPURenderer之类的运行时信息再选择对应便捷函数。 - 解析器能力边界:
NodeParser只负责提取函数签名(函数名、参数、返回类型)与基础结构,超出自定义复杂宏的代码应尽量在函数体内保持"可解析"的常规写法。 - 调用参数一一对应:具名实参对象的键名必须与函数签名形参名一致;使用数组实参时注意顺序与个数。
- 引用与分发:
FunctionNode会被CodeNode标记为global = true以参与全局缓存,多个节点引用同一个函数实例是安全的——函数声明只会被注册一次(declarationRegistered幂等控制)。
小结
FunctionNode 是 three.js 节点材质体系中"原生着色器代码与 TSL 组合式材质"之间的桥梁:
| 关注点 | 结论 |
|---|---|
| 类的定位 | 继承自 CodeNode,把原生 GLSL/WGSL 函数提升为 TSL 可调用节点 |
| 便捷入口 | wgslFn( code, includes ) 与 glslFn( code, includes ) |
| 构造函数 | new FunctionNode( code, includes, language ),三参数均有默认值 |
| 依赖注入 | 第二参数 includes 数组声明函数间依赖,生成时先编译依赖 |
| 签名解析 | 由渲染后端注入的 GLSLNodeParser / WGSLNodeParser 负责 parseFunction |
| 调用机制 | 实参经 FunctionCallNode 还原为原生调用串,支持具名/数组与指针参数 |
| 覆盖钩子 | generateNodeType / getInputs / getMemberType / generate |
掌握它之后,你既可以把现成的 GLSL/WGSL 算法资产快速接入 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 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