首页
/ three.js 节点材质中的 FunctionNode 详解:用 wgslFn / glslFn 注入原生着色器函数

three.js 节点材质中的 FunctionNode 详解:用 wgslFn / glslFn 注入原生着色器函数

2026-09-06 19:16:25作者:胡唯隽

导读

在 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.jsCodeNode.jsFunctionCallNode.js)与真实示例展开。本文所描述的能力同时适用于 WebGL 与 WebGPU 渲染后端。

什么是 FunctionNode

FunctionNode 表示一个原生着色器函数。它的类继承链为:

EventDispatcher → Node → CodeNode → FunctionNode

即它继承自 CodeNode,而 CodeNode 本身就是"原生代码段"节点的基类——CodeNode 持有 code(原生代码字符串)、includes(依赖节点数组)与 language(语言标识)三个核心属性,并将其序列化/反序列化(见 src/nodes/code/CodeNode.js 中的 serialize/deserialize)。FunctionNodeCodeNode 之上进一步把代码段提升为可解析、可调用、可推断签名的函数。

它的典型用途是"用原生着色器语言实现节点材质的某些方面":

  • 需要精确控制 GPU 端计算过程、使用原生内建函数或算法移植代码时;
  • 代码结构存在循环、多次函数互相调用、三层嵌套遍历等,用 TSL 组合表达繁琐时;
  • 团队已有大量现成 GLSL/WGSL 函数代码,希望零改写地接入 TSL 材质时。

整个 FunctionNodesrc/nodes/code/FunctionNode.js 中实现,并通过 src/nodes/Nodes.jsexport { default as FunctionNode })与 src/nodes/TSL.jsexport * from './code/FunctionNode.js')对外暴露,因此可以从 threethree/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 ) } );

拆解这个例子,可以得到三条关键经验:

  1. 第一个参数是原生代码字符串,需要写完整的 WGSL/GLSL 函数定义(含函数名、参数列表、返回类型与函数体)。
  2. 第二个参数是依赖数组(includes)someFn 的函数体内部调用了 desaturate,因此 desaturateWGSLFn 必须作为 include 传入,否则生成的着色器里找不到被调用函数的声明。CodeNode.generate() 在生成代码时会先逐个 include.build( builder, 'void' ),把依赖函数先编译进着色器(见 src/nodes/code/CodeNode.js)。
  3. 返回值是一个可调用的节点代理。把 { 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),其生成逻辑要点如下:

  1. 先调用父类 CodeNode.generate 保证 includes 依赖先被编译;
  2. 取得解析出的函数名与类型;
  3. 通过 builder.registerDeclaration() 把函数声明注册一次(以 nodeData.declarationRegistered 做幂等标记),保证同一函数在最终着色器中只声明一遍;
  4. 若输出目标是 'property' 则返回函数名(供引用函数地址),否则返回 格式化的函数调用表达式,如 desaturate( color )

函数调用是如何生成的:FunctionCallNode

当你执行 someWGSLFn( { color: texture( map ) } ) 时,实际触发的是 FunctionNode 代理上的 call() 逻辑。官方把"一次函数调用"建模为独立的 FunctionCallNodesrc/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 由渲染后端决定,而不是由你自由选择:

两者的 parseFunction( source ) 实现分别位于 src/nodes/parsers/GLSLNodeParser.jssrc/renderers/webgpu/nodes/WGSLNodeParser.js,共同抽象基类为 src/nodes/core/NodeParser.js。它们从函数源码中解析出函数名、参数(名称 + 类型)与返回类型。

由此可以得到一条重要的实践结论:wgslFn 中的代码应以 WGSL 语法书写(如 vec3<f32>-> 返回箭头),glslFn 中的代码应以 GLSL 语法书写(如 vec3float),并与实际的渲染后端匹配。若后端与语言不匹配,语法解析或最终编译都可能失败。

实战示例:从 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 材质管线,也可以用原生语言写出高性能的复杂着色器逻辑——而不必离开节点材质这套统一、可组合的体系。

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