Three.js GLSLNodeFunction 解析:GLSL 着色器节点函数的解析与代码重组原理
【免费下载链接】three.jsJavaScript 3D Library.项目地址: https://gitcode.com/GitHub_Trending/th/three.js
本文深入讲解 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 的前提。
【免费下载链接】three.jsJavaScript 3D Library.项目地址: https://gitcode.com/GitHub_Trending/th/three.js
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考