Three.js GLSLNodeFunction 解析:GLSL 着色器节点函数的解析与代码重组原理
本文深入讲解 three.js 节点系统(TSL / Node Material)中的 GLSLNodeFunction 类:它如何把一段原生 GLSL 函数源码解析为结构化的节点函数(返回值类型、参数列表、函数体与头部代码),并在构建阶段被重组、改名后注入最终着色器。读完本文,你将理解 GLSL 原生代码在 three.js 节点材质中的接入机制,以及如何借助 #pragma main、in/out/inout、精度限定符等语法特性编写可被节点系统正确识别的 GLSL 函数。
类定位:GLSL 语言的 NodeFunction 实现
在 three.js 的节点体系中,NodeFunction(src/nodes/core/NodeFunction.js)是所有"原生着色器函数"的抽象基类:它记录了函数返回类型(type)、输入参数列表(inputs)、函数名(name)和精度限定符(precision),并要求子类实现 getCode() 返回可注入着色器的原生代码。官方对它的注释强调:与其它 Node* 模块一样,NodeFunction 只在构建(building)期间使用,不会出现在用户层代码中。
GLSLNodeFunction 就是针对 GLSL 语言的具体子类,位于 src/nodes/parsers/GLSLNodeFunction.js。它的职责很纯粹:
- 接收一段 GLSL 源码(字符串);
- 通过正则解析出"声明行 + 函数体";
- 把参数列表逐项转换为
NodeFunctionInput输入描述; - 在
getCode()中按需重组并输出最终 GLSL 代码。
与此相对,three.js 还提供了面向 WebGPU 的 WGSLNodeParser(src/nodes/parsers/../renderers/webgpu/nodes/WGSLNodeParser.js 所引用的解析器),两者共同支撑"一套节点材质,多种后端着色语言"的架构。
构造函数:new GLSLNodeFunction( source )
new GLSLNodeFunction( source )
- source:一段合法的 GLSL 函数源码字符串(如
float add( float a, float b ) { return a + b; })。
构造函数内部并不是简单的字符串存储,而是立即调用模块内部的 parse() 解析器(GLSLNodeFunction.js),从源码中一次性抽取以下字段:
| 字段 | 含义 | 来源 |
|---|---|---|
type |
函数返回类型 | 声明正则第 2 组捕获 |
inputs |
NodeFunctionInput[] 输入数组 |
对参数列表做词法切分与逐项解析 |
name |
函数名 | 声明正则第 3 组捕获(可省略) |
precision |
精度限定符(highp/mediump/lowp) |
声明正则第 1 组捕获 |
inputsCode |
原始参数字符串(未 trim) | 括号内原文 |
blockCode |
函数体(含花括号) | 声明匹配之后的剩余源码 |
headerCode |
主函数声明之前的头部代码 | #pragma main 之前的源码 |
解析完成后,基类构造函数被调用:super( type, inputs, name, precision ),把前四项固化到 NodeFunction 实例上(NodeFunction.js);随后把 inputsCode、blockCode、headerCode 这三个原始字符串片段挂在子类实例上,供后续 getCode() 使用。
值得注意,每次构造都触发一次完整的正则解析。在构建期,FunctionNode 会以节点数据(node data)为粒度缓存解析结果,避免同一段代码在多次调用中被重复解析(见下文"调用链"一节)。
底层解析流程:三段式源码切分
源码级实现(GLSLNodeFunction.js)的解析过程可以拆解为三个步骤:
1. 识别 #pragma main,切分头部与主体
模块内部定义了常量 pragmaMain = '#pragma main'。parse() 首先查找该标记在源码中的位置:
- 若存在
#pragma main:标记之前的全部内容记为headerCode,标记之后的源码作为mainCode; - 若不存在:整段源码作为
mainCode,headerCode为空字符串。
随后,mainCode 开头处的空白、// 行注释与 /* ... */ 块注释会被剥离,再交给声明正则匹配。这意味着 #pragma main 机制允许你在同一段 GLSL 源码顶部先写出辅助声明(如辅助函数、宏、uniform),再以该标记指明"从这里开始才是要解析的入口函数"。重组后的最终代码中,headerCode 会原样前置到函数声明之前。
2. 正则匹配函数声明行
解析器使用两个全局正则:
const declarationRegexp = /^\s*(highp|mediump|lowp)?\s*([a-z_0-9]+)\s*([a-z_0-9]+)?\s*\(([\s\S]*?)\)/i;
const propertiesRegexp = /[a-z_0-9]+/ig;
declarationRegexp 依次捕获四段内容(第 4 组 ([\s\S]*?) 为非贪婪匹配,用于截取括号内全部参数文本):
- 可选精度:
highp、mediump或lowp; - 返回类型:一串
[a-z_0-9](配合/i不区分大小写),如void、vec3、float; - 可选函数名:紧随类型之后的标识符(也可缺省,见下文说明);
- 参数区原文:一对圆括号内的所有字符。
如果匹配失败,或捕获组数量不是预期的 5 项,则直接抛出异常:
throw new Error( 'THREE.FunctionNode: Function is not a GLSL code.' );
也就是说,无法被该正则识别的源码(例如缺少标准函数声明结构)会在构造阶段立即报错,而非静默产出畸形代码。
3. 词法切分参数列表
得到参数区原文(inputsCode)后,用 propertiesRegexp 把所有连续的字母、数字、下划线 token 逐个收集到 propsMatches,再按 GLSL 参数语法顺序逐项消费:
[const] [in|out|inout] 类型 [数组长度数字] 参数名
每次循环逻辑如下(GLSLNodeFunction.js):
- 若当前 token 是
const,置isConst = true并跳过; - 若下一个 token 是
in/out/inout,作为参数方向限定符qualifier记录并跳过; - 接下来的 token 作为参数
type; - 若紧随其后的 token 能被
Number.parseInt解析为数字,则视为数组长度count(对应NodeFunctionInput中 "If the input is an Array, count will be the length" 的语义),并消费该 token;否则count = null; - 最后一个 token 作为参数
name; - 每轮产出一个
new NodeFunctionInput( type, name, count, qualifier, isConst ),推入inputs数组。
NodeFunctionInput(src/nodes/core/NodeFunctionInput.js)的五个字段中,type、name 是必填,count 默认 null,qualifier 默认 ''(仅 GLSL 有意义),isConst 默认 false(仅 GLSL 有意义)。最终该输入数组就是构建期拼装调用表达式、生成 uniform/attribute 声明时的依据。
解析结果:blockCode 的截取
声明行匹配结束的位置(declaration[ 0 ].length)之后的所有源码即为函数体:
const blockCode = mainCode.substring( declaration[ 0 ].length );
对于 float add( float a, float b ) { return a + b; } 这类源码,blockCode 就是 { return a + b; } 整段;getCode() 正是基于它是否为非空来判断该函数是否有实现体。
getCode:将结构化字段重组回 GLSL
getCode( name = this.name ) 重写自基类 NodeFunction#getCode(基类默认实现只输出一条 warn( 'Abstract function.' ),见 NodeFunction.js),其重组逻辑位于 GLSLNodeFunction.js:
getCode( name = this.name ) {
let code;
const blockCode = this.blockCode;
if ( blockCode !== '' ) {
const { type, inputsCode, headerCode, precision } = this;
let declarationCode = `${ type } ${ name } ( ${ inputsCode.trim() } )`;
if ( precision !== '' ) {
declarationCode = `${ precision } ${ declarationCode }`;
}
code = headerCode + declarationCode + blockCode;
} else {
// interface function
code = '';
}
return code;
}
行为要点:
- 默认
name取this.name,即构造函数解析出的原名;但当外部传入新名称时(构建期常由NodeBuilder分配防冲突的 property name),函数名会被替换——这正是多段 GLSL 代码合并进同一着色器时避免命名冲突的关键。 - 函数体非空时,按
headerCode + [precision] + type + name + ( inputsCode.trim() ) + blockCode的顺序拼接:精度限定符仅在有值时才前置;参数区会做一次trim()以清理边界空白。 - 函数体为空(
blockCode === '')时视为"接口函数"(interface function),返回空字符串,即不产生任何可注入的声明代码。
可以看到,getCode() 输出的结构始终忠实于原始输入:头部声明 + 函数签名 + 函数体,保证语义与用户书写的 GLSL 等价。
调用链:从 FunctionNode 到后端解析器
GLSLNodeFunction 单独使用意义有限,它在 three.js 中承担着"GLSL 后端函数解析器"的产出角色:
- 解析器包装:GLSLNodeParser.js 继承
NodeParser(src/nodes/core/NodeParser.js),其parseFunction( source )方法直接返回new GLSLNodeFunction( source ); - 后端装配:在 WebGL(WebGL fallback 后端)的 GLSLNodeBuilder.js 中,构造函数以
new GLSLNodeParser()作为解析器(该文件约第 188 行),因此该后端所有需要"把原生函数源码结构化"的场景都会落到GLSLNodeFunction;而 WebGPU 后端则装配WGSLNodeParser,二者互不干扰; - 上层入口:节点材质中书写 GLSL 原生逻辑通常经由
FunctionNode(src/nodes/code/FunctionNode.js)。FunctionNode.getNodeFunction()先从当前 builder 的节点数据缓存中查找是否已解析过,未命中时才调用builder.parser.parseFunction( this.code )并缓存结果(FunctionNode.js)。随后generate()阶段通过builder.getPropertyName()得到去重后的函数名,并调用getNodeFunction().getCode( propertyName )把重组代码写入最终着色器(FunctionNode.js)。TSL 提供的glslFn( code, includes )便捷工厂即创建语言为'glsl'的FunctionNode(同一文件 L167-L178)。
因此,一条典型的链路是:开发者用 glslFn 声明 GLSL 函数 → 构建期 GLSLNodeBuilder(含 GLSLNodeParser)解析该函数 → 生成 GLSLNodeFunction → 输出可注入 GLSL 着色器的函数代码。这个机制意味着节点材质里可以嵌入一段手写 GLSL(例如老项目中已有的工具函数),并像普通节点一样参与材质合成。
写作 GLSL 源码时的注意事项
结合本类解析器的实现,向 three.js 节点材质提供 GLSL 函数源码时应注意:
- 必须给出标准函数声明:返回类型不可省略;解析依赖"类型 函数名( 参数 )"的固定结构,无法识别的文本会触发
THREE.FunctionNode: Function is not a GLSL code.异常; - 精度限定符位于最前:
highp vec4 fn( ... )中的highp会被识别为precision并原样保留在输出声明中; - 方向限定符与 const:
const、in、out、inout均被解析进NodeFunctionInput(其中const与方向限定符为 GLSL 特有信息),可用于构建期判断参数语义; - 善用
#pragma main:需要向源码顶部注入辅助函数、宏或 uniform 声明时,把它们写在#pragma main之前即可自动成为headerCode; - 函数名会可能被替换:最终注入的代码以 builder 分配的名字为准,因此不要依赖源码内的函数名在最终着色器中保持绝对不变。
相关源码路径速查
- GLSLNodeFunction.js:本文主角,GLSL 节点函数解析与重组实现;
- NodeFunction.js:抽象基类,定义
type/inputs/name/precision与getCode()契约; - NodeFunctionInput.js:输入参数描述类;
- GLSLNodeParser.js:产出
GLSLNodeFunction的解析器; - GLSLNodeBuilder.js:WebGL fallback 后端中装配 GLSL 解析器的节点构建器;
- FunctionNode.js:原生着色器函数节点,提供
glslFn/wgslFn入口; - WGSLNodeParser.js:WebGPU 端对应的 WGSL 解析器。
综上,GLSLNodeFunction 是 three.js 节点系统向 WebGL 后端"翻译"用户 GLSL 代码的第一道工序:它用轻量正则把文本化 GLSL 拆成结构化的返回类型、输入、函数体与头部,再在 getCode() 中按需重组输出。理解它的解析边界(对声明结构的要求)与重组规则(精度、参数原样保留、函数名可替换、#pragma main 头部分离),是安全、高效地在节点材质中嵌入手写 GLSL 的前提。
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