首页
/ Three.js GLSLNodeFunction 解析:GLSL 着色器节点函数的解析与代码重组原理

Three.js GLSLNodeFunction 解析:GLSL 着色器节点函数的解析与代码重组原理

2026-09-06 19:23:40作者:凤尚柏Louis

本文深入讲解 three.js 节点系统(TSL / Node Material)中的 GLSLNodeFunction 类:它如何把一段原生 GLSL 函数源码解析为结构化的节点函数(返回值类型、参数列表、函数体与头部代码),并在构建阶段被重组、改名后注入最终着色器。读完本文,你将理解 GLSL 原生代码在 three.js 节点材质中的接入机制,以及如何借助 #pragma mainin/out/inout、精度限定符等语法特性编写可被节点系统正确识别的 GLSL 函数。

类定位:GLSL 语言的 NodeFunction 实现

在 three.js 的节点体系中,NodeFunctionsrc/nodes/core/NodeFunction.js)是所有"原生着色器函数"的抽象基类:它记录了函数返回类型(type)、输入参数列表(inputs)、函数名(name)和精度限定符(precision),并要求子类实现 getCode() 返回可注入着色器的原生代码。官方对它的注释强调:与其它 Node* 模块一样,NodeFunction 只在构建(building)期间使用,不会出现在用户层代码中。

GLSLNodeFunction 就是针对 GLSL 语言的具体子类,位于 src/nodes/parsers/GLSLNodeFunction.js。它的职责很纯粹:

  1. 接收一段 GLSL 源码(字符串);
  2. 通过正则解析出"声明行 + 函数体";
  3. 把参数列表逐项转换为 NodeFunctionInput 输入描述;
  4. getCode() 中按需重组并输出最终 GLSL 代码。

与此相对,three.js 还提供了面向 WebGPU 的 WGSLNodeParsersrc/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);随后把 inputsCodeblockCodeheaderCode 这三个原始字符串片段挂在子类实例上,供后续 getCode() 使用。

值得注意,每次构造都触发一次完整的正则解析。在构建期,FunctionNode 会以节点数据(node data)为粒度缓存解析结果,避免同一段代码在多次调用中被重复解析(见下文"调用链"一节)。

底层解析流程:三段式源码切分

源码级实现(GLSLNodeFunction.js)的解析过程可以拆解为三个步骤:

1. 识别 #pragma main,切分头部与主体

模块内部定义了常量 pragmaMain = '#pragma main'parse() 首先查找该标记在源码中的位置:

  • 若存在 #pragma main:标记之前的全部内容记为 headerCode,标记之后的源码作为 mainCode
  • 若不存在:整段源码作为 mainCodeheaderCode 为空字符串。

随后,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]*?) 为非贪婪匹配,用于截取括号内全部参数文本):

  1. 可选精度highpmediumplowp
  2. 返回类型:一串 [a-z_0-9](配合 /i 不区分大小写),如 voidvec3float
  3. 可选函数名:紧随类型之后的标识符(也可缺省,见下文说明);
  4. 参数区原文:一对圆括号内的所有字符。

如果匹配失败,或捕获组数量不是预期的 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 数组。

NodeFunctionInputsrc/nodes/core/NodeFunctionInput.js)的五个字段中,typename 是必填,count 默认 nullqualifier 默认 ''(仅 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;

}

行为要点:

  • 默认 namethis.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 继承 NodeParsersrc/nodes/core/NodeParser.js),其 parseFunction( source ) 方法直接返回 new GLSLNodeFunction( source )
  • 后端装配:在 WebGL(WebGL fallback 后端)的 GLSLNodeBuilder.js 中,构造函数以 new GLSLNodeParser() 作为解析器(该文件约第 188 行),因此该后端所有需要"把原生函数源码结构化"的场景都会落到 GLSLNodeFunction;而 WebGPU 后端则装配 WGSLNodeParser,二者互不干扰;
  • 上层入口:节点材质中书写 GLSL 原生逻辑通常经由 FunctionNodesrc/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 函数源码时应注意:

  1. 必须给出标准函数声明:返回类型不可省略;解析依赖"类型 函数名( 参数 )"的固定结构,无法识别的文本会触发 THREE.FunctionNode: Function is not a GLSL code. 异常;
  2. 精度限定符位于最前highp vec4 fn( ... ) 中的 highp 会被识别为 precision 并原样保留在输出声明中;
  3. 方向限定符与 constconstinoutinout 均被解析进 NodeFunctionInput(其中 const 与方向限定符为 GLSL 特有信息),可用于构建期判断参数语义;
  4. 善用 #pragma main:需要向源码顶部注入辅助函数、宏或 uniform 声明时,把它们写在 #pragma main 之前即可自动成为 headerCode
  5. 函数名会可能被替换:最终注入的代码以 builder 分配的名字为准,因此不要依赖源码内的函数名在最终着色器中保持绝对不变。

相关源码路径速查

综上,GLSLNodeFunction 是 three.js 节点系统向 WebGL 后端"翻译"用户 GLSL 代码的第一道工序:它用轻量正则把文本化 GLSL 拆成结构化的返回类型、输入、函数体与头部,再在 getCode() 中按需重组输出。理解它的解析边界(对声明结构的要求)与重组规则(精度、参数原样保留、函数名可替换、#pragma main 头部分离),是安全、高效地在节点材质中嵌入手写 GLSL 的前提。

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