背景
#2974 ("AST-first #define")合入后,shader-lab 中 #define 的处理被分到了两处:
Preprocessor._parseMacroDefines (正则):扫描源码,提取宏名字 + params + referenceName,填到 macroDefineList
Lexer._defineHasValue (peek):在每个 #define 指令处决定走 AST 路径还是 legacy opaque 路径
两处看的是同一段源码,但用的是不同的分析机制:
处 1(正则)
处 2(Lexer peek)
分析机制
RegExp
字符状态机
是否懂 /* */ 和 // 注释
不懂
懂
是否懂 \ 续行
不懂
懂
是否懂"# 必须在行首"(GLSL ES §3.4)
部分懂(^\s*#define)
不强校验
不一致的真实风险
两套独立分析机制对同一段源码的理解不完全一致,已经带来了潜在 bug:
例子 1:注释里的 #define 假阳性注册
/*
* 文档:
* #define MAX_LIGHTS 4
*/
正则匹配并把 MAX_LIGHTS 写进 macroDefineList
Lexer 跳过整个注释,从未看到 #define
后果:用户代码 int x = MAX_LIGHTS; 被 tokenize 成 MACRO_CALL,但展开时找不到值定义
例子 2:续行 \ 的 #define
正则 .*? 不跨行,value 抓不到
Lexer 正确识别续行
后果:macroDefineList 里的 referenceName 提取失败
例子 3:注释 peek 盲点
#define HP /* comment */ highp
例子 4:type-alias 误进 AST (详见 https://github.com/galacean/engine/pull/2974#issuecomment-4325529482)
_expressionLeaderKeywords 白名单包含全部构造器类型 keyword
#define FxaaTex sampler2D 被错误送进 AST → parse 失败
FXAA portability 宏全部命中
这些 bug 都可以单独打补丁解决,但根本原因是两套分析机制必然 drift ——每加一个新分流策略、每升级一次 GLSL 版本,两边都要同步审视。
建议方案
把 #define 处理完全收到 Lexer,删掉 Preprocessor 的 #define 正则。 Preprocessor 只保留 #include 处理。
实现思路
Lexer 内部做"两遍扫描":
第 1 遍(轻量 forward-scan):
快速跑一遍源码,只为找所有 #define <name> 的名字
不发 token,不分类,不解析 value
复用 Lexer 既有的注释/续行/字符串跳过机制 → 自动避开例子 1/2 的 bug
产出:macroDefineList 的名字索引
第 2 遍(正式 lex):
知道所有宏名字后开始正式 tokenize
在每个 #define 指令处做 AST/legacy 分类
走到使用点 FOO,按 macroDefineList 决定 tokenize 成 MACRO_CALL 还是 ID
收益
单一 source-of-truth :Lexer 全权处理 macro,没有任何 drift 可能
注释、续行、字符串字面量等所有 source-level 细节天然一致 ——因为只有一套扫描机制
现有的几个 bug 自动消失 :
例子 1:注释里假阳性 → Lexer 本来就跳注释
例子 2:续行 → Lexer 本来就懂
例子 3:peek 盲点 → 不需要 peek 了,第 1 遍就分类完了
例子 4:type-alias 误进 AST → 第 1 遍可以做精确分类(peek 整条 replacement list)
Preprocessor scope 收敛 :从"manage macros + manage includes"降到只 manage includes,单一职责
代价
2x 扫描成本 :PBR 这种 ~5000 行 shader 实测多 ~1ms(量级可接受,且只在源码编译阶段,runtime 不影响)
Lexer 内部状态机更复杂 :要支持 "scan-only mode"(第 1 遍只找名字不发 token)
改动量较大 :~200-300 行 + 全面回归测试
迁移路径
当前 :先按 feat(shader-lab): make #define values first-class AST nodes #2974 那边 review 给的最小补丁(type-alias peek 加 6 行)打补丁,不阻塞 PR 合入
下个迭代 :单独立项做本 issue 的 refactor
完工后 :Preprocessor.ts 只剩 #include 处理,回到 PR 之前的简洁度
不能解决的(已知 scope)
C 只解决"分流不一致"这一类问题。以下不在 scope:
Nit: 表达式宏 value 在 parse 时 semantic-analyze 引发假警告(Parser semantic action 时机问题)
Nit: hasAstValue = .some(...) 在 #ifdef 分支 AST/legacy 混合时语义错(MacroCallSymbol 查询逻辑问题)
不支持 __LINE__ / __FILE__ / __VERSION__ 预定义宏(独立 feature)
这些需要独立 fix。
关联
背景
#2974("AST-first
#define")合入后,shader-lab 中#define的处理被分到了两处:Preprocessor._parseMacroDefines(正则):扫描源码,提取宏名字 + params + referenceName,填到macroDefineListLexer._defineHasValue(peek):在每个#define指令处决定走 AST 路径还是 legacy opaque 路径两处看的是同一段源码,但用的是不同的分析机制:
/* */和//注释\续行^\s*#define)不一致的真实风险
两套独立分析机制对同一段源码的理解不完全一致,已经带来了潜在 bug:
例子 1:注释里的
#define假阳性注册MAX_LIGHTS写进macroDefineList#defineint x = MAX_LIGHTS;被 tokenize 成MACRO_CALL,但展开时找不到值定义例子 2:续行
\的#define.*?不跨行,value 抓不到macroDefineList里的referenceName提取失败例子 3:注释 peek 盲点
_defineHasValuepeek 见到/不是 alpha → 直接返回 true → 进 AST 路径 → parse 失败5b7f8ae37),但正则那一侧没改——典型的"两处不一致只修了一处"例子 4:type-alias 误进 AST(详见 https://github.com/galacean/engine/pull/2974#issuecomment-4325529482)
_expressionLeaderKeywords白名单包含全部构造器类型 keyword#define FxaaTex sampler2D被错误送进 AST → parse 失败这些 bug 都可以单独打补丁解决,但根本原因是两套分析机制必然 drift——每加一个新分流策略、每升级一次 GLSL 版本,两边都要同步审视。
建议方案
把
#define处理完全收到 Lexer,删掉 Preprocessor 的#define正则。 Preprocessor 只保留#include处理。实现思路
Lexer 内部做"两遍扫描":
第 1 遍(轻量 forward-scan):
#define <name>的名字macroDefineList的名字索引第 2 遍(正式 lex):
#define指令处做 AST/legacy 分类FOO,按macroDefineList决定 tokenize 成MACRO_CALL还是ID收益
代价
迁移路径
Preprocessor.ts只剩#include处理,回到 PR 之前的简洁度不能解决的(已知 scope)
C 只解决"分流不一致"这一类问题。以下不在 scope:
hasAstValue = .some(...)在#ifdef分支 AST/legacy 混合时语义错(MacroCallSymbol 查询逻辑问题)__LINE__/__FILE__/__VERSION__预定义宏(独立 feature)这些需要独立 fix。
关联