diff --git a/examples/package.json b/examples/package.json index 42cd8bc48e..82acd12cad 100644 --- a/examples/package.json +++ b/examples/package.json @@ -19,6 +19,7 @@ "@galacean/engine-math": "workspace:*", "@galacean/engine-rhi-webgl": "workspace:*", "@galacean/engine-shader": "workspace:*", + "@galacean/engine-shader-analyzer": "workspace:*", "@galacean/engine-shader-compiler": "workspace:*", "@galacean/engine-toolkit": "latest", "@galacean/engine-toolkit-stats": "latest", diff --git a/examples/src/shader-playground.ts b/examples/src/shader-playground.ts new file mode 100644 index 0000000000..a45cb84cd7 --- /dev/null +++ b/examples/src/shader-playground.ts @@ -0,0 +1,783 @@ +/** + * @title Shader Playground - 实时诊断 + * @category Shader 教程 + */ +import { + ShaderAnalyzer, + formatDiagnostic, + DiagnosticType, + DiagnosticCategory, + DIAGNOSTIC_CATEGORY +} from "@galacean/engine-shader-analyzer"; +import * as dat from "dat.gui"; + +function pass(body: string): string { + return `Shader "playground" {\n SubShader "Default" {\n Pass "p" {\n${body}\n }\n }\n}`; +} + +const MULTIPLE_ERRORS_LABEL = "Multiple errors"; + +const MACRO_SAMPLES: Record = { + "宏定义 / 对象式 #define": pass(` #define BRANCH_SCALE 0.5 + void vert() { gl_Position = vec4(0.0); } + void frag() { gl_FragColor = vec4(BRANCH_SCALE); } + VertexShader = vert; + FragmentShader = frag;`), + + "宏定义 / 函数式 #define": pass(` #define APPLY_SCALE(value) ((value) * 0.5) + void vert() { gl_Position = vec4(0.0); } + void frag() { gl_FragColor = vec4(APPLY_SCALE(1.0)); } + VertexShader = vert; + FragmentShader = frag;`), + + "宏分支 / #ifdef / #else 互斥": pass(` #ifdef USE_BRANCH_VALUE + float u_branchValue; + #else + float u_branchValue; + #endif + void vert() { gl_Position = vec4(0.0); } + void frag() { gl_FragColor = vec4(u_branchValue); } + VertexShader = vert; + FragmentShader = frag;`), + + "宏分支 / #ifdef / #elif 完整互补": pass(` #ifdef USE_BRANCH_VALUE + float u_branchValue; + #elif !defined(USE_BRANCH_VALUE) + float u_branchValue; + #endif + void vert() { gl_Position = vec4(0.0); } + void frag() { gl_FragColor = vec4(u_branchValue); } + VertexShader = vert; + FragmentShader = frag;`), + + "宏分支 / #ifdef / #elif !宏值 互补": pass(` #ifdef USE_BRANCH_VALUE + float u_branchValue; + #elif !USE_BRANCH_VALUE + float u_branchValue; + #endif + void vert() { gl_Position = vec4(0.0); } + void frag() { gl_FragColor = vec4(u_branchValue); } + VertexShader = vert; + FragmentShader = frag;`), + + "宏分支 / #ifdef / #elif 同条件不可达": pass(` #ifdef USE_BRANCH_VALUE + float u_branchValue; + #elif defined(USE_BRANCH_VALUE) + float u_branchValue; + #endif + void vert() { gl_Position = vec4(0.0); } + void frag() { gl_FragColor = vec4(u_branchValue); } + VertexShader = vert; + FragmentShader = frag;`), + + "宏分支 / 非法 #elif 表达式": pass(` #ifdef USE_BRANCH_VALUE + float u_branchValue; + #elif 123 defined(USE_BRANCH_VALUE) + float u_branchValue; + #endif + void vert() { gl_Position = vec4(0.0); } + void frag() { gl_FragColor = vec4(u_branchValue); } + VertexShader = vert; + FragmentShader = frag;`), + + "宏分支 / #ifndef / #else 互斥": pass(` #ifndef DISABLE_BRANCH_VALUE + float u_branchValue; + #else + float u_branchValue; + #endif + void vert() { gl_Position = vec4(0.0); } + void frag() { gl_FragColor = vec4(u_branchValue); } + VertexShader = vert; + FragmentShader = frag;`), + + "宏分支 / #ifndef / #elif 存在遗漏": pass(` #ifndef DISABLE_BRANCH_VALUE + float u_branchValue; + #elif A + float u_branchValue; + #endif + void vert() { gl_Position = vec4(0.0); } + void frag() { gl_FragColor = vec4(u_branchValue); } + VertexShader = vert; + FragmentShader = frag;`), + + "宏分支 / #ifndef / #elif 完整互补": pass(` #ifndef DISABLE_BRANCH_VALUE + float u_branchValue; + #elif defined(DISABLE_BRANCH_VALUE) + float u_branchValue; + #endif + void vert() { gl_Position = vec4(0.0); } + void frag() { gl_FragColor = vec4(u_branchValue); } + VertexShader = vert; + FragmentShader = frag;`), + + "宏分支 / #if / #elif / #else 互斥": pass(` #if MODE == 1 + float u_mode; + #elif MODE == 2 + float u_mode; + #else + float u_mode; + #endif + void vert() { gl_Position = vec4(0.0); } + void frag() { gl_FragColor = vec4(u_mode); } + VertexShader = vert; + FragmentShader = frag;`), + + "宏分支 / 复杂算术条件完整覆盖": pass(` #if A + B > 1 + float u_complex; + #else + float u_complex; + #endif + void vert() { gl_Position = vec4(0.0); } + void frag() { gl_FragColor = vec4(u_complex); } + VertexShader = vert; + FragmentShader = frag;`), + + "宏分支 / 复杂算术条件覆盖未知": pass(` #if A + B > 1 + float u_complex; + #endif + void vert() { gl_Position = vec4(0.0); } + void frag() { gl_FragColor = vec4(u_complex); } + VertexShader = vert; + FragmentShader = frag;`), + + "宏分支 / 复杂算术条件互斥声明": pass(` #if A + B > 1 + float u_complex; + #endif + #if A + B <= 1 + float u_complex; + #endif + void vert() { gl_Position = vec4(0.0); } + void frag() { gl_FragColor = vec4(0.0); } + VertexShader = vert; + FragmentShader = frag;`), + + "宏分支 / 嵌套互斥分支": pass(` #ifdef OUTER + #ifdef INNER + float u_nested; + #else + float u_nested; + #endif + #else + float u_nested; + #endif + void vert() { gl_Position = vec4(0.0); } + void frag() { gl_FragColor = vec4(u_nested); } + VertexShader = vert; + FragmentShader = frag;`), + + "宏分支 / 独立宏的全局重定义": pass(` #ifdef FIRST_SOURCE + float u_conflict; + #endif + #ifdef SECOND_SOURCE + float u_conflict; + #endif + void vert() { gl_Position = vec4(0.0); } + void frag() { gl_FragColor = vec4(u_conflict); } + VertexShader = vert; + FragmentShader = frag;`), + + "宏分支 / canonical include guard 重复": pass(` #ifndef BRANCH_SAMPLE_INCLUDED + #define BRANCH_SAMPLE_INCLUDED + float u_guarded; + #endif + #ifndef BRANCH_SAMPLE_INCLUDED + #define BRANCH_SAMPLE_INCLUDED + float u_guarded; + #endif + void vert() { gl_Position = vec4(0.0); } + void frag() { gl_FragColor = vec4(u_guarded); } + VertexShader = vert; + FragmentShader = frag;`), + + "宏分支 / #undef 重新打开 guard": pass(` #ifndef RESETTABLE_INCLUDED + #define RESETTABLE_INCLUDED + float u_resettable; + #endif + #undef RESETTABLE_INCLUDED + #ifndef RESETTABLE_INCLUDED + #define RESETTABLE_INCLUDED + float u_resettable; + #endif + void vert() { gl_Position = vec4(0.0); } + void frag() { gl_FragColor = vec4(u_resettable); } + VertexShader = vert; + FragmentShader = frag;`), + + "宏分支 / 独立局部宏可能并存": pass(` void vert() { gl_Position = vec4(0.0); } + void frag() { + #ifdef CALLER_A + float localValue = 0.0; + #endif + #ifdef CALLER_B + float localValue = 1.0; + #endif + gl_FragColor = vec4(0.0); + } + VertexShader = vert; + FragmentShader = frag;`), + + "宏分支 / 同一 arm 重复": pass(` #ifdef BROKEN_ARM + float u_duplicate; + float u_duplicate; + #endif + void vert() { gl_Position = vec4(0.0); } + void frag() { gl_FragColor = vec4(0.0); } + VertexShader = vert; + FragmentShader = frag;`), + + "宏分支 / struct 成员分歧": pass(` #ifdef HAS_VALUE + struct BranchData { float value; }; + #else + struct BranchData { float other; }; + #endif + BranchData data; + void vert() { gl_Position = vec4(0.0); } + void frag() { gl_FragColor = vec4(data.value); } + VertexShader = vert; + FragmentShader = frag;`), + + "宏分支 / 未定义宏按零参与比较": pass(` #if !defined(MODE) + float u_value; + #endif + #if MODE == 0 + float u_value; + #endif + void vert() { gl_Position = vec4(0.0); } + void frag() { gl_FragColor = vec4(0.0); } + VertexShader = vert; + FragmentShader = frag;`), + + "宏分支 / 条件 #undef 未执行": pass(` #ifndef CONDITIONAL_GUARD + #define CONDITIONAL_GUARD + float u_value; + #endif + #if !defined(CONDITIONAL_GUARD) + #undef CONDITIONAL_GUARD + #endif + #ifndef CONDITIONAL_GUARD + #define CONDITIONAL_GUARD + float u_value; + #endif + void vert() { gl_Position = vec4(0.0); } + void frag() { gl_FragColor = vec4(u_value); } + VertexShader = vert; + FragmentShader = frag;`), + + "宏分支 / 定义后的嵌套检查": pass(` #ifndef G + #define G + #ifdef G + float u_value; + #endif + void frag() { gl_FragColor = vec4(u_value); } + #endif + void vert() { gl_Position = vec4(0.0); } + VertexShader = vert; + FragmentShader = frag;`), + + "宏分支 / 声明未覆盖引用": pass(` #ifdef A + #ifdef B + float u_value; + #endif + void frag() { gl_FragColor = vec4(u_value); } + #endif + void vert() { gl_Position = vec4(0.0); } + VertexShader = vert; + FragmentShader = frag;`), + + "宏分支 / #if 0 死分支": pass(` #if 0 + float u_value; + #endif + #if 0 + float u_value; + #endif + void vert() { gl_Position = vec4(0.0); } + void frag() { gl_FragColor = vec4(0.0); } + VertexShader = vert; + FragmentShader = frag;`), + + "宏分支 / #elif 继承前置否定": pass(` #if A + float u_first; + #elif B + float u_value; + #endif + #if A + float u_value; + #endif + void vert() { gl_Position = vec4(0.0); } + void frag() { gl_FragColor = vec4(0.0); } + VertexShader = vert; + FragmentShader = frag;`) +}; + +const SAMPLES: Record = { + [MULTIPLE_ERRORS_LABEL]: pass(` mat4 renderer_MVPMat; + vec2 u_uv; + float u_a; + float u_a; // Redefinition + struct Attributes { vec3 POSITION; }; + vec3 getColor() { return 1.0; } // InvalidReturnType + void vert(Attributes attr) { gl_Position = renderer_MVPMat * vec4(attr.POSITION, 1.0); } + void frag() { + float a = u_uv.z; // InvalidSwizzle + a = missingFn(a); // UndefinedFunction + gl_FragColor = vec4(a, 0.0, 0.0, 1.0); + } + VertexShader = vert; + FragmentShader = frag;`), + + ...MACRO_SAMPLES, + + [DiagnosticType.SyntaxError]: pass(` void frag() { vec3 = ; } + FragmentShader = frag;`), + + [DiagnosticType.NoMatchingOverload]: pass(` float f(float a) { return a; } + void frag() { gl_FragColor = vec4(f(vec3(0.0))); } + FragmentShader = frag;`), + + [DiagnosticType.RecursiveFunction]: pass(` struct Attributes { vec3 POSITION; }; + float fib(float x) { return fib(x); } // direct recursion + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(fib(1.0)); } + VertexShader = vert; + FragmentShader = frag;`), + + [DiagnosticType.Redefinition]: pass(` float u_a; + float u_a; // Redefinition (variable, same scope) + float f(float x) { return x; } + float f(float x) { return x * 2.0; } // Redefinition (function, same signature) + float f(vec2 x) { return x.x; } // OK — overload (different signature) + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(u_a + f(1.0) + f(vec2(0.0))); } + VertexShader = vert; + FragmentShader = frag;`), + + [DiagnosticType.AmbiguousMacroBranchResolution]: pass(` void frag() { + #ifdef USE_CONST_SIZE + const int N = 2; + #else + int N = 2; + #endif + float values[N]; // branch-dependent const qualification + gl_FragColor = vec4(values[0]); + } + void vert() { gl_Position = vec4(0.0); } + VertexShader = vert; + FragmentShader = frag;`), + + [DiagnosticType.AmbiguousMacroBranchType]: pass(` void frag() { + #ifdef USE_VEC3 + vec3 branchColor; + #else + vec4 branchColor; + #endif + gl_FragColor = vec4(branchColor.x); + } + void vert() { gl_Position = vec4(0.0); } + VertexShader = vert; + FragmentShader = frag;`), + + [DiagnosticType.UndefinedFunction]: pass(` struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = doesNotExist(1.0); } + VertexShader = vert; + FragmentShader = frag;`), + + [DiagnosticType.UnknownVariable]: pass(` struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(undeclared_color, 1.0); } + VertexShader = vert; + FragmentShader = frag;`), + + [DiagnosticType.UnknownType]: pass(` RUNTIME_TYPE u_value; + void vert() { gl_Position = vec4(0.0); } + void frag() { gl_FragColor = vec4(1.0); } + VertexShader = vert; + FragmentShader = frag;`), + + [DiagnosticType.AssignTypeMismatch]: pass(` struct A { vec3 v; }; + struct B { vec3 v; }; + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { + float a = 1.0; + vec3 b = vec3(0.0); + a = b; // vec3 -> float + A x; B y; + x = y; // struct A -> struct B + gl_FragColor = vec4(a, a, a, 1.0); + } + VertexShader = vert; + FragmentShader = frag;`), + + [DiagnosticType.ConstDivideByZero]: pass(` void frag() { int x = 1 / 0; gl_FragColor = vec4(float(x)); } + FragmentShader = frag;`), + + [DiagnosticType.ConstructorArgCount]: pass(` void frag() { + vec3 v = vec3(1.0, 2.0); // too few (need 3, got 2) + vec4 w = vec4(1.0, 2.0, 3.0, 4.0, 5.0); // too many (need 4, got 5) + mat3 m = mat3(1.0, 2.0, 3.0, 4.0, 5.0); // matrix too few (need 9, got 5) + gl_FragColor = vec4(v, 1.0) + w + vec4(m[0], 1.0); + } + FragmentShader = frag;`), + + [DiagnosticType.ConstructorArgType]: pass(` mediump sampler2D u_tex; + void frag() { vec2 v = vec2(u_tex, 1.0); gl_FragColor = vec4(v, 0.0, 1.0); } + FragmentShader = frag;`), + + [DiagnosticType.ExpectedSampler]: pass( + ` void frag() { vec2 uv = vec2(0.0); vec4 c = texture(uv, uv); gl_FragColor = c; } + FragmentShader = frag;` + ), + + [DiagnosticType.IndexOutOfBounds]: pass( + ` void frag() { vec3 v = vec3(0.0); float y = v[5]; gl_FragColor = vec4(y); } + FragmentShader = frag;` + ), + + [DiagnosticType.InvalidBinaryOperands]: pass( + ` void frag() { bool b = true; float x = b + 1.0; gl_FragColor = vec4(x); } + FragmentShader = frag;` + ), + + [DiagnosticType.InvalidSwizzle]: pass(` vec2 u_uv; + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(u_uv.z, 0.0, 0.0, 1.0); } // vec2 has no .z + VertexShader = vert; + FragmentShader = frag;`), + + [DiagnosticType.InvalidUnaryOperand]: pass( + ` void frag() { float u_f = 1.0; bool ok = !u_f; gl_FragColor = vec4(0.0); } + FragmentShader = frag;` + ), + + [DiagnosticType.NonIndexableType]: pass( + ` void frag() { float f = 1.0; float y = f[0]; gl_FragColor = vec4(y); } + FragmentShader = frag;` + ), + + [DiagnosticType.NonIntegerIndex]: pass( + ` void frag() { vec3 v = vec3(0.0); float y = v[1.5]; gl_FragColor = vec4(y); } + FragmentShader = frag;` + ), + + [DiagnosticType.ShiftOutOfRange]: pass(` void frag() { int x = 1 << 40; gl_FragColor = vec4(float(x)); } + FragmentShader = frag;`), + + [DiagnosticType.UndeclaredStructMember]: pass(` struct Varyings { vec4 v; }; + Varyings vert() { Varyings o; o.v = vec4(0.0); return o; } + void frag(Varyings i) { gl_FragColor = i.notAField; } + VertexShader = vert; + FragmentShader = frag;`), + + [DiagnosticType.NonConstArraySize]: pass( + ` void frag() { int n = 3; float a[n]; gl_FragColor = vec4(a[0]); } + FragmentShader = frag;` + ), + + [DiagnosticType.NonConstInitializer]: pass(` float u_scale; + void frag() { + const float ok1 = 1.0 + 2.0; // OK — literal fold + const float ok2 = sin(0.5); // OK — builtin on const + const float bad = u_scale + sin(0.5); // NonConstInitializer (uniform mixed in) + gl_FragColor = vec4(ok1 + ok2 + bad); + } + FragmentShader = frag;`), + + [DiagnosticType.NonConstructibleReturnType]: pass(` mediump sampler2D u_tex; + sampler2D getTex() { return u_tex; } // sampler return — illegal + struct Material { mediump sampler2D tex; }; + Material u_m; + Material getMat() { return u_m; } // struct containing sampler — illegal + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = texture2D(getMat().tex, vec2(0.0)); } + VertexShader = vert; + FragmentShader = frag;`), + + [DiagnosticType.InvalidEntryReturnType]: pass(` struct Attributes { vec3 POSITION; }; + float vert(Attributes attr) { gl_Position = vec4(0.0); return 1.0; } + void frag() { gl_FragColor = vec4(0.0); } + VertexShader = vert; + FragmentShader = frag;`), + + [DiagnosticType.InvalidReturnType]: pass(` struct Attributes { vec3 POSITION; }; + vec3 getColor() { return 1.0; } // float vs vec3 + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(getColor(), 1.0); } + VertexShader = vert; + FragmentShader = frag;`), + + [DiagnosticType.MisplacedControlFlow]: pass(` void frag() { gl_FragColor = vec4(0.0); break; } + FragmentShader = frag;`), + + [DiagnosticType.MissingReturn]: pass(` float getX() { float a = 1.0; } + void frag() { gl_FragColor = vec4(getX()); } + FragmentShader = frag;`), + + [DiagnosticType.NonBoolCondition]: pass(` struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { float a = 1.0; if (a) { gl_FragColor = vec4(0.0); } } + VertexShader = vert; + FragmentShader = frag;`), + + [DiagnosticType.DuplicateEntryAssignment]: pass(` float u_a; + float u_a; // Redefinition — should surface too + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void vert2(Attributes attr) { gl_Position = vec4(0.0); } + void frag() { gl_FragColor = vec4(u_a); } + VertexShader = vert; + VertexShader = vert2; // DuplicateEntryAssignment (first wins) + FragmentShader = frag;`), + + [DiagnosticType.EntryNotFound]: pass(` struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(0.0); } + VertexShader = vrt; // 'vrt' is not a function + FragmentShader = frag;`), + + [DiagnosticType.GlFragColorWithMrt]: pass(` struct MRT { vec4 c0; }; + void vert() { gl_Position = vec4(0.0); } + MRT frag() { MRT o; o.c0 = vec4(0.0); gl_FragColor = vec4(0.0); return o; } + VertexShader = vert; + FragmentShader = frag;`), + + [DiagnosticType.InvalidIOStruct]: pass(` struct Attributes { vec3 POSITION; }; + Varyings vert(Attributes attr) { Varyings o; gl_Position = vec4(0.0); return o; } + void frag() { gl_FragColor = vec4(0.0); } + VertexShader = vert; + FragmentShader = frag;`), + + [DiagnosticType.MissingEntry]: pass(` mat4 renderer_MVPMat; + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = renderer_MVPMat * vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(0.0); } + VertexShader = vert;`), + + [DiagnosticType.MissingVertexPosition]: pass(` struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { } + void frag() { gl_FragColor = vec4(0.0); } + VertexShader = vert; + FragmentShader = frag;`), + + [DiagnosticType.NestedIOStruct]: pass(` struct Attributes { vec3 POSITION; }; + struct Inner { vec4 v; }; + struct Varyings { Inner nested; }; + Varyings vert(Attributes attr) { Varyings o; o.nested.v = vec4(attr.POSITION, 1.0); return o; } + void frag(Varyings i) { gl_FragColor = i.nested.v; } + VertexShader = vert; + FragmentShader = frag;`), + + [DiagnosticType.NonFlatIntegerVarying]: pass(` struct Attributes { vec3 POSITION; }; + struct Varyings { vec4 pos; int id; }; + Varyings vert(Attributes attr) { Varyings o; o.pos = vec4(attr.POSITION, 1.0); o.id = 0; gl_Position = o.pos; return o; } + void frag(Varyings i) { gl_FragColor = vec4(float(i.id)); } + VertexShader = vert; + FragmentShader = frag;`), + + [DiagnosticType.StructRoleConflict]: pass(` struct IO { vec4 v; }; + IO vert(IO attr) { IO o; gl_Position = vec4(0.0); return o; } + void frag() { gl_FragColor = vec4(0.0); } + VertexShader = vert; + FragmentShader = frag;`), + + [DiagnosticType.BitwiseOrOnNonBitmask]: pass( + ` BlendState bs { SourceColorBlendFactor = BlendFactor.One | BlendFactor.Zero; }` + ), + + [DiagnosticType.InvalidEnumValue]: pass(` BlendState bs { SourceColorBlendFactor = BlendFactor.NotReal; }`), + + [DiagnosticType.InvalidRenderQueueVariable]: pass(` RenderQueueType = undefinedQueueVar;`), + + [DiagnosticType.InvalidRenderStateProperty]: pass(` BlendState bs { NotARealProperty = true; }`), + + [DiagnosticType.InvalidRenderStateVariable]: pass(` DepthState = undefinedDepthVar;`), + + [DiagnosticType.MixedEnumTypes]: pass( + ` BlendState bs { ColorWriteMask = ColorWriteMask.Red | CullMode.Front; }` + ), + + [DiagnosticType.DerivativeInVertexShader]: pass(` struct Attributes { vec3 POSITION; }; + float helper(float x) { return dFdx(x); } // called from vert transitively — illegal + void vert(Attributes attr) { + float d = dFdx(attr.POSITION.x); // direct dFdx in vertex — illegal + gl_Position = vec4(attr.POSITION, d + helper(attr.POSITION.y)); + } + void frag() { gl_FragColor = vec4(0.0); } + VertexShader = vert; FragmentShader = frag;`), + + [DiagnosticType.NonFloatDerivativeArg]: pass(` void frag() { + int x = 3; + float d = dFdx(x); + gl_FragColor = vec4(d); + } + FragmentShader = frag;`), + + [DiagnosticType.InvalidArraySize]: pass(` struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { float a[0]; float b[4]; gl_FragColor = vec4(a[0] + b[0]); } + VertexShader = vert; + FragmentShader = frag;`), + + [DiagnosticType.EmptyStruct]: pass(` struct Empty { + #ifdef EMPTY_MEMBER + #endif + }; + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(0.0); } + VertexShader = vert; FragmentShader = frag;`) +}; + +const CATEGORY_LABEL: Record = { + [DiagnosticCategory.Syntax]: "语法", + [DiagnosticCategory.Symbol]: "符号", + [DiagnosticCategory.Type]: "类型", + [DiagnosticCategory.Constant]: "常量", + [DiagnosticCategory.ControlFlow]: "控制流", + [DiagnosticCategory.PipelineIO]: "管线 IO", + [DiagnosticCategory.RenderState]: "RenderState" +}; + +const CATEGORY_ORDER = Object.values(DiagnosticCategory); +const LABEL_TO_KEY: Record = { [MULTIPLE_ERRORS_LABEL]: MULTIPLE_ERRORS_LABEL }; +for (const label of Object.keys(MACRO_SAMPLES)) LABEL_TO_KEY[label] = label; + +const codeKeys = Object.keys(SAMPLES).filter( + (key) => key !== MULTIPLE_ERRORS_LABEL && !(key in MACRO_SAMPLES) +) as DiagnosticType[]; +codeKeys.sort((a, b) => { + const ca = CATEGORY_ORDER.indexOf(DIAGNOSTIC_CATEGORY[a]); + const cb = CATEGORY_ORDER.indexOf(DIAGNOSTIC_CATEGORY[b]); + return ca !== cb ? ca - cb : a.localeCompare(b); +}); +for (const code of codeKeys) LABEL_TO_KEY[`${CATEGORY_LABEL[DIAGNOSTIC_CATEGORY[code]]} / ${code}`] = code; + +const DEFAULT_KEY = MULTIPLE_ERRORS_LABEL; + +const ERROR_COLOR = "#f14c4c"; +const WARNING_COLOR = "#cca700"; + +const style = document.createElement("style"); +style.textContent = ` + html, body { height: 100%; margin: 0; background: #1e1e1e; } + #pg { display: flex; height: 100vh; color: #d4d4d4; + font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } + + #pane { display: flex; flex: 1; min-width: 0; } + #gutter { flex: 0 0 52px; box-sizing: border-box; overflow: hidden; + padding: 16px 8px 16px 0; text-align: right; color: #6a6a6a; user-select: none; + background: #1e1e1e; border-right: 1px solid #2a2a2a; white-space: pre; + font: inherit; line-height: 1.6; } + #ed { box-sizing: border-box; flex: 1; min-width: 0; margin: 0; padding: 16px; border: 0; + font: inherit; line-height: 1.6; tab-size: 2; white-space: pre; word-wrap: normal; + color: #d4d4d4; background: transparent; caret-color: #d4d4d4; + resize: none; outline: none; overflow: auto; } + + #out { width: 42%; min-width: 360px; overflow: auto; border-left: 1px solid #333; padding: 12px 16px; } + #pg h3 { margin: 0 0 12px; font-size: 11px; letter-spacing: 1px; text-transform: uppercase; color: #888; } + #pg .ok { color: #4ec9b0; } + + #pg .diag { margin: 0 0 14px; border-left: 3px solid #888; padding: 8px 12px; background: #252526; + border-radius: 3px; } + #pg .diag.error { border-color: ${ERROR_COLOR}; } + #pg .diag.warning { border-color: ${WARNING_COLOR}; } + #pg .diag pre { margin: 0; white-space: pre; overflow-x: auto; + font: inherit; line-height: 1.5; } + #pg .diag .gut { color: #6a6a6a; } + #pg .diag .src { color: #d4d4d4; } + #pg .diag.error .hl { color: ${ERROR_COLOR}; } + #pg .diag.warning .hl { color: ${WARNING_COLOR}; } +`; +document.head.appendChild(style); +document.body.innerHTML = + `
` + + `
` + + `
` + + `
` + + `
`; + +const editor = document.getElementById("ed") as HTMLTextAreaElement; +const gutter = document.getElementById("gutter") as HTMLDivElement; +const output = document.getElementById("out") as HTMLDivElement; + +const analyzer = new ShaderAnalyzer(); + +type Diag = ReturnType["diagnostics"][number]; + +function escapeHtml(text: string): string { + return text.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c] as string); +} + +function renderConsoleBlock(d: Diag): string { + const text = formatDiagnostic(d); + const lines = text.split("\n"); + + const rows = lines.map((line, i) => { + if (i === 0) return `${escapeHtml(line)}`; + + const gutterMatch = line.match(/^(\s*\d* \| )(.*)$/); + if (!gutterMatch) return escapeHtml(line); + const gutter = `${escapeHtml(gutterMatch[1])}`; + const content = gutterMatch[2]; + const contentClass = /^[\^ ]*$/.test(content) ? "hl" : "src"; + return `${gutter}${escapeHtml(content)}`; + }); + + return `
${rows.join("\n")}
`; +} + +const config = { diagnostic: DEFAULT_KEY }; + +function renderGutter(lineCount: number): void { + let lineNumbers = ""; + for (let i = 1; i <= lineCount; i++) lineNumbers += i + "\n"; + gutter.textContent = lineNumbers; +} + +function renderConsole(diagnostics: Diag[]): void { + if (diagnostics.length === 0) { + output.innerHTML = `

Diagnostics (0)

✓ No diagnostics
`; + return; + } + const sorted = [...diagnostics].sort( + (a, b) => a.range.start.line - b.range.start.line || a.range.start.column - b.range.start.column + ); + output.innerHTML = `

Diagnostics (${sorted.length})

` + sorted.map(renderConsoleBlock).join(""); +} + +function render(): void { + const src = editor.value; + const { diagnostics } = analyzer.analyze(src); + renderGutter(src.split("\n").length); + renderConsole(diagnostics); +} + +function syncScroll(): void { + gutter.scrollTop = editor.scrollTop; +} + +editor.addEventListener("scroll", syncScroll); + +let renderTimer = 0; +editor.addEventListener("input", () => { + clearTimeout(renderTimer); + renderTimer = window.setTimeout(render, 150); +}); + +const gui = new dat.GUI(); +gui + .add(config, "diagnostic", Object.keys(LABEL_TO_KEY)) + .name("Diagnostic") + .onChange((label: string) => { + editor.value = SAMPLES[LABEL_TO_KEY[label]]; + editor.scrollTop = 0; + editor.scrollLeft = 0; + syncScroll(); + render(); + }); + +editor.value = SAMPLES[DEFAULT_KEY]; +render(); diff --git a/examples/vite.config.js b/examples/vite.config.js index 4fb4964800..4ac582a7e7 100644 --- a/examples/vite.config.js +++ b/examples/vite.config.js @@ -68,6 +68,7 @@ module.exports = { "@galacean/engine-lottie", "@galacean/engine-spine", "@galacean/engine-shader-compiler", + "@galacean/engine-shader-analyzer", "@galacean/engine-shader", "@galacean/engine-ui", "@galacean/engine-xr", diff --git a/package.json b/package.json index 033e655ee4..8282f32052 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,6 @@ "odiff-bin": "^2.5.0", "prettier": "^3.0.0", "rollup": "^2.36.1", - "rollup-plugin-jscc": "^2.0.0", "rollup-plugin-serve": "^1.1.0", "rollup-plugin-swc3": "^0.10.1", "ts-node": "^10", diff --git a/packages/core/src/shader/ShaderMacroProcessor.ts b/packages/core/src/shader/ShaderMacroProcessor.ts index 4bdaf6b90f..a52e25c822 100644 --- a/packages/core/src/shader/ShaderMacroProcessor.ts +++ b/packages/core/src/shader/ShaderMacroProcessor.ts @@ -339,13 +339,40 @@ export class ShaderMacroProcessor { return !ShaderMacroProcessor._evalCondition(cond.c, valueMacros, funcMacros); case "bool": return cond.v; + case "raw": + return ShaderMacroProcessor._evalRawCondition(cond.e, valueMacros, funcMacros); } } + private static _evalRawCondition( + expression: string, + valueMacros: Map, + funcMacros: Map + ): boolean { + const withDefinedValues = expression.replace( + /\bdefined\s*(?:\(\s*([A-Za-z_]\w*)\s*\)|([A-Za-z_]\w*))/g, + (_match, parenthesized: string | undefined, bare: string | undefined) => { + const name = parenthesized ?? bare!; + return valueMacros.has(name) || funcMacros.has(name) ? "1" : "0"; + } + ); + const expandedNames = ShaderMacroProcessor._expandedNames; + expandedNames.clear(); + const expanded = ShaderMacroProcessor._recursiveExpandMacro( + withDefinedValues, + valueMacros, + funcMacros, + expandedNames + ); + return new PreprocessorExpressionEvaluator(expanded).evaluate() !== 0; + } + /** * Evaluate a comparison operator. */ private static _compareValues(numVal: number, op: string, value: number): boolean { + numVal |= 0; + value |= 0; switch (op) { case "==": return numVal === value; @@ -485,3 +512,223 @@ export class ShaderMacroProcessor { ); } } + +type ExpressionTokenKind = "number" | "identifier" | "operator" | "end"; + +interface ExpressionToken { + kind: ExpressionTokenKind; + text: string; +} + +const expressionPrecedence: Readonly> = { + "||": 1, + "&&": 2, + "|": 3, + "^": 4, + "&": 5, + "==": 6, + "!=": 6, + "<": 7, + "<=": 7, + ">": 7, + ">=": 7, + "<<": 8, + ">>": 8, + "+": 9, + "-": 9, + "*": 10, + "/": 10, + "%": 10 +}; + +class PreprocessorExpressionEvaluator { + private readonly _tokens: ExpressionToken[]; + private _index = 0; + + constructor(expression: string) { + this._tokens = tokenizePreprocessorExpression(expression); + } + + evaluate(): number { + const value = this._parseConditional(true); + const token = this._current(); + if (token.kind !== "end") this._invalid(token); + return value; + } + + private _parseConditional(active: boolean): number { + const condition = this._parseBinary(1, active); + if (!this._consume("?")) return condition; + const whenTrue = this._parseConditional(active && condition !== 0); + if (!this._consume(":")) this._invalid(this._current()); + const whenFalse = this._parseConditional(active && condition === 0); + return !active ? 0 : condition !== 0 ? whenTrue : whenFalse; + } + + private _parseBinary(minPrecedence: number, active: boolean): number { + let left = this._parseUnary(active); + while (true) { + const operator = this._current().text; + const precedence = expressionPrecedence[operator]; + if (precedence === undefined || precedence < minPrecedence) return left; + this._index++; + const rightActive = active && !((operator === "&&" && left === 0) || (operator === "||" && left !== 0)); + const right = this._parseBinary(precedence + 1, rightActive); + if (active) left = evaluateBinaryExpression(left, operator, right); + } + } + + private _parseUnary(active: boolean): number { + const token = this._current(); + if ( + token.kind === "operator" && + (token.text === "+" || token.text === "-" || token.text === "!" || token.text === "~") + ) { + this._index++; + const value = this._parseUnary(active); + if (!active) return 0; + switch (token.text) { + case "+": + return value | 0; + case "-": + return -value | 0; + case "!": + return value === 0 ? 1 : 0; + case "~": + return ~value; + } + } + return this._parsePrimary(active); + } + + private _parsePrimary(active: boolean): number { + const token = this._current(); + if (token.kind === "number") { + this._index++; + return active ? parseIntegerLiteral(token.text) : 0; + } + if (token.kind === "identifier") { + this._index++; + return 0; + } + if (this._consume("(")) { + const value = this._parseConditional(active); + if (!this._consume(")")) this._invalid(this._current()); + return value; + } + this._invalid(token); + } + + private _consume(text: string): boolean { + if (this._current().text !== text) return false; + this._index++; + return true; + } + + private _current(): ExpressionToken { + return this._tokens[this._index]; + } + + private _invalid(token: ExpressionToken): never { + throw new Error(`Invalid preprocessor expression near '${token.text || "end of expression"}'.`); + } +} + +function tokenizePreprocessorExpression(expression: string): ExpressionToken[] { + const tokens: ExpressionToken[] = []; + let index = 0; + while (index < expression.length) { + const char = expression[index]; + if (/\s/.test(char)) { + index++; + continue; + } + if (expression.startsWith("//", index)) break; + if (expression.startsWith("/*", index)) { + const end = expression.indexOf("*/", index + 2); + if (end < 0) throw new Error("Invalid preprocessor expression: unterminated comment."); + index = end + 2; + continue; + } + const identifier = /^[A-Za-z_][A-Za-z0-9_]*/.exec(expression.slice(index))?.[0]; + if (identifier) { + tokens.push({ kind: "identifier", text: identifier }); + index += identifier.length; + continue; + } + const number = /^(?:0[xX][0-9A-Fa-f]+|0[0-7]*|[1-9][0-9]*)(?:[uUlL]{0,3})/.exec(expression.slice(index))?.[0]; + if (number) { + tokens.push({ kind: "number", text: number }); + index += number.length; + continue; + } + const pair = ["||", "&&", "==", "!=", "<=", ">=", "<<", ">>"].find((operator) => + expression.startsWith(operator, index) + ); + if (pair) { + tokens.push({ kind: "operator", text: pair }); + index += pair.length; + continue; + } + if ("|^&<>+-*/%!~?:()".includes(char)) { + tokens.push({ kind: "operator", text: char }); + index++; + continue; + } + throw new Error(`Invalid preprocessor expression near '${char}'.`); + } + tokens.push({ kind: "end", text: "" }); + return tokens; +} + +function parseIntegerLiteral(literal: string): number { + const value = literal.replace(/[uUlL]+$/, ""); + if (/^0[xX]/.test(value)) return parseInt(value.slice(2), 16) | 0; + if (/^0[0-7]+$/.test(value)) return parseInt(value, 8) | 0; + return Number(value) | 0; +} + +function evaluateBinaryExpression(left: number, operator: string, right: number): number { + switch (operator) { + case "||": + return left !== 0 || right !== 0 ? 1 : 0; + case "&&": + return left !== 0 && right !== 0 ? 1 : 0; + case "|": + return left | right; + case "^": + return left ^ right; + case "&": + return left & right; + case "==": + return left === right ? 1 : 0; + case "!=": + return left !== right ? 1 : 0; + case "<": + return left < right ? 1 : 0; + case "<=": + return left <= right ? 1 : 0; + case ">": + return left > right ? 1 : 0; + case ">=": + return left >= right ? 1 : 0; + case "<<": + return left << right; + case ">>": + return left >> right; + case "+": + return (left + right) | 0; + case "-": + return (left - right) | 0; + case "*": + return Math.imul(left, right); + case "/": + if (right === 0) throw new Error("Division by zero in preprocessor expression."); + return Math.trunc(left / right) | 0; + case "%": + if (right === 0) throw new Error("Division by zero in preprocessor expression."); + return left % right | 0; + default: + throw new Error(`Invalid preprocessor operator '${operator}'.`); + } +} diff --git a/packages/core/src/shader/index.ts b/packages/core/src/shader/index.ts index fec8f701ff..90e42b5e59 100644 --- a/packages/core/src/shader/index.ts +++ b/packages/core/src/shader/index.ts @@ -7,6 +7,7 @@ export { RenderQueueType } from "./enums/RenderQueueType"; export { RenderStateElementKey } from "./enums/RenderStateElementKey"; export { ShaderDataGroup } from "./enums/ShaderDataGroup"; export { ShaderLanguage } from "./enums/ShaderLanguage"; +export { ShaderPreprocessorDirective } from "./enums/ShaderPreprocessorDirective"; export { ShaderPropertyType } from "./enums/ShaderPropertyType"; export { StencilOperation } from "./enums/StencilOperation"; export { Shader } from "./Shader"; diff --git a/packages/design/src/shader-compiler/ICondition.ts b/packages/design/src/shader-compiler/ICondition.ts index 884a2f2801..77d756adac 100644 --- a/packages/design/src/shader-compiler/ICondition.ts +++ b/packages/design/src/shader-compiler/ICondition.ts @@ -50,6 +50,14 @@ export interface BoolCondition { v: boolean; } +/** Preprocessor expression preserved for runtime evaluation with the active macro set. */ +export interface RawCondition { + /** Serialized condition kind. */ + t: "raw"; + /** Original preprocessor expression. */ + e: string; +} + export type Condition = | DefinedCondition | NotDefinedCondition @@ -57,7 +65,8 @@ export type Condition = | AndCondition | OrCondition | NotCondition - | BoolCondition; + | BoolCondition + | RawCondition; /** * Preprocessor instruction tuple: `[directive, ...operands]` diff --git a/packages/design/src/shader-compiler/IShaderProgram.ts b/packages/design/src/shader-compiler/IShaderProgram.ts new file mode 100644 index 0000000000..4376fb9857 --- /dev/null +++ b/packages/design/src/shader-compiler/IShaderProgram.ts @@ -0,0 +1,4 @@ +/** + * Opaque parsed shader-pass program shared by the compiler and analyzer. + */ +export interface IShaderProgram {} diff --git a/packages/design/src/shader-compiler/index.ts b/packages/design/src/shader-compiler/index.ts index 9629e43ab7..2c62fbdb68 100644 --- a/packages/design/src/shader-compiler/index.ts +++ b/packages/design/src/shader-compiler/index.ts @@ -1,10 +1,12 @@ export type { IShaderCompiler } from "./IShaderCompiler"; +export type { IShaderProgram } from "./IShaderProgram"; export type { Condition, DefinedCondition, NotDefinedCondition, CompareCondition, BoolCondition, + RawCondition, ShaderInstruction } from "./ICondition"; export type { IPrecompiledShader, IPrecompiledSubShader, IPrecompiledPass } from "./IPrecompiledShader"; diff --git a/packages/design/src/shader-compiler/shaderSource/IShaderPassSource.ts b/packages/design/src/shader-compiler/shaderSource/IShaderPassSource.ts index bf3fa52398..07295c70f9 100644 --- a/packages/design/src/shader-compiler/shaderSource/IShaderPassSource.ts +++ b/packages/design/src/shader-compiler/shaderSource/IShaderPassSource.ts @@ -1,4 +1,5 @@ import { IRenderStates } from "./IRenderStates"; +import { IShaderPosition } from "./IShaderPosition"; import { IStatement } from "./IStatement"; export interface IShaderPassSource { @@ -11,4 +12,8 @@ export interface IShaderPassSource { contents: string; vertexEntry: string; fragmentEntry: string; + /** Source range of the vertex entry-point name. */ + vertexEntryLocation?: { start: IShaderPosition; end: IShaderPosition }; + /** Source range of the fragment entry-point name. */ + fragmentEntryLocation?: { start: IShaderPosition; end: IShaderPosition }; } diff --git a/packages/shader-analyzer/README.md b/packages/shader-analyzer/README.md new file mode 100644 index 0000000000..bdf5943312 --- /dev/null +++ b/packages/shader-analyzer/README.md @@ -0,0 +1,55 @@ +# @galacean/engine-shader-analyzer + +Standalone ShaderLab and ESSL diagnostics for authoring tools. The analyzer does not create an Engine instance and does not participate in runtime shader code generation. + +## JavaScript API + +```ts +import { DiagnosticSeverity, ShaderAnalyzer } from "@galacean/engine-shader-analyzer"; + +const { diagnostics } = new ShaderAnalyzer().analyze(shaderSource, { + file: "Assets/Shaders/PBR.shader", + includeMap: { + "ShaderLibrary/Common.glsl": commonSource + }, + basePathForIncludeKey: "shaders://root/Assets/Shaders/" +}); + +const hasErrors = diagnostics.some(({ severity }) => severity === DiagnosticSeverity.Error); +``` + +Diagnostic lines and columns are one-based for display. Offsets are zero-based so editors can map ranges directly onto their text models. + +## CLI + +If the dependency packages already exist in the npm cache, install the analyzer tarball directly: + +```sh +npm install --offline ./galacean-engine-shader-analyzer-*.tgz +``` + +For a fully disconnected install with an empty cache, provide the analyzer and its runtime dependency tarballs together: + +```sh +npm install --offline \ + ./galacean-engine-math-*.tgz \ + ./galacean-engine-core-*.tgz \ + ./galacean-engine-shader-parser-*.tgz \ + ./galacean-engine-shader-analyzer-*.tgz +``` + +Analyze a file and resolve `#include` paths from a shader directory: + +```sh +galacean-shader-analyzer --include-root Assets/Shaders Assets/Shaders/PBR.shader +``` + +Read from stdin and return structured JSON: + +```sh +galacean-shader-analyzer --json - < Assets/Shaders/PBR.shader +``` + +The file argument may be omitted to read stdin. The CLI exits with `0` when there are no error diagnostics (warnings are allowed), `1` when at least one error is present, and `2` for invalid command-line usage. + +Run `galacean-shader-analyzer --help` for the complete command reference. diff --git a/packages/shader-analyzer/package.json b/packages/shader-analyzer/package.json new file mode 100644 index 0000000000..00c4d33cea --- /dev/null +++ b/packages/shader-analyzer/package.json @@ -0,0 +1,41 @@ +{ + "name": "@galacean/engine-shader-analyzer", + "version": "2.0.0-alpha.33", + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + }, + "repository": { + "url": "https://github.com/galacean/engine.git" + }, + "license": "MIT", + "main": "dist/main.js", + "module": "dist/module.js", + "debug": "src/index.ts", + "types": "types/index.d.ts", + "exports": { + ".": { + "types": "./types/index.d.ts", + "import": "./dist/module.js", + "require": "./dist/main.js" + }, + "./package.json": "./package.json" + }, + "bin": { + "galacean-shader-analyzer": "./dist/cli.js" + }, + "scripts": { + "b:types": "tsc" + }, + "files": [ + "README.md", + "dist/**/*", + "types/**/*" + ], + "dependencies": { + "@galacean/engine-shader-parser": "workspace:*" + }, + "devDependencies": { + "@galacean/engine-design": "workspace:*" + } +} diff --git a/packages/shader-analyzer/src/Diagnostic.ts b/packages/shader-analyzer/src/Diagnostic.ts new file mode 100644 index 0000000000..130230ba4a --- /dev/null +++ b/packages/shader-analyzer/src/Diagnostic.ts @@ -0,0 +1,46 @@ +import { formatDiagnosticSource } from "@galacean/engine-shader-parser/internal/analyzer"; +import { DiagnosticType } from "./DiagnosticType"; + +/** Severity assigned to a shader diagnostic. */ +export enum DiagnosticSeverity { + Error = "error", + Warning = "warning" +} + +/** Structured diagnostic produced while analyzing a shader. */ +export interface Diagnostic { + /** Severity of the diagnostic. */ + severity: DiagnosticSeverity; + /** Semantic rule reported by the diagnostic. */ + code: DiagnosticType; + /** Human-readable explanation of the reported rule violation. */ + message: string; + /** Source file associated with the range, when supplied by the host. */ + file?: string; + /** Source range containing the reported issue. Lines and columns are 1-based; offsets are 0-based. */ + range: { + start: { line: number; column: number; offset: number }; + end: { line: number; column: number; offset: number }; + }; + /** Source text containing the reported issue. */ + relatedSource?: string; +} + +export { DiagnosticType }; + +/** + * Formats a diagnostic with a source excerpt and caret markers. + * @param diagnostic - Diagnostic to format. + * @returns Formatted diagnostic text. + */ +export function formatDiagnostic(diagnostic: Diagnostic): string { + const { start, end } = diagnostic.range; + return formatDiagnosticSource( + diagnostic.relatedSource, + { + start: { line: start.line - 1, column: start.column - 1 }, + end: { line: end.line - 1, column: end.column - 1 } + }, + `${diagnostic.code}: ${diagnostic.message}` + ); +} diff --git a/packages/shader-analyzer/src/DiagnosticCategory.ts b/packages/shader-analyzer/src/DiagnosticCategory.ts new file mode 100644 index 0000000000..0e797e68ff --- /dev/null +++ b/packages/shader-analyzer/src/DiagnosticCategory.ts @@ -0,0 +1,77 @@ +import { DiagnosticType } from "./DiagnosticType"; + +/** High-level category assigned to a diagnostic type. */ +export enum DiagnosticCategory { + Syntax = "syntax", + Symbol = "symbol", + Type = "type", + Constant = "constant", + ControlFlow = "controlFlow", + PipelineIO = "pipelineIO", + RenderState = "renderState" +} + +/** Maps every diagnostic type to its high-level category. */ +export const DIAGNOSTIC_CATEGORY: Record = { + [DiagnosticType.SyntaxError]: DiagnosticCategory.Syntax, + [DiagnosticType.PreprocessorError]: DiagnosticCategory.Syntax, + + [DiagnosticType.UndefinedFunction]: DiagnosticCategory.Symbol, + [DiagnosticType.UnknownVariable]: DiagnosticCategory.Symbol, + [DiagnosticType.NoMatchingOverload]: DiagnosticCategory.Symbol, + [DiagnosticType.Redefinition]: DiagnosticCategory.Symbol, + [DiagnosticType.UseBeforeDeclaration]: DiagnosticCategory.Symbol, + [DiagnosticType.RecursiveFunction]: DiagnosticCategory.Symbol, + [DiagnosticType.LocalFunctionPrototype]: DiagnosticCategory.Symbol, + [DiagnosticType.AmbiguousMacroBranchType]: DiagnosticCategory.Symbol, + [DiagnosticType.AmbiguousMacroBranchResolution]: DiagnosticCategory.Symbol, + + [DiagnosticType.InvalidSwizzle]: DiagnosticCategory.Type, + [DiagnosticType.UnknownType]: DiagnosticCategory.Type, + [DiagnosticType.UndeclaredStructMember]: DiagnosticCategory.Type, + [DiagnosticType.AssignTypeMismatch]: DiagnosticCategory.Type, + [DiagnosticType.InvalidAssignmentTarget]: DiagnosticCategory.Type, + [DiagnosticType.ConstDivideByZero]: DiagnosticCategory.Type, + [DiagnosticType.ShiftOutOfRange]: DiagnosticCategory.Type, + [DiagnosticType.IndexOutOfBounds]: DiagnosticCategory.Type, + [DiagnosticType.NonIntegerIndex]: DiagnosticCategory.Type, + [DiagnosticType.NonIndexableType]: DiagnosticCategory.Type, + [DiagnosticType.ExpectedSampler]: DiagnosticCategory.Type, + [DiagnosticType.InvalidUnaryOperand]: DiagnosticCategory.Type, + [DiagnosticType.InvalidBinaryOperands]: DiagnosticCategory.Type, + [DiagnosticType.ConstructorArgType]: DiagnosticCategory.Type, + [DiagnosticType.ConstructorArgCount]: DiagnosticCategory.Type, + [DiagnosticType.EmptyStruct]: DiagnosticCategory.Type, + [DiagnosticType.InvalidArraySize]: DiagnosticCategory.Type, + [DiagnosticType.InvalidVoidVariable]: DiagnosticCategory.Type, + [DiagnosticType.NonFloatDerivativeArg]: DiagnosticCategory.Type, + + [DiagnosticType.NonConstInitializer]: DiagnosticCategory.Constant, + [DiagnosticType.NonConstArraySize]: DiagnosticCategory.Constant, + [DiagnosticType.NonConstructibleReturnType]: DiagnosticCategory.Constant, + + [DiagnosticType.InvalidReturnType]: DiagnosticCategory.ControlFlow, + [DiagnosticType.MissingReturn]: DiagnosticCategory.ControlFlow, + [DiagnosticType.NonBoolCondition]: DiagnosticCategory.ControlFlow, + [DiagnosticType.MisplacedControlFlow]: DiagnosticCategory.ControlFlow, + [DiagnosticType.InvalidEntryReturnType]: DiagnosticCategory.ControlFlow, + [DiagnosticType.DerivativeInVertexShader]: DiagnosticCategory.ControlFlow, + + [DiagnosticType.InvalidIOStruct]: DiagnosticCategory.PipelineIO, + [DiagnosticType.StructRoleConflict]: DiagnosticCategory.PipelineIO, + [DiagnosticType.DuplicateEntryAssignment]: DiagnosticCategory.PipelineIO, + [DiagnosticType.MissingEntry]: DiagnosticCategory.PipelineIO, + [DiagnosticType.EntryNotFound]: DiagnosticCategory.PipelineIO, + [DiagnosticType.GlFragColorWithMrt]: DiagnosticCategory.PipelineIO, + [DiagnosticType.BareGlFragData]: DiagnosticCategory.PipelineIO, + [DiagnosticType.NestedIOStruct]: DiagnosticCategory.PipelineIO, + [DiagnosticType.MissingVertexPosition]: DiagnosticCategory.PipelineIO, + [DiagnosticType.NonFlatIntegerVarying]: DiagnosticCategory.PipelineIO, + + [DiagnosticType.InvalidRenderStateProperty]: DiagnosticCategory.RenderState, + [DiagnosticType.InvalidEnumValue]: DiagnosticCategory.RenderState, + [DiagnosticType.BitwiseOrOnNonBitmask]: DiagnosticCategory.RenderState, + [DiagnosticType.MixedEnumTypes]: DiagnosticCategory.RenderState, + [DiagnosticType.InvalidRenderStateVariable]: DiagnosticCategory.RenderState, + [DiagnosticType.InvalidRenderQueueVariable]: DiagnosticCategory.RenderState +}; diff --git a/packages/shader-analyzer/src/DiagnosticType.ts b/packages/shader-analyzer/src/DiagnosticType.ts new file mode 100644 index 0000000000..48fa289cd0 --- /dev/null +++ b/packages/shader-analyzer/src/DiagnosticType.ts @@ -0,0 +1,67 @@ +/** + * Semantic classification of a shader diagnostic. + * + * Severity is reported separately. + */ +export enum DiagnosticType { + SyntaxError = "SyntaxError", + PreprocessorError = "PreprocessorError", + + UndefinedFunction = "UndefinedFunction", + UnknownVariable = "UnknownVariable", + NoMatchingOverload = "NoMatchingOverload", + Redefinition = "Redefinition", + UseBeforeDeclaration = "UseBeforeDeclaration", + LocalFunctionPrototype = "LocalFunctionPrototype", + AmbiguousMacroBranchType = "AmbiguousMacroBranchType", + AmbiguousMacroBranchResolution = "AmbiguousMacroBranchResolution", + + InvalidSwizzle = "InvalidSwizzle", + UnknownType = "UnknownType", + UndeclaredStructMember = "UndeclaredStructMember", + AssignTypeMismatch = "AssignTypeMismatch", + InvalidAssignmentTarget = "InvalidAssignmentTarget", + ConstDivideByZero = "ConstDivideByZero", + ShiftOutOfRange = "ShiftOutOfRange", + IndexOutOfBounds = "IndexOutOfBounds", + NonIntegerIndex = "NonIntegerIndex", + NonIndexableType = "NonIndexableType", + ExpectedSampler = "ExpectedSampler", + InvalidUnaryOperand = "InvalidUnaryOperand", + InvalidBinaryOperands = "InvalidBinaryOperands", + ConstructorArgType = "ConstructorArgType", + ConstructorArgCount = "ConstructorArgCount", + NonConstInitializer = "NonConstInitializer", + NonConstArraySize = "NonConstArraySize", + EmptyStruct = "EmptyStruct", + InvalidArraySize = "InvalidArraySize", + InvalidVoidVariable = "InvalidVoidVariable", + NonFloatDerivativeArg = "NonFloatDerivativeArg", + + InvalidReturnType = "InvalidReturnType", + MissingReturn = "MissingReturn", + NonBoolCondition = "NonBoolCondition", + RecursiveFunction = "RecursiveFunction", + NonConstructibleReturnType = "NonConstructibleReturnType", + MisplacedControlFlow = "MisplacedControlFlow", + DerivativeInVertexShader = "DerivativeInVertexShader", + + InvalidIOStruct = "InvalidIOStruct", + InvalidEntryReturnType = "InvalidEntryReturnType", + StructRoleConflict = "StructRoleConflict", + DuplicateEntryAssignment = "DuplicateEntryAssignment", + MissingEntry = "MissingEntry", + EntryNotFound = "EntryNotFound", + GlFragColorWithMrt = "GlFragColorWithMrt", + BareGlFragData = "BareGlFragData", + NestedIOStruct = "NestedIOStruct", + MissingVertexPosition = "MissingVertexPosition", + NonFlatIntegerVarying = "NonFlatIntegerVarying", + + InvalidRenderStateProperty = "InvalidRenderStateProperty", + InvalidEnumValue = "InvalidEnumValue", + BitwiseOrOnNonBitmask = "BitwiseOrOnNonBitmask", + MixedEnumTypes = "MixedEnumTypes", + InvalidRenderStateVariable = "InvalidRenderStateVariable", + InvalidRenderQueueVariable = "InvalidRenderQueueVariable" +} diff --git a/packages/shader-analyzer/src/PreprocessorExpressionValidator.ts b/packages/shader-analyzer/src/PreprocessorExpressionValidator.ts new file mode 100644 index 0000000000..70dd5f87ab --- /dev/null +++ b/packages/shader-analyzer/src/PreprocessorExpressionValidator.ts @@ -0,0 +1,289 @@ +import { DiagnosticSeverity, DiagnosticType, type Diagnostic } from "./Diagnostic"; +import { positionAt } from "./sourcePosition"; + +type TokenKind = "identifier" | "number" | "operator" | "end" | "invalid"; + +interface Token { + kind: TokenKind; + text: string; + start: number; + end: number; +} + +interface ParseFailure { + message: string; + token: Token; + certain: boolean; +} + +class ExpressionParseFailure extends Error implements ParseFailure { + constructor( + message: string, + readonly token: Token, + readonly certain: boolean + ) { + super(message); + } +} + +const binaryPrecedence: Readonly> = { + "||": 1, + "&&": 2, + "|": 3, + "^": 4, + "&": 5, + "==": 6, + "!=": 6, + "<": 7, + "<=": 7, + ">": 7, + ">=": 7, + "<<": 8, + ">>": 8, + "+": 9, + "-": 9, + "*": 10, + "/": 10, + "%": 10 +}; + +/** + * Validates preprocessor-expression syntax without evaluating macro configurations. + * @param source - Shader source containing preprocessor directives. + * @param file - Optional logical source name attached to diagnostics. + * @returns Diagnostics for syntax errors that remain certain before macro expansion. + */ +export function validatePreprocessorExpressions(source: string, file?: string): Diagnostic[] { + const diagnostics: Diagnostic[] = []; + const linePattern = /^[\t ]*#[\t ]*(if|elif)\b(.*)$/gm; + const logicalSource = maskCommentsAndJoinContinuedLines(source); + let match: RegExpExecArray | null; + while ((match = linePattern.exec(logicalSource))) { + const expression = match[2]; + const expressionOffset = match.index + match[0].length - expression.length; + const parser = new ExpressionParser(expression); + const failure = parser.parse(); + if (!failure || (!failure.certain && parser.sawExpandableIdentifier)) continue; + + const startOffset = expressionOffset + failure.token.start; + const endOffset = expressionOffset + Math.max(failure.token.end, failure.token.start + 1); + diagnostics.push({ + severity: DiagnosticSeverity.Error, + code: DiagnosticType.PreprocessorError, + message: failure.message, + file, + range: { + start: positionAt(source, startOffset), + end: positionAt(source, Math.min(endOffset, source.length)) + }, + relatedSource: source + }); + } + return diagnostics; +} + +function maskCommentsAndJoinContinuedLines(source: string): string { + const characters = source.split(""); + let index = 0; + let inBlockComment = false; + + while (index < characters.length) { + if (inBlockComment) { + if (source.startsWith("*/", index)) { + characters[index] = characters[index + 1] = " "; + index += 2; + inBlockComment = false; + } else { + if (characters[index] !== "\n" && characters[index] !== "\r") characters[index] = " "; + index++; + } + continue; + } + + if (source.startsWith("//", index)) { + while (index < characters.length && characters[index] !== "\n" && characters[index] !== "\r") { + characters[index++] = " "; + } + continue; + } + if (source.startsWith("/*", index)) { + characters[index] = characters[index + 1] = " "; + index += 2; + inBlockComment = true; + continue; + } + if (characters[index] === "\\") { + const next = characters[index + 1]; + if (next === "\n") { + characters[index] = characters[index + 1] = " "; + index += 2; + continue; + } + if (next === "\r" && characters[index + 2] === "\n") { + characters[index] = characters[index + 1] = characters[index + 2] = " "; + index += 3; + continue; + } + } + index++; + } + + return characters.join(""); +} + +class ExpressionParser { + private readonly _tokens: Token[]; + private _index = 0; + sawExpandableIdentifier = false; + + constructor(source: string) { + this._tokens = tokenize(source); + } + + parse(): ParseFailure | undefined { + try { + this._parseConditional(); + const token = this._current(); + if (token.kind !== "end") { + const followsExpandableFunctionName = this.sawExpandableIdentifier && token.text === "("; + const certain = !followsExpandableFunctionName && (token.kind !== "identifier" || token.text === "defined"); + this._fail(`Unexpected token '${token.text}' in preprocessor expression.`, token, certain); + } + } catch (failure) { + if (failure instanceof ExpressionParseFailure) return failure; + throw failure; + } + } + + private _parseConditional(): void { + this._parseBinary(1); + if (!this._consume("?")) return; + this._parseConditional(); + if (!this._consume(":")) this._fail("Expected ':' in conditional preprocessor expression.", this._current(), true); + this._parseConditional(); + } + + private _parseBinary(minPrecedence: number): void { + this._parseUnary(); + while (true) { + const token = this._current(); + const precedence = binaryPrecedence[token.text]; + if (precedence === undefined || precedence < minPrecedence) return; + this._index++; + this._parseBinary(precedence + 1); + } + } + + private _parseUnary(): void { + const token = this._current(); + if ( + token.kind === "operator" && + (token.text === "+" || token.text === "-" || token.text === "!" || token.text === "~") + ) { + this._index++; + this._parseUnary(); + return; + } + this._parsePrimary(); + } + + private _parsePrimary(): void { + const token = this._current(); + if (token.kind === "number") { + this._index++; + return; + } + if (token.kind === "identifier") { + if (token.text === "defined") { + this._parseDefined(); + } else { + this.sawExpandableIdentifier = true; + this._index++; + } + return; + } + if (this._consume("(")) { + this._parseConditional(); + if (!this._consume(")")) this._fail("Expected ')' in preprocessor expression.", this._current(), false); + return; + } + if (token.kind === "end") + this._fail("Expected an operand before the end of the preprocessor expression.", token, true); + this._fail(`Expected an operand, found '${token.text}'.`, token, true); + } + + private _parseDefined(): void { + this._index++; + const parenthesized = this._consume("("); + const name = this._current(); + if (name.kind !== "identifier" || name.text === "defined") { + this._fail("Expected a macro name after 'defined'.", name, true); + } + this._index++; + if (parenthesized && !this._consume(")")) { + this._fail("Expected ')' after the macro name in 'defined(...)'.", this._current(), true); + } + } + + private _consume(text: string): boolean { + if (this._current().text !== text) return false; + this._index++; + return true; + } + + private _current(): Token { + return this._tokens[this._index]; + } + + private _fail(message: string, token: Token, certain: boolean): never { + throw new ExpressionParseFailure(message, token, certain); + } +} + +function tokenize(source: string): Token[] { + const tokens: Token[] = []; + let index = 0; + while (index < source.length) { + const start = index; + const char = source[index]; + if (/\s/.test(char)) { + index++; + continue; + } + if (source.startsWith("//", index)) break; + if (source.startsWith("/*", index)) { + const end = source.indexOf("*/", index + 2); + index = end === -1 ? source.length : end + 2; + continue; + } + const identifier = /^[A-Za-z_][A-Za-z0-9_]*/.exec(source.slice(index))?.[0]; + if (identifier) { + index += identifier.length; + tokens.push({ kind: "identifier", text: identifier, start, end: index }); + continue; + } + const number = /^(?:0[xX][0-9A-Fa-f]+|0[0-7]*|[1-9][0-9]*)(?:[uUlL]{0,3})/.exec(source.slice(index))?.[0]; + if (number) { + index += number.length; + tokens.push({ kind: "number", text: number, start, end: index }); + continue; + } + const operator = ["||", "&&", "==", "!=", "<=", ">=", "<<", ">>"].find((candidate) => + source.startsWith(candidate, index) + ); + if (operator) { + index += operator.length; + tokens.push({ kind: "operator", text: operator, start, end: index }); + continue; + } + if ("|^&<>+-*/%!~?:()".includes(char)) { + index++; + tokens.push({ kind: "operator", text: char, start, end: index }); + continue; + } + index++; + tokens.push({ kind: "invalid", text: char, start, end: index }); + } + tokens.push({ kind: "end", text: "", start: source.length, end: source.length }); + return tokens; +} diff --git a/packages/shader-analyzer/src/ShaderAnalysisInfo.ts b/packages/shader-analyzer/src/ShaderAnalysisInfo.ts new file mode 100644 index 0000000000..7486accc9d --- /dev/null +++ b/packages/shader-analyzer/src/ShaderAnalysisInfo.ts @@ -0,0 +1,164 @@ +import { + ASTNode, + BaseToken, + FnSymbol, + isBranchReachable, + ShaderClueIR, + ShaderCoreInfo, + TreeNode, + type ShaderEntryPointInfo, + type ShaderRange +} from "@galacean/engine-shader-parser/internal/analyzer"; + +/** + * Analyzer-only graph and reachability information derived from neutral shader IR. + * @internal + */ +export class ShaderAnalysisInfo { + /** References to the legacy single fragment output. */ + readonly glFragColorReferences: ShaderRange[] = []; + + /** References to the fragment output array, before indexed uses are filtered. */ + readonly glFragDataReferences: ShaderRange[] = []; + + private readonly _callGraph = new Map>(); + private readonly _writes = new Map>(); + private readonly _functionsByName = new Map(); + + /** + * Builds analyzer-only facts without modifying the neutral IR or backend information. + * @param ir - Neutral shader IR. + * @param coreInfo - Backend entry and IO facts for the same IR. + */ + constructor( + readonly ir: ShaderClueIR, + readonly coreInfo: ShaderCoreInfo + ) { + this._indexFunctions(ir.program); + this._indexFunctionBodies(); + } + + /** + * Finds every function reachable from an entry, including the entry declarations themselves. + * @param entry - Entry point to traverse. + * @returns Reachable function identities. + */ + reachableFunctions(entry: ShaderEntryPointInfo): ReadonlySet { + const reachable = new Set(); + const pending = entry.functions.map((symbol) => symbol.astNode); + while (pending.length) { + const functionNode = pending.pop()!; + if (reachable.has(functionNode)) continue; + reachable.add(functionNode); + for (const callee of this._callGraph.get(functionNode) ?? []) pending.push(callee); + } + return reachable; + } + + /** + * Checks whether an entry or a reachable helper writes a named value. + * @param entry - Entry point whose call graph is inspected. + * @param name - Leftmost identifier of the assignment target. + * @returns Whether a reachable assignment writes the identifier. + */ + hasReachableWrite(entry: ShaderEntryPointInfo, name: string): boolean { + for (const functionNode of this.reachableFunctions(entry)) { + if (this._writes.get(functionNode)?.has(name)) return true; + } + return false; + } + + /** + * Returns every parsed function declaration. + * @returns Function identities retained by the neutral IR. + */ + *functions(): IterableIterator { + for (const functions of this._functionsByName.values()) yield* functions; + } + + /** + * Returns functions directly called by a function. + * @param functionNode - Caller function identity. + * @returns Direct callees. + */ + calleesOf(functionNode: ASTNode.FunctionDefinition): ReadonlySet { + return this._callGraph.get(functionNode) ?? emptyFunctions; + } + + private _indexFunctions(node: TreeNode): void { + if (node instanceof ASTNode.FunctionDefinition) { + const name = node.protoType.ident.lexeme; + const functions = this._functionsByName.get(name) ?? []; + functions.push(node); + this._functionsByName.set(name, functions); + return; + } + for (const child of node.children) { + if (child instanceof TreeNode) this._indexFunctions(child); + } + } + + private _indexFunctionBodies(): void { + for (const functions of this._functionsByName.values()) { + for (const functionNode of functions) this._walkFunction(functionNode, functionNode.statements); + } + } + + private _walkFunction(functionNode: ASTNode.FunctionDefinition, node: TreeNode): void { + if (!isBranchReachable(node._branch) || node instanceof ASTNode.MacroDefine) return; + if (node instanceof ASTNode.FunctionCallGeneric) this._recordCall(functionNode, node); + if (node instanceof ASTNode.VariableIdentifier) { + const child = node.children[0]; + if (child instanceof BaseToken) { + if (child.lexeme === "gl_FragColor") this.glFragColorReferences.push(node.location); + else if (child.lexeme === "gl_FragData") this.glFragDataReferences.push(node.location); + } + } + if (node instanceof ASTNode.AssignmentExpression && node.children.length === 3) { + const lhs = node.children[0]; + if (lhs instanceof TreeNode) { + const name = leftmostIdentifier(lhs); + if (name) { + const writes = this._writes.get(functionNode) ?? new Set(); + writes.add(name); + this._writes.set(functionNode, writes); + } + } + } + for (const child of node.children) { + if (child instanceof TreeNode) this._walkFunction(functionNode, child); + } + } + + private _recordCall(caller: ASTNode.FunctionDefinition, call: ASTNode.FunctionCallGeneric): void { + if (!(call.fnSymbol instanceof FnSymbol)) return; + const callees = this._callGraph.get(caller) ?? new Set(); + callees.add(call.fnSymbol.astNode); + this._callGraph.set(caller, callees); + } +} + +const emptyFunctions: ReadonlySet = new Set(); + +function leftmostIdentifier(node: TreeNode): string | undefined { + let current = node; + while (true) { + if (current instanceof ASTNode.VariableIdentifier) { + const child = current.children[0]; + return child instanceof BaseToken ? child.lexeme : undefined; + } + if (current instanceof ASTNode.PostfixExpression && current.children.length) { + const base = current.children[0]; + if (!(base instanceof TreeNode)) return undefined; + current = base; + continue; + } + if (current instanceof ASTNode.ExpressionAstNode && current.children.length === 1) { + const child = current.children[0]; + if (!(child instanceof TreeNode)) return undefined; + current = child; + continue; + } + return undefined; + } +} diff --git a/packages/shader-analyzer/src/ShaderAnalyzer.ts b/packages/shader-analyzer/src/ShaderAnalyzer.ts new file mode 100644 index 0000000000..28d45d040a --- /dev/null +++ b/packages/shader-analyzer/src/ShaderAnalyzer.ts @@ -0,0 +1,243 @@ +import { + ChunkOutputCache, + parseShaderPass, + ShaderCoreInfo, + ShaderCompilerUtils, + ShaderSourceParser, + type PreprocessSourceMapSegment +} from "@galacean/engine-shader-parser/internal/analyzer"; +import type { ShaderRange } from "@galacean/engine-shader-parser/internal/analyzer"; +import type { IShaderPassSource, IShaderSource, IStatement } from "@galacean/engine-design"; +import type { Diagnostic } from "./Diagnostic"; +import { DiagnosticType } from "./Diagnostic"; +import { gseErrorToDiagnostic } from "./convert"; +import { validatePreprocessorExpressions } from "./PreprocessorExpressionValidator"; +import { ShaderValidator } from "./ShaderValidator"; +import { ShaderAnalysisInfo } from "./ShaderAnalysisInfo"; +import { ShaderIOValidator } from "./ShaderIOValidator"; +import { positionAt } from "./sourcePosition"; + +/** + * Maps canonical shader include paths to source chunks. + * + * Keys use the same root-relative convention as compiler include resolution; an undefined value + * represents a known path whose source is unavailable. + */ +export type ShaderIncludeMap = Readonly>; + +/** + * Controls include resolution and source attribution for one analysis request. + */ +export interface AnalyzerOptions { + /** `#include` lookup table; keys are include paths, values are chunk sources. */ + includeMap?: ShaderIncludeMap; + /** Base URL used to resolve relative `#include` paths. */ + basePathForIncludeKey?: string; + /** Logical file name attached to diagnostics. */ + file?: string; +} + +/** + * Contains every structured diagnostic produced for one ShaderLab document. + */ +export interface AnalysisResult { + /** Structured diagnostics from shader-source structure parsing and per-pass GLSL analysis. */ + diagnostics: Diagnostic[]; +} + +/** + * Analyzes ShaderLab source and GLSL semantics without generating backend source. + * + * The analyzer consumes parser facts through the analyzer-support entry independently from runtime compilation, so its + * diagnostics cannot alter or block GLES code generation. + */ +export class ShaderAnalyzer { + /** + * Analyzes shader source. + * @param source - ShaderLab source to analyze. + * @param options - Analysis options. + * @returns Structured diagnostics. + */ + analyze(source: string, options?: AnalyzerOptions): AnalysisResult { + const includeMap = options?.includeMap ?? {}; + const chunkOutputCache: ChunkOutputCache = new Map(); + const diagnostics: Diagnostic[] = []; + + try { + diagnostics.push(...validatePreprocessorExpressions(source, options?.file)); + ShaderCompilerUtils.clearAllShaderCompilerObjectPool(); + const sourceResult = ShaderSourceParser.parseWithErrors(source); + const shaderSource: IShaderSource = sourceResult.shaderSource; + diagnostics.push(...sourceResult.errors.map((error) => gseErrorToDiagnostic(error))); + for (const subShader of shaderSource.subShaders) { + for (const pass of subShader.passes) { + if (pass.isUsePass) continue; + const statements = shaderSource.pendingContents.concat(subShader.pendingContents, pass.pendingContents); + const skipSemanticValidation = diagnostics.some( + (diagnostic) => + diagnostic.code === DiagnosticType.PreprocessorError && + statements.some( + (statement) => + diagnostic.range.start.offset >= statement.range.start.index && + diagnostic.range.start.offset <= statement.range.end.index + ) + ); + this._analyzePass( + pass, + statements, + source, + diagnostics, + includeMap, + chunkOutputCache, + options?.basePathForIncludeKey, + options?.file, + skipSemanticValidation + ); + } + } + } catch (e) { + diagnostics.push(gseErrorToDiagnostic(e instanceof Error ? e : new Error(String(e)))); + } + + if (options?.file) { + for (const diagnostic of diagnostics) diagnostic.file ??= options.file; + } + return { diagnostics }; + } + + private _analyzePass( + pass: IShaderPassSource, + statements: readonly IStatement[], + source: string, + diagnostics: Diagnostic[], + includeMap: ShaderIncludeMap, + chunkOutputCache: ChunkOutputCache, + basePathForIncludeKey: string | undefined, + file: string | undefined, + skipSemanticValidation: boolean + ): void { + const { vertexEntry, fragmentEntry } = pass; + const passDiagnostics: Diagnostic[] = []; + let passText: string | undefined; + let preprocessSourceMap: PreprocessSourceMapSegment[] = []; + try { + const parsed = parseShaderPass(pass.contents, includeMap, chunkOutputCache, basePathForIncludeKey); + const { ir, errors } = parsed; + passText = parsed.passText; + preprocessSourceMap = parsed.sourceMap; + for (const error of errors) { + const diagnostic = gseErrorToDiagnostic(error); + if ( + !skipSemanticValidation || + diagnostic.code === DiagnosticType.SyntaxError || + diagnostic.code === DiagnosticType.PreprocessorError + ) { + passDiagnostics.push(diagnostic); + } + } + if (ir && !skipSemanticValidation) { + const coreInfo = ShaderCoreInfo.create(ir, vertexEntry, fragmentEntry); + const analysisInfo = new ShaderAnalysisInfo(ir, coreInfo); + passDiagnostics.push(...ShaderValidator.validate(analysisInfo).map((error) => gseErrorToDiagnostic(error))); + passDiagnostics.push( + ...ShaderIOValidator.validate( + analysisInfo, + pass.vertexEntryLocation as ShaderRange | undefined, + pass.fragmentEntryLocation as ShaderRange | undefined + ).map((error) => gseErrorToDiagnostic(error)) + ); + } + } catch (e) { + passDiagnostics.push(gseErrorToDiagnostic(e instanceof Error ? e : new Error(String(e)))); + } + + const sourceMap = createPassSourceMap(statements); + for (const diagnostic of passDiagnostics) { + if (passText !== undefined && diagnostic.relatedSource === passText) { + remapPreprocessedDiagnostic(diagnostic, preprocessSourceMap); + } + if (sourceMap.generatedSource === pass.contents && diagnostic.relatedSource === pass.contents) { + remapDiagnostic(diagnostic, sourceMap.segments, source); + } + diagnostic.file ??= file; + diagnostics.push(diagnostic); + } + } +} + +function remapPreprocessedDiagnostic(diagnostic: Diagnostic, segments: readonly PreprocessSourceMapSegment[]): void { + const startSegment = findPreprocessSegment(diagnostic.range.start.offset, segments, false); + if (!startSegment) return; + const endSegment = findPreprocessSegment(diagnostic.range.end.offset, segments, true); + const startOffset = startSegment.sourceStart + diagnostic.range.start.offset - startSegment.generatedStart; + let endOffset = startOffset; + if (endSegment && endSegment.source === startSegment.source && endSegment.file === startSegment.file) { + endOffset = endSegment.sourceStart + diagnostic.range.end.offset - endSegment.generatedStart; + } + diagnostic.range = { + start: positionAt(startSegment.source, startOffset), + end: positionAt(startSegment.source, endOffset) + }; + diagnostic.relatedSource = startSegment.source; + diagnostic.file = startSegment.file ?? diagnostic.file; +} + +function findPreprocessSegment( + offset: number, + segments: readonly PreprocessSourceMapSegment[], + isEnd: boolean +): PreprocessSourceMapSegment | undefined { + for (const segment of segments) { + if ( + offset >= segment.generatedStart && + (isEnd ? offset <= segment.generatedEnd && offset > segment.generatedStart : offset < segment.generatedEnd) + ) { + return segment; + } + } + const last = segments[segments.length - 1]; + return last && offset === last.generatedEnd ? last : undefined; +} + +interface SourceMapSegment { + generatedStart: number; + generatedEnd: number; + sourceStart: number; +} + +function createPassSourceMap(statements: readonly IStatement[]): { + generatedSource: string; + segments: SourceMapSegment[]; +} { + const segments: SourceMapSegment[] = []; + let generatedSource = ""; + for (let index = 0; index < statements.length; index++) { + if (index > 0) generatedSource += "\n"; + const statement = statements[index]; + const generatedStart = generatedSource.length; + generatedSource += statement.content; + segments.push({ + generatedStart, + generatedEnd: generatedSource.length, + sourceStart: statement.range.start.index + }); + } + return { generatedSource, segments }; +} + +function remapDiagnostic(diagnostic: Diagnostic, segments: readonly SourceMapSegment[], source: string): void { + const start = remapOffset(diagnostic.range.start.offset, segments); + if (start === undefined) return; + const end = remapOffset(diagnostic.range.end.offset, segments) ?? start; + diagnostic.range = { start: positionAt(source, start), end: positionAt(source, end) }; + diagnostic.relatedSource = source; +} + +function remapOffset(offset: number, segments: readonly SourceMapSegment[]): number | undefined { + for (let index = 0; index < segments.length; index++) { + const segment = segments[index]; + if (offset >= segment.generatedStart && offset <= segment.generatedEnd) { + return segment.sourceStart + offset - segment.generatedStart; + } + } +} diff --git a/packages/shader-analyzer/src/ShaderIOValidator.ts b/packages/shader-analyzer/src/ShaderIOValidator.ts new file mode 100644 index 0000000000..136a256257 --- /dev/null +++ b/packages/shader-analyzer/src/ShaderIOValidator.ts @@ -0,0 +1,228 @@ +import { + GSError, + GSErrorName, + Keyword, + ShaderCompilerUtils, + ShaderStructRole, + StructSymbol, + SymbolInfo, + TypeSystem, + ESymbolType, + ShaderPosition, + type ShaderRange +} from "@galacean/engine-shader-parser/internal/analyzer"; +import type { ShaderAnalysisInfo } from "./ShaderAnalysisInfo"; +import { DiagnosticType } from "./DiagnosticType"; + +const zeroPosition = new ShaderPosition(); +zeroPosition.set(0, 0, 0); +Object.freeze(zeroPosition); + +/** + * Validates pipeline IO facts produced by `ShaderCoreInfo`. + * @internal + */ +export class ShaderIOValidator { + private static readonly _lookup = new SymbolInfo("", null); + + /** + * Validates stage entries and IO without participating in backend generation. + * @param analysis - Analyzer-only facts and their neutral/core backing data. + * @param vertexEntryLocation - ShaderLab source range of the vertex entry binding. + * @param fragmentEntryLocation - ShaderLab source range of the fragment entry binding. + * @returns Analyzer errors for invalid pipeline IO. + */ + static validate( + analysis: ShaderAnalysisInfo, + vertexEntryLocation?: ShaderRange | ShaderPosition, + fragmentEntryLocation?: ShaderRange | ShaderPosition + ): GSError[] { + const { ir, coreInfo } = analysis; + const source = ir.source; + const errors: GSError[] = []; + + if (coreInfo.vertexEntry.name && !coreInfo.vertexEntry.functions.length) { + this._entryNotFound(errors, coreInfo.vertexEntry.name, vertexEntryLocation, source); + } + if (coreInfo.fragmentEntry.name && !coreInfo.fragmentEntry.functions.length) { + this._entryNotFound(errors, coreInfo.fragmentEntry.name, fragmentEntryLocation, source); + } + + this._validateVertex(analysis, errors); + this._validateFragment(analysis, errors); + this._validateRoleConflicts(analysis, errors); + this._validateStructMembers(analysis, errors); + + if (coreInfo.io.mrtStructs.length && analysis.glFragColorReferences.length) { + this._error( + errors, + DiagnosticType.GlFragColorWithMrt, + "gl_FragColor cannot be used with MRT (Multiple Render Targets).", + analysis.glFragColorReferences[0], + source + ); + } + + if (coreInfo.vertexEntry.functions.length && !analysis.hasReachableWrite(coreInfo.vertexEntry, "gl_Position")) { + this._error( + errors, + DiagnosticType.MissingVertexPosition, + "Vertex shader must write gl_Position.", + coreInfo.vertexEntry.functions[0].astNode.protoType.returnType.location, + source + ); + } + + return errors; + } + + private static _validateVertex(analysis: ShaderAnalysisInfo, errors: GSError[]): void { + const { coreInfo, ir } = analysis; + const symbolTable = ir.shaderData.symbolTable; + for (const functionSymbol of coreInfo.vertexEntry.functions) { + const proto = functionSymbol.astNode.protoType; + const returnType = proto.returnType; + if (typeof returnType.type === "string") { + if (!this._findStructs(symbolTable, returnType.type).length) { + this._error( + errors, + DiagnosticType.InvalidIOStruct, + `Invalid varying struct: "${returnType.type}".`, + returnType.location, + ir.source + ); + } + } else if (returnType.type !== Keyword.VOID) { + this._error( + errors, + DiagnosticType.InvalidEntryReturnType, + "vertex main entry can only return struct or void.", + returnType.location, + ir.source + ); + } + + const attribute = proto.parameterList?.[0]; + if (attribute && typeof attribute.typeInfo.type === "string") { + if (!this._findStructs(symbolTable, attribute.typeInfo.type).length) { + this._error( + errors, + DiagnosticType.InvalidIOStruct, + `Invalid attribute struct: "${attribute.typeInfo.type}".`, + attribute.astNode.location, + ir.source + ); + } + } + } + } + + private static _validateFragment(analysis: ShaderAnalysisInfo, errors: GSError[]): void { + const { coreInfo, ir } = analysis; + const symbolTable = ir.shaderData.symbolTable; + for (const functionSymbol of coreInfo.fragmentEntry.functions) { + const returnType = functionSymbol.astNode.protoType.returnType; + if (typeof returnType.type === "string") { + if (!this._findStructs(symbolTable, returnType.type).length) { + this._error( + errors, + DiagnosticType.InvalidIOStruct, + `Invalid MRT struct: ${returnType.type}`, + returnType.location, + ir.source + ); + } + } else if (returnType.type !== Keyword.VOID && returnType.type !== Keyword.VEC4) { + this._error( + errors, + DiagnosticType.InvalidEntryReturnType, + "fragment main entry can only return struct, vec4, or void.", + returnType.location, + ir.source + ); + } + } + } + + private static _validateRoleConflicts(analysis: ShaderAnalysisInfo, errors: GSError[]): void { + for (const conflict of analysis.coreInfo.roleConflicts) { + this._error( + errors, + DiagnosticType.StructRoleConflict, + `Cannot use the same struct as ${conflict.roles.join(" and ")}.`, + conflict.struct.location, + analysis.ir.source + ); + } + } + + private static _validateStructMembers(analysis: ShaderAnalysisInfo, errors: GSError[]): void { + const { io } = analysis.coreInfo; + const inspect = (structs: readonly StructSymbol["astNode"][], role: ShaderStructRole): void => { + for (const struct of structs) { + for (const prop of struct.propList) { + if (typeof prop.typeInfo.type === "string") { + this._error( + errors, + DiagnosticType.NestedIOStruct, + `IO struct member '${prop.ident.lexeme}' cannot be a struct ('${prop.typeInfo.type}'); nested IO structs are not allowed.`, + prop.ident.location, + analysis.ir.source + ); + } else if ( + role === ShaderStructRole.Varying && + !prop.isFlat && + TypeSystem.isIntegerType(prop.typeInfo.type) + ) { + this._error( + errors, + DiagnosticType.NonFlatIntegerVarying, + `Integer varying '${prop.ident.lexeme}' must be declared 'flat'.`, + prop.ident.location, + analysis.ir.source + ); + } + } + } + }; + inspect(io.attributeStructs, ShaderStructRole.Attribute); + inspect(io.varyingStructs, ShaderStructRole.Varying); + inspect(io.mrtStructs, ShaderStructRole.Mrt); + } + + private static _findStructs( + symbolTable: ShaderAnalysisInfo["ir"]["shaderData"]["symbolTable"], + name: string + ): StructSymbol[] { + const lookup = this._lookup; + lookup.set(name, ESymbolType.STRUCT); + return symbolTable.getSymbols(lookup, true, []); + } + + private static _entryNotFound( + errors: GSError[], + entry: string, + location: ShaderRange | ShaderPosition | undefined, + source: string + ): void { + this._error( + errors, + DiagnosticType.EntryNotFound, + `Entry function '${entry}' not found.`, + location ?? zeroPosition, + source + ); + } + + private static _error( + errors: GSError[], + code: DiagnosticType, + message: string, + location: ShaderRange | ShaderPosition, + source: string + ): void { + errors.push( + ShaderCompilerUtils.createGSError(message, GSErrorName.CompilationError, source, location, code) as GSError + ); + } +} diff --git a/packages/shader-analyzer/src/ShaderValidator.ts b/packages/shader-analyzer/src/ShaderValidator.ts new file mode 100644 index 0000000000..b6b229ba1f --- /dev/null +++ b/packages/shader-analyzer/src/ShaderValidator.ts @@ -0,0 +1,1296 @@ +import { + ASTNode, + BaseToken, + branchAnalysis, + ESymbolType, + ETokenType, + GSError, + GSErrorName, + isBranchReachable, + Keyword, + NodeChild, + ParserUtils, + ShaderCompilerUtils, + ShaderRange, + StructSymbol, + SymbolInfo, + TreeNode, + TypeAny, + TypeSystem, + VarSymbol, + FnSymbol +} from "@galacean/engine-shader-parser/internal/analyzer"; +import { getBranchCoverage } from "@galacean/engine-shader-parser/internal/analyzer"; +import { DiagnosticType } from "./DiagnosticType"; +import type { ShaderAnalysisInfo } from "./ShaderAnalysisInfo"; + +/** + * Walk-local context threaded down the recursion: the enclosing function (for the declared return + * type and the recursion self-call check), the current loop nesting depth (for break/continue), and + * the pipeline stage of the enclosing entry function (for derivative-in-vertex-shader). These can't + * be read off a node post-parse — the parser carried them as transient SA state — so the walk + * reconstructs them as it descends. + */ +interface WalkContext { + currentFunction: ASTNode.FunctionDefinition | null; + loopDepth: number; + /** + * Pipeline stage of the enclosing entry function, or `null` when outside an entry (top-level + * declarations, helper functions). Set in the FunctionDefinition branch by matching the function + * name against the pass's vertex / fragment entry names. + */ + currentStage: "vertex" | "fragment" | null; +} + +/** Fragment-only derivative builtins (GLSL ES 3.00 §8.9) — illegal in the vertex stage. */ +const DERIVATIVE_BUILTINS = new Set(["dFdx", "dFdy", "fwidth"]); + +/** + * Post-parse validation pass. Walks the already-typed AST (the parser built the symbol table and + * inferred `.type` inline; only validation moved here) and collects diagnostics. The pass source is + * passed in — `parseShaderPass` clears `ShaderCompilerUtils.processingPassText` on exit, so the + * caller supplies the same source context the inline check carried. + */ +export class ShaderValidator { + /** + * Validate an already-parsed program and return collected diagnostics. + * @param analysis neutral IR plus analyzer-only graph information + * @returns diagnostics as `GSError[]` + */ + static validate(analysis: ShaderAnalysisInfo): GSError[] { + const v = new ShaderValidator(analysis); + v._walk(analysis.ir.program, { currentFunction: null, loopDepth: 0, currentStage: null }); + v._reportMutualRecursion(); + v._reportDerivativeReachableFromVertex(); + v._reportBareGlFragData(); + return v._errors; + } + + /** Scratch SymbolInfo reused by `_nonAssignableReason` for VAR lookups — avoids per-call allocation. */ + private static _varLookup = new SymbolInfo("", ESymbolType.VAR); + /** Scratch symbol and output reused while resolving custom type references. */ + private static _typeLookup = new SymbolInfo("", ESymbolType.STRUCT); + private static _typeStructScratch: SymbolInfo[] = []; + + private _errors: GSError[] = []; + /** + * Start indices of `gl_FragData` reference locations that appear as the base of a + * `PostfixExpression[base [ index ]]` — the legal `gl_FragData[i]` shape. Collected by + * `_checkPostfix` during the walk, then used by `_reportBareGlFragData` to strike these off the + * `shaderData.glFragDataReferences` list; the residue is bare use. + */ + private _indexedGlFragDataStarts = new Set(); + /** Function definition → derivative call sites inside its body. Post-walk pass reports the ones + * reachable from the vertex entry via the call graph. */ + private _derivativeSites = new Map(); + + private readonly _source: string; + private readonly _vertexEntry: string; + private readonly _fragmentEntry: string; + private readonly _shaderData: ASTNode.GLShaderProgram["shaderData"]; + + private constructor(private readonly _analysis: ShaderAnalysisInfo) { + this._source = _analysis.ir.source; + this._vertexEntry = _analysis.coreInfo.vertexEntry.name; + this._fragmentEntry = _analysis.coreInfo.fragmentEntry.name; + this._shaderData = _analysis.ir.shaderData; + } + + private _walk(node: TreeNode, ctx: WalkContext): void { + if (!isBranchReachable(node._branch)) return; + if (node instanceof ASTNode.MacroDefine) return; + // A FunctionDefinition becomes the enclosing function for its subtree (GLSL has no nested + // functions, so it always replaces rather than nests); an iteration statement (for/while/do) + // raises the loop depth for its subtree. + let childCtx = ctx; + if (node instanceof ASTNode.FunctionDefinition) { + this._checkFunctionReturn(node); + // Enter the entry function's stage for its subtree so derivative-in-vertex-shader can fire. + // A helper called by both entries stays `null` — only calls inside the vertex entry itself flag. + const name = node.protoType.ident.lexeme; + const stage: WalkContext["currentStage"] = + name === this._vertexEntry && this._vertexEntry + ? "vertex" + : name === this._fragmentEntry && this._fragmentEntry + ? "fragment" + : null; + childCtx = { currentFunction: node, loopDepth: ctx.loopDepth, currentStage: stage }; + } else if (node instanceof ASTNode.IterationStatement) { + this._checkIterationCondition(node); + childCtx = { + currentFunction: ctx.currentFunction, + loopDepth: ctx.loopDepth + 1, + currentStage: ctx.currentStage + }; + } else if (node instanceof ASTNode.SelectionStatement) { + this._checkNonBoolCondition(node); + } else if (node instanceof ASTNode.ConditionalExpression) { + this._checkTernaryCondition(node); + } else if (node instanceof ASTNode.JumpStatement) { + this._checkJump(node, ctx); + } else if (node instanceof ASTNode.FunctionCallGeneric) { + this._checkConstructorArgs(node); + this._checkRecursiveCall(node, ctx); + this._checkDerivativeCall(node, ctx); + } else if (node instanceof ASTNode.UnaryExpression) { + this._checkUnaryOperand(node); + } else if (node instanceof ASTNode.MultiplicativeExpression) { + if (!this._checkArithmeticOperation(node)) { + this._checkConstDivideByZero(node); + } + } else if (node instanceof ASTNode.AdditiveExpression) { + this._checkArithmeticOperation(node); + } else if (node instanceof ASTNode.ShiftExpression) { + this._checkShiftRange(node); + this._checkIntegerBinaryOperands(node); + } else if ( + node instanceof ASTNode.AndExpression || + node instanceof ASTNode.ExclusiveOrExpression || + node instanceof ASTNode.InclusiveOrExpression + ) { + this._checkIntegerBinaryOperands(node); + } else if ( + node instanceof ASTNode.LogicalAndExpression || + node instanceof ASTNode.LogicalXorExpression || + node instanceof ASTNode.LogicalOrExpression + ) { + this._checkScalarBoolBinaryOperands(node); + } else if (node instanceof ASTNode.PostfixExpression) { + this._checkPostfix(node); + } else if (node instanceof ASTNode.FunctionDeclarator) { + this._checkReturnType(node); + } else if (node instanceof ASTNode.FunctionProtoType) { + this._checkLocalFunctionPrototype(node, ctx); + } else if (node instanceof ASTNode.StructSpecifier) { + this._checkStructSpecifier(node); + } else if (node instanceof ASTNode.TypeSpecifier && !(node.parent instanceof ASTNode.FunctionIdentifier)) { + this._checkCustomTypeReference(node); + } else if (node instanceof ASTNode.SingleDeclaration || node instanceof ASTNode.VariableDeclaration) { + this._checkVariableDeclarator(node.declarator); + } else if (node instanceof ASTNode.InitDeclaratorList && node.declarator) { + this._checkVariableDeclarator(node.declarator); + } else if (node instanceof ASTNode.ArraySpecifier) { + this._checkArraySpecifier(node); + } else if (node instanceof ASTNode.AssignmentExpression) { + this._checkAssignmentTarget(node); + this._checkAssignmentType(node); + } + const children = node.children; + if (children) { + for (const child of children) { + if (child instanceof TreeNode) this._walk(child, childCtx); + } + } + } + + /** + * `gl_FragData` is a fragment-output *array* — legal only when indexed (`gl_FragData[i] = ...`). + * A bare reference (r-value, l-value, swizzle, function arg) is invalid GLSL. `ShaderAnalysisInfo` + * collects every `gl_FragData` location; `_checkPostfix` + * records the base of every `gl_FragData[i]` shape in `_indexedGlFragDataStarts`. Anything left + * over — first occurrence only — is reported here. + */ + private _reportBareGlFragData(): void { + for (const loc of this._analysis.glFragDataReferences) { + if (this._indexedGlFragDataStarts.has(loc.start.index)) continue; + this._push( + "'gl_FragData' must be indexed — write to `gl_FragData[i]` or return an MRT struct.", + loc, + DiagnosticType.BareGlFragData + ); + return; + } + } + + private _push(message: string, location: ShaderRange, code: DiagnosticType): void { + this._errors.push( + ShaderCompilerUtils.createGSError(message, GSErrorName.CompilationError, this._source, location, code) as GSError + ); + } + + private _pushWarning(message: string, location: ShaderRange, code: DiagnosticType): void { + this._errors.push( + ShaderCompilerUtils.createGSError(message, GSErrorName.CompilationWarn, this._source, location, code) as GSError + ); + } + + private _checkCustomTypeReference(node: ASTNode.TypeSpecifier): void { + if (!node.isCustom) return; + const typeName = (node.children[0] as ASTNode.TypeSpecifierNonArray).children[0]; + if (!(typeName instanceof BaseToken)) return; + + const lookup = ShaderValidator._typeLookup; + lookup.set(typeName.lexeme, ESymbolType.STRUCT); + const symbolTable = this._shaderData.symbolTable; + const structs = symbolTable.getSymbols(lookup, true, ShaderValidator._typeStructScratch); + const referenceIndex = typeName.location.start.index; + let priorStructCount = 0; + for (let i = 0, n = structs.length; i < n; i++) { + const struct = structs[i] as StructSymbol; + if (struct.astNode.ident && struct.astNode.ident.location.start.index < referenceIndex) { + structs[priorStructCount++] = struct; + } + } + structs.length = priorStructCount; + + if (!structs.length) { + if (symbolTable.hasSymbol(lookup)) { + this._push( + `Type '${typeName.lexeme}' is declared only after this reference or in an unavailable macro branch.`, + typeName.location, + DiagnosticType.UseBeforeDeclaration + ); + } else { + this._pushWarning( + `Unknown type '${typeName.lexeme}' — ensure it is provided at runtime as a macro.`, + typeName.location, + DiagnosticType.UnknownType + ); + } + return; + } + + const coverage = getBranchCoverage( + structs.map((struct) => struct.branchSignature ?? []), + node._branch + ); + if (coverage === "uncovered") { + this._push( + `Type '${typeName.lexeme}' is unavailable under at least one macro configuration reaching this reference.`, + typeName.location, + DiagnosticType.UseBeforeDeclaration + ); + } else if (coverage === "unknown") { + this._pushWarning( + `Type '${typeName.lexeme}' may be unavailable under some macro configurations; align its declaration and reference conditions.`, + typeName.location, + DiagnosticType.UseBeforeDeclaration + ); + } + } + + /** + * GLSL ES §5.8: "the left operand of the assignment operator must be an l-value". The parser only + * checks type compatibility on assignment; this catches the shapes that couldn't ever be written + * to — macros, function returns, constant literals, `const`-qualified variables, and compound + * expressions (r-values by construction). Descend through wrapper nodes (single-child expression + * chains, parenthesised primaries, postfix `.field` / `[i]` peeling) and report the first shape + * that isn't assignable. + */ + private _checkAssignmentTarget(node: ASTNode.AssignmentExpression): void { + // Only the ternary `lhs op rhs` shape has an LHS to inspect; the single-child form is a pure + // r-value chain that reaches AssignmentExpression only because of the grammar's precedence tree. + if (node.children.length !== 3) return; + const lhs = node.children[0] as ASTNode.ExpressionAstNode; + const reason = this._nonAssignableReason(lhs); + if (reason) { + this._push( + `Cannot assign to ${reason} — the left operand of '=' must be a modifiable l-value.`, + lhs.location, + DiagnosticType.InvalidAssignmentTarget + ); + } + } + + private _checkAssignmentType(node: ASTNode.AssignmentExpression): void { + if (node.children.length !== 3) return; + const lhs = node.children[0] as ASTNode.ExpressionAstNode; + const operator = node.children[1] as ASTNode.AssignmentOperator; + const rhs = node.children[2] as ASTNode.AssignmentExpression; + const operatorType = (operator.children[0] as BaseToken | undefined)?.type; + const compoundOperator = + operatorType === ETokenType.MUL_ASSIGN + ? "*" + : operatorType === ETokenType.DIV_ASSIGN + ? "/" + : operatorType === ETokenType.MOD_ASSIGN + ? "%" + : operatorType === ETokenType.ADD_ASSIGN + ? "+" + : operatorType === ETokenType.SUB_ASSIGN + ? "-" + : undefined; + const arithmetic = compoundOperator + ? TypeSystem.arithmeticOperation(lhs.type, rhs.type, compoundOperator) + : undefined; + if (arithmetic?.valid === false) { + this._push( + `Operator '${compoundOperator}=' cannot combine '${TypeSystem.typeName(lhs.type)}' and '${TypeSystem.typeName(rhs.type)}'.`, + node.location, + DiagnosticType.InvalidBinaryOperands + ); + return; + } + const assignedType = arithmetic?.resultType ?? rhs.type; + if (!TypeSystem.isAssignable(lhs.type, assignedType)) { + this._push( + `Cannot assign a value of type '${TypeSystem.typeName(rhs.type)}' to '${TypeSystem.typeName(lhs.type)}'.`, + node.location, + DiagnosticType.AssignTypeMismatch + ); + } + } + + private _checkVariableDeclarator(declarator: ASTNode.VariableDeclaratorInfo): void { + const { identifier, initializer, isConst, typeInfo } = declarator; + if (typeInfo.type === Keyword.VOID) { + this._push( + `Illegal use of type 'void' — '${identifier.lexeme}' cannot be declared as void.`, + identifier.location, + DiagnosticType.InvalidVoidVariable + ); + } + if (initializer && !typeInfo.arraySpecifier && !TypeSystem.isAssignable(typeInfo.type, initializer.type)) { + this._push( + `Cannot initialize '${identifier.lexeme}' of type '${TypeSystem.typeName( + typeInfo.type + )}' from '${TypeSystem.typeName(initializer.type)}'.`, + initializer.location, + DiagnosticType.AssignTypeMismatch + ); + } + if (initializer && isConst && !ParserUtils.isConstExpr(initializer)) { + this._push( + `'${identifier.lexeme}': const initializer must be a constant expression.`, + initializer.location, + DiagnosticType.NonConstInitializer + ); + } + } + + private _checkArraySpecifier(node: ASTNode.ArraySpecifier): void { + if (typeof node.size !== "number" || node.size > 0) return; + const expression = node.children[1]; + if (!(expression instanceof TreeNode)) return; + this._push( + `Array size ${node.size} must be greater than zero.`, + expression.location, + DiagnosticType.InvalidArraySize + ); + } + + /** + * Describe why `node` is not an l-value, or return undefined if it is one. The descent mirrors + * the grammar's operator-precedence chain — single-child wrappers pass through, r-value-only + * constructs (function calls, compound arithmetic, ternary) terminate with a specific reason. + */ + private _nonAssignableReason(node: TreeNode): string | undefined { + // Compound / arithmetic / logical / relational shapes produce r-values; the grammar wraps them + // in AssignmentExpression → ConditionalExpression → ... → PrimaryExpression when only a + // single-child pass-through fires, so a >1-child form of any of these terminates as non-lvalue. + if (node instanceof ASTNode.ConditionalExpression && node.children.length > 1) { + return "a ternary expression result"; + } + if ( + (node instanceof ASTNode.LogicalOrExpression || + node instanceof ASTNode.LogicalXorExpression || + node instanceof ASTNode.LogicalAndExpression || + node instanceof ASTNode.InclusiveOrExpression || + node instanceof ASTNode.ExclusiveOrExpression || + node instanceof ASTNode.AndExpression || + node instanceof ASTNode.EqualityExpression || + node instanceof ASTNode.RelationalExpression || + node instanceof ASTNode.ShiftExpression || + node instanceof ASTNode.AdditiveExpression || + node instanceof ASTNode.MultiplicativeExpression) && + node.children.length > 1 + ) { + return "a compound expression"; + } + if (node instanceof ASTNode.UnaryExpression && node.children.length > 1) { + return "a unary-operator result"; + } + if (node instanceof ASTNode.FunctionCallGeneric) { + return "a function call result"; + } + if (node instanceof ASTNode.PostfixExpression) { + const base = node.children[0]; + if (!(base instanceof TreeNode)) return "an unassignable postfix expression"; + return this._nonAssignableReason(base); + } + if (node instanceof ASTNode.PrimaryExpression) { + if (node.children.length === 1) { + const child = node.children[0]; + if (child instanceof ASTNode.VariableIdentifier) return this._nonAssignableReason(child); + if (child instanceof BaseToken) { + if (child.type === ETokenType.INT_CONSTANT || child.type === ETokenType.FLOAT_CONSTANT) { + return "a numeric literal"; + } + if (child.type === Keyword.True || child.type === Keyword.False) return "a boolean literal"; + } + return undefined; + } + // Parenthesised: `( expr )` — l-value-ness passes through the wrapped expression. + const inner = node.children[1]; + if (inner instanceof TreeNode) return this._nonAssignableReason(inner); + return undefined; + } + if (node instanceof ASTNode.VariableIdentifier) { + const child = node.children[0]; + // A macro may expand to a legal l-value; its expansion is validated by the runtime compiler. + if (child instanceof ASTNode.MacroCallSymbol || child instanceof ASTNode.MacroCallFunction) return undefined; + if (child instanceof BaseToken) { + const lookup = ShaderValidator._varLookup; + lookup.set(child.lexeme, ESymbolType.VAR); + const symbol = this._shaderData.symbolTable.getSymbol(lookup, true, node._branch, branchAnalysis); + if (symbol instanceof VarSymbol) { + if (symbol.isConst) return "a const-qualified variable"; + // GLSL ES §5.9: uniforms, inputs, and samplers are not l-values. Check sampler before + // uniform — a sampler is *always* uniform in ES (§4.1.7), so both branches would fire, + // but "a sampler" is a more actionable diagnostic than the generic uniform text. + if (TypeSystem.isSamplerType(symbol.dataType?.type)) return "a sampler"; + // Galacean's implicit uniform (global, no initializer). Driver rejects `u_i++` as + // "l-value required (can't modify a uniform "u_i")". + if (symbol.isUniform) return "a uniform variable"; + } + } + return undefined; + } + // Single-child expression wrappers (Expression, ConditionalExpression when children.length===1, + // etc.) don't add semantics — descend into the child. + if (node.children.length === 1) { + const child = node.children[0]; + if (child instanceof TreeNode) return this._nonAssignableReason(child); + } + return undefined; + } + + /** + * `if (cond)` — cond must be a bool. GLSL ES has no implicit scalar→bool, so a float/int + * condition is an error. Skip TypeAny (unknown) to avoid false positives (continue-with-unknown). + */ + private _checkNonBoolCondition(node: ASTNode.SelectionStatement): void { + const condition = node.children.find((c) => c instanceof ASTNode.ExpressionAstNode) as + | ASTNode.ExpressionAstNode + | undefined; + if (!condition) return; + this._reportNonBoolCondition(condition, "'if' condition"); + } + + /** + * `while (cond)` / `for (init; cond; step)` — cond must be a bool. Same rule as `if`; the + * grammar wraps it in `Condition` (WHILE) or `ForRestStatement > ConditionOpt > Condition` (FOR). + * A `Condition` in `type id = init` form uses the initializer's type. + */ + private _checkIterationCondition(node: ASTNode.IterationStatement): void { + const children = node.children; + const kw = children[0]; + if (!(kw instanceof BaseToken)) return; + if (kw.type === Keyword.WHILE) { + const cond = children[2]; + if (cond instanceof ASTNode.Condition) this._checkConditionNode(cond, "'while' condition"); + } else if (kw.type === Keyword.FOR) { + const rest = children[3]; + if (rest instanceof ASTNode.ForRestStatement) { + const opt = rest.children[0]; + if (opt instanceof ASTNode.ConditionOpt && opt.children.length === 1) { + const inner = opt.children[0]; + if (inner instanceof ASTNode.Condition) this._checkConditionNode(inner, "'for' condition"); + } + } + } + } + + /** + * Resolve a `Condition` node (either `expression` or `type id = initializer` form) to its + * expression-typed slot and delegate to the shared non-bool reporter. + */ + private _checkConditionNode(cond: ASTNode.Condition, label: string): void { + const c = cond.children; + // `condition: expression` + if (c.length === 1 && c[0] instanceof ASTNode.ExpressionAstNode) { + this._reportNonBoolCondition(c[0] as ASTNode.ExpressionAstNode, label); + return; + } + // `condition: fully_specified_type id '=' initializer` — the initializer at children[3] carries the type. + if (c.length === 4 && c[3] instanceof ASTNode.ExpressionAstNode) { + this._reportNonBoolCondition(c[3] as ASTNode.ExpressionAstNode, label); + } + } + + /** + * `cond ? a : b` — cond must be a bool. children[0] is the condition (LogicalOrExpression); + * the 1-child collapse form is not a ternary and is skipped. + */ + private _checkTernaryCondition(node: ASTNode.ConditionalExpression): void { + if (node.children.length !== 5) return; + const condition = node.children[0]; + if (!(condition instanceof ASTNode.ExpressionAstNode)) return; + this._reportNonBoolCondition(condition, "ternary condition"); + } + + /** Emit NonBoolCondition when `condition.type` is known and not bool. Skips TypeAny. */ + private _reportNonBoolCondition(condition: ASTNode.ExpressionAstNode, label: string): void { + const t = condition.type; + if (t !== TypeAny && t !== Keyword.BOOL) { + this._push( + `${label[0].toUpperCase()}${label.slice(1)} must be a bool, got '${TypeSystem.typeName(t)}'.`, + condition.location, + DiagnosticType.NonBoolCondition + ); + } + } + + /** + * A builtin numeric constructor (`vecN(...)` etc.) cannot take a sampler/struct argument + * (ConstructorArgType), and a vecN needs exactly N components — too few OR too many is + * ConstructorArgCount. A single scalar is a valid splat (short-circuit). + */ + private _checkConstructorArgs(node: ASTNode.FunctionCallGeneric): void { + const functionIdentifier = node.children[0] as ASTNode.FunctionIdentifier; + if (!functionIdentifier.isBuiltin) return; + if (!(node.children.length === 4 && node.children[2] instanceof ASTNode.FunctionCallParameterList)) return; + const list = node.children[2] as ASTNode.FunctionCallParameterList; + const badIndex = list.paramSig.findIndex((t) => TypeSystem.isSamplerType(t) || typeof t === "string"); + if (badIndex >= 0) { + const argNode = list.paramNodes[badIndex] as TreeNode | undefined; + this._push( + `Cannot construct '${TypeSystem.typeName(functionIdentifier.ident)}' from a '${TypeSystem.typeName( + list.paramSig[badIndex] + )}' argument.`, + argNode?.location ?? list.location, + DiagnosticType.ConstructorArgType + ); + return; + } + // A vecN / matN constructor needs exactly N components (or N×M for matrices) from its args. + // Skip when we can't count any side. A single scalar is a valid splat and short-circuits before + // the exact-count check. Mismatch either direction is ConstructorArgCount. + const matrixNeed = TypeSystem.matrixComponentCount(functionIdentifier.ident); + const need = TypeSystem.vectorComponentCount(functionIdentifier.ident) || matrixNeed; + if (need <= 0) return; + // GLSL ES §5.4.3: `matN(matM)` — a matrix constructor with a single matrix argument. Always + // legal regardless of M vs N (source is truncated / padded diagonally). Short-circuit before + // the component-count check, which would otherwise fire on the source's total component count. + if (matrixNeed > 0 && list.paramSig.length === 1 && TypeSystem.matrixComponentCount(list.paramSig[0]) > 0) { + return; + } + // GLSL ES §5.4.2: constructing a shorter vector from one longer vector drops trailing + // components, e.g. `vec3(vec4Value)`. The reverse direction still lacks components. + if (matrixNeed === 0 && list.paramSig.length === 1 && TypeSystem.vectorComponentCount(list.paramSig[0]) >= need) { + return; + } + let total = 0; + let countable = list.paramSig.length > 0; + for (const t of list.paramSig) { + const c = TypeSystem.isScalarType(t) + ? 1 + : TypeSystem.vectorComponentCount(t) || TypeSystem.matrixComponentCount(t); + if (c === 0) { + countable = false; + break; + } + total += c; + } + const singleScalar = list.paramSig.length === 1 && TypeSystem.isScalarType(list.paramSig[0]); + if (countable && !singleScalar && total !== need) { + this._push( + `Constructor '${TypeSystem.typeName(functionIdentifier.ident)}' needs ${need} components but the arguments provide ${total}.`, + list.location, + DiagnosticType.ConstructorArgCount + ); + } + } + + /** + * Unary operand-type rules: `!` needs bool, `~` needs integer, `-`/`+` need numeric. The operand + * type is read directly (not the deduced result), so this fires for known operands and skips + * TypeAny (continue-with-unknown); `++`/`--` reduce with a raw token child and are not handled here. + */ + private _checkUnaryOperand(node: ASTNode.UnaryExpression): void { + if (node.children.length !== 2) return; + // Prefix `++`/`--` — first child is the raw INC_OP/DEC_OP token (not wrapped in UnaryOperator). + // The operand must be an l-value per §5.9, same rule as postfix `++`/`--`. + const firstChild = node.children[0]; + if ( + firstChild instanceof BaseToken && + (firstChild.type === ETokenType.INC_OP || firstChild.type === ETokenType.DEC_OP) + ) { + const operand = node.children[1]; + if (operand instanceof TreeNode) { + const reason = this._nonAssignableReason(operand); + if (reason) { + this._push( + `Cannot apply '${firstChild.lexeme}' to ${reason} — the operand of '${firstChild.lexeme}' must be a modifiable l-value.`, + operand.location, + DiagnosticType.InvalidAssignmentTarget + ); + } + } + return; + } + if (!(firstChild instanceof ASTNode.UnaryOperator)) return; + const opToken = firstChild.children[0]; + const operand = node.children[1] as ASTNode.ExpressionAstNode; + const t = operand.type; + if (!(opToken instanceof BaseToken) || t === TypeAny) return; + let bad = false; + switch (opToken.type) { + case ETokenType.BANG: + bad = !TypeSystem.isBoolType(t); + break; + case ETokenType.TILDE: + bad = !TypeSystem.isIntegerType(t); + break; + case ETokenType.DASH: + case ETokenType.PLUS: + bad = TypeSystem.isBoolType(t) || TypeSystem.isSamplerType(t) || typeof t === "string"; + break; + } + if (bad) { + this._push( + `Operator '${opToken.lexeme}' cannot be applied to operand of type '${TypeSystem.typeName(t)}'.`, + node.location, + DiagnosticType.InvalidUnaryOperand + ); + } + } + + /** Validate and infer arithmetic from the same TypeSystem operation result used by the parser. */ + private _checkArithmeticOperation(node: ASTNode.MultiplicativeExpression | ASTNode.AdditiveExpression): boolean { + if (node.children.length !== 3) return false; + const left = node.children[0]; + const right = node.children[2]; + const operator = node.children[1]; + if (!(left instanceof ASTNode.ExpressionAstNode) || !(right instanceof ASTNode.ExpressionAstNode)) return false; + const operatorLexeme = operator instanceof BaseToken ? operator.lexeme : "op"; + const result = TypeSystem.arithmeticOperation(left.type, right.type, operatorLexeme); + if (result.valid !== false) return false; + this._push( + `Operator '${operatorLexeme}' cannot combine '${TypeSystem.typeName(left.type)}' and '${TypeSystem.typeName(right.type)}'.`, + node.location, + DiagnosticType.InvalidBinaryOperands + ); + return true; + } + + /** + * GLSL ES §6.1: function declarations (prototypes) may only appear at global scope. The grammar + * accepts `int g();` inside a function body, so without this check the parser cascades into a + * misleading `EntryNotFound`. A `FunctionProtoType` wrapped in a `FunctionDefinition` is the + * body path — that's a legal definition, not a prototype declaration. + */ + private _checkLocalFunctionPrototype(node: ASTNode.FunctionProtoType, ctx: WalkContext): void { + if (!ctx.currentFunction) return; + if (node.parent instanceof ASTNode.FunctionDefinition) return; + this._push( + `Function prototype '${node.ident.lexeme}' cannot be declared inside a function body — declare it at global scope.`, + node.location, + DiagnosticType.LocalFunctionPrototype + ); + } + + /** + * `<<` `>>` `&` `|` `^` — all take integer scalar-or-vector operands per §5.9. Same + * direct-operand contract as `_checkModuloOperandsInteger`. + */ + private _checkIntegerBinaryOperands( + node: + | ASTNode.ShiftExpression + | ASTNode.AndExpression + | ASTNode.ExclusiveOrExpression + | ASTNode.InclusiveOrExpression + ): void { + if (node.children.length !== 3) return; + const op = node.children[1]; + const opLexeme = op instanceof BaseToken ? op.lexeme : "op"; + const bad = this._firstNonIntegerOperand(node.children[0], node.children[2]); + if (bad) { + this._push( + `Operator '${opLexeme}' requires integer operands, got '${TypeSystem.typeName(bad.type)}'.`, + bad.location, + DiagnosticType.InvalidBinaryOperands + ); + } + } + + /** + * `&&` `||` `^^` — each operand must be `bool` scalar per §5.9. GLSL ES rejects `bvecN` operands + * (desktop-GL 4.x does allow it; ES 3.00 does not). + */ + private _checkScalarBoolBinaryOperands( + node: ASTNode.LogicalAndExpression | ASTNode.LogicalXorExpression | ASTNode.LogicalOrExpression + ): void { + if (node.children.length !== 3) return; + const op = node.children[1]; + const opLexeme = op instanceof BaseToken ? op.lexeme : "op"; + const bad = this._firstNonScalarBoolOperand(node.children[0], node.children[2]); + if (bad) { + this._push( + `Operator '${opLexeme}' requires scalar bool operands, got '${TypeSystem.typeName(bad.type)}'.`, + bad.location, + DiagnosticType.InvalidBinaryOperands + ); + } + } + + /** First operand whose direct type is neither integer nor `TypeAny`. */ + private _firstNonIntegerOperand(a: NodeChild, b: NodeChild): ASTNode.ExpressionAstNode | undefined { + if (a instanceof ASTNode.ExpressionAstNode && a.type !== TypeAny && !TypeSystem.isIntegerType(a.type)) return a; + if (b instanceof ASTNode.ExpressionAstNode && b.type !== TypeAny && !TypeSystem.isIntegerType(b.type)) return b; + return undefined; + } + + /** First operand whose direct type is neither `bool` nor `TypeAny`. */ + private _firstNonScalarBoolOperand(a: NodeChild, b: NodeChild): ASTNode.ExpressionAstNode | undefined { + if (a instanceof ASTNode.ExpressionAstNode && a.type !== TypeAny && a.type !== Keyword.BOOL) return a; + if (b instanceof ASTNode.ExpressionAstNode && b.type !== TypeAny && b.type !== Keyword.BOOL) return b; + return undefined; + } + + /** + * Integer division/modulo by a compile-time constant zero is an error; float `1.0/0.0` yields Inf + * (unspecified, not an error). `%` is integer-only in GLSL ES; `/` qualifies only when the result + * type deduced to an integer (int/int) — FLOAT or TypeAny don't flag. Only reached with clean + * operands (the arithmetic-operand check already suppressed bad ones), so it scans operands once. + */ + private _checkConstDivideByZero(node: ASTNode.MultiplicativeExpression): void { + if (node.children.length !== 3) return; + const op = node.children[1]; + // Gate on the operator before touching operands: only `/` and `%` can divide by zero. + if (!(op instanceof BaseToken) || (op.type !== ETokenType.PERCENT && op.type !== ETokenType.SLASH)) return; + if (op.type === ETokenType.SLASH && !TypeSystem.isIntegerType(node.type)) return; + const divisor = node.children[2]; + if (divisor instanceof TreeNode && ParserUtils.constNumericValue(divisor) === 0) { + this._push( + op.type === ETokenType.PERCENT ? "Modulo by constant zero." : "Division by constant zero.", + divisor.location, + DiagnosticType.ConstDivideByZero + ); + } + } + + /** A shift by a constant amount outside [0, 32) is out of range — GLSL ES int/uint are 32-bit. */ + private _checkShiftRange(node: ASTNode.ShiftExpression): void { + if (node.children.length !== 3) return; + const amount = node.children[2]; + if (!(amount instanceof TreeNode)) return; + const n = ParserUtils.constNumericValue(amount); + if (n !== undefined && (n < 0 || n >= 32)) { + this._push( + `Shift amount ${n} is out of range; must be in [0, 32).`, + amount.location, + DiagnosticType.ShiftOutOfRange + ); + } + } + + /** + * Stateless postfix checks: an invalid swizzle on a known vector (`InvalidSwizzle`), and the + * `base[index]` family — `gl_FragData[i]` (`GlFragData`), a scalar non-array base (`NonIndexableType`), + * a non-integer index (`NonIntegerIndex`), and a constant index past a known vector/array size + * (`IndexOutOfBounds`). The struct-field (`else if`) path stays inline in the parser since it reads the + * symbol table; preserve the original control flow here (early returns, gl_FragData-vs-index branching). + */ + private _checkPostfix(node: ASTNode.PostfixExpression): void { + const children = node.children; + // `postfix ++` / `postfix --` — the operand must be an l-value per §5.9. + if (children.length === 2 && children[1] instanceof BaseToken) { + const op = children[1]; + const operand = children[0]; + if ((op.type === ETokenType.INC_OP || op.type === ETokenType.DEC_OP) && operand instanceof TreeNode) { + const reason = this._nonAssignableReason(operand); + if (reason) { + this._push( + `Cannot apply '${op.lexeme}' to ${reason} — the operand of '${op.lexeme}' must be a modifiable l-value.`, + operand.location, + DiagnosticType.InvalidAssignmentTarget + ); + } + } + return; + } + if (children.length === 3 && children[2] instanceof BaseToken) { + const base = children[0] as ASTNode.ExpressionAstNode; + const field = children[2]; + // GLSL ES §5.5: `.field` on a receiver that is not a struct, scalar, or vector is invalid. + // The driver rejects `s.rr` (sampler) or `f().xx` (void return) with "field selection requires + // structure, vector, or interface block on left hand side". Skip TypeAny — unknown types keep + // the wiggle room. Struct types are strings; `ParserUtils.swizzleError` already returns null + // for them and the parser's inline UndeclaredStructMember path takes over. + const baseType = base.type; + if ( + baseType !== undefined && + baseType !== TypeAny && + typeof baseType !== "string" && + TypeSystem.vectorComponentCount(baseType) === 0 && + !TypeSystem.isScalarType(baseType) + ) { + this._push( + `Field selection '.${field.lexeme}' requires a structure, vector, or scalar receiver — got '${TypeSystem.typeName(baseType)}'.`, + field.location, + DiagnosticType.InvalidSwizzle + ); + return; + } + const swizzleError = ParserUtils.swizzleError(base.type, field.lexeme); + if (swizzleError) { + this._push(swizzleError, field.location, DiagnosticType.InvalidSwizzle); + } + } else if (children.length === 4) { + // `base [ index ]`. + const base = children[0] as ASTNode.ExpressionAstNode; + const index = children[2]; + // `gl_FragData[i]` — record the base's location so `_reportBareGlFragData` treats this + // occurrence as legal rather than reporting it as a bare use. + if (ParserUtils.extractDirectIdentLexeme(base) === "gl_FragData") { + this._indexedGlFragDataStarts.add(base.location.start.index); + } + // A scalar (non-array) base can't be indexed at all. Resolve the base to a bare variable so an + // array (`a[3]`) or a vector (`v[0]`) is excluded; non-variable/compound bases stay unknown. + if (TypeSystem.isScalarType(base.type)) { + const baseIdent = ParserUtils.unwrapBareIdentifier(base, { allowParens: true }); + if (baseIdent && !baseIdent.isArray) { + const m = `Type '${TypeSystem.typeName(base.type)}' is not indexable.`; + this._push(m, base.location, DiagnosticType.NonIndexableType); + } + } + if (!(index instanceof ASTNode.ExpressionAstNode)) return; + // The index must be an integer; a constant integer index past a known vector's size is out of bounds. + const indexType = index.type; + if (indexType !== TypeAny && !TypeSystem.isIntegerType(indexType)) { + const m = `Index must be an integer, got '${TypeSystem.typeName(indexType)}'.`; + this._push(m, index.location, DiagnosticType.NonIntegerIndex); + return; + } + // Array-of-vector base like `ivec2 arr[N]` — a[i] indexes the outer array, not the inner + // vec2. Skip the vector-bounds check when the base is an array; the array-size check + // still runs below when the array size is known at compile time. + const baseArrayIdent = ParserUtils.unwrapBareIdentifier(base, { allowParens: true }); + const baseIsArray = !!baseArrayIdent?.isArray; + const size = baseIsArray ? 0 : TypeSystem.vectorComponentCount(base.type); + if (size > 0) { + const n = ParserUtils.constNumericValue(index); + if (n !== undefined && (n < 0 || n >= size)) { + const m = `Index ${n} is out of bounds for a ${size}-component vector.`; + this._push(m, index.location, DiagnosticType.IndexOutOfBounds); + } + } else { + // A constant index past a fixed-size array's bounds is out of bounds — the spec + // requires bounds-checking fixed-size arrays as well as vectors. Unsized / non-array + // bases keep arraySize undefined. + const baseIdent = ParserUtils.unwrapBareIdentifier(base, { allowParens: true }); + const arraySize = baseIdent?.arraySize; + if (arraySize !== undefined) { + const n = ParserUtils.constNumericValue(index); + if (n !== undefined && (n < 0 || n >= arraySize)) { + const m = `Index ${n} is out of bounds for an array of size ${arraySize}.`; + this._push(m, index.location, DiagnosticType.IndexOutOfBounds); + } + } + } + } + } + + /** + * A sampler (opaque) type or a struct containing a sampler cannot be returned by value — GLSL + * forbids returning opaque types (spec §6.1). Struct returns look up the members via the symbol + * table; recursion through nested structs is bounded by declaration order (a struct can only name + * an already-declared struct). + */ + private _checkReturnType(node: ASTNode.FunctionDeclarator): void { + const returnType = node.returnType; + const t = returnType.type; + if (TypeSystem.isSamplerType(t)) { + this._push( + `Function return type '${TypeSystem.typeName(t)}' is not constructible; samplers cannot be returned.`, + returnType.location, + DiagnosticType.NonConstructibleReturnType + ); + return; + } + if (typeof t === "string" && this._structContainsSampler(t, new Set())) { + this._push( + `Function return type '${t}' is not constructible; structs containing samplers cannot be returned.`, + returnType.location, + DiagnosticType.NonConstructibleReturnType + ); + } + } + + private static _structLookup = new SymbolInfo("", null); + private static _structScratch: StructSymbol[] = []; + + /** + * Recursively check whether a struct (by name) contains a sampler member at any nesting depth. + * `visited` breaks cycles that could arise via mutually-referenced typedefs / macros. + */ + private _structContainsSampler(structName: string, visited: Set): boolean { + if (visited.has(structName)) return false; + visited.add(structName); + const lookup = ShaderValidator._structLookup; + lookup.set(structName, ESymbolType.STRUCT); + const structs = this._shaderData.symbolTable.getSymbols( + lookup as unknown as StructSymbol, + false, + ShaderValidator._structScratch + ); + for (const s of structs) { + const astNode = s.astNode as ASTNode.StructSpecifier | undefined; + const propList = astNode?.propList; + if (!propList) continue; + for (const prop of propList) { + const pt = prop.typeInfo.type; + if (TypeSystem.isSamplerType(pt)) return true; + if (typeof pt === "string" && this._structContainsSampler(pt, visited)) return true; + } + } + return false; + } + + /** + * Function-level MissingReturn: a non-void function whose body does not guarantee a return on + * every control-flow path. A simple per-path CFG: a block guarantees return if its last executed + * statement is either a `return value;` or an `if/else` where both arms guarantee. Loops / macros + * / switch are conservatively treated as "may not return" — a `for {…return…}` doesn't count + * because the loop might not execute. The void-with-value case is reported per-jump in + * `_checkJump` (the parser no longer records `returnStatement` for void functions — codegen + * invariant, see AST.ts FunctionDefinition.semanticAnalyze). + */ + private _checkFunctionReturn(node: ASTNode.FunctionDefinition): void { + const returnType = node.protoType.returnType; + if (returnType.type === Keyword.VOID) return; + if (!ShaderValidator._blockGuaranteesReturn(node.statements)) { + this._push(`No return statement found.`, returnType.location, DiagnosticType.MissingReturn); + } + } + + /** True if `node` (a block-like or statement wrapper) definitely returns on every path. */ + private static _blockGuaranteesReturn(node: TreeNode | undefined): boolean { + if (!node) return false; + // A JumpStatement whose keyword is RETURN — `return value;` (children.length === 3) or + // `return;` (children.length === 2). Only valid in void, but still terminates the path. + if (node instanceof ASTNode.JumpStatement) { + const kw = node.children[0]; + return kw instanceof BaseToken && kw.type === Keyword.RETURN; + } + // If/else — both arms must guarantee. `if` alone (no else) doesn't guarantee: the else path + // falls through. + if (node instanceof ASTNode.SelectionStatement) { + // Grammar: IF '(' expression ')' statement (ELSE statement)? + const children = node.children; + if (children.length !== 7) return false; + return ( + ShaderValidator._blockGuaranteesReturn(children[4] as TreeNode) && + ShaderValidator._blockGuaranteesReturn(children[6] as TreeNode) + ); + } + // `#ifdef … #else … #endif` inside a function body. Analyzer must model the same visibility + // that codegen does — if every reachable branch (including `#else`) terminates in a return, + // the whole `#if` block is a return-guarantee. Without an `#else`, the runtime-preprocessor + // may see zero arms match, so we conservatively say no. + if (node instanceof ASTNode.MacroIfStatement) { + return ShaderValidator._macroIfGuaranteesReturn(node); + } + // A block/statement wrapper: walk to the last real statement of a block and recurse. + const last = ShaderValidator._lastStatementOf(node); + if (last && last !== node) return ShaderValidator._blockGuaranteesReturn(last); + return false; + } + + /** + * `macro_if_statement → macro_push_context statement_list macro_branch`. A `macro_branch` is + * either `[macro_pop_context]` (bare `#endif`, no `#else`, so at runtime the untaken side + * falls through), `[macro_else_expression, statement_list, macro_pop_context]`, or + * `[macro_elif_expression, statement_list, macro_branch]`. For the whole `#if` to guarantee a + * return, the leading arm's `statement_list` must return AND the tail must terminate on all + * remaining arms — the recursion also rejects the bare-endif case. + */ + private static _macroIfGuaranteesReturn(node: ASTNode.MacroIfStatement): boolean { + const c = node.children; + if (c.length !== 3) return false; + const leadStatements = c[1] as TreeNode; + const tailBranch = c[2] as ASTNode.MacroBranch; + if (!ShaderValidator._blockGuaranteesReturn(leadStatements)) return false; + return ShaderValidator._macroBranchGuaranteesReturn(tailBranch); + } + + private static _macroBranchGuaranteesReturn(node: ASTNode.MacroBranch): boolean { + const c = node.children; + // Bare `#endif` — no `#else`, runtime side may fall through. Reject. + if (c.length === 1) return false; + if (c.length === 3) { + // `#else #endif` — one final arm, must return. + if (c[0] instanceof ASTNode.MacroElseExpression) { + return ShaderValidator._blockGuaranteesReturn(c[1] as TreeNode); + } + // `#elif ` — this arm returns AND the tail terminates on all remaining. + if (c[0] instanceof ASTNode.MacroElifExpression) { + return ( + ShaderValidator._blockGuaranteesReturn(c[1] as TreeNode) && + ShaderValidator._macroBranchGuaranteesReturn(c[2] as ASTNode.MacroBranch) + ); + } + } + return false; + } + + /** + * Descend through the block/statement wrappers used by the grammar (Statement, SimpleStatement, + * CompoundStatement, CompoundStatementNoScope, StatementList) to the last real statement of a + * block. Returns `undefined` for empty blocks; returns the input for a non-block leaf. + */ + private static _lastStatementOf(node: TreeNode): TreeNode | undefined { + if ( + node instanceof ASTNode.Statement || + node instanceof ASTNode.SimpleStatement || + node instanceof ASTNode.CompoundStatement || + node instanceof ASTNode.CompoundStatementNoScope + ) { + const children = node.children; + // `{}` — empty block. + if (children.length === 2) return undefined; + // Walk into the non-brace children (a Statement / StatementList) and take the last real leaf. + for (const child of children) { + if (child instanceof TreeNode) { + const inner = ShaderValidator._lastStatementOf(child); + if (inner) return inner; + } + } + return undefined; + } + if (node instanceof ASTNode.StatementList) { + // Left-recursive: the last child is always the newest Statement. + const children = node.children; + for (let i = children.length - 1; i >= 0; i--) { + const c = children[i]; + if (c instanceof TreeNode) { + const inner = ShaderValidator._lastStatementOf(c); + if (inner) return inner; + } + } + return undefined; + } + return node; + } + + /** + * Jump-statement checks needing walk-local context: `InvalidReturnType` fires per-jump — a value + * return in a `void` function, or a `return value;` whose value isn't assignable to the declared + * non-void return type. A `break`/`continue` at loop depth 0 (outside any loop) is + * `MisplacedControlFlow`. + */ + private _checkJump(node: ASTNode.JumpStatement, ctx: WalkContext): void { + const children = node.children; + const keyword = ASTNode._unwrapToken(children[0]).type; + if (keyword === Keyword.RETURN) { + if (!ctx.currentFunction) return; + const declared = ctx.currentFunction.protoType.returnType.type; + if (declared === Keyword.VOID) { + // `void f() { return value; }` — the value at children[1] is illegal. + if (children.length === 3) { + this._push("Return in void function.", children[1].location, DiagnosticType.InvalidReturnType); + } + } else if (children.length !== 3) { + this._push( + "Return in a non-void function must provide a value.", + node.location, + DiagnosticType.InvalidReturnType + ); + } else { + const returned = (children[1] as ASTNode.ExpressionAstNode).type; + if (declared != undefined && !TypeSystem.isAssignable(declared, returned)) { + this._push( + `Cannot return a value of type '${TypeSystem.typeName(returned)}' from a function returning '${TypeSystem.typeName(declared)}'.`, + children[1].location, + DiagnosticType.InvalidReturnType + ); + } + } + } else if ((keyword === Keyword.BREAK || keyword === Keyword.CONTINUE) && ctx.loopDepth === 0) { + this._push( + `'${keyword === Keyword.BREAK ? "break" : "continue"}' is only allowed inside a loop.`, + node.location, + DiagnosticType.MisplacedControlFlow + ); + } + } + + /** + * GLSL forbids recursion: a call whose callee name AND parameter signature match the enclosing + * function (the same overload) is `RecursiveFunction`. The parser short-circuits this same case + * during overload resolution (so it isn't mis-reported as Undefined/NoMatchingOverload); the + * exact-signature match avoids flagging a call to a different overload of the same name. + */ + private _checkRecursiveCall(node: ASTNode.FunctionCallGeneric, ctx: WalkContext): void { + const currentFunction = ctx.currentFunction; + if (!currentFunction) return; + const functionIdentifier = node.children[0] as ASTNode.FunctionIdentifier; + if (functionIdentifier.isBuiltin) return; + const fnIdent = functionIdentifier.ident as string; + const proto = currentFunction.protoType; + const callee = node.fnSymbol; + if (callee instanceof FnSymbol) { + if (callee.astNode !== currentFunction) { + return; + } + this._push( + `Recursive call to '${fnIdent}' is not allowed (GLSL forbids recursion).`, + functionIdentifier.location, + DiagnosticType.RecursiveFunction + ); + return; + } + if (proto.ident.lexeme !== fnIdent) return; + + let callSig: ASTNode.FunctionCallParameterList["paramSig"] | undefined; + if (node.children.length === 4 && node.children[2] instanceof ASTNode.FunctionCallParameterList) { + callSig = (node.children[2] as ASTNode.FunctionCallParameterList).paramSig; + } + const headerSig = proto.paramSig ?? []; + const cSig = callSig ?? []; + if (headerSig.length === cSig.length && headerSig.every((t, i) => t === cSig[i])) { + this._push( + `Recursive call to '${fnIdent}' is not allowed (GLSL forbids recursion).`, + node.location, + DiagnosticType.RecursiveFunction + ); + } + } + + /** + * After the walk, find call-graph cycles of length ≥ 2 (mutual recursion) and report each cycle + * on its lexicographically-first participant. Direct self-recursion is already reported at the + * call site by `_checkRecursiveCall`, so ignore length-1 cycles here. + */ + private _reportMutualRecursion(): void { + // Iterative DFS with a recursion stack — for each starting fn, look for a back-edge to something + // already on the stack that isn't the immediate self edge. + const seen = new Set(); + const reported = new Set(); + for (const start of this._analysis.functions()) { + if (seen.has(start)) continue; + const stack: ASTNode.FunctionDefinition[] = [start]; + const onStack = new Set([start]); + const iters: Array> = [this._analysis.calleesOf(start).values()]; + while (stack.length) { + const it = iters[iters.length - 1]; + const step = it.next(); + if (step.done) { + const done = stack.pop()!; + onStack.delete(done); + seen.add(done); + iters.pop(); + continue; + } + const next = step.value; + if (onStack.has(next)) { + // cycle detected — extract the participants from the stack + const cycleStart = stack.indexOf(next); + const cycle = stack.slice(cycleStart); + if (cycle.length >= 2) { + const marker = cycle.reduce((first, candidate) => + candidate.protoType.ident.lexeme < first.protoType.ident.lexeme ? candidate : first + ); + if (!reported.has(marker)) { + reported.add(marker); + this._push( + `Mutual recursion detected in call chain: ${cycle + .map((fn) => fn.protoType.ident.lexeme) + .join(" → ")} → ${next.protoType.ident.lexeme} (GLSL forbids recursion).`, + marker.protoType.ident.location, + DiagnosticType.RecursiveFunction + ); + } + } + continue; + } + if (seen.has(next)) continue; + stack.push(next); + onStack.add(next); + iters.push(this._analysis.calleesOf(next).values()); + } + } + } + + /** + * Post-walk pass: transitively reach from the vertex entry via the call graph, and report any + * derivative call site inside a reachable helper. Helpers called only from the fragment entry + * are silent; helpers on both paths get flagged (the vertex path evaluates them illegally). + */ + private _reportDerivativeReachableFromVertex(): void { + const vertexEntry = this._analysis.coreInfo.vertexEntry; + if (!vertexEntry.name) return; + const reachable = new Set(this._analysis.reachableFunctions(vertexEntry)); + // Vertex entry itself is handled inline in `_checkDerivativeCall`; skip it here. + for (const entry of vertexEntry.functions) reachable.delete(entry.astNode); + for (const fn of reachable) { + const sites = this._derivativeSites.get(fn); + if (!sites) continue; + for (const s of sites) { + this._push( + `Derivative function '${s.name}' is reached from the vertex entry via '${fn.protoType.ident.lexeme}' — derivatives are fragment-only.`, + s.location, + DiagnosticType.DerivativeInVertexShader + ); + } + } + } + + /** + * Fragment-only derivative builtins (`dFdx`/`dFdy`/`fwidth`) — illegal in the vertex shader + * (`DerivativeInVertexShader`) and require a float/floatN argument (`NonFloatDerivativeArg`). + * Only user-callable functions reach here (isBuiltin=false, since the identifier is a string name, + * not a type keyword); constructors like `vec3(...)` never match a derivative name. + */ + private _checkDerivativeCall(node: ASTNode.FunctionCallGeneric, ctx: WalkContext): void { + const functionIdentifier = node.children[0] as ASTNode.FunctionIdentifier; + if (functionIdentifier.isBuiltin) return; + const name = functionIdentifier.lexeme; + if (!DERIVATIVE_BUILTINS.has(name)) return; + + if (ctx.currentStage === "vertex") { + this._push( + `Derivative function '${name}' is not allowed in the vertex shader (fragment-only).`, + node.location, + DiagnosticType.DerivativeInVertexShader + ); + } else if (ctx.currentFunction) { + // Record for the post-walk reachability pass: a helper that calls dFdx is illegal when the + // vertex entry transitively reaches it, even if the helper itself is `currentStage === null`. + const enclosing = ctx.currentFunction; + let sites = this._derivativeSites.get(enclosing); + if (!sites) { + sites = []; + this._derivativeSites.set(enclosing, sites); + } + sites.push({ name, location: node.location }); + } + + // Spec: derivative builtins take `genType` (float/vec2/vec3/vec4); anything else is a type error. + if (node.children.length === 4 && node.children[2] instanceof ASTNode.FunctionCallParameterList) { + const list = node.children[2] as ASTNode.FunctionCallParameterList; + const paramSig = list.paramSig; + if (paramSig.length === 1) { + const t = paramSig[0]; + if (t !== TypeAny && !ShaderValidator._isFloatOrFloatVector(t)) { + const argNode = list.paramNodes[0] as TreeNode | undefined; + this._push( + `'${name}' expects a float or floatN argument, got '${TypeSystem.typeName(t)}'.`, + argNode?.location ?? list.location, + DiagnosticType.NonFloatDerivativeArg + ); + } + } + } + } + + /** + * `struct Foo { ... }` — flags an empty body (`EmptyStruct`). An empty body is unreachable via + * the plain grammar (`struct_declaration_list` requires ≥1 declaration → SyntaxError first), but a + * fully-macro-guarded body can reduce to zero collected props; the check catches that shape rather + * than the surface form. + */ + private _checkStructSpecifier(node: ASTNode.StructSpecifier): void { + const propList = node.propList; + if (!propList || propList.length === 0) { + const location = node.ident?.location ?? node.location; + this._push("Struct declaration must contain at least one member.", location, DiagnosticType.EmptyStruct); + } + } + + /** float / vec2 / vec3 / vec4 — the `genType` family derivative builtins accept. */ + private static _isFloatOrFloatVector(t: unknown): boolean { + return t === Keyword.FLOAT || t === Keyword.VEC2 || t === Keyword.VEC3 || t === Keyword.VEC4; + } +} diff --git a/packages/shader-analyzer/src/cli.ts b/packages/shader-analyzer/src/cli.ts new file mode 100644 index 0000000000..1167c676a7 --- /dev/null +++ b/packages/shader-analyzer/src/cli.ts @@ -0,0 +1,143 @@ +import { lstatSync, readFileSync } from "node:fs"; +import { dirname, join, relative, resolve, sep } from "node:path"; +import type { IncludeMap } from "@galacean/engine-shader-parser/internal/analyzer"; +import { ShaderAnalyzer } from "./ShaderAnalyzer"; +import { DiagnosticSeverity, formatDiagnostic } from "./Diagnostic"; + +interface CliOptions { + file: string; + includeRoot?: string; + json: boolean; + help: boolean; +} + +const USAGE = "Usage: galacean-shader-analyzer [--json] [--include-root directory] [file|-]"; +const HELP = `${USAGE} + +Options: + --json Print machine-readable diagnostics. + --include-root directory Resolve shader includes below this directory. + -h, --help Show this help. + +Input: + file Analyze one ShaderLab file. + - or omitted Read ShaderLab source from stdin. + +Exit codes: + 0 No error diagnostics (warnings may be present). + 1 At least one error diagnostic. + 2 Invalid command-line usage. + +Examples: + galacean-shader-analyzer Assets/Shaders/PBR.shader + galacean-shader-analyzer --json --include-root Assets/Shaders Assets/Shaders/PBR.shader + galacean-shader-analyzer --json -`; + +try { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + console.log(HELP); + } else { + run(options); + } +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + console.error(USAGE); + process.exitCode = 2; +} + +function run(options: CliOptions): void { + const source = readFileSync(options.file === "-" ? 0 : options.file, "utf8"); + const includeRoot = options.includeRoot ? resolve(options.includeRoot) : undefined; + const includeMap = includeRoot ? createLazyIncludeMap(includeRoot) : undefined; + const basePathForIncludeKey = + includeRoot && options.file !== "-" ? sourceBasePath(options.file, includeRoot) : undefined; + const diagnostics = new ShaderAnalyzer().analyze(source, { + file: options.file, + includeMap, + basePathForIncludeKey + }).diagnostics; + + if (options.json) { + console.log(JSON.stringify({ file: options.file, diagnostics }, null, 2)); + } else { + for (const diagnostic of diagnostics) { + const { line, column } = diagnostic.range.start; + console.log( + `${diagnostic.file ?? options.file}:${line}:${column} ${diagnostic.severity} ${formatDiagnostic(diagnostic)}` + ); + } + } + if (diagnostics.some((diagnostic) => diagnostic.severity === DiagnosticSeverity.Error)) process.exitCode = 1; +} + +function parseArgs(args: string[]): CliOptions { + let file = "-"; + let includeRoot: string | undefined; + let json = false; + let help = false; + let hasFile = false; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--json") { + json = true; + } else if (arg === "--include-root") { + includeRoot = args[++i]; + if (!includeRoot) throw new Error("--include-root requires a directory."); + } else if (arg === "--help" || arg === "-h") { + help = true; + } else if (arg.startsWith("-") && arg !== "-") { + throw new Error(`Unknown option '${arg}'.`); + } else if (hasFile) { + throw new Error("Only one shader file may be analyzed at a time."); + } else { + file = arg; + hasFile = true; + } + } + return { file, includeRoot, json, help }; +} + +function createLazyIncludeMap(root: string): IncludeMap { + const cache = Object.create(null) as Record; + return new Proxy(cache, { + get(target, includeName): string | undefined { + if (typeof includeName !== "string") return undefined; + if (Object.prototype.hasOwnProperty.call(target, includeName)) return target[includeName]; + + let path = root; + try { + let stats: ReturnType | undefined; + for (const segment of includeName.split("/")) { + path = join(path, segment); + stats = lstatSync(path); + if (stats.isSymbolicLink()) { + target[includeName] = undefined; + return undefined; + } + } + const source = stats?.isFile() ? readFileSync(path, "utf8") : undefined; + target[includeName] = source; + return source; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT" && code !== "ENOTDIR") throw error; + target[includeName] = undefined; + return undefined; + } + } + }); +} + +function sourceBasePath(file: string, includeRoot: string): string | undefined { + const sourceDirectory = dirname(resolve(file)); + const relativeDirectory = relative(includeRoot, sourceDirectory); + if (relativeDirectory.startsWith("..")) return undefined; + const suffix = relativeDirectory ? `${toIncludeKey(relativeDirectory)}/` : ""; + return `shaders://root/${suffix}`; +} + +function toIncludeKey(path: string): string { + return sep === "/" ? path : path.split(sep).join("/"); +} diff --git a/packages/shader-analyzer/src/convert.ts b/packages/shader-analyzer/src/convert.ts new file mode 100644 index 0000000000..2a3ec2c145 --- /dev/null +++ b/packages/shader-analyzer/src/convert.ts @@ -0,0 +1,51 @@ +import type { Diagnostic } from "./Diagnostic"; +import { DiagnosticType, DiagnosticSeverity } from "./Diagnostic"; +import { GSError, GSErrorName } from "@galacean/engine-shader-parser/internal/analyzer"; + +/** + * Converts a parser error to a structured diagnostic. + * @param error - Error reported while parsing or analyzing shader source. + * @returns Structured diagnostic for the error. + */ +export function gseErrorToDiagnostic(error: Error): Diagnostic { + if (!(error instanceof GSError)) { + // Non-GSError (e.g. thrown from lexer/preprocess) — best-effort + return { + severity: DiagnosticSeverity.Error, + code: DiagnosticType.SyntaxError, + message: error.message, + range: { start: { line: 1, column: 1, offset: 0 }, end: { line: 1, column: 1, offset: 0 } } + }; + } + + const severity = error.name === GSErrorName.CompilationWarn ? DiagnosticSeverity.Warning : DiagnosticSeverity.Error; + const code = isDiagnosticType(error.code) ? error.code : DiagnosticType.SyntaxError; + + return { + severity, + code, + message: error.message, + file: error.file, + range: gSErrorLocationToRange(error.location), + relatedSource: error.source || undefined + }; +} + +function isDiagnosticType(code: string | undefined): code is DiagnosticType { + return code !== undefined && Object.values(DiagnosticType).includes(code as DiagnosticType); +} + +function gSErrorLocationToRange(location: InstanceType["location"]): Diagnostic["range"] { + if ("start" in location && "end" in location) { + // ShaderRange + return { + start: { line: location.start.line + 1, column: location.start.column + 1, offset: location.start.index }, + end: { line: location.end.line + 1, column: location.end.column + 1, offset: location.end.index } + }; + } + // ShaderPosition + return { + start: { line: location.line + 1, column: location.column + 1, offset: location.index }, + end: { line: location.line + 1, column: location.column + 1, offset: location.index } + }; +} diff --git a/packages/shader-analyzer/src/index.ts b/packages/shader-analyzer/src/index.ts new file mode 100644 index 0000000000..2a95b72107 --- /dev/null +++ b/packages/shader-analyzer/src/index.ts @@ -0,0 +1,5 @@ +export { ShaderAnalyzer } from "./ShaderAnalyzer"; +export type { AnalyzerOptions, AnalysisResult, ShaderIncludeMap } from "./ShaderAnalyzer"; +export type { Diagnostic } from "./Diagnostic"; +export { DiagnosticType, DiagnosticSeverity, formatDiagnostic } from "./Diagnostic"; +export { DiagnosticCategory, DIAGNOSTIC_CATEGORY } from "./DiagnosticCategory"; diff --git a/packages/shader-analyzer/src/sourcePosition.ts b/packages/shader-analyzer/src/sourcePosition.ts new file mode 100644 index 0000000000..8503b21dbb --- /dev/null +++ b/packages/shader-analyzer/src/sourcePosition.ts @@ -0,0 +1,22 @@ +import type { Diagnostic } from "./Diagnostic"; + +/** + * Converts a source offset to the analyzer's one-based diagnostic position. + * @param source - Source text containing the offset. + * @param offset - Zero-based UTF-16 offset into the source. + * @returns One-based line and column plus the original offset. + * @internal + */ +export function positionAt(source: string, offset: number): Diagnostic["range"]["start"] { + let line = 1; + let column = 1; + for (let index = 0; index < offset; index++) { + if (source.charCodeAt(index) === 10) { + line++; + column = 1; + } else { + column++; + } + } + return { line, column, offset }; +} diff --git a/packages/shader-analyzer/tsconfig.json b/packages/shader-analyzer/tsconfig.json new file mode 100644 index 0000000000..7436de3689 --- /dev/null +++ b/packages/shader-analyzer/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "module": "esnext", + "target": "esnext", + "declaration": true, + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "experimentalDecorators": true, + "declarationDir": "types", + "emitDeclarationOnly": true, + "noImplicitOverride": true, + "sourceMap": true, + "incremental": false, + "skipLibCheck": true, + "stripInternal": true + }, + "include": ["src/**/*"] +} diff --git a/packages/shader-compiler/README.md b/packages/shader-compiler/README.md index 229571e75f..ebaa0b0f5a 100644 --- a/packages/shader-compiler/README.md +++ b/packages/shader-compiler/README.md @@ -26,13 +26,7 @@ const shader = Shader.create(galaceanShaderCode); engine.run() ``` -There are two versions of the shader compiler: `Release` and `Verbose`. The `Verbose` version offers more user-friendly diagnostic information for debugging shader compilation errors, while the Release version provides superior performance. - -you can use `Verbose` version by import: - -```ts -import { ShaderCompiler } from "@galacean/engine-shader-compiler/verbose"; -``` +Authoring diagnostics are provided separately by `@galacean/engine-shader-analyzer`; the runtime compiler does not include analyzer diagnostics. ## CFG Grammar conflict detection diff --git a/packages/shader-compiler/package.json b/packages/shader-compiler/package.json index 9dbf4fe7b5..22db134fb0 100644 --- a/packages/shader-compiler/package.json +++ b/packages/shader-compiler/package.json @@ -31,11 +31,6 @@ "require": "./bundler/precompile.cjs.js", "types": "./types/bundler/precompile.d.ts" }, - "./verbose": { - "import": "./dist/module.verbose.js", - "require": "./dist/main.verbose.js", - "types": "./types/index.d.ts" - }, "./src/*": "./src/*.ts", "./package.json": "./package.json" }, @@ -56,13 +51,13 @@ "files": [ "dist/**/*", "bundler/**/*", - "types/**/*", - "verbose/package.json" + "types/**/*" ], "dependencies": { "@rollup/pluginutils": "^5.0.0", "@galacean/engine-math": "workspace:*", - "@galacean/engine-core": "workspace:*" + "@galacean/engine-core": "workspace:*", + "@galacean/engine-shader-parser": "workspace:*" }, "devDependencies": { "@galacean/engine-design": "workspace:*" diff --git a/packages/shader-compiler/rollup.config.js b/packages/shader-compiler/rollup.config.js index 75ca7a85c3..92ef2e6164 100644 --- a/packages/shader-compiler/rollup.config.js +++ b/packages/shader-compiler/rollup.config.js @@ -1,20 +1,20 @@ // Self-contained build for the shader compiler package. // // Produces a self-contained runtime + bundler plumbing for the -// `shader-precompile` CLI. shader-compiler is a standalone offline compiler -// (see `src/enums/README.md`) — at runtime it only needs `Color` from +// `shader-precompile` CLI. shader-compiler is a standalone offline compiler — +// at runtime it only needs `Color` from // `@galacean/engine-math`, which we bundle in directly from math's `src/` // (via `mainFields: ["debug"]`) so this build has zero workspace dist // prerequisites and works on a cold checkout. // // The root rollup later rebuilds the runtime entry with `external: math` // (sharing the math package at runtime instead of inlining) and adds the -// `_VERBOSE` split + UMD/browser formats. Both products are correct; this -// one only has to live long enough for `pnpm precompile` to run. +// UMD/browser formats. Both products are correct; this one only has to live +// long enough for `pnpm precompile` to run. import resolve from "@rollup/plugin-node-resolve"; import commonjs from "@rollup/plugin-commonjs"; import swc from "rollup-plugin-swc3"; -import jscc from "rollup-plugin-jscc"; +import { fileURLToPath } from "node:url"; const bundlerExternal = [ // Pulled in dynamically by precompile.ts (`await import("../dist/main.js")`); @@ -45,15 +45,18 @@ const swcPluginRuntime = swc({ sourceMaps: true }); -// Strip `// #if _VERBOSE` … `// #endif` blocks. The root rollup later -// rebuilds with `_VERBOSE: true` as a sibling `*.verbose.js` output. -const jsccPlugin = jscc({ values: { _VERBOSE: false } }); - // Nothing externalized at the runtime entry: `@galacean/engine-math` is // resolved to its `src/index.ts` via `mainFields: ["debug"]` and bundled // inline (no math/dist prerequisite). `@galacean/engine-design` imports are // all `import type` and erased by swc before they reach rollup. const runtimeExternal = []; +const shaderParserRuntimeEntry = fileURLToPath(new URL("../shader-parser/src/runtime.ts", import.meta.url)); +const workspaceShaderParserSource = { + name: "workspace-shader-parser-source", + resolveId(id) { + if (id === "@galacean/engine-shader-parser/internal") return shaderParserRuntimeEntry; + } +}; export default [ // Bootstrap runtime (release-mode). Lets the bundler CLI's @@ -66,14 +69,12 @@ export default [ ], external: runtimeExternal, plugins: [ - // `mainFields: ["debug"]` resolves workspace packages directly to their - // source (`debug` → `src/index.ts` by repo convention), so this build - // never depends on any other workspace dist being present and always - // uses the freshest source — no stale-dist risk on warm starts. - resolve({ extensions: [".js", ".ts"], mainFields: ["debug"] }), + // The parser root is intentionally unresolvable, so cold builds bind its runtime subpath + // directly to workspace source instead of publishing a source-only package condition. + workspaceShaderParserSource, + resolve({ extensions: [".js", ".ts"], mainFields: ["debug", "module", "main"] }), swcPluginRuntime, - commonjs(), - jsccPlugin + commonjs() ] }, { diff --git a/packages/shader-compiler/src/GSError.ts b/packages/shader-compiler/src/GSError.ts deleted file mode 100644 index da4565bbec..0000000000 --- a/packages/shader-compiler/src/GSError.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { ShaderPosition } from "./common/ShaderPosition"; -import { ShaderRange } from "./common/ShaderRange"; - -export class GSError extends Error { - static wrappingLineCount = 2; - - constructor( - name: GSErrorName, - message: string, - public readonly location: ShaderRange | ShaderPosition, - public readonly source: string, - public readonly file?: string - ) { - super(message); - this.name = name; - } - - override toString(): string { - let start: ShaderPosition, end: ShaderPosition; - const { message, location, source } = this; - if (!source) { - return message; - } - - if (location instanceof ShaderPosition) { - start = end = location; - } else { - start = location.start; - end = location.end; - } - const lines = source.split("\n"); - - let diagnosticMessage = `${this.name}: ${message}\n\n`; - - // #if _VERBOSE - const lineSplit = "|···"; - - const wrappingLineCount = GSError.wrappingLineCount; - for (let i = start.line - wrappingLineCount, n = end.line + wrappingLineCount; i <= n; i++) { - const line = lines[i]; - diagnosticMessage += lineSplit + `${line}\n`; - - if (i < start.line || i > end.line) continue; - - let remarkStart = 0; - let remarkEnd = line.length; - let paddingLength = lineSplit.length; - if (i === start.line) { - remarkStart = start.column; - paddingLength += start.column; - } - if (i === end.line) { - remarkEnd = end.column; - } - const remarkLength = Math.max(remarkEnd - remarkStart, 1); - - diagnosticMessage += " ".repeat(paddingLength) + "^".repeat(remarkLength) + "\n"; - } - // #endif - - return diagnosticMessage; - } -} - -export enum GSErrorName { - PreprocessorError = "PreprocessorError", - CompilationError = "CompilationError", - ScannerError = "ScannerError", - CompilationWarn = "CompilationWarning" -} diff --git a/packages/shader-compiler/src/ParserUtils.ts b/packages/shader-compiler/src/ParserUtils.ts deleted file mode 100644 index 1fd6770370..0000000000 --- a/packages/shader-compiler/src/ParserUtils.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { ETokenType, GalaceanDataType, TypeAny } from "./common"; -import { BaseToken as Token } from "./common/BaseToken"; -import { ASTNode, TreeNode } from "./parser/AST"; -import { GrammarSymbol, NoneTerminal } from "./parser/GrammarSymbol"; -// #if _VERBOSE -import { Keyword } from "./common/enums/Keyword"; -import State from "./lalr/State"; -// #endif - -export class ParserUtils { - static unwrapNodeByType(node: TreeNode, type: NoneTerminal): T | undefined { - const child = node.children[0]; - if (child instanceof Token) return; - if (child.nt === type) return child as T; - return ParserUtils.unwrapNodeByType(child, type); - } - - /** - * Parse a function-macro parameter-list lexeme (`"(a, b, c)"`) into its parameter - * names. An empty list `"()"` yields `[]`. Leading/trailing whitespace around each - * name is trimmed. Empty entries from consecutive commas are filtered out. - */ - static parseMacroParamList(lexeme: string): string[] { - const inner = lexeme.replace(/^\s*\(\s*|\s*\)\s*$/g, ""); - if (!inner) return []; - return inner - .split(",") - .map((s) => s.trim()) - .filter(Boolean); - } - - /** - * Walk single-child precedence-chain wrappers down to a `VariableIdentifier`, - * returning the leaf node (or `undefined` for any compound expression). - * - * `allowParens` chooses between two callers' needs: - * - `true` — descend through `( expression )` form, so `(v)` resolves to - * `v`. Use when the caller substitutes at the expression root - * (aliasing, renaming); user-written parens carry no extra meaning. - * - `false` — any 3-child `PrimaryExpression` aborts; `(v)` stays - * compound. Use when the caller treats the unwrapped node as a single - * token (IO-struct arg drop, alias detection); user-written parens - * must not collapse. - */ - static unwrapBareIdentifier( - node: TreeNode, - options: { allowParens: boolean } - ): ASTNode.VariableIdentifier | undefined { - let cur: TreeNode = node; - while (true) { - if (cur instanceof ASTNode.VariableIdentifier) return cur; - if (options.allowParens && cur instanceof ASTNode.PrimaryExpression && cur.children.length === 3) { - const inner = cur.children[1]; - if (!(inner instanceof TreeNode)) return undefined; - cur = inner; - continue; - } - if (cur instanceof ASTNode.ExpressionAstNode && cur.children.length === 1) { - const child = cur.children[0]; - if (!(child instanceof TreeNode)) return undefined; - cur = child; - continue; - } - return undefined; - } - } - - /** - * Lexeme variant of `unwrapBareIdentifier({ allowParens: true })` for callers - * that already work with strings. Returns `null` for compound expressions. - */ - static extractDirectIdentLexeme(expr: TreeNode): string | null { - const ident = ParserUtils.unwrapBareIdentifier(expr, { allowParens: true }); - if (!ident) return null; - const child = ident.children[0]; - return child instanceof Token ? child.lexeme : null; - } - - // #if _VERBOSE - /** - * Check if type `tb` is compatible with type `ta`. - */ - static typeCompatible(ta: GalaceanDataType, tb: GalaceanDataType | undefined) { - if (tb == undefined || tb === TypeAny) return true; - if (ta === Keyword.INT) { - return ta === tb || tb === Keyword.UINT; - } - return ta === tb; - } - - static toString(sm: GrammarSymbol) { - if (this.isTerminal(sm)) { - return ETokenType[sm] ?? Keyword[sm]; - } - return NoneTerminal[sm]; - } - // #endif - - static isTerminal(sm: GrammarSymbol) { - return sm < NoneTerminal.START; - } - - /** - * @internal - */ - // #if _VERBOSE - static printStatePool(logPath: string) { - let output = ""; - - console.log("========== Parser Pool =========="); - - let count = 0; - for (const state of State.pool.values()) { - count++; - let tmp = ""; - tmp += `${state.id}: \n`.padEnd(4); - for (const psItem of state.items) { - tmp += " " + psItem.toString() + "\n"; - } - output += tmp; - } - - console.log("state count:", count); - console.log(output); - } - // #endif -} diff --git a/packages/shader-compiler/src/Preprocessor.ts b/packages/shader-compiler/src/Preprocessor.ts deleted file mode 100644 index ca0cb04f28..0000000000 --- a/packages/shader-compiler/src/Preprocessor.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { ASTNode } from "./parser/AST"; -import type { BranchSignature } from "./common/BaseToken"; - -// Mirrors `ShaderPass._shaderRootPath`; inlined to keep shader-compiler standalone. -const SHADER_ROOT_PATH = "shaders://root/"; - -export type IncludeMap = { readonly [includeName: string]: string | undefined }; - -export type ChunkOutputCache = Map; - -export interface MacroDefineInfo { - isFunction: boolean; - params: string[]; - /** Value AST. Set when the replacement list parses as `expression` (which - * includes comma-separated lists per C99 §6.10.3); absent for the GLSL ES - * 3.00 §3.4 opaque cases the grammar can't reduce (empty, type-alias keyword, - * trailing punctuation, unbalanced bracket, trailing operator). Identifier - * references inside are collected by `MacroCallSymbol._collectIdentifierRefs` - * walking this subtree. */ - valueAst?: ASTNode.Expression; - /** Whitespace-normalized directive text. Dedup key against re-includes in - * the same branch; differing values produce different keys. */ - dedupKey: string; - /** `#ifdef` branch at registration time; call sites filter to visible entries. */ - branch: BranchSignature; -} - -export interface MacroDefineList { - [macroName: string]: MacroDefineInfo[]; -} - -export class Preprocessor { - // Block-comment alternation prevents expanding `#include` inside doc comments. - private static readonly _includeReg = /\/\*[\s\S]*?\*\/|^[ \t]*#include +"([\w\d./]+)"/gm; - - static parse( - source: string, - basePathForIncludeKey: string, - includeMap: IncludeMap, - chunkOutputCache: ChunkOutputCache - ): string { - return source.replace(this._includeReg, (match, includeName) => - includeName ? this._replace(includeName, basePathForIncludeKey, includeMap, chunkOutputCache) : match - ); - } - - private static _replace( - includeName: string, - basePathForIncludeKey: string, - includeMap: IncludeMap, - chunkOutputCache: ChunkOutputCache - ): string { - let path: string; - if (includeName[0] === ".") { - path = new URL(includeName, basePathForIncludeKey).href.substring(SHADER_ROOT_PATH.length); - } else { - path = includeName; - } - - const chunk = includeMap[path]; - if (!chunk) { - console.error(`Shader slice "${path}" not founded.`); - return ""; - } - - let cached = chunkOutputCache.get(path); - if (cached === undefined) { - cached = this.parse(chunk, basePathForIncludeKey, includeMap, chunkOutputCache); - chunkOutputCache.set(path, cached); - } - return cached; - } -} diff --git a/packages/shader-compiler/src/ShaderBackend.ts b/packages/shader-compiler/src/ShaderBackend.ts new file mode 100644 index 0000000000..f2be71e9bf --- /dev/null +++ b/packages/shader-compiler/src/ShaderBackend.ts @@ -0,0 +1,16 @@ +import type { IShaderInfo } from "@galacean/engine-design"; +import type { ShaderClueIR, ShaderCoreInfo } from "@galacean/engine-shader-parser/internal"; + +/** + * Internal boundary implemented by shader source backends. + * @internal + */ +export interface ShaderBackend { + /** + * Generates target source from neutral shader facts. + * @param ir - Neutral shader IR backed by the parsed program. + * @param coreInfo - Entry and IO facts required by code generation. + * @returns Generated stage source. + */ + generate(ir: ShaderClueIR, coreInfo: ShaderCoreInfo): IShaderInfo; +} diff --git a/packages/shader-compiler/src/ShaderCompiler.ts b/packages/shader-compiler/src/ShaderCompiler.ts index 7cd2e7c051..012439b753 100644 --- a/packages/shader-compiler/src/ShaderCompiler.ts +++ b/packages/shader-compiler/src/ShaderCompiler.ts @@ -1,63 +1,60 @@ import { Color } from "@galacean/engine-math"; import { ShaderLanguage } from "@galacean/engine-core"; +import { Logger } from "@galacean/engine-core"; import type { IPrecompiledShader, IRenderStates, IShaderSource } from "@galacean/engine-design"; import type { IShaderProgramSource } from "@galacean/engine-design/types/shader-compiler/IShaderProgramSource"; import { GLES100Visitor, GLES300Visitor } from "./codeGen"; -import { ShaderPosition, ShaderRange } from "./common"; -import { Lexer } from "./lexer"; +import { ShaderClueIR, ShaderCoreInfo } from "@galacean/engine-shader-parser/internal"; +import { Lexer } from "@galacean/engine-shader-parser/internal"; import { ShaderInstructionEncoder } from "./ShaderInstructionEncoder"; -import { ShaderTargetParser } from "./parser"; -import { Preprocessor, IncludeMap, ChunkOutputCache } from "./Preprocessor"; -import { ShaderCompilerUtils } from "./ShaderCompilerUtils"; -import { ShaderSourceParser } from "./sourceParser/ShaderSourceParser"; +import { ShaderTargetParser } from "@galacean/engine-shader-parser/internal"; +import { Preprocessor, IncludeMap, ChunkOutputCache } from "@galacean/engine-shader-parser/internal"; +import { ShaderCompilerUtils } from "@galacean/engine-shader-parser/internal"; +import { ShaderSourceParser } from "@galacean/engine-shader-parser/internal"; +import type { ShaderSourceParseResult } from "@galacean/engine-shader-parser/internal"; +import type { ShaderBackend } from "./ShaderBackend"; + +class ShaderSourceParseError extends Error { + constructor(readonly errors: readonly Error[]) { + super(errors.map((error) => error.toString()).join("\n")); + this.name = "ShaderSourceParseError"; + } +} +/** + * Compiles ShaderLab sources into GLES programs and precompiled instructions. + * + * Source parsing and backend generation remain independent of authoring diagnostics; structural + * source errors reject compilation before a partial precompiled artifact can be serialized. + */ export class ShaderCompiler { - private static _parser = ShaderTargetParser.create(); - private static _shaderPositionPool = ShaderCompilerUtils.createObjectPool(ShaderPosition); - private static _shaderRangePool = ShaderCompilerUtils.createObjectPool(ShaderRange); - - // #if _VERBOSE - static _processingPassText?: string; - // #endif + private static _parser?: ShaderTargetParser; private _includeMap: IncludeMap = {}; private readonly _chunkOutputCache: ChunkOutputCache = new Map(); - /** Replace the `#include` lookup table and clear the derived chunk cache. */ + /** + * Replaces the `#include` lookup table and clears the derived chunk cache. + * @param includeMap - Canonical include paths mapped to shader chunks. + * @internal + */ _setIncludeMap(includeMap: IncludeMap): void { this._includeMap = includeMap; this._chunkOutputCache.clear(); } - static createPosition(index: number, line?: number, column?: number): ShaderPosition { - const position = this._shaderPositionPool.get(); - position.set( - index, - // #if _VERBOSE - line, - column - // #endif - ); - return position; - } - - static createRange(start: ShaderPosition, end: ShaderPosition): ShaderRange { - const range = this._shaderRangePool.get(); - range.set(start, end); - return range; - } - + /** + * Parses one ShaderLab document into its source structure. + * @param sourceCode - Complete ShaderLab source. + * @returns Parsed subshaders, passes, entries, and render states. + * @throws ShaderSourceParseError when source-structure diagnostics were produced. + * @internal + */ _parseShaderSource(sourceCode: string): IShaderSource { - ShaderCompilerUtils.clearAllShaderCompilerObjectPool(); - const shaderSource = ShaderSourceParser.parse(sourceCode); - - // #if _VERBOSE - this._logErrors(ShaderSourceParser.errors); - // #endif - - return shaderSource; + return this._requireValidShaderSource(this._parseShaderSourceWithErrors(sourceCode)); } + /** @internal */ _parseShaderPass( source: string, vertexEntry: string, @@ -66,49 +63,61 @@ export class ShaderCompiler { basePathForIncludeKey: string ): IShaderProgramSource | undefined { const macroDefineList = {}; - const noIncludeContent = Preprocessor.parse( + const { content: noIncludeContent, errors: preprocessErrors } = Preprocessor.parseWithErrors( source, basePathForIncludeKey, this._includeMap, this._chunkOutputCache ); + if (preprocessErrors.length) { + for (const error of preprocessErrors) Logger.error(error.toString()); + return undefined; + } const lexer = new Lexer(noIncludeContent, macroDefineList); const tokens = lexer.tokenize(); - const { _parser: parser } = ShaderCompiler; - - ShaderCompiler._processingPassText = noIncludeContent; - - const program = parser.parse(tokens, macroDefineList); - - // #if _VERBOSE - this._logErrors(parser.errors); - // #endif - - if (!program) { + const parser = (ShaderCompiler._parser ??= ShaderTargetParser.create()); + + ShaderCompilerUtils.processingPassText = noIncludeContent; + + // finally so a parse miss (early return) or a codegen throw can't leave `processingPassText` + // pointing at this pass's text — the next compile would otherwise stamp errors with stale source. + try { + const program = parser.parse(tokens, macroDefineList); + if (!program) return undefined; + const ir = new ShaderClueIR(program, noIncludeContent); + const coreInfo = ShaderCoreInfo.create(ir, vertexEntry, fragmentEntry); + return this._generate(ir, coreInfo, backend); + } catch (error) { + Logger.error(error instanceof Error ? error.toString() : String(error)); return undefined; + } finally { + ShaderCompilerUtils.processingPassText = undefined; } + } - const codeGen = backend === ShaderLanguage.GLSLES100 ? GLES100Visitor.getVisitor() : GLES300Visitor.getVisitor(); - - const ret = codeGen.visitShaderProgram(program, vertexEntry, fragmentEntry); - ShaderCompiler._processingPassText = undefined; - - // #if _VERBOSE - this._logErrors(codeGen.errors); - // #endif - + private _generate(ir: ShaderClueIR, coreInfo: ShaderCoreInfo, backend: ShaderLanguage): IShaderProgramSource { + if (!coreInfo.vertexEntry.functions.length) { + throw new Error(`Vertex entry function '${coreInfo.vertexEntry.name}' not found.`); + } + if (!coreInfo.fragmentEntry.functions.length) { + throw new Error(`Fragment entry function '${coreInfo.fragmentEntry.name}' not found.`); + } + const codeGen: ShaderBackend = + backend === ShaderLanguage.GLSLES100 ? GLES100Visitor.getVisitor() : GLES300Visitor.getVisitor(); + const ret = codeGen.generate(ir, coreInfo); if (ret) { ret.vertexShaderInstructions = ShaderInstructionEncoder.parse(ret.vertex); ret.fragmentShaderInstructions = ShaderInstructionEncoder.parse(ret.fragment); } - return ret; } + /** @internal */ _precompile(sourceCode: string, platformTarget: ShaderLanguage, basePathForIncludeKey: string): IPrecompiledShader { - const shaderSource = this._parseShaderSource(sourceCode); + const sourceResult = this._parseShaderSourceWithErrors(sourceCode); + const shaderSource = this._requireValidShaderSource(sourceResult); const subShaders = shaderSource.subShaders.map((sub) => ({ name: sub.name, @@ -155,6 +164,16 @@ export class ShaderCompiler { }; } + private _parseShaderSourceWithErrors(sourceCode: string): ShaderSourceParseResult { + ShaderCompilerUtils.clearAllShaderCompilerObjectPool(); + return ShaderSourceParser.parseWithErrors(sourceCode); + } + + private _requireValidShaderSource(result: ShaderSourceParseResult): IShaderSource { + if (result.errors.length) throw new ShaderSourceParseError(result.errors); + return result.shaderSource; + } + private _serializeRenderStates(renderStates: IRenderStates): { constantMap: Record; variableMap: Record; @@ -173,17 +192,4 @@ export class ShaderCompiler { variableMap: renderStates.variableMap }; } - - // #if _VERBOSE - /** - * @internal - */ - _logErrors(errors: Error[]) { - if (errors.length === 0) return; - console.error(`${errors.length} errors occur!`); - for (const err of errors) { - console.error(err.toString()); - } - } - // #endif } diff --git a/packages/shader-compiler/src/ShaderInstructionEncoder.ts b/packages/shader-compiler/src/ShaderInstructionEncoder.ts index 82fb7f0c4d..5eacf3c766 100644 --- a/packages/shader-compiler/src/ShaderInstructionEncoder.ts +++ b/packages/shader-compiler/src/ShaderInstructionEncoder.ts @@ -1,27 +1,9 @@ import type { Condition, ShaderInstruction } from "@galacean/engine-design"; +import { ShaderPreprocessorDirective } from "@galacean/engine-core"; +import { parsePreprocessorCondition } from "@galacean/engine-shader-parser/internal"; export type { ShaderInstruction } from "@galacean/engine-design"; -/** Must stay in sync with ShaderPreprocessorDirective in @galacean/engine-core */ -const ShaderPreprocessorDirective = { - Text: 0, - IfDef: 1, - IfNdef: 2, - IfCmp: 3, - IfExpr: 4, - Else: 5, - Endif: 6, - Define: 7, - DefineVal: 8, - DefineFunc: 9, - Undef: 10 -} as const; - -interface ExprCtx { - s: string; - i: number; -} - /** * @internal */ @@ -176,6 +158,14 @@ export class ShaderInstructionEncoder { } } + private static _parseCondition(expression: string): Condition { + try { + return parsePreprocessorCondition(expression); + } catch { + return { t: "raw", e: expression }; + } + } + private static _findDirectiveStart(source: string, from: number, length: number): number { let i = from; while (i < length) { @@ -226,192 +216,4 @@ export class ShaderInstructionEncoder { const idx = s.indexOf("//"); return idx >= 0 ? s.substring(0, idx).trimEnd() : s; } - - private static _parseCondition(expr: string): Condition { - const ctx: ExprCtx = { s: expr.trim(), i: 0 }; - return ShaderInstructionEncoder._parseOr(ctx); - } - - private static _skipWs(ctx: ExprCtx): void { - while ( - ctx.i < ctx.s.length && - (ctx.s.charCodeAt(ctx.i) === 32 /* space */ || ctx.s.charCodeAt(ctx.i) === 9) /* tab */ - ) - ctx.i++; - } - - private static _parseOr(ctx: ExprCtx): Condition { - let left = ShaderInstructionEncoder._parseAnd(ctx); - ShaderInstructionEncoder._skipWs(ctx); - while ( - ctx.i < ctx.s.length - 1 && - ctx.s.charCodeAt(ctx.i) === 124 /* '|' */ && - ctx.s.charCodeAt(ctx.i + 1) === 124 /* '|' */ - ) { - ctx.i += 2; - ShaderInstructionEncoder._skipWs(ctx); - left = { t: "or", l: left, r: ShaderInstructionEncoder._parseAnd(ctx) }; - ShaderInstructionEncoder._skipWs(ctx); - } - return left; - } - - private static _parseAnd(ctx: ExprCtx): Condition { - let left = ShaderInstructionEncoder._parseUnary(ctx); - ShaderInstructionEncoder._skipWs(ctx); - while ( - ctx.i < ctx.s.length - 1 && - ctx.s.charCodeAt(ctx.i) === 38 /* '&' */ && - ctx.s.charCodeAt(ctx.i + 1) === 38 /* '&' */ - ) { - ctx.i += 2; - ShaderInstructionEncoder._skipWs(ctx); - left = { t: "and", l: left, r: ShaderInstructionEncoder._parseUnary(ctx) }; - ShaderInstructionEncoder._skipWs(ctx); - } - return left; - } - - private static _parseUnary(ctx: ExprCtx): Condition { - ShaderInstructionEncoder._skipWs(ctx); - if (ctx.s.charCodeAt(ctx.i) === 33 /* '!' */) { - ctx.i++; - ShaderInstructionEncoder._skipWs(ctx); - return { t: "not", c: ShaderInstructionEncoder._parsePrimary(ctx) }; - } - return ShaderInstructionEncoder._parsePrimary(ctx); - } - - private static _parsePrimary(ctx: ExprCtx): Condition { - ShaderInstructionEncoder._skipWs(ctx); - const { s } = ctx; - - // Parenthesized expression - if (s.charCodeAt(ctx.i) === 40 /* '(' */) { - ctx.i++; - ShaderInstructionEncoder._skipWs(ctx); - const inner = ShaderInstructionEncoder._parseOr(ctx); - ShaderInstructionEncoder._skipWs(ctx); - if (s.charCodeAt(ctx.i) === 41 /* ')' */) ctx.i++; - return inner; - } - - // defined(MACRO) or defined MACRO - if (s.substring(ctx.i, ctx.i + 7) === "defined") { - ctx.i += 7; - ShaderInstructionEncoder._skipWs(ctx); - const hasParen = s.charCodeAt(ctx.i) === 40; /* '(' */ - if (hasParen) ctx.i++; - ShaderInstructionEncoder._skipWs(ctx); - const name = ShaderInstructionEncoder._scanIdentifier(ctx); - ShaderInstructionEncoder._skipWs(ctx); - if (hasParen && s.charCodeAt(ctx.i) === 41 /* ')' */) ctx.i++; - return { t: "def", m: name }; - } - - // Numeric literal - if (ctx.i < s.length && ShaderInstructionEncoder._isDigit(s.charCodeAt(ctx.i))) { - const lhsNum = ShaderInstructionEncoder._scanNumber(ctx); - ShaderInstructionEncoder._skipWs(ctx); - const op = ShaderInstructionEncoder._scanOp(ctx); - if (op) { - ShaderInstructionEncoder._skipWs(ctx); - return { - t: "bool", - v: ShaderInstructionEncoder._evalNumOp(lhsNum, op, ShaderInstructionEncoder._scanNumber(ctx)) - }; - } - return { t: "bool", v: lhsNum !== 0 }; - } - - // Identifier — comparison or defined check - const name = ShaderInstructionEncoder._scanIdentifier(ctx); - if (!name) return { t: "bool", v: false }; - ShaderInstructionEncoder._skipWs(ctx); - const op = ShaderInstructionEncoder._scanOp(ctx); - if (op) { - ShaderInstructionEncoder._skipWs(ctx); - return { t: "cmp", m: name, op, v: ShaderInstructionEncoder._scanNumber(ctx) }; - } - return { t: "def", m: name }; - } - - private static _isDigit(charCode: number): boolean { - return charCode >= 48 /* '0' */ && charCode <= 57 /* '9' */; - } - - private static _isAlnum(charCode: number): boolean { - return ( - (charCode >= 65 /* 'A' */ && charCode <= 90) /* 'Z' */ || - (charCode >= 97 /* 'a' */ && charCode <= 122) /* 'z' */ || - (charCode >= 48 /* '0' */ && charCode <= 57) /* '9' */ || - charCode === 95 /* '_' */ - ); - } - - private static _scanIdentifier(ctx: ExprCtx): string { - const start = ctx.i; - while (ctx.i < ctx.s.length && ShaderInstructionEncoder._isAlnum(ctx.s.charCodeAt(ctx.i))) ctx.i++; - return ctx.s.substring(start, ctx.i); - } - - private static _scanNumber(ctx: ExprCtx): number { - const start = ctx.i; - if (ctx.s.charCodeAt(ctx.i) === 45 /* '-' */) ctx.i++; - while ( - ctx.i < ctx.s.length && - (ShaderInstructionEncoder._isDigit(ctx.s.charCodeAt(ctx.i)) || ctx.s.charCodeAt(ctx.i) === 46) /* '.' */ - ) - ctx.i++; - return Number(ctx.s.substring(start, ctx.i)) || 0; - } - - private static _scanOp(ctx: ExprCtx): string { - const c = ctx.s.charCodeAt(ctx.i); - const c2 = ctx.i + 1 < ctx.s.length ? ctx.s.charCodeAt(ctx.i + 1) : 0; - if (c === 61 /* '=' */ && c2 === 61 /* '=' */) { - ctx.i += 2; - return "=="; - } - if (c === 33 /* '!' */ && c2 === 61 /* '=' */) { - ctx.i += 2; - return "!="; - } - if (c === 62 /* '>' */ && c2 === 61 /* '=' */) { - ctx.i += 2; - return ">="; - } - if (c === 60 /* '<' */ && c2 === 61 /* '=' */) { - ctx.i += 2; - return "<="; - } - if (c === 62 /* '>' */) { - ctx.i++; - return ">"; - } - if (c === 60 /* '<' */) { - ctx.i++; - return "<"; - } - return ""; - } - - private static _evalNumOp(lhs: number, op: string, rhs: number): boolean { - switch (op) { - case "==": - return lhs === rhs; - case "!=": - return lhs !== rhs; - case ">": - return lhs > rhs; - case "<": - return lhs < rhs; - case ">=": - return lhs >= rhs; - case "<=": - return lhs <= rhs; - default: - return false; - } - } } diff --git a/packages/shader-compiler/src/codeGen/CodeGenVisitor.ts b/packages/shader-compiler/src/codeGen/CodeGenVisitor.ts index 15dc6a2a10..2f17d6623e 100644 --- a/packages/shader-compiler/src/codeGen/CodeGenVisitor.ts +++ b/packages/shader-compiler/src/codeGen/CodeGenVisitor.ts @@ -1,18 +1,14 @@ -import { ShaderPosition, ShaderRange } from "../common"; -import { BaseToken } from "../common/BaseToken"; -import { GSErrorName } from "../GSError"; -import { ASTNode, TreeNode } from "../parser/AST"; -import { NoneTerminal } from "../parser/GrammarSymbol"; -import { ESymbolType, FnSymbol } from "../parser/symbolTable"; -import { NodeChild, StructProp } from "../parser/types"; -import { ParserUtils } from "../ParserUtils"; -import { ShaderCompiler } from "../ShaderCompiler"; -import { StructRole, VisitorContext } from "./VisitorContext"; -// #if _VERBOSE -import { GSError } from "../GSError"; -// #endif +import { BaseToken } from "@galacean/engine-shader-parser/internal"; +import { ASTNode, TreeNode } from "@galacean/engine-shader-parser/internal"; +import { NoneTerminal } from "@galacean/engine-shader-parser/internal"; +import { ESymbolType, FnSymbol } from "@galacean/engine-shader-parser/internal"; +import { NodeChild, StructProp } from "@galacean/engine-shader-parser/internal"; +import { ParserUtils } from "@galacean/engine-shader-parser/internal"; +import { ShaderStructRole } from "@galacean/engine-shader-parser/internal"; +import type { ICodeGenVisitor } from "@galacean/engine-shader-parser/internal"; +import { VisitorContext } from "./VisitorContext"; import { ReturnableObjectPool } from "@galacean/engine-core"; -import { Keyword } from "../common/enums/Keyword"; +import { Keyword } from "@galacean/engine-shader-parser/internal"; import { TempArray } from "../TempArray"; import { ICodeSegment } from "./types"; @@ -20,11 +16,7 @@ import { ICodeSegment } from "./types"; * @internal * The code generator */ -export abstract class CodeGenVisitor { - // #if _VERBOSE - readonly errors: Error[] = []; - // #endif - +export abstract class CodeGenVisitor implements ICodeGenVisitor { abstract getAttributeProp(prop: StructProp): string; abstract getVaryingProp(prop: StructProp): string; abstract getMRTProp(prop: StructProp): string; @@ -33,7 +25,7 @@ export abstract class CodeGenVisitor { defaultCodeGen(children: NodeChild[]) { const pool = CodeGenVisitor._tmpArrayPool; - let ret = pool.get(); + const ret = pool.get(); ret.dispose(); for (const child of children) { if (child instanceof BaseToken) { @@ -57,23 +49,19 @@ export abstract class CodeGenVisitor { const prop = children[2]; if (prop instanceof BaseToken) { - // Struct role priority: `_structVarMap` by bare root ident (covers forward-declared types like `Varyings o;`), - // then AST static type (normal path when `semanticAnalyze` resolved `postExpr.type`). - let role: StructRole | undefined; + // Struct role priority: the current stage's var map by bare root ident (covers forward-declared + // types like `Varyings o;`), then AST static type (normal path when `semanticAnalyze` resolved + // `postExpr.type`). Splitting the map per stage prevents a same-named param/local (e.g. `input` + // in both `mainVert(a2v input)` and `mainFrag(v2f input)`) from collapsing into one role. + let role: ShaderStructRole | undefined; const directRoot = ParserUtils.extractDirectIdentLexeme(postExpr); - if (directRoot) role = context._structVarMap[directRoot]; + if (directRoot) role = context.getStructVarRole(directRoot); if (!role) role = context.getStructRole(postExpr.type); if (role) { - const error = - role === "attribute" - ? context.referenceAttribute(prop) - : role === "varying" - ? context.referenceVarying(prop) - : context.referenceMRTProp(prop); - // #if _VERBOSE - if (error) this.errors.push(error); - // #endif + if (role === ShaderStructRole.Attribute) context.referenceAttribute(prop); + else if (role === ShaderStructRole.Varying) context.referenceVarying(prop); + else context.referenceMRTProp(prop); return prop.lexeme; } @@ -84,19 +72,14 @@ export abstract class CodeGenVisitor { } else if (derivationLength === 4) { const identNode = children[0] as ASTNode.PostfixExpression; const indexNode = children[2] as ASTNode.Expression; - const identLexeme = identNode.codeGen(this); - const indexLexeme = indexNode.codeGen(this); - if (identLexeme === "gl_FragData") { - this._reportError(identNode.location, "Please use MRT struct instead of gl_FragData."); - } - return `${identLexeme}[${indexLexeme}]`; + return `${identNode.codeGen(this)}[${indexNode.codeGen(this)}]`; } return this.defaultCodeGen(node.children); } visitVariableIdentifier(node: ASTNode.VariableIdentifier): string { - for (let name of node.referenceGlobalSymbolNames) { + for (const name of node.referenceGlobalSymbolNames) { VisitorContext.context.referenceGlobal(name, ESymbolType.Any); } @@ -228,10 +211,9 @@ export abstract class CodeGenVisitor { if (fullType instanceof ASTNode.FullySpecifiedType && fullType.typeSpecifier.isCustom) { const context = VisitorContext.context; // Global variables whose declared type is a varying/attribute/mrt struct - // (e.g. `Varyings o;`) are not emitted as `uniform`. The variable itself is - // already registered in `_structVarMap` by the pre-pass in - // `GLESVisitor._collectAllStructVars`, so `visitPostfixExpression` can - // flatten `o.field` at macro-value codegen time. + // (e.g. `Varyings o;`) are not emitted as `uniform`. The variable's role comes + // from `ShaderCoreInfo`'s per-stage struct-var maps (module globals populate both), + // so `visitPostfixExpression` can flatten `o.field` at macro-value codegen time. if (context.getStructRole(fullType.typeSpecifier.lexeme)) { return ""; } @@ -314,20 +296,8 @@ export abstract class CodeGenVisitor { const isAttributeStruct = attributeStructs.indexOf(node) !== -1; const isMRTStruct = mrtStructs.indexOf(node) !== -1; - if (isVaryingStruct && isAttributeStruct) { - this._reportError(node.location, "cannot use same struct as Varying and Attribute"); - } - - if (isVaryingStruct && isMRTStruct) { - this._reportError(node.location, "cannot use same struct as Varying and MRT"); - } - - if (isAttributeStruct && isMRTStruct) { - this._reportError(node.location, "cannot use same struct as Attribute and MRT"); - } - if (isVaryingStruct || isAttributeStruct || isMRTStruct) { - let result: ICodeSegment[] = []; + const result: ICodeSegment[] = []; result.push( ...node.macroExpressions.map((item) => ({ @@ -378,12 +348,4 @@ export abstract class CodeGenVisitor { return this.defaultCodeGen(fnNode.children); } } - - protected _reportError(loc: ShaderRange | ShaderPosition, message: string): void { - // #if _VERBOSE - this.errors.push(new GSError(GSErrorName.CompilationError, message, loc, ShaderCompiler._processingPassText)); - // #else - console.error(message); - // #endif - } } diff --git a/packages/shader-compiler/src/codeGen/GLES100.ts b/packages/shader-compiler/src/codeGen/GLES100.ts index 2c2e0faa93..dc0d957647 100644 --- a/packages/shader-compiler/src/codeGen/GLES100.ts +++ b/packages/shader-compiler/src/codeGen/GLES100.ts @@ -1,6 +1,6 @@ -import { BaseToken } from "../common/BaseToken"; -import { ASTNode } from "../parser/AST"; -import { StructProp } from "../parser/types"; +import { BaseToken } from "@galacean/engine-shader-parser/internal"; +import { ASTNode } from "@galacean/engine-shader-parser/internal"; +import { StructProp } from "@galacean/engine-shader-parser/internal"; import { GLESVisitor } from "./GLESVisitor"; import { VisitorContext } from "./VisitorContext"; @@ -32,10 +32,9 @@ export class GLES100Visitor extends GLESVisitor { if (postExpr instanceof ASTNode.PostfixExpression && context.isMRTStruct(postExpr.type)) { const propReferenced = children[2] as BaseToken; const prop = context.mrtList.find((item) => item.ident.lexeme === propReferenced.lexeme); - if (!prop) { - this._reportError(propReferenced.location, `not found mrt property: ${propReferenced.lexeme}`); - return ""; - } + // The parser already validated struct fields (UndeclaredStructMember); a miss here is an + // already-errored shader, so emit nothing rather than re-report. + if (!prop) return ""; return `gl_FragData[${prop.mrtIndex!}]`; } return super.visitPostfixExpression(node); diff --git a/packages/shader-compiler/src/codeGen/GLES300.ts b/packages/shader-compiler/src/codeGen/GLES300.ts index 065f0c1664..f7092a9bc2 100644 --- a/packages/shader-compiler/src/codeGen/GLES300.ts +++ b/packages/shader-compiler/src/codeGen/GLES300.ts @@ -1,7 +1,7 @@ -import { EShaderStage } from "../common/enums/ShaderStage"; -import { ASTNode } from "../parser/AST"; -import { ShaderData } from "../parser/ShaderInfo"; -import { StructProp } from "../parser/types"; +import { EShaderStage } from "@galacean/engine-shader-parser/internal"; +import { ASTNode } from "@galacean/engine-shader-parser/internal"; +import { ShaderData } from "@galacean/engine-shader-parser/internal"; +import { StructProp } from "@galacean/engine-shader-parser/internal"; import { GLESVisitor } from "./GLESVisitor"; import { ICodeSegment } from "./types"; import { VisitorContext } from "./VisitorContext"; @@ -87,9 +87,9 @@ export class GLES300Visitor extends GLESVisitor { override visitVariableIdentifier(node: ASTNode.VariableIdentifier): string { const { context } = VisitorContext; if (context.stage === EShaderStage.FRAGMENT && node.getLexeme(this) === "gl_FragColor") { + // A conflicting fragment-output contract has no valid backend declaration to emit. if (context.mrtStructs.length) { - this._reportError(node.location, "gl_FragColor cannot be used with MRT (Multiple Render Targets)."); - return; + return ""; } this._registerFragColorVariable(); return V3_GL_FragColor; diff --git a/packages/shader-compiler/src/codeGen/GLESVisitor.ts b/packages/shader-compiler/src/codeGen/GLESVisitor.ts index ccc12b81fb..0172d51275 100644 --- a/packages/shader-compiler/src/codeGen/GLESVisitor.ts +++ b/packages/shader-compiler/src/codeGen/GLESVisitor.ts @@ -1,19 +1,21 @@ import type { IShaderInfo } from "@galacean/engine-design"; -import { BaseToken } from "../common/BaseToken"; -import { EShaderStage } from "../common/enums/ShaderStage"; -import { Keyword } from "../common/enums/Keyword"; -import { ASTNode, TreeNode } from "../parser/AST"; -import { NodeChild } from "../parser/types"; -import { ShaderData } from "../parser/ShaderInfo"; -import { ESymbolType, FnSymbol, StructSymbol, SymbolInfo } from "../parser/symbolTable"; +import { BaseToken } from "@galacean/engine-shader-parser/internal"; +import { EShaderStage } from "@galacean/engine-shader-parser/internal"; +import { Keyword } from "@galacean/engine-shader-parser/internal"; +import { ASTNode, TreeNode } from "@galacean/engine-shader-parser/internal"; +import { NodeChild } from "@galacean/engine-shader-parser/internal"; +import { ShaderData } from "@galacean/engine-shader-parser/internal"; +import { ESymbolType, FnSymbol, SymbolInfo } from "@galacean/engine-shader-parser/internal"; +import type { ShaderClueIR, ShaderCoreInfo, ShaderEntryPointInfo } from "@galacean/engine-shader-parser/internal"; import { CodeGenVisitor } from "./CodeGenVisitor"; import { ICodeSegment } from "./types"; -import { StructRole, VisitorContext } from "./VisitorContext"; +import { VisitorContext } from "./VisitorContext"; +import type { ShaderBackend } from "../ShaderBackend"; /** * @internal */ -export abstract class GLESVisitor extends CodeGenVisitor { +export abstract class GLESVisitor extends CodeGenVisitor implements ShaderBackend { private _globalCodeArray: ICodeSegment[] = []; private static _lookupSymbol: SymbolInfo = new SymbolInfo("", null); private static _serializedGlobalKey = new Set(); @@ -30,161 +32,52 @@ export abstract class GLESVisitor extends CodeGenVisitor { } } - visitShaderProgram(node: ASTNode.GLShaderProgram, vertexEntry: string, fragmentEntry: string): IShaderInfo { - // #if _VERBOSE - this.errors.length = 0; - // #endif + generate(ir: ShaderClueIR, coreInfo: ShaderCoreInfo): IShaderInfo { VisitorContext.reset(); this.reset(); + const node = ir.program; const shaderData = node.shaderData; const context = VisitorContext.context; context._passSymbolTable = shaderData.symbolTable; - const outerGlobalMacroDeclarations = shaderData.getOuterGlobalMacroDeclarations(); - - // `_structVarMap` must span both stages so global `#define` references rewrite consistently across vertex/fragment outputs. - this._collectAllStructVars(vertexEntry, fragmentEntry); + const outerGlobalMacroDeclarations = coreInfo.outerGlobalMacroDeclarations; + const { io } = coreInfo; + context.attributeStructs.push(...io.attributeStructs); + context.attributeList.push(...io.attributeList); + context.varyingStructs.push(...io.varyingStructs); + context.varyingList.push(...io.varyingList); + context.mrtStructs.push(...io.mrtStructs); + context.mrtList.push(...io.mrtList); + for (const varName in io.vertexStructVarMap) { + context.registerStructVar(EShaderStage.VERTEX, varName, io.vertexStructVarMap[varName]); + } + for (const varName in io.fragmentStructVarMap) { + context.registerStructVar(EShaderStage.FRAGMENT, varName, io.fragmentStructVarMap[varName]); + } return { - vertex: this._vertexMain(vertexEntry, shaderData, outerGlobalMacroDeclarations), - fragment: this._fragmentMain(fragmentEntry, shaderData, outerGlobalMacroDeclarations) + vertex: this._vertexMain(coreInfo.vertexEntry, shaderData, outerGlobalMacroDeclarations), + fragment: this._fragmentMain(coreInfo.fragmentEntry, shaderData, outerGlobalMacroDeclarations) }; } - /** Populate `_structVarMap` for varying/attribute/mrt-typed variables across both stages before codegen. */ - private _collectAllStructVars(vertexEntry: string, fragmentEntry: string): void { - const context = VisitorContext.context; - const lookupSymbol = GLESVisitor._lookupSymbol; - const symbolTable = context._passSymbolTable; - - // Roles from entry signatures: vertex param[0]=attribute, return=varying; fragment param[0]=varying, return=mrt. - const structRoles: Record = Object.create(null); - - const addEntryRoles = (entry: string, paramRole: StructRole, returnRole: StructRole): FnSymbol[] => { - lookupSymbol.set(entry, ESymbolType.FN); - const fns = symbolTable.getSymbols(lookupSymbol, true, []); - for (const fn of fns) { - const proto = fn.astNode.protoType; - const param0 = proto.parameterList?.[0]; - if (param0 && typeof param0.typeInfo?.type === "string") { - structRoles[param0.typeInfo.typeLexeme] = paramRole; - } - if (typeof proto.returnType.type === "string") { - structRoles[proto.returnType.type] = returnRole; - } - } - return fns; - }; - - const entryFns = addEntryRoles(vertexEntry, "attribute", "varying").concat( - addEntryRoles(fragmentEntry, "varying", "mrt") - ); - - const registerByType = (typeLexeme: string | undefined, varName: string): void => { - if (!typeLexeme) return; - const role = structRoles[typeLexeme]; - if (role) context.registerStructVar(varName, role); - }; - - const walkLocals = (node: TreeNode): void => { - for (const child of node.children) { - if (child instanceof ASTNode.InitDeclaratorList) { - const typeLexeme = child.typeInfo?.typeLexeme; - if (typeLexeme && structRoles[typeLexeme]) { - this._extractLocalVarNames(child, context, structRoles[typeLexeme]); - } - } else if (child instanceof TreeNode) { - walkLocals(child); - } - } - }; - - for (const fn of entryFns) { - const proto = fn.astNode.protoType; - if (proto.parameterList) { - for (const param of proto.parameterList) { - if (param.ident && typeof param.typeInfo?.type === "string") { - registerByType(param.typeInfo.typeLexeme, param.ident.lexeme); - } - } - } - walkLocals(fn.astNode.statements); - } - - // Register module-level globals whose type carries a role (e.g. `Varyings o;`). - symbolTable.forEach((sym) => { - if (sym.type === ESymbolType.VAR) registerByType(sym.dataType?.typeLexeme, sym.ident); - }); - } - private _vertexMain( - entry: string, + entryInfo: ShaderEntryPointInfo, data: ShaderData, - outerGlobalMacroDeclarations: ASTNode.GlobalDeclaration[] + outerGlobalMacroDeclarations: readonly ASTNode.GlobalDeclaration[] ): string { const context = VisitorContext.context; context.stage = EShaderStage.VERTEX; - context.stageEntry = entry; - - const lookupSymbol = GLESVisitor._lookupSymbol; - const symbolTable = data.symbolTable; - lookupSymbol.set(entry, ESymbolType.FN); - const fnSymbols = symbolTable.getSymbols(lookupSymbol, true, []); - if (!fnSymbols.length) throw `no entry function found: ${entry}`; - - const { attributeStructs, attributeList, varyingStructs, varyingList } = context; - fnSymbols.forEach((fnSymbol) => { - const fnNode = fnSymbol.astNode; - const returnType = fnNode.protoType.returnType; - - if (typeof returnType.type === "string") { - lookupSymbol.set(returnType.type, ESymbolType.STRUCT); - const varyingSymbols = symbolTable.getSymbols(lookupSymbol, true, []); - if (!varyingSymbols.length) { - this._reportError(returnType.location, `invalid varying struct: "${returnType.type}".`); - } else { - for (let i = 0; i < varyingSymbols.length; i++) { - const varyingSymbol = varyingSymbols[i]; - const astNode = varyingSymbol.astNode; - varyingStructs.push(astNode); - for (const prop of astNode.propList) { - varyingList.push(prop); - } - } - } - } else if (returnType.type !== Keyword.VOID) { - this._reportError(returnType.location, "vertex main entry can only return struct or void."); - } + context.stageEntry = entryInfo.name; - const paramList = fnNode.protoType.parameterList; - const attributeParam = paramList?.[0]; - if (attributeParam) { - const attributeType = attributeParam.typeInfo.type; - if (typeof attributeType === "string") { - lookupSymbol.set(attributeType, ESymbolType.STRUCT); - const attributeSymbols = symbolTable.getSymbols(lookupSymbol, true, []); - if (!attributeSymbols.length) { - this._reportError(attributeParam.astNode.location, `invalid attribute struct: "${attributeType}".`); - } else { - for (let i = 0; i < attributeSymbols.length; i++) { - const attributeSymbol = attributeSymbols[i]; - const astNode = attributeSymbol.astNode; - attributeStructs.push(astNode); - for (const prop of astNode.propList) { - attributeList.push(prop); - } - } - } - } - } - }); + // Attribute/varying structs were collected in ShaderCoreInfo // Pre-walk global `#define` values so referenced struct properties emit `attribute`/`varying` declarations. this._preRegisterGlobalMacroRefs(outerGlobalMacroDeclarations); const globalCodeArray = this._globalCodeArray; - VisitorContext.context.referenceGlobal(entry, ESymbolType.FN); + VisitorContext.context.referenceGlobal(entryInfo.name, ESymbolType.FN); this._getGlobalSymbol(globalCodeArray); this._getCustomStruct(context.attributeStructs, globalCodeArray); @@ -204,56 +97,28 @@ export abstract class GLESVisitor extends CodeGenVisitor { } private _fragmentMain( - entry: string, + entryInfo: ShaderEntryPointInfo, data: ShaderData, - outerGlobalMacroStatements: ASTNode.GlobalDeclaration[] + outerGlobalMacroStatements: readonly ASTNode.GlobalDeclaration[] ): string { const context = VisitorContext.context; context.stage = EShaderStage.FRAGMENT; - context.stageEntry = entry; - - const lookupSymbol = GLESVisitor._lookupSymbol; - const { symbolTable } = data; - lookupSymbol.set(entry, ESymbolType.FN); - const fnSymbols = symbolTable.getSymbols(lookupSymbol, true, []); - if (!fnSymbols?.length) throw `no entry function found: ${entry}`; - - // Fragment varying info inherits from vertex stage (preserved across `context.reset(false)`). - fnSymbols.forEach((fnSymbol) => { - const fnNode = fnSymbol.astNode; - const { returnStatement } = fnNode; + context.stageEntry = entryInfo.name; + // MRT structs come from ShaderCoreInfo; here only mark the fragment return statements. + entryInfo.functions.forEach((fnSymbol) => { + const { returnStatement } = fnSymbol.astNode; if (returnStatement) { returnStatement.isFragReturnStatement = true; } - - const { type: returnDataType, location: returnLocation } = fnNode.protoType.returnType; - if (typeof returnDataType === "string") { - lookupSymbol.set(returnDataType, ESymbolType.STRUCT); - const mrtSymbols = symbolTable.getSymbols(lookupSymbol, true, []); - if (!mrtSymbols.length) { - this._reportError(returnLocation, `invalid mrt struct: ${returnDataType}`); - } else { - for (let i = 0; i < mrtSymbols.length; i++) { - const mrtSymbol = mrtSymbols[i]; - const astNode = mrtSymbol.astNode; - context.mrtStructs.push(astNode); - for (const prop of astNode.propList) { - context.mrtList.push(prop); - } - } - } - } else if (returnDataType !== Keyword.VOID && returnDataType !== Keyword.VEC4) { - this._reportError(returnLocation, "fragment main entry can only return struct or vec4."); - } }); - // `_structVarMap` is already populated in `visitShaderProgram` with both stages' - // variables; just pre-walk macro refs so struct codegen sees the references. + // Both stage struct-var maps are already populated from ShaderCoreInfo; just + // pre-walk macro refs so struct codegen sees the references. this._preRegisterGlobalMacroRefs(outerGlobalMacroStatements); const globalCodeArray = this._globalCodeArray; - VisitorContext.context.referenceGlobal(entry, ESymbolType.FN); + VisitorContext.context.referenceGlobal(entryInfo.name, ESymbolType.FN); this._getGlobalSymbol(globalCodeArray); this._getCustomStruct(context.varyingStructs, globalCodeArray); @@ -272,32 +137,13 @@ export abstract class GLESVisitor extends CodeGenVisitor { return globalCode; } - private _extractLocalVarNames(node: ASTNode.InitDeclaratorList, context: VisitorContext, role: StructRole): void { - const children = node.children; - if (children.length === 1) { - const singleDecl = children[0] as ASTNode.SingleDeclaration; - const identChildren = singleDecl.children; - if (identChildren.length >= 2 && identChildren[1] instanceof BaseToken) { - context.registerStructVar(identChildren[1].lexeme, role); - } - } else if (children.length >= 3) { - const initDeclList = children[0]; - if (initDeclList instanceof ASTNode.InitDeclaratorList) { - this._extractLocalVarNames(initDeclList, context, role); - } - if (children[2] instanceof BaseToken) { - context.registerStructVar((children[2] as BaseToken).lexeme, role); - } - } - } - /** * Pre-walk `#define` values in global macro declarations and register any * `structVar.prop` member accesses as referenced struct props. This must run before * struct codegen emits the declaration lists (`attribute …`, `varying …`, `MRT …`), * otherwise properties used only from macros would be missing from the output. */ - private _preRegisterGlobalMacroRefs(macros: ASTNode.GlobalDeclaration[]): void { + private _preRegisterGlobalMacroRefs(macros: readonly ASTNode.GlobalDeclaration[]): void { for (const macro of macros) { this._walkMacroDefineTokens(macro.children); } @@ -356,7 +202,7 @@ export abstract class GLESVisitor extends CodeGenVisitor { } } - private _getGlobalMacroDeclarations(macros: ASTNode.GlobalDeclaration[], out: ICodeSegment[]): void { + private _getGlobalMacroDeclarations(macros: readonly ASTNode.GlobalDeclaration[], out: ICodeSegment[]): void { const context = VisitorContext.context; const referencedGlobals = context._referencedGlobals; const referencedGlobalMacroASTs = context._referencedGlobalMacroASTs; @@ -375,7 +221,7 @@ export abstract class GLESVisitor extends CodeGenVisitor { const child = macro.children[0]; if (child instanceof ASTNode.GlobalMacroIfStatement) { - let result: ICodeSegment[] = []; + const result: ICodeSegment[] = []; result.push( ...macro.macroExpressions.map((item) => ({ text: item instanceof BaseToken ? item.lexeme : item.codeGen(this), diff --git a/packages/shader-compiler/src/codeGen/VisitorContext.ts b/packages/shader-compiler/src/codeGen/VisitorContext.ts index 7f816edfcd..9cdf120185 100644 --- a/packages/shader-compiler/src/codeGen/VisitorContext.ts +++ b/packages/shader-compiler/src/codeGen/VisitorContext.ts @@ -1,15 +1,9 @@ -import { BaseToken } from "../common/BaseToken"; -import { EShaderStage } from "../common/enums/ShaderStage"; -import { SymbolTable } from "../common/SymbolTable"; -import { GSErrorName } from "../GSError"; -import { ASTNode, TreeNode } from "../parser/AST"; -import { ESymbolType, SymbolInfo } from "../parser/symbolTable"; -import { StructProp } from "../parser/types"; -import { ShaderCompiler } from "../ShaderCompiler"; -import { ShaderCompilerUtils } from "../ShaderCompilerUtils"; - -/** Role of a struct type in the shader compiler's IO flattening. */ -export type StructRole = "varying" | "attribute" | "mrt"; +import { BaseToken } from "@galacean/engine-shader-parser/internal"; +import { EShaderStage } from "@galacean/engine-shader-parser/internal"; +import { SymbolTable } from "@galacean/engine-shader-parser/internal"; +import { ASTNode, TreeNode } from "@galacean/engine-shader-parser/internal"; +import { ESymbolType, SymbolInfo } from "@galacean/engine-shader-parser/internal"; +import { ShaderStructRole, StructProp } from "@galacean/engine-shader-parser/internal"; /** @internal */ export class VisitorContext { @@ -42,12 +36,12 @@ export class VisitorContext { _referencedGlobals: Record; _referencedGlobalMacroASTs: TreeNode[] = []; /** - * Maps variable names (function params, locals, and globals whose type is a - * varying/attribute/mrt struct) to their role. Populated during stage setup and - * used by macro-value rewriting to recognize `varName.prop` patterns that should - * be flattened against the IO lists. + * Per-stage variable-to-role maps. Split so a same-named param/local in both entries + * (e.g. `input`) resolves to the correct role for the current stage; module-level + * globals populate both maps. Codegen picks the map via `getStructVarRole(varName)`. */ - _structVarMap: Record; + _vertexStructVarMap: Record; + _fragmentStructVarMap: Record; _passSymbolTable: SymbolTable; @@ -67,10 +61,10 @@ export class VisitorContext { this._referencedGlobals = Object.create(null); this._referencedGlobalMacroASTs.length = 0; if (resetAll) { - // Struct-var bindings are pass-scoped, not stage-scoped — global `#define` - // values must see the same bindings in both the vertex and fragment outputs, - // so we keep the map across the vertex→fragment stage transition. - this._structVarMap = Object.create(null); + // Struct-var bindings are pass-scoped; both stage maps are cleared here and + // repopulated from `ShaderCoreInfo` before codegen. + this._vertexStructVarMap = Object.create(null); + this._fragmentStructVarMap = Object.create(null); } } @@ -87,33 +81,33 @@ export class VisitorContext { } /** Return the role of a struct type, or undefined if it isn't one of the IO roles. */ - getStructRole(typeLexeme: string): StructRole | undefined { - if (this.isAttributeStruct(typeLexeme)) return "attribute"; - if (this.isVaryingStruct(typeLexeme)) return "varying"; - if (this.isMRTStruct(typeLexeme)) return "mrt"; + getStructRole(typeLexeme: string): ShaderStructRole | undefined { + if (this.isAttributeStruct(typeLexeme)) return ShaderStructRole.Attribute; + if (this.isVaryingStruct(typeLexeme)) return ShaderStructRole.Varying; + if (this.isMRTStruct(typeLexeme)) return ShaderStructRole.Mrt; } - /** Register a variable as holding a value of a varying/attribute/mrt struct type. */ - registerStructVar(varName: string, role: StructRole): void { - this._structVarMap[varName] = role; + /** Register a variable in a specific stage as holding a varying/attribute/mrt struct value. */ + registerStructVar(stage: EShaderStage, varName: string, role: ShaderStructRole): void { + const map = stage === EShaderStage.VERTEX ? this._vertexStructVarMap : this._fragmentStructVarMap; + map[varName] = role; } - referenceAttribute(ident: BaseToken): Error | void { - return this._referenceProp( - "attribute", - ident.lexeme, - this.attributeList, - this._referencedAttributeList, - ident.location - ); + /** Look up the role of a struct-typed variable in the stage currently being generated. */ + getStructVarRole(varName: string): ShaderStructRole | undefined { + return (this.stage === EShaderStage.VERTEX ? this._vertexStructVarMap : this._fragmentStructVarMap)[varName]; } - referenceVarying(ident: BaseToken): Error | void { - return this._referenceProp("varying", ident.lexeme, this.varyingList, this._referencedVaryingList, ident.location); + referenceAttribute(ident: BaseToken): void { + this._referenceProp(ident.lexeme, this.attributeList, this._referencedAttributeList); } - referenceMRTProp(ident: BaseToken): Error | void { - return this._referenceProp("mrt", ident.lexeme, this.mrtList, this._referencedMRTList, ident.location); + referenceVarying(ident: BaseToken): void { + this._referenceProp(ident.lexeme, this.varyingList, this._referencedVaryingList); + } + + referenceMRTProp(ident: BaseToken): void { + this._referenceProp(ident.lexeme, this.mrtList, this._referencedMRTList); } referenceGlobal(ident: string, type: ESymbolType): void { @@ -126,23 +120,10 @@ export class VisitorContext { this._passSymbolTable.getSymbols(lookupSymbol, true, this._referencedGlobals[ident]); } - private _referenceProp( - role: StructRole, - name: string, - list: StructProp[], - refList: Record, - location: any - ): Error | void { + // Track which IO props are actually referenced (drives in/out emission). A missing member is no + // longer flagged here — that's the parser's struct-field check (UndeclaredStructMember). + private _referenceProp(name: string, list: StructProp[], refList: Record): void { if (refList[name]) return; - const props = list.filter((item) => item.ident.lexeme === name); - if (!props.length) { - return ShaderCompilerUtils.createGSError( - `referenced ${role} not found: ${name}`, - GSErrorName.CompilationError, - ShaderCompiler._processingPassText, - location - ); - } - refList[name] = props; + refList[name] = list.filter((item) => item.ident.lexeme === name); } } diff --git a/packages/shader-compiler/src/codeGen/types.ts b/packages/shader-compiler/src/codeGen/types.ts index e779586af1..0e1ef73e83 100644 --- a/packages/shader-compiler/src/codeGen/types.ts +++ b/packages/shader-compiler/src/codeGen/types.ts @@ -2,7 +2,7 @@ import type { IShaderSource } from "@galacean/engine-design"; export type IRenderState = [ /** Constant RenderState. */ - Record, + Record, /** Variable RenderState. */ Record ]; diff --git a/packages/shader-compiler/src/common/BaseToken.ts b/packages/shader-compiler/src/common/BaseToken.ts deleted file mode 100644 index deced0df39..0000000000 --- a/packages/shader-compiler/src/common/BaseToken.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { ETokenType } from "./types"; -import { ShaderRange, ShaderPosition } from "."; -import { ShaderCompiler } from "../ShaderCompiler"; -import type { IPoolElement } from "@galacean/engine-core"; -import { ShaderCompilerUtils } from "../ShaderCompilerUtils"; - -/** - * One condition in a branch signature: `defined: true` for `#ifdef X` (the - * branch is active when `X` is defined), `defined: false` for `#ifndef X` / - * after `#else` (active when `X` is undefined). - */ -export interface BranchConstraint { - name: string; - defined: boolean; -} - -/** - * Snapshot of the `#ifdef`/`#ifndef`/`#else` stack at a source position. An - * empty signature means unconditional (top-level). Constraints are conjunctive: - * the position is active iff every constraint holds. Produced by the Lexer - * (the sole branch-stack maintainer) and stamped onto every emitted token + - * every registered `MacroDefineInfo`. - */ -export type BranchSignature = readonly BranchConstraint[]; - -// Canonical empty branch signature shared by all default tokens — avoids -// per-token allocation. The Lexer overwrites `branch` after `scanToken()` -// for tokens that are inside an `#ifdef`. -export const EMPTY_BRANCH: BranchSignature = []; - -export class BaseToken implements IPoolElement { - static pool = ShaderCompilerUtils.createObjectPool(BaseToken); - - type: T; - lexeme: string; - location: ShaderRange; - /** Branch signature snapshot at the point this token was emitted. Empty - * signature (default) means top-level / unconditional. The Lexer tags - * every token; downstream code (AST nodes built from tokens) can read - * the field directly to know which `#ifdef` branch they're inside. */ - branch: BranchSignature = EMPTY_BRANCH; - - set(type: T, lexeme: string, start?: ShaderPosition); - set(type: T, lexeme: string, location?: ShaderRange); - set(type: T, lexeme: string, arg?: ShaderRange | ShaderPosition) { - this.type = type; - this.lexeme = lexeme; - this.branch = EMPTY_BRANCH; - if (arg) { - if (arg instanceof ShaderRange) { - this.location = arg as ShaderRange; - } else { - const end = ShaderCompiler.createPosition( - arg.index + lexeme.length, - // #if _VERBOSE - arg.line, - arg.column + lexeme.length - // #endif - ); - this.location = ShaderCompiler.createRange(arg, end); - } - } - } - - dispose(): void {} -} - -export const EOF = new BaseToken(); -EOF.set(ETokenType.EOF, "/EOF"); diff --git a/packages/shader-compiler/src/common/SymbolTable.ts b/packages/shader-compiler/src/common/SymbolTable.ts deleted file mode 100644 index 4df92eb72c..0000000000 --- a/packages/shader-compiler/src/common/SymbolTable.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { Logger } from "@galacean/engine-core"; -import { IBaseSymbol } from "./IBaseSymbol"; - -export class SymbolTable { - private _table: Map = new Map(); - - insert(symbol: T, isInMacroBranch = false): void { - symbol.isInMacroBranch = isInMacroBranch; - - const entry = this._table.get(symbol.ident) ?? []; - for (let i = 0, n = entry.length; i < n; i++) { - if (entry[i].isInMacroBranch) continue; - if (entry[i].equal(symbol)) { - Logger.warn("Replace symbol:", symbol.ident); - entry[i] = symbol; - return; - } - } - - entry.push(symbol); - this._table.set(symbol.ident, entry); - } - - getSymbol(symbol: T, includeMacro = false): T | undefined { - const entry = this._table.get(symbol.ident); - if (entry) { - for (let i = entry.length - 1; i >= 0; i--) { - const item = entry[i]; - if (!includeMacro && item.isInMacroBranch) continue; - if (item.equal(symbol)) return item; - } - } - } - - getSymbols(symbol: T, includeMacro = false, out: T[]): T[] { - out.length = 0; - this._getSymbols(symbol, includeMacro, out); - - return out; - } - - /** - * @internal - */ - _getSymbols(symbol: T, includeMacro = false, out: T[]): T[] { - const entry = this._table.get(symbol.ident); - - if (entry) { - for (let i = entry.length - 1; i >= 0; i--) { - const item = entry[i]; - if (!includeMacro && item.isInMacroBranch) continue; - if (item.equal(symbol)) out.push(item); - } - } - - return out; - } - - /** Iterate every registered symbol. Order within a name bucket is insertion order. */ - forEach(callback: (symbol: T) => void): void { - for (const entries of this._table.values()) { - for (let i = 0, n = entries.length; i < n; i++) { - callback(entries[i]); - } - } - } -} diff --git a/packages/shader-compiler/src/common/SymbolTableStack.ts b/packages/shader-compiler/src/common/SymbolTableStack.ts deleted file mode 100644 index 5052fc4bcb..0000000000 --- a/packages/shader-compiler/src/common/SymbolTableStack.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { IBaseSymbol } from "./IBaseSymbol"; -import { SymbolTable } from "./SymbolTable"; - -export class SymbolTableStack> { - stack: T[] = []; - - /** - * @internal - */ - _macroLevel = 0; - - get scope(): T { - return this.stack[this.stack.length - 1]; - } - - get isInMacroBranch(): boolean { - return this._macroLevel > 0; - } - - pushScope(scope: T): void { - this.stack.push(scope); - } - - clear(): void { - this.stack.length = 0; - } - - popScope(): T | undefined { - return this.stack.pop(); - } - - insert(symbol: S): void { - this.scope.insert(symbol, this.isInMacroBranch); - } - - lookup(symbol: S, includeMacro = false): S | undefined { - for (let i = this.stack.length - 1; i >= 0; i--) { - const symbolTable = this.stack[i]; - const result = symbolTable.getSymbol(symbol, includeMacro); - if (result) return result; - } - return undefined; - } - - lookupAll(symbol: S, includeMacro = false, out: S[]): S[] { - out.length = 0; - for (let i = this.stack.length - 1; i >= 0; i--) { - const symbolTable = this.stack[i]; - symbolTable._getSymbols(symbol, includeMacro, out); - } - return out; - } -} diff --git a/packages/shader-compiler/src/index.ts b/packages/shader-compiler/src/index.ts index af0042affb..aba5cbfacc 100644 --- a/packages/shader-compiler/src/index.ts +++ b/packages/shader-compiler/src/index.ts @@ -1,13 +1,12 @@ +import { Logger } from "@galacean/engine-core"; + export { ShaderCompiler } from "./ShaderCompiler"; -export * from "./GSError"; +export { GSError, GSErrorName } from "@galacean/engine-shader-parser/internal"; -//@ts-ignore +/** + * Version of the shader compiler package, replaced with the package version during builds. + */ export const version = `__buildVersion`; -let mode = "Release"; -// #if _VERBOSE -mode = "Verbose"; -// #endif - -console.log(`Galacean Engine Shader Compiler Version: ${version} | Mode: ${mode}`); +Logger.info(`Galacean Engine Shader Compiler Version: ${version}`); diff --git a/packages/shader-compiler/src/parser/SemanticAnalyzer.ts b/packages/shader-compiler/src/parser/SemanticAnalyzer.ts deleted file mode 100644 index fe553bb734..0000000000 --- a/packages/shader-compiler/src/parser/SemanticAnalyzer.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { Logger } from "@galacean/engine-core"; -import { ShaderRange } from "../common"; -import { SymbolTable } from "../common/SymbolTable"; -import { SymbolTableStack } from "../common/SymbolTableStack"; -import { GSError, GSErrorName } from "../GSError"; -import { SymbolInfo } from "../parser/symbolTable"; -import { ShaderCompiler } from "../ShaderCompiler"; -import { ASTNode, TreeNode } from "./AST"; -import { ShaderData } from "./ShaderInfo"; -import { NodeChild } from "./types"; - -import { MacroDefineList } from "../Preprocessor"; - -export type TranslationRule = (sa: SemanticAnalyzer, ...tokens: NodeChild[]) => T; - -/** - * @internal - * The semantic analyzer of `ShaderCompiler` compiler. - * - Build symbol table - * - Static analysis - */ -export default class SemanticAnalyzer { - /** - * @internal - */ - static _lookupSymbol: SymbolInfo = new SymbolInfo("", null); - - semanticStack: TreeNode[] = []; - acceptRule?: TranslationRule = undefined; - symbolTableStack: SymbolTableStack> = new SymbolTableStack(); - curFunctionInfo: { - header?: ASTNode.FunctionDeclarator; - returnStatement?: ASTNode.JumpStatement; - } = {}; - private _shaderData = new ShaderData(); - private _translationRuleTable: Map = new Map(); - - private _macroDefineList: MacroDefineList; - - // #if _VERBOSE - readonly errors: Error[] = []; - // #endif - - get shaderData() { - return this._shaderData; - } - - get macroDefineList(): MacroDefineList { - return this._macroDefineList; - } - - constructor() { - this.pushScope(); - } - - reset(macroDefineList: MacroDefineList) { - this._macroDefineList = macroDefineList; - this.semanticStack.length = 0; - this._shaderData = new ShaderData(); - this.symbolTableStack.clear(); - this.pushScope(); - // #if _VERBOSE - this.errors.length = 0; - // #endif - } - - pushScope() { - this.symbolTableStack.pushScope(new SymbolTable()); - } - - popScope() { - return this.symbolTableStack.popScope(); - } - - addTranslationRule(pid: number, rule: TranslationRule) { - this._translationRuleTable.set(pid, rule); - } - - getTranslationRule(pid: number) { - return this._translationRuleTable.get(pid); - } - - reportError(loc: ShaderRange, message: string): void { - // #if _VERBOSE - this.errors.push(new GSError(GSErrorName.CompilationError, message, loc, ShaderCompiler._processingPassText)); - // #else - console.error(message); - // #endif - } - - reportWarning(loc: ShaderRange, message: string): void { - Logger.warn(new GSError(GSErrorName.CompilationWarn, message, loc, ShaderCompiler._processingPassText).toString()); - } -} diff --git a/packages/shader-compiler/src/sourceParser/index.ts b/packages/shader-compiler/src/sourceParser/index.ts deleted file mode 100644 index f066c49649..0000000000 --- a/packages/shader-compiler/src/sourceParser/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { ShaderSourceParser } from "./ShaderSourceParser"; diff --git a/packages/shader-compiler/tsconfig.json b/packages/shader-compiler/tsconfig.json index f959fa90c9..d3081bad9d 100644 --- a/packages/shader-compiler/tsconfig.json +++ b/packages/shader-compiler/tsconfig.json @@ -11,8 +11,7 @@ "noImplicitOverride": true, "sourceMap": true, "incremental": false, - "skipLibCheck": true, - "stripInternal": true + "skipLibCheck": true }, "include": ["src/**/*"], "ts-node": { diff --git a/packages/shader-compiler/verbose/package.json b/packages/shader-compiler/verbose/package.json deleted file mode 100644 index a22d7747e9..0000000000 --- a/packages/shader-compiler/verbose/package.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "license": "MIT", - "main": "../dist/main.verbose.js", - "module": "../dist/module.verbose.js", - "debug": "../src/index.ts", - "types": "../types/index.d.ts", - "umd": { - "name": "Galacean.ShaderCompiler" - } -} diff --git a/packages/shader-parser/internal/analyzer/package.json b/packages/shader-parser/internal/analyzer/package.json new file mode 100644 index 0000000000..58ae021700 --- /dev/null +++ b/packages/shader-parser/internal/analyzer/package.json @@ -0,0 +1,5 @@ +{ + "main": "../../dist/main.analyzer.js", + "module": "../../dist/module.analyzer.js", + "types": "../../types/index.d.ts" +} diff --git a/packages/shader-parser/internal/package.json b/packages/shader-parser/internal/package.json new file mode 100644 index 0000000000..98d5ddfc0b --- /dev/null +++ b/packages/shader-parser/internal/package.json @@ -0,0 +1,5 @@ +{ + "main": "../dist/main.js", + "module": "../dist/module.js", + "types": "../types/runtime.d.ts" +} diff --git a/packages/shader-parser/package.json b/packages/shader-parser/package.json new file mode 100644 index 0000000000..7835c4be59 --- /dev/null +++ b/packages/shader-parser/package.json @@ -0,0 +1,41 @@ +{ + "name": "@galacean/engine-shader-parser", + "version": "2.0.0-alpha.33", + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + }, + "repository": { + "url": "https://github.com/galacean/engine.git" + }, + "license": "MIT", + "exports": { + "./internal": { + "types": "./types/runtime.d.ts", + "import": "./dist/module.js", + "require": "./dist/main.js" + }, + "./internal/analyzer": { + "types": "./types/index.d.ts", + "import": "./dist/module.analyzer.js", + "require": "./dist/main.analyzer.js" + }, + "./package.json": "./package.json" + }, + "scripts": { + "b:types": "tsc" + }, + "files": [ + "dist/**/*", + "types/**/*", + "internal/package.json", + "internal/analyzer/package.json" + ], + "dependencies": { + "@galacean/engine-core": "workspace:*", + "@galacean/engine-math": "workspace:*" + }, + "devDependencies": { + "@galacean/engine-design": "workspace:*" + } +} diff --git a/packages/shader-parser/src/GSError.ts b/packages/shader-parser/src/GSError.ts new file mode 100644 index 0000000000..7e4f2f1574 --- /dev/null +++ b/packages/shader-parser/src/GSError.ts @@ -0,0 +1,45 @@ +import { ShaderPosition } from "./common/ShaderPosition"; +import { ShaderRange } from "./common/ShaderRange"; +import { formatDiagnosticSource } from "./formatDiagnostic"; + +/** Error reported while parsing or analyzing shader source. */ +export class GSError extends Error { + /** + * Creates a shader error. + * @param name - Error category. + * @param message - Error message. + * @param location - Source location of the error. + * @param source - Source text containing the error. + * @param file - Optional source file name. + * @param code - Optional diagnostic code. + */ + constructor( + name: GSErrorName, + message: string, + public readonly location: ShaderRange | ShaderPosition, + public readonly source: string | undefined, + public readonly file?: string, + public readonly code?: string + ) { + super(message); + this.name = name; + } + + /** + * Formats the error with source context when the authoring parser is available. + * @returns Human-readable error text. + */ + override toString(): string { + const { location } = this; + const range = "start" in location ? location : { start: location, end: location }; + return formatDiagnosticSource(this.source || undefined, range, `${this.name}: ${this.message}`); + } +} + +/** Category assigned to a {@link GSError}. */ +export enum GSErrorName { + PreprocessorError = "PreprocessorError", + CompilationError = "CompilationError", + ScannerError = "ScannerError", + CompilationWarn = "CompilationWarning" +} diff --git a/packages/shader-parser/src/ParserUtils.ts b/packages/shader-parser/src/ParserUtils.ts new file mode 100644 index 0000000000..7d27f20936 --- /dev/null +++ b/packages/shader-parser/src/ParserUtils.ts @@ -0,0 +1,256 @@ +import { ETokenType, GalaceanDataType } from "./common"; +import { BaseToken as Token } from "./common/BaseToken"; +import { ASTNode, TreeNode } from "./parser/AST"; +import { BuiltinFunction } from "./parser/builtin"; +import { GrammarSymbol, NoneTerminal } from "./parser/GrammarSymbol"; +import { Keyword } from "./common/enums/Keyword"; +import { VarSymbol } from "./parser/symbolTable"; +import { TypeSystem } from "./parser/TypeSystem"; + +export class ParserUtils { + private static _swizzleSets = ["xyzw", "rgba", "stpq"]; + + static unwrapNodeByType(node: TreeNode, type: NoneTerminal): T | undefined { + const child = node.children[0]; + if (child instanceof Token) return; + if (child.nt === type) return child as T; + return ParserUtils.unwrapNodeByType(child, type); + } + + /** + * Parse a function-macro parameter-list lexeme (`"(a, b, c)"`) into its parameter + * names. An empty list `"()"` yields `[]`. Leading/trailing whitespace around each + * name is trimmed. Empty entries from consecutive commas are filtered out. + */ + static parseMacroParamList(lexeme: string): string[] { + const inner = lexeme.replace(/^\s*\(\s*|\s*\)\s*$/g, ""); + if (!inner) return []; + return inner + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + } + + /** + * Walk single-child precedence-chain wrappers down to a `VariableIdentifier`, + * returning the leaf node (or `undefined` for any compound expression). + * + * `allowParens` chooses between two callers' needs: + * - `true` — descend through `( expression )` form, so `(v)` resolves to + * `v`. Use when the caller substitutes at the expression root + * (aliasing, renaming); user-written parens carry no extra meaning. + * - `false` — any 3-child `PrimaryExpression` aborts; `(v)` stays + * compound. Use when the caller treats the unwrapped node as a single + * token (IO-struct arg drop, alias detection); user-written parens + * must not collapse. + */ + static unwrapBareIdentifier( + node: TreeNode, + options: { allowParens: boolean } + ): ASTNode.VariableIdentifier | undefined { + let cur: TreeNode = node; + while (true) { + if (cur instanceof ASTNode.VariableIdentifier) return cur; + if (options.allowParens && cur instanceof ASTNode.PrimaryExpression && cur.children.length === 3) { + const inner = cur.children[1]; + if (!(inner instanceof TreeNode)) return undefined; + cur = inner; + continue; + } + if (cur instanceof ASTNode.ExpressionAstNode && cur.children.length === 1) { + const child = cur.children[0]; + if (!(child instanceof TreeNode)) return undefined; + cur = child; + continue; + } + return undefined; + } + } + + /** + * Lexeme variant of `unwrapBareIdentifier({ allowParens: true })` for callers + * that already work with strings. Returns `null` for compound expressions. + */ + static extractDirectIdentLexeme(expr: TreeNode): string | null { + const ident = ParserUtils.unwrapBareIdentifier(expr, { allowParens: true }); + if (!ident) return null; + const child = ident.children[0]; + return child instanceof Token ? child.lexeme : null; + } + + /** + * Validate a `.field` access on a vector as a GLSL swizzle. Returns an error message when the + * access is an invalid swizzle on a known vector type, or `null` when it is valid or the base + * is not a known vector (struct member / scalar / unresolved — left for other checks). + */ + static swizzleError(baseType: GalaceanDataType | undefined, swizzle: string): string | null { + const size = TypeSystem.vectorComponentCount(baseType); + if (size === 0) return null; + if (swizzle.length < 1 || swizzle.length > 4) { + return `Invalid swizzle ".${swizzle}": a vector swizzle selects 1-4 components.`; + } + const sets = ParserUtils._swizzleSets; + let setIndex = -1; + for (const ch of swizzle) { + let matched = false; + for (let s = 0; s < sets.length; s++) { + const idx = sets[s].indexOf(ch); + if (idx === -1) continue; + if (setIndex === -1) setIndex = s; + else if (setIndex !== s) + return `Invalid swizzle ".${swizzle}": components must come from one set (xyzw, rgba, or stpq).`; + if (idx >= size) + return `Invalid swizzle ".${swizzle}": component '${ch}' is out of range for a ${size}-component vector.`; + matched = true; + break; + } + if (!matched) return `Invalid swizzle ".${swizzle}": '${ch}' is not a vector component.`; + } + return null; + } + + /** + * Evaluate an expression node to its compile-time numeric literal, unwrapping the single-child + * precedence chain and parenthesised groups. Returns `undefined` for anything that is not a plain + * numeric literal (identifiers, compound/arithmetic expressions) — callers treat that as "not a + * known constant" and skip (continue-with-unknown), so this never produces a false positive. + */ + static constNumericValue(node: TreeNode): number | undefined { + let cur: TreeNode = node; + while (true) { + if (cur instanceof ASTNode.PrimaryExpression) { + if (cur.children.length === 3) { + const inner = cur.children[1]; + if (!(inner instanceof TreeNode)) return undefined; + cur = inner; + continue; + } + const leaf = cur.children[0]; + if ( + leaf instanceof Token && + (leaf.type === ETokenType.INT_CONSTANT || leaf.type === ETokenType.FLOAT_CONSTANT) + ) { + const n = Number(leaf.lexeme); + return Number.isNaN(n) ? undefined : n; + } + return undefined; + } + if (cur instanceof ASTNode.ExpressionAstNode && cur.children.length === 1) { + const child = cur.children[0]; + if (!(child instanceof TreeNode)) return undefined; + cur = child; + continue; + } + return undefined; + } + } + + /** Recursively walk a `type_qualifier` token chain for a keyword (e.g. `const`, `flat`; test by value as CONST === 0). */ + static hasQualifier(node: TreeNode, keyword: Keyword): boolean { + for (const child of node.children) { + if (child instanceof Token) { + if (child.type === keyword) return true; + } else if (child instanceof TreeNode && ParserUtils.hasQualifier(child, keyword)) { + return true; + } + } + return false; + } + + /** + * Whether an expression is a compile-time constant per GLSL ES §4.3.3. Covers numeric literals, + * bare identifiers whose symbol is `const`, `#define`d names, built-in function calls whose + * arguments are themselves constant (e.g. `sin(0.5)`, `vec3(1.0, 2.0, 3.0)`), and any compound + * expression (binary / unary / ternary) whose sub-expressions are all constant. + * Non-constant references (uniforms, non-const locals) return false. Called only by diagnostics + * (NonConstInitializer / NonConstArraySize) — codegen doesn't consult it. + * @param node - Expression to classify using its retained symbol-resolution facts. + * @returns Whether every reachable operand is a compile-time constant. + */ + static isConstExpr(node: TreeNode): boolean { + if (ParserUtils.constNumericValue(node) !== undefined) return true; + if (ParserUtils._isBooleanLiteral(node)) return true; + const ident = ParserUtils.unwrapBareIdentifier(node, { allowParens: true }); + if (ident) { + const child = ident.children[0]; + // A `#define`'d name at use is lexed as a MACRO_CALL (only registered macros become one), so + // it's a compile-time constant — its replacement is fixed before the compiler runs. + if (child instanceof ASTNode.MacroCallSymbol || child instanceof ASTNode.MacroCallFunction) return true; + if (!(child instanceof Token)) return false; + const symbols = ident.resolvedSymbols(); + return symbols.length > 0 && symbols.every((symbol) => symbol instanceof VarSymbol && symbol.isConst); + } + // Built-in function call: constant iff every argument is constant. `FunctionIdentifier.isBuiltin` + // covers keyword constructors (vec3/mat3/...); regular built-in functions (sin/cos/sqrt/...) + // are identified via `BuiltinFunction.isExist`. User-defined calls stay non-constant since a + // user function body may reach uniforms transitively. + if (node instanceof ASTNode.FunctionCallGeneric) { + const fnIdent = node.children[0] as ASTNode.FunctionIdentifier; + const isConstructor = fnIdent.isBuiltin; + const isBuiltinFn = typeof fnIdent.ident === "string" && BuiltinFunction.isExist(fnIdent.ident); + if (!isConstructor && !isBuiltinFn) return false; + const list = node.children[2]; + if (!(list instanceof ASTNode.FunctionCallParameterList)) return true; + for (const arg of list.paramNodes) { + if (arg instanceof TreeNode && !ParserUtils.isConstExpr(arg)) return false; + } + return true; + } + // Compound expression (binary / unary / ternary / shift / additive / multiplicative): constant + // iff every sub-expression is constant. A non-const operand short-circuits — this is how + // `u_uniform + sin(0.5)` correctly reports non-const even though `sin(0.5)` is const. + if (node instanceof ASTNode.ExpressionAstNode) { + let sawSubExpr = false; + for (const c of node.children) { + if (c instanceof ASTNode.ExpressionAstNode) { + sawSubExpr = true; + if (!ParserUtils.isConstExpr(c)) return false; + } + } + return sawSubExpr; + } + return false; + } + + private static _isBooleanLiteral(node: TreeNode): boolean { + let current = node; + while (true) { + if (current instanceof ASTNode.PrimaryExpression) { + if (current.children.length === 3) { + const child = current.children[1]; + if (!(child instanceof TreeNode)) return false; + current = child; + continue; + } + const token = current.children[0]; + return token instanceof Token && (token.type === Keyword.True || token.type === Keyword.False); + } + if (current instanceof ASTNode.ExpressionAstNode && current.children.length === 1) { + const child = current.children[0]; + if (!(child instanceof TreeNode)) return false; + current = child; + continue; + } + return false; + } + } + + /** The first arithmetic-binary operand whose type can't be an operand (bool/sampler/struct), else undefined. */ + static firstNonArithmeticOperand(a: TreeNode | Token, b: TreeNode | Token): ASTNode.ExpressionAstNode | undefined { + for (const n of [a, b]) { + if (n instanceof ASTNode.ExpressionAstNode && TypeSystem.nonArithmeticOperand(n.type)) return n; + } + return undefined; + } + + static toString(sm: GrammarSymbol) { + if (this.isTerminal(sm)) { + return ETokenType[sm] ?? Keyword[sm]; + } + return NoneTerminal[sm]; + } + + static isTerminal(sm: GrammarSymbol) { + return sm < NoneTerminal.START; + } +} diff --git a/packages/shader-parser/src/Preprocessor.ts b/packages/shader-parser/src/Preprocessor.ts new file mode 100644 index 0000000000..2162bb764c --- /dev/null +++ b/packages/shader-parser/src/Preprocessor.ts @@ -0,0 +1,205 @@ +import type { ASTNode } from "./parser/AST"; +import type { BranchSignature } from "./common/BaseToken"; +import { Logger } from "@galacean/engine-core"; +import { GSError, GSErrorName } from "./GSError"; +import { ShaderPosition } from "./common/ShaderPosition"; +import type { ShaderSourceMapSegment } from "./ir"; + +// Mirrors `ShaderPass._shaderRootPath` (from core's ShaderPass). +const SHADER_ROOT_PATH = "shaders://root/"; + +export type IncludeMap = { readonly [includeName: string]: string | undefined }; + +export interface PreprocessResult { + /** Expanded shader source. */ + content: string; + /** Include-resolution failures collected while expanding the source. */ + errors: GSError[]; + /** Mapping from expanded offsets back to the source chunks that produced them. */ + sourceMap: ShaderSourceMapSegment[]; +} + +export type ChunkOutputCache = Map; + +export interface MacroDefineInfo { + isFunction: boolean; + params: string[]; + /** Value AST. Set when the replacement list parses as `expression` (which + * includes comma-separated lists per C99 §6.10.3); absent for the GLSL ES + * 3.00 §3.4 opaque cases the grammar can't reduce (empty, type-alias keyword, + * trailing punctuation, unbalanced bracket, trailing operator). Identifier + * references inside are collected by `MacroCallSymbol._collectIdentifierRefs` + * walking this subtree. */ + valueAst?: ASTNode.Expression; + /** Whitespace-normalized directive text. Dedup key against re-includes in + * the same branch; differing values produce different keys. */ + dedupKey: string; + /** `#ifdef` branch at registration time; call sites filter to visible entries. */ + branch: BranchSignature; +} + +export interface MacroDefineList { + [macroName: string]: MacroDefineInfo[]; +} + +export class Preprocessor { + // Block-comment alternation prevents expanding `#include` inside doc comments. + private static readonly _includeReg = /\/\*[\s\S]*?\*\/|^[ \t]*#include +"([\w\d./]+)"/gm; + + static parse( + source: string, + basePathForIncludeKey: string, + includeMap: IncludeMap, + chunkOutputCache: ChunkOutputCache + ): string { + const result = this.parseWithErrors(source, basePathForIncludeKey, includeMap, chunkOutputCache); + for (const error of result.errors) Logger.error(error.toString()); + return result.content; + } + + /** + * Expands includes and returns any include-resolution failures with source locations. + * + * @param source - Source to preprocess. + * @param basePathForIncludeKey - Base URL for relative include paths. + * @param includeMap - Include-path lookup table. + * @param chunkOutputCache - Cache for expanded include chunks. + * @returns The expanded source and collected errors. + */ + static parseWithErrors( + source: string, + basePathForIncludeKey: string, + includeMap: IncludeMap, + chunkOutputCache: ChunkOutputCache + ): PreprocessResult { + return this._expand(source, basePathForIncludeKey, includeMap, chunkOutputCache, new Set()); + } + + private static _expand( + source: string, + basePathForIncludeKey: string, + includeMap: IncludeMap, + chunkOutputCache: ChunkOutputCache, + activeIncludePaths: Set, + sourceFile?: string + ): PreprocessResult { + const errors: GSError[] = []; + const sourceMap: ShaderSourceMapSegment[] = []; + const parts: string[] = []; + let sourceOffset = 0; + let generatedOffset = 0; + let match: RegExpExecArray | null; + const includeReg = new RegExp(this._includeReg.source, this._includeReg.flags); + + const appendSource = (start: number, end: number): void => { + if (end <= start) return; + const text = source.slice(start, end); + parts.push(text); + sourceMap.push({ + generatedStart: generatedOffset, + generatedEnd: generatedOffset + text.length, + sourceStart: start, + source, + file: sourceFile + }); + generatedOffset += text.length; + }; + + while ((match = includeReg.exec(source))) { + appendSource(sourceOffset, match.index); + const includeName = match[1]; + if (!includeName) { + appendSource(match.index, includeReg.lastIndex); + sourceOffset = includeReg.lastIndex; + continue; + } + + const path = this._resolveIncludePath(includeName, basePathForIncludeKey); + if (!path) { + errors.push( + this._createIncludeError( + source, + match.index, + `Cannot resolve relative shader include "${includeName}" without a shader base path.`, + sourceFile + ) + ); + sourceOffset = includeReg.lastIndex; + continue; + } + + const chunk = includeMap[path]; + if (!chunk) { + errors.push( + this._createIncludeError(source, match.index, `Shader include "${path}" was not found.`, sourceFile) + ); + sourceOffset = includeReg.lastIndex; + continue; + } + + if (activeIncludePaths.has(path)) { + errors.push( + this._createIncludeError(source, match.index, `Shader include cycle detected at "${path}".`, sourceFile) + ); + sourceOffset = includeReg.lastIndex; + continue; + } + + let expanded = chunkOutputCache.get(path); + if (!expanded) { + activeIncludePaths.add(path); + try { + expanded = this._expand( + chunk, + this._canonicalIncludeURL(path), + includeMap, + chunkOutputCache, + activeIncludePaths, + path + ); + chunkOutputCache.set(path, expanded); + } finally { + activeIncludePaths.delete(path); + } + } + parts.push(expanded.content); + for (const segment of expanded.sourceMap) { + sourceMap.push({ + generatedStart: generatedOffset + segment.generatedStart, + generatedEnd: generatedOffset + segment.generatedEnd, + sourceStart: segment.sourceStart, + source: segment.source, + file: segment.file + }); + } + generatedOffset += expanded.content.length; + errors.push(...expanded.errors); + sourceOffset = includeReg.lastIndex; + } + appendSource(sourceOffset, source.length); + return { content: parts.join(""), errors, sourceMap }; + } + + private static _resolveIncludePath(includeName: string, basePathForIncludeKey: string): string | undefined { + try { + const url = + includeName[0] === "." ? new URL(includeName, basePathForIncludeKey) : new URL(includeName, SHADER_ROOT_PATH); + return url.href.startsWith(SHADER_ROOT_PATH) ? url.href.substring(SHADER_ROOT_PATH.length) : undefined; + } catch { + return undefined; + } + } + + private static _canonicalIncludeURL(path: string): string { + return new URL(path, SHADER_ROOT_PATH).href; + } + + private static _createIncludeError(source: string, offset: number, message: string, file?: string): GSError { + const before = source.slice(0, offset); + const line = before.split("\n").length - 1; + const lastBreak = Math.max(before.lastIndexOf("\n"), before.lastIndexOf("\r")); + const position = new ShaderPosition(); + position.set(offset, line, offset - lastBreak - 1); + return new GSError(GSErrorName.PreprocessorError, message, position, source, file); + } +} diff --git a/packages/shader-compiler/src/ShaderCompilerUtils.ts b/packages/shader-parser/src/ShaderCompilerUtils.ts similarity index 57% rename from packages/shader-compiler/src/ShaderCompilerUtils.ts rename to packages/shader-parser/src/ShaderCompilerUtils.ts index 6937d09096..ffbd36eef0 100644 --- a/packages/shader-compiler/src/ShaderCompilerUtils.ts +++ b/packages/shader-parser/src/ShaderCompilerUtils.ts @@ -2,12 +2,15 @@ import { ClearableObjectPool, type IPoolElement } from "@galacean/engine-core"; import { GSErrorName } from "./GSError"; import { ShaderRange } from "./common/ShaderRange"; import { ShaderPosition } from "./common/ShaderPosition"; -// #if _VERBOSE import { GSError } from "./GSError"; -// #endif export class ShaderCompilerUtils { private static _shaderCompilerObjectPoolSet: ClearableObjectPool[] = []; + private static _shaderPositionPool = ShaderCompilerUtils.createObjectPool(ShaderPosition); + private static _shaderRangePool = ShaderCompilerUtils.createObjectPool(ShaderRange); + + /** Source text of the pass being compiled, attached to diagnostics as context. */ + static processingPassText?: string; static createObjectPool(type: new () => T) { const pool = new ClearableObjectPool(type); @@ -15,6 +18,18 @@ export class ShaderCompilerUtils { return pool; } + static createPosition(index: number, line = 0, column = 0): ShaderPosition { + const position = ShaderCompilerUtils._shaderPositionPool.get(); + position.set(index, line, column); + return position; + } + + static createRange(start: ShaderPosition, end: ShaderPosition): ShaderRange { + const range = ShaderCompilerUtils._shaderRangePool.get(); + range.set(start, end); + return range; + } + static clearAllShaderCompilerObjectPool() { for (let i = 0, n = ShaderCompilerUtils._shaderCompilerObjectPoolSet.length; i < n; i++) { ShaderCompilerUtils._shaderCompilerObjectPoolSet[i].clear(); @@ -24,17 +39,11 @@ export class ShaderCompilerUtils { static createGSError( message: string, errorName: GSErrorName, - source: string, + source: string | undefined, location: ShaderRange | ShaderPosition, + code?: string, file?: string ): Error { - // #if _VERBOSE - return new GSError(errorName, message, location, source, file); - // #else - console.error(message); - const err = new Error(message); - err.name = errorName; - return err; - // #endif + return new GSError(errorName, message, location, source, file, code); } } diff --git a/packages/shader-compiler/src/common/BaseLexer.ts b/packages/shader-parser/src/common/BaseLexer.ts similarity index 93% rename from packages/shader-compiler/src/common/BaseLexer.ts rename to packages/shader-parser/src/common/BaseLexer.ts index 921b283447..f4c1286aa2 100644 --- a/packages/shader-compiler/src/common/BaseLexer.ts +++ b/packages/shader-parser/src/common/BaseLexer.ts @@ -1,8 +1,8 @@ import { ShaderPosition, ShaderRange } from "."; import { GSErrorName } from "../GSError"; -import { ShaderCompiler } from "../ShaderCompiler"; import { ShaderCompilerUtils } from "../ShaderCompilerUtils"; import { BaseToken } from "./BaseToken"; +import { Logger } from "@galacean/engine-core"; export type OnToken = (token: BaseToken, scanner: BaseLexer) => void; @@ -84,10 +84,8 @@ export abstract class BaseLexer { protected _currentIndex = 0; protected _source: string; - // #if _VERBOSE protected _column = 0; protected _line = 0; - // #endif get currentIndex(): number { return this._currentIndex; @@ -97,7 +95,6 @@ export abstract class BaseLexer { return this._source; } - // #if _VERBOSE get line() { return this._line; } @@ -105,7 +102,6 @@ export abstract class BaseLexer { get column() { return this._column; } - // #endif constructor(source?: string) { this._source = source; @@ -114,19 +110,11 @@ export abstract class BaseLexer { setSource(source: string): void { this._source = source; this._currentIndex = 0; - // #if _VERBOSE this._line = this._column = 0; - // #endif } getShaderPosition(backOffset = 0): ShaderPosition { - return ShaderCompiler.createPosition( - this._currentIndex - backOffset, - // #if _VERBOSE - this._line, - this._column - backOffset - // #endif - ); + return ShaderCompilerUtils.createPosition(this._currentIndex - backOffset, this._line, this._column - backOffset); } isEnd(): boolean { @@ -142,7 +130,6 @@ export abstract class BaseLexer { } advance(count: number): void { - // #if _VERBOSE const source = this._source; const startIndex = this._currentIndex; for (let i = 0; i < count; i++) { @@ -153,7 +140,6 @@ export abstract class BaseLexer { this._column += 1; } } - // #endif this._currentIndex += count; } @@ -223,11 +209,9 @@ export abstract class BaseLexer { return null; } - throwError(pos: ShaderPosition | ShaderRange, ...msgs: any[]) { + throwError(pos: ShaderPosition | ShaderRange, ...msgs: unknown[]) { const error = ShaderCompilerUtils.createGSError(msgs.join(" "), GSErrorName.ScannerError, this._source, pos); - // #if _VERBOSE - console.error(error!.toString()); - // #endif + Logger.error(error.toString()); throw error; } diff --git a/packages/shader-parser/src/common/BaseToken.ts b/packages/shader-parser/src/common/BaseToken.ts new file mode 100644 index 0000000000..dbd831ea9e --- /dev/null +++ b/packages/shader-parser/src/common/BaseToken.ts @@ -0,0 +1,121 @@ +import { ETokenType } from "./types"; +import { ShaderRange, ShaderPosition } from "."; +import type { IPoolElement } from "@galacean/engine-core"; +import { ShaderCompilerUtils } from "../ShaderCompilerUtils"; + +/** + * One condition in a branch signature: `defined: true` for `#ifdef X` (the + * branch is active when `X` is defined), `defined: false` for `#ifndef X` / + * after `#else` (active when `X` is undefined). + */ +export interface BranchConstraint { + name: string; + defined: boolean; + /** Lexical conditional-chain identity. All `#if/#elif/#else` arms in one chain share it. */ + conditionalGroup?: number; + /** Lexical arm within `conditionalGroup`; different arms cannot execute together. */ + conditionalArm?: number; + /** Whether this conditional chain covers every configuration. */ + conditionalComplete?: boolean; + /** Number of arms in this complete conditional chain. */ + conditionalArmCount?: number; + /** Reachability of each arm in this complete conditional chain. */ + conditionalReachableArms?: readonly boolean[]; + /** A recognized `#if` condition; unsupported expressions stay undefined. */ + condition?: BranchCondition; + /** Conditions of earlier arms that must be false for this `#elif`/`#else` arm to run. */ + precedingConditions?: readonly BranchCondition[]; + /** + * Shared `#undef` events for this guard macro. Each guard records the event index at its entry + * (and again when it defines itself) so conflict checks only consider invalidations between two + * guard occurrences. + * @internal + */ + guardUndefBranches?: readonly BranchSignature[]; + /** @internal */ + guardUndefStart?: number; + /** Whether this arm has directly defined its own guard macro before the current source position. */ + selfGuarding?: boolean; +} + +/** A single-macro condition that can be compared without evaluating a macro configuration. */ +export type BranchCondition = + | { kind: "constant"; value: boolean } + | { kind: "defined"; name: string; defined: boolean; version: number } + | { + kind: "comparison"; + name: string; + operator: "==" | "!=" | ">" | ">=" | "<" | "<="; + value: number; + version: number; + } + | { + /** Canonicalized conjunction/disjunction of simple macro conditions. */ + kind: "expression"; + expression: string; + operator: "&&" | "||"; + operands: readonly BranchCondition[]; + names: readonly string[]; + versions: readonly number[]; + negated: boolean; + /** Whether the expression preserves a canonical comparison that this layer must not evaluate. */ + opaque?: boolean; + }; + +/** + * Snapshot of the `#ifdef`/`#ifndef`/`#else` stack at a source position. An + * empty signature means unconditional (top-level). Constraints are conjunctive: + * the position is active iff every constraint holds. Produced by the Lexer + * (the sole branch-stack maintainer) and stamped onto every emitted token + + * every registered `MacroDefineInfo`. + */ +export type BranchSignature = readonly BranchConstraint[]; + +/** Result of checking whether macro-guarded declarations cover a reference site. */ +export type BranchCoverage = "covered" | "uncovered" | "unknown"; + +/** Whether two declarations are proven to coexist, proven exclusive, or unresolved. */ +export type DeclarationCoexistence = "coexist" | "exclusive" | "unknown"; + +// Canonical empty branch signature shared by all default tokens — avoids +// per-token allocation. The Lexer overwrites `branch` after `scanToken()` +// for tokens that are inside an `#ifdef`. +export const EMPTY_BRANCH: BranchSignature = []; + +export { sameBranch } from "./BranchIdentity"; + +export class BaseToken implements IPoolElement { + static pool = ShaderCompilerUtils.createObjectPool(BaseToken); + + type: T; + lexeme: string; + location: ShaderRange; + /** Branch signature snapshot at the point this token was emitted. Empty + * signature (default) means top-level / unconditional. The Lexer tags + * every token; downstream code (AST nodes built from tokens) can read + * the field directly to know which `#ifdef` branch they're inside. */ + branch: BranchSignature = EMPTY_BRANCH; + inMacroDefinition = false; + + set(type: T, lexeme: string, start?: ShaderPosition); + set(type: T, lexeme: string, location?: ShaderRange); + set(type: T, lexeme: string, arg?: ShaderRange | ShaderPosition) { + this.type = type; + this.lexeme = lexeme; + this.branch = EMPTY_BRANCH; + this.inMacroDefinition = false; + if (arg) { + if (arg instanceof ShaderRange) { + this.location = arg as ShaderRange; + } else { + const end = ShaderCompilerUtils.createPosition(arg.index + lexeme.length, arg.line, arg.column + lexeme.length); + this.location = ShaderCompilerUtils.createRange(arg, end); + } + } + } + + dispose(): void {} +} + +export const EOF = new BaseToken(); +EOF.set(ETokenType.EOF, "/EOF"); diff --git a/packages/shader-parser/src/common/BranchAnalysis.ts b/packages/shader-parser/src/common/BranchAnalysis.ts new file mode 100644 index 0000000000..2f54bc6734 --- /dev/null +++ b/packages/shader-parser/src/common/BranchAnalysis.ts @@ -0,0 +1,869 @@ +import type { BranchSemantics } from "./BranchSemantics"; +import type { + BranchCondition, + BranchConstraint, + BranchCoverage, + BranchSignature, + DeclarationCoexistence +} from "./BaseToken"; +import { sameBranch, sameCondition, sameExpression } from "./BranchIdentity"; + +/** + * Whether two simple macro conditions are exact logical negations. + * @param left - First simple condition. + * @param right - Second simple condition. + * @returns Whether exactly one condition holds for every macro value. + */ +export function areConditionsComplementary(left?: BranchCondition, right?: BranchCondition): boolean { + if (!left || !right) return false; + if (left.kind === "constant" || right.kind === "constant") { + return left.kind === "constant" && right.kind === "constant" && left.value !== right.value; + } + if (left.kind === "expression" || right.kind === "expression") { + return ( + left.kind === "expression" && + right.kind === "expression" && + sameExpression(left, right) && + left.negated !== right.negated + ); + } + if (left.kind !== right.kind || left.name !== right.name || left.version !== right.version) return false; + if (left.kind === "defined" && right.kind === "defined") return left.defined !== right.defined; + if (left.kind !== "comparison" || right.kind !== "comparison") return false; + + if (hasExactIntegerValue(left) && hasExactIntegerValue(right)) { + return complementaryConditionKey(left) === simpleConditionKey(right); + } + if (left.value !== right.value) return false; + + return ( + (left.operator === "==" && right.operator === "!=") || + (left.operator === "!=" && right.operator === "==") || + (left.operator === ">" && right.operator === "<=") || + (left.operator === ">=" && right.operator === "<") || + (left.operator === "<" && right.operator === ">=") || + (left.operator === "<=" && right.operator === ">") + ); +} + +/** + * Whether a `#if`/`#elif` chain contains an arm that covers every remaining macro configuration. + * @param constraints - Conditions in source order within one lexical conditional chain. + * @returns Whether no implicit fall-through configuration remains. + * @internal + */ +export function isConditionalChainExhaustive(constraints: readonly BranchConstraint[]): boolean { + for (let i = 0, n = constraints.length; i < n; i++) { + const condition = constraints[i].condition; + if (condition?.kind === "constant" && condition.value) return true; + if (condition && isConditionImplied(condition, constraints[i].precedingConditions ?? [])) return true; + for (let j = 0; j < i; j++) { + if (areConditionsComplementary(constraints[j].condition, condition)) return true; + } + } + return false; +} + +/** + * `defBranch` is visible from `callSiteBranch` when every macro configuration that reaches the + * reference also reaches the declaration. Compatibility alone is not enough: a declaration in + * `#ifdef A` is not visible from an unconditional reference, because `A` can be absent there. + * Extracted from Lexer so common/SymbolTable can consume it without pulling the whole lexer in as + * a dependency. + * @param defBranch - Declaration-side branch signature. + * @param callSiteBranch - Reference-side branch signature. + * @returns Whether the declaration is visible from the reference branch. + */ +export function isBranchVisibleFrom(defBranch: BranchSignature, callSiteBranch: BranchSignature): boolean { + if (!isBranchReachable(callSiteBranch) || !canBranchesOverlap(defBranch, callSiteBranch)) return false; + + const callConditions = getConditions(callSiteBranch); + for (let i = 0, n = defBranch.length; i < n; i++) { + const constraint = defBranch[i]; + if (constraint.conditionalGroup !== undefined) { + const matchingArm = callSiteBranch.find( + (candidate) => + candidate.conditionalGroup === constraint.conditionalGroup && + candidate.conditionalArm === constraint.conditionalArm + ); + const otherArm = callSiteBranch.find( + (candidate) => + candidate.conditionalGroup === constraint.conditionalGroup && + candidate.conditionalArm !== constraint.conditionalArm + ); + if (otherArm) return false; + if (matchingArm) continue; + } + + const required = getConstraintConditions(constraint); + if (!constraint.condition && required.length === 0) return false; + for (let j = 0, m = required.length; j < m; j++) { + if (!isConditionImplied(required[j], callConditions)) return false; + } + } + return true; +} + +/** + * Whether a later declaration can coexist with an earlier declaration in one preprocessed shader. + * Besides ordinary mutually-exclusive conditional arms, an earlier self-defining `#ifndef` arm + * suppresses later `#ifndef` arms for that macro unless a compatible intervening `#undef` reopened + * the guard. Argument order therefore follows source/insertion order. + * @param earlier - Branch signature of an existing declaration. + * @param later - Branch signature of the declaration currently being inserted. + * @returns Whether both declarations can be emitted by one macro configuration. + */ +export function canDeclarationsCoexist(earlier: BranchSignature, later: BranchSignature): boolean { + return getDeclarationCoexistence(earlier, later) !== "exclusive"; +} + +/** + * Classify whether two declarations can be emitted by one macro configuration. + * @param earlier - Branch signature of an existing declaration. + * @param later - Branch signature of the declaration currently being inserted. + * @returns Proven coexistence, proven exclusivity, or an unresolved complex condition. + */ +export function getDeclarationCoexistence(earlier: BranchSignature, later: BranchSignature): DeclarationCoexistence { + if (!canBranchesOverlap(earlier, later)) return "exclusive"; + + for (let i = 0, n = earlier.length; i < n; i++) { + const left = earlier[i]; + if (left.defined || !left.selfGuarding) continue; + for (let j = 0, m = later.length; j < m; j++) { + const right = later[j]; + if ( + !right.defined && + right.name === left.name && + left.conditionalGroup !== undefined && + right.conditionalGroup !== undefined && + right.conditionalGroup > left.conditionalGroup + ) { + if (!hasCompatibleGuardUndef(earlier, left, later, right)) return "exclusive"; + } + } + } + + const combined = [...earlier, ...later]; + if (!hasOnlyAtomicConditions(combined)) return "unknown"; + return isAtomicConjunctionSatisfiable(getConditions(combined)) ? "coexist" : "exclusive"; +} + +/** + * Determines whether a lexical branch can be emitted by at least one macro configuration. + * @param branch - Branch constraints to test. + * @returns Whether the constraints are satisfiable. + */ +export function isBranchReachable(branch: BranchSignature): boolean { + const conditions = getConditions(branch); + for (let i = 0, n = conditions.length; i < n; i++) { + const condition = conditions[i]; + if (condition.kind === "constant" && !condition.value) return false; + for (let j = i + 1; j < n; j++) { + if (areConditionsMutuallyExclusive(conditions[i], conditions[j])) return false; + } + } + return true; +} + +/** + * Determines whether two lexical branches can be emitted by one macro configuration. + * @param left - First branch signature. + * @param right - Second branch signature. + * @returns Whether the combined constraints are satisfiable. + */ +export function canBranchesOverlap(left: BranchSignature, right: BranchSignature): boolean { + for (let i = 0, n = left.length; i < n; i++) { + const leftConstraint = left[i]; + for (let j = 0, m = right.length; j < m; j++) { + const rightConstraint = right[j]; + if ( + leftConstraint.conditionalGroup !== undefined && + leftConstraint.conditionalGroup === rightConstraint.conditionalGroup && + leftConstraint.conditionalArm !== rightConstraint.conditionalArm + ) { + return false; + } + } + } + + return isBranchReachable([...left, ...right]); +} + +/** + * Whether declarations from a complete set of conditional arms cover every configuration that can + * reach `callSiteBranch`. A single declaration must be guaranteed by the call site; alternatively, + * one declaration in every arm of an exhaustive conditional chain is sufficient. + * @param candidates - Branch signatures of matching declarations in one lexical scope. + * @param callSiteBranch - Branch signature at the reference. + * @returns Whether the reference is backed by a declaration on every reachable macro path. + */ +export function canBranchesCoverCallsite( + candidates: readonly BranchSignature[], + callSiteBranch: BranchSignature +): boolean { + return getBranchCoverage(candidates, callSiteBranch) === "covered"; +} + +/** + * Classify declaration coverage without treating an incomplete symbolic proof as a definite error. + * + * `uncovered` is returned only when a concrete counterexample follows from atomic macro facts. + * Complex or opaque expressions stay `unknown`, allowing diagnostic clients to warn without + * blocking code generation. + * @param candidates - Branch signatures of matching declarations in one lexical scope. + * @param callSiteBranch - Branch signature at the reference. + * @returns Whether coverage is proven, disproven, or unknown. + */ +export function getBranchCoverage( + candidates: readonly BranchSignature[], + callSiteBranch: BranchSignature +): BranchCoverage { + const normalizedCandidates = candidates.map(removeSelfGuardingConstraints); + const normalizedCallsite = removeSelfGuardingConstraints(callSiteBranch); + if (canCandidateSetCoverCallsite(normalizedCandidates, normalizedCallsite)) return "covered"; + + const uniqueCandidates: BranchSignature[] = []; + for (let i = 0, n = normalizedCandidates.length; i < n; i++) { + const candidate = normalizedCandidates[i]; + if (!uniqueCandidates.some((existing) => sameBranch(existing, candidate))) uniqueCandidates.push(candidate); + } + uniqueCandidates.sort(compareBranchSourceOrder); + return hasAtomicCoverageCounterexample(uniqueCandidates, normalizedCallsite) ? "uncovered" : "unknown"; +} + +function compareBranchSourceOrder(left: BranchSignature, right: BranchSignature): number { + const length = Math.min(left.length, right.length); + for (let i = 0; i < length; i++) { + const leftGroup = left[i].conditionalGroup ?? Number.MAX_SAFE_INTEGER; + const rightGroup = right[i].conditionalGroup ?? Number.MAX_SAFE_INTEGER; + if (leftGroup !== rightGroup) return leftGroup - rightGroup; + const leftArm = left[i].conditionalArm ?? Number.MAX_SAFE_INTEGER; + const rightArm = right[i].conditionalArm ?? Number.MAX_SAFE_INTEGER; + if (leftArm !== rightArm) return leftArm - rightArm; + } + return left.length - right.length; +} + +function hasAtomicCoverageCounterexample( + candidates: readonly BranchSignature[], + callSiteBranch: BranchSignature +): boolean { + if (candidates.length === 0) return false; + if (candidates.every((candidate) => !canBranchesOverlap(candidate, callSiteBranch))) return true; + if (!hasOnlyAtomicConditions(callSiteBranch) || candidates.some((candidate) => !hasOnlyAtomicConditions(candidate))) { + return false; + } + + const counterexampleFacts = getConditions(callSiteBranch); + if (!isAtomicConjunctionSatisfiable(counterexampleFacts)) return false; + // This greedily keeps the first satisfiable negation; failure is unknown, not proof that no witness exists. + for (let i = 0, n = candidates.length; i < n; i++) { + const candidateConditions = getConditions(candidates[i]); + if (!isAtomicConjunctionSatisfiable([...counterexampleFacts, ...candidateConditions])) continue; + + let excluded = false; + for (let j = 0, m = candidateConditions.length; j < m; j++) { + const negated = negateCondition(candidateConditions[j]); + if (isAtomicConjunctionSatisfiable([...counterexampleFacts, negated])) { + counterexampleFacts.push(negated); + excluded = true; + break; + } + } + if (!excluded) return false; + } + return true; +} + +function hasOnlyAtomicConditions(branch: BranchSignature): boolean { + for (let i = 0, n = branch.length; i < n; i++) { + const constraint = branch[i]; + if (!constraint.condition || constraint.condition.kind === "expression") return false; + if (!hasExactIntegerValue(constraint.condition)) return false; + if (constraint.precedingConditions?.some((condition) => condition.kind === "expression")) return false; + if (constraint.precedingConditions?.some((condition) => !hasExactIntegerValue(condition))) return false; + } + return true; +} + +function hasExactIntegerValue(condition: BranchCondition): boolean { + return ( + condition.kind !== "comparison" || + (Number.isSafeInteger(condition.value) && Math.abs(condition.value) < Number.MAX_SAFE_INTEGER) + ); +} + +function isAtomicConjunctionSatisfiable(conditions: readonly BranchCondition[]): boolean { + const states = new Map< + string, + { + defined?: boolean; + comparisons: Extract[]; + } + >(); + for (let i = 0, n = conditions.length; i < n; i++) { + const condition = conditions[i]; + if (condition.kind === "constant") { + if (!condition.value) return false; + continue; + } + if (condition.kind === "expression") return false; + + const key = `${condition.name}:${condition.version}`; + const state = states.get(key) ?? { comparisons: [] }; + states.set(key, state); + if (condition.kind === "defined") { + if (state.defined !== undefined && state.defined !== condition.defined) return false; + state.defined = condition.defined; + } else { + state.comparisons.push(condition); + } + } + + for (const state of states.values()) { + if (state.defined === false) { + if (state.comparisons.some((comparison) => !matchesComparison(0, comparison))) return false; + continue; + } + + let exact: number | undefined; + let minimum = Number.NEGATIVE_INFINITY; + let maximum = Number.POSITIVE_INFINITY; + const excluded = new Set(); + for (let i = 0, n = state.comparisons.length; i < n; i++) { + const comparison = state.comparisons[i]; + if (comparison.operator === "==") { + if (exact !== undefined && exact !== comparison.value) return false; + exact = comparison.value; + } else if (comparison.operator === "!=") { + excluded.add(comparison.value); + } else { + const candidateLower = lowerBound(comparison); + if (candidateLower) { + minimum = Math.max(minimum, candidateLower.value + (candidateLower.inclusive ? 0 : 1)); + } + const candidateUpper = upperBound(comparison); + if (candidateUpper) { + maximum = Math.min(maximum, candidateUpper.value - (candidateUpper.inclusive ? 0 : 1)); + } + } + } + + if (exact !== undefined) { + if (excluded.has(exact)) return false; + if (state.comparisons.some((comparison) => !matchesComparison(exact!, comparison))) return false; + continue; + } + if (minimum > maximum) return false; + if (Number.isFinite(minimum) && Number.isFinite(maximum)) { + let excludedInRange = 0; + for (const value of excluded) { + if (value >= minimum && value <= maximum) excludedInRange++; + } + if (maximum - minimum + 1 <= excludedInRange) return false; + } + } + return true; +} + +/** + * A canonical include guard only controls whether its own chunk is emitted. Once the lexer has + * proved that the arm defines that same macro, the guard must not become an additional requirement + * for references outside the chunk. Other enclosing constraints still describe real visibility. + */ +function removeSelfGuardingConstraints(branch: BranchSignature): BranchSignature { + return branch.some((constraint) => constraint.selfGuarding) + ? branch.filter((constraint) => !constraint.selfGuarding) + : branch; +} + +function canCandidateSetCoverCallsite( + candidates: readonly BranchSignature[], + callSiteBranch: BranchSignature +): boolean { + if (!isBranchReachable(callSiteBranch)) return true; + const compatible = candidates.filter((candidate) => canBranchesOverlap(candidate, callSiteBranch)); + for (let i = 0, n = compatible.length; i < n; i++) { + if (isBranchVisibleFrom(compatible[i], callSiteBranch)) return true; + } + + const groups = new Set(); + for (let i = 0, n = compatible.length; i < n; i++) { + const candidate = compatible[i]; + for (let j = 0, m = candidate.length; j < m; j++) { + const constraint = candidate[j]; + if ( + constraint.conditionalComplete && + constraint.conditionalGroup !== undefined && + constraint.conditionalArmCount !== undefined + ) { + groups.add(constraint.conditionalGroup); + } + } + } + + for (const group of groups) { + const callSiteConstraint = callSiteBranch.find((constraint) => constraint.conditionalGroup === group); + if (callSiteConstraint?.conditionalArm !== undefined) { + const inCurrentArm = compatible + .filter( + (candidate) => + candidate.find((constraint) => constraint.conditionalGroup === group)?.conditionalArm === + callSiteConstraint.conditionalArm + ) + .map((candidate) => removeConditionalGroup(candidate, group)); + if ( + inCurrentArm.length && + canCandidateSetCoverCallsite(inCurrentArm, removeConditionalGroup(callSiteBranch, group)) + ) { + return true; + } + continue; + } + + const representative = compatible + .map((candidate) => candidate.find((constraint) => constraint.conditionalGroup === group)) + .find((constraint) => constraint?.conditionalArmCount !== undefined); + const armCount = representative?.conditionalArmCount; + if (armCount === undefined) continue; + + let everyArmCovered = true; + for (let arm = 0; arm < armCount; arm++) { + const armReachable = representative?.conditionalReachableArms?.[arm] ?? true; + if (!armReachable) continue; + const armCandidates = compatible + .filter( + (candidate) => candidate.find((constraint) => constraint.conditionalGroup === group)?.conditionalArm === arm + ) + .map((candidate) => removeConditionalGroup(candidate, group)); + if (!armCandidates.length || !canCandidateSetCoverCallsite(armCandidates, callSiteBranch)) { + everyArmCovered = false; + break; + } + } + if (everyArmCovered) return true; + } + + if (canComplementarySimpleCandidatesCoverCallsite(compatible, callSiteBranch)) return true; + return false; +} + +function canComplementarySimpleCandidatesCoverCallsite( + candidates: readonly BranchSignature[], + callSiteBranch: BranchSignature +): boolean { + const seen = new Set(); + for (let i = 0, n = candidates.length; i < n; i++) { + const candidate = candidates[i]; + for (let j = 0, m = candidate.length; j < m; j++) { + const constraint = candidate[j]; + const condition = constraint.condition; + if (!condition || constraint.precedingConditions?.length) continue; + + const remaining = [...candidate.slice(0, j), ...candidate.slice(j + 1)]; + if (!isBranchVisibleFrom(remaining, callSiteBranch)) continue; + + const remainderKey = branchKey(remaining); + const conditionKey = simpleConditionKey(condition); + const complementKey = complementaryConditionKey(condition); + if (complementKey && seen.has(`${remainderKey}|${complementKey}`)) return true; + seen.add(`${remainderKey}|${conditionKey}`); + } + } + return false; +} + +function removeConditionalGroup(branch: BranchSignature, group: number): BranchSignature { + return branch.filter((constraint) => constraint.conditionalGroup !== group); +} + +function branchKey(branch: BranchSignature): string { + return branch + .map((constraint) => [ + constraint.name, + constraint.defined, + simpleConditionKey(constraint.condition), + constraint.precedingConditions?.map(simpleConditionKey).join(",") + ]) + .join("|"); +} + +function simpleConditionKey(condition?: BranchCondition): string { + if (!condition) return ""; + if (condition.kind === "constant") return `constant:${condition.value}`; + if (condition.kind === "defined") return `defined:${condition.name}:${condition.version}:${condition.defined}`; + if (condition.kind === "expression") { + return `expression:${condition.expression}:${condition.names.map((name, i) => `${name}:${condition.versions[i]}`).join(",")}:${condition.negated}`; + } + const normalized = normalizeIntegerComparison(condition); + return `comparison:${normalized.name}:${normalized.version}:${normalized.operator}:${normalized.value}`; +} + +function complementaryConditionKey(condition: BranchCondition): string | undefined { + if (condition.kind === "constant") return `constant:${!condition.value}`; + if (condition.kind === "defined") return `defined:${condition.name}:${condition.version}:${!condition.defined}`; + if (condition.kind === "expression") { + return `expression:${condition.expression}:${condition.names.map((name, i) => `${name}:${condition.versions[i]}`).join(",")}:${!condition.negated}`; + } + + if (!hasExactIntegerValue(condition)) { + const operator = + condition.operator === "==" + ? "!=" + : condition.operator === "!=" + ? "==" + : condition.operator === ">" + ? "<=" + : condition.operator === ">=" + ? "<" + : condition.operator === "<" + ? ">=" + : ">"; + return `comparison:${condition.name}:${condition.version}:${operator}:${condition.value}`; + } + + const normalized = normalizeIntegerComparison(condition); + const operator = + normalized.operator === "==" + ? "!=" + : normalized.operator === "!=" + ? "==" + : normalized.operator === ">=" + ? "<=" + : ">="; + const value = + normalized.operator === ">=" + ? normalized.value - 1 + : normalized.operator === "<=" + ? normalized.value + 1 + : normalized.value; + return `comparison:${normalized.name}:${normalized.version}:${operator}:${value}`; +} + +function normalizeIntegerComparison( + condition: Extract +): Extract { + if (!hasExactIntegerValue(condition)) return condition; + if (condition.operator === ">") return { ...condition, operator: ">=", value: condition.value + 1 }; + if (condition.operator === "<") return { ...condition, operator: "<=", value: condition.value - 1 }; + return condition; +} + +function hasCompatibleGuardUndef( + earlier: BranchSignature, + earlierGuard: BranchConstraint, + later: BranchSignature, + laterGuard: BranchConstraint +): boolean { + const events = laterGuard.guardUndefBranches; + if (!events) return false; + + const start = earlierGuard.guardUndefStart ?? 0; + const end = laterGuard.guardUndefStart ?? 0; + for (let i = start; i < end; i++) { + const event = events[i]; + if (canBranchesOverlap(earlier, event) && canBranchesOverlap(event, later)) return true; + } + return false; +} + +function getConditions(branch: BranchSignature): BranchCondition[] { + const conditions: BranchCondition[] = []; + for (let i = 0, n = branch.length; i < n; i++) conditions.push(...getConstraintConditions(branch[i])); + return conditions; +} + +function getConstraintConditions(constraint: BranchConstraint): readonly BranchCondition[] { + const conditions = constraint.precedingConditions ? [...constraint.precedingConditions] : []; + if (constraint.condition) conditions.push(constraint.condition); + return conditions; +} + +function isConditionImplied(required: BranchCondition, facts: readonly BranchCondition[]): boolean { + if (required.kind === "constant") return required.value; + if (facts.some((fact) => sameCondition(fact, required))) return true; + + if (required.kind === "expression") { + if (required.opaque) return false; + if (!required.negated && required.operator === "&&") { + if (required.operands.every((operand) => isConditionImplied(operand, facts))) return true; + } + if (!required.negated && required.operator === "||") { + if (required.operands.some((operand) => isConditionImplied(operand, facts))) return true; + } + if (required.negated && required.operator === "||") { + if (required.operands.every((operand) => isConditionImplied(negateCondition(operand), facts))) return true; + } + if (required.negated && required.operator === "&&") { + if (required.operands.some((operand) => isConditionImplied(negateCondition(operand), facts))) return true; + } + return facts.length > 1 && doDefinedBooleanFactsImply(required, facts); + } + + for (let i = 0, n = facts.length; i < n; i++) { + const fact = facts[i]; + if (fact.kind === "constant") continue; + if (fact.kind === "expression") { + if (expressionImpliesCondition(fact, required)) return true; + continue; + } + if (fact.name !== required.name || fact.version !== required.version) continue; + if (conditionImplies(fact, required)) return true; + } + return facts.length > 1 && doDefinedBooleanFactsImply(required, facts); +} + +/** + * Propagates `defined(MACRO)` facts through conjunctions and disjunctions, + * such as `(A || B) && !A => B`, without enumerating macro configurations. + */ +function doDefinedBooleanFactsImply(required: BranchCondition, facts: readonly BranchCondition[]): boolean { + const known: BranchCondition[] = []; + let changed = true; + while (changed) { + changed = false; + for (let i = 0, n = facts.length; i < n; i++) { + const simplified = simplifyDefinedBooleanCondition(facts[i], known); + if (simplified.kind === "constant" && !simplified.value) return true; + changed = collectGuaranteedDefinedFacts(simplified, known) || changed; + } + } + const simplifiedRequired = simplifyDefinedBooleanCondition(required, known); + return simplifiedRequired.kind === "constant" && simplifiedRequired.value; +} + +function collectGuaranteedDefinedFacts(condition: BranchCondition, known: BranchCondition[]): boolean { + if (condition.kind === "constant" || condition.kind === "comparison") return false; + if (condition.kind === "defined") { + if (known.some((candidate) => sameCondition(candidate, condition))) return false; + known.push(condition); + return true; + } + if (condition.opaque) return false; + if (!condition.negated && condition.operator === "&&") { + return condition.operands.reduce( + (changed, operand) => collectGuaranteedDefinedFacts(operand, known) || changed, + false + ); + } + if (condition.negated && condition.operator === "||") { + return condition.operands.reduce( + (changed, operand) => collectGuaranteedDefinedFacts(negateCondition(operand), known) || changed, + false + ); + } + return false; +} + +function simplifyDefinedBooleanCondition( + condition: BranchCondition, + known: readonly BranchCondition[] +): BranchCondition { + if (condition.kind === "constant" || condition.kind === "comparison") return condition; + if (condition.kind === "defined") { + if (known.some((candidate) => sameCondition(candidate, condition))) return { kind: "constant", value: true }; + if (known.some((candidate) => areConditionsComplementary(candidate, condition))) { + return { kind: "constant", value: false }; + } + return condition; + } + if (condition.opaque) return condition; + + const operands = condition.operands.map((operand) => simplifyDefinedBooleanCondition(operand, known)); + const hasTrue = operands.some((operand) => operand.kind === "constant" && operand.value); + const hasFalse = operands.some((operand) => operand.kind === "constant" && !operand.value); + const remaining = operands.filter((operand) => operand.kind !== "constant"); + const innerValue = + condition.operator === "&&" + ? hasFalse + ? false + : remaining.length === 0 + ? true + : undefined + : hasTrue + ? true + : remaining.length === 0 + ? false + : undefined; + if (innerValue !== undefined) return { kind: "constant", value: condition.negated ? !innerValue : innerValue }; + if (!condition.negated && remaining.length === 1) return remaining[0]; + return { ...condition, operands: remaining }; +} + +function expressionImpliesCondition( + fact: Extract, + required: Exclude +): boolean { + if (fact.opaque) return false; + if (!fact.negated && fact.operator === "&&") { + return fact.operands.some((operand) => isConditionImplied(required, [operand])); + } + if (!fact.negated && fact.operator === "||") { + return fact.operands.every((operand) => isConditionImplied(required, [operand])); + } + if (fact.negated && fact.operator === "||") { + return fact.operands.some((operand) => isConditionImplied(required, [negateCondition(operand)])); + } + if (fact.negated && fact.operator === "&&") { + return fact.operands.every((operand) => isConditionImplied(required, [negateCondition(operand)])); + } + return false; +} + +function negateCondition(condition: BranchCondition): BranchCondition { + if (condition.kind === "constant") return { kind: "constant", value: !condition.value }; + if (condition.kind === "defined") return { ...condition, defined: !condition.defined }; + if (condition.kind === "expression") return { ...condition, negated: !condition.negated }; + switch (condition.operator) { + case "==": + return { ...condition, operator: "!=" }; + case "!=": + return { ...condition, operator: "==" }; + case ">": + return { ...condition, operator: "<=" }; + case ">=": + return { ...condition, operator: "<" }; + case "<": + return { ...condition, operator: ">=" }; + case "<=": + return { ...condition, operator: ">" }; + } +} + +function conditionImplies( + fact: Exclude, + required: Exclude +): boolean { + if (fact.kind === "expression" || required.kind === "expression") return sameCondition(fact, required); + if (fact.kind === "defined") { + if (required.kind === "defined") return fact.defined === required.defined; + return !fact.defined && matchesComparison(0, required); + } + if (required.kind === "defined") { + return required.defined && !matchesComparison(0, fact); + } + + if (fact.operator === "==") return matchesComparison(fact.value, required); + if (required.operator === "!=") return !matchesComparison(required.value, fact); + + const factLower = lowerBound(fact); + const factUpper = upperBound(fact); + const requiredLower = lowerBound(required); + const requiredUpper = upperBound(required); + if (requiredLower && (!factLower || !isLowerBoundAtLeast(factLower, requiredLower))) return false; + if (requiredUpper && (!factUpper || !isUpperBoundAtMost(factUpper, requiredUpper))) return false; + return !!(requiredLower || requiredUpper); +} + +function areConditionsMutuallyExclusive(left?: BranchCondition, right?: BranchCondition): boolean { + if (!left || !right) return false; + if (left.kind === "constant" || right.kind === "constant") { + return (left.kind === "constant" && !left.value) || (right.kind === "constant" && !right.value); + } + if (left.kind === "expression" || right.kind === "expression") { + return ( + left.kind === "expression" && + right.kind === "expression" && + sameExpression(left, right) && + left.negated !== right.negated + ); + } + if (left.name !== right.name || left.version !== right.version) return false; + + if (left.kind === "defined") { + if (right.kind === "defined") return left.defined !== right.defined; + return !left.defined && !matchesComparison(0, right); + } + if (right.kind === "defined") return !right.defined && !matchesComparison(0, left); + + if (left.operator === "==") return !matchesComparison(left.value, right); + if (right.operator === "==") return !matchesComparison(right.value, left); + + const leftLower = lowerBound(left); + const rightLower = lowerBound(right); + const leftUpper = upperBound(left); + const rightUpper = upperBound(right); + return ( + (leftLower !== undefined && rightUpper !== undefined && isEmptyInterval(leftLower, rightUpper)) || + (rightLower !== undefined && leftUpper !== undefined && isEmptyInterval(rightLower, leftUpper)) + ); +} + +function isLowerBoundAtLeast( + actual: { value: number; inclusive: boolean }, + required: { value: number; inclusive: boolean } +): boolean { + return ( + actual.value > required.value || (actual.value === required.value && (required.inclusive || !actual.inclusive)) + ); +} + +function isUpperBoundAtMost( + actual: { value: number; inclusive: boolean }, + required: { value: number; inclusive: boolean } +): boolean { + return ( + actual.value < required.value || (actual.value === required.value && (required.inclusive || !actual.inclusive)) + ); +} + +function matchesComparison(value: number, comparison: Extract): boolean { + switch (comparison.operator) { + case "==": + return value === comparison.value; + case "!=": + return value !== comparison.value; + case ">": + return value > comparison.value; + case ">=": + return value >= comparison.value; + case "<": + return value < comparison.value; + case "<=": + return value <= comparison.value; + } +} + +function lowerBound( + comparison: Extract +): { value: number; inclusive: boolean } | undefined { + switch (comparison.operator) { + case ">": + return { value: comparison.value, inclusive: false }; + case ">=": + return { value: comparison.value, inclusive: true }; + default: + return undefined; + } +} + +function upperBound( + comparison: Extract +): { value: number; inclusive: boolean } | undefined { + switch (comparison.operator) { + case "<": + return { value: comparison.value, inclusive: false }; + case "<=": + return { value: comparison.value, inclusive: true }; + default: + return undefined; + } +} + +function isEmptyInterval( + lower: { value: number; inclusive: boolean }, + upper: { value: number; inclusive: boolean } +): boolean { + return lower.value > upper.value || (lower.value === upper.value && (!lower.inclusive || !upper.inclusive)); +} + +/** Analyzer-grade branch reasoning kept outside the runtime parser module graph. @internal */ +/** Analyzer branch-proof implementation injected into analyzer parser instances. @internal */ +export const branchAnalysis: BranchSemantics = { + canBranchesOverlap, + canDeclarationsCoexist, + getBranchCoverage, + getDeclarationCoexistence, + isBranchReachable, + isBranchVisibleFrom +}; diff --git a/packages/shader-parser/src/common/BranchIdentity.ts b/packages/shader-parser/src/common/BranchIdentity.ts new file mode 100644 index 0000000000..d3a8499593 --- /dev/null +++ b/packages/shader-parser/src/common/BranchIdentity.ts @@ -0,0 +1,75 @@ +import type { BranchCondition, BranchSignature } from "./BaseToken"; + +/** + * Whether two signatures express the same macro conditions. Lexical group/arm identity is + * intentionally ignored: repeated include-guard blocks have different lexical identities but + * the same condition, and macro-definition deduplication relies on that equivalence. + * @param a - First branch signature. + * @param b - Second branch signature. + * @returns Whether both signatures contain the same ordered macro conditions. + */ +export function sameBranch(a: BranchSignature, b: BranchSignature): boolean { + if (a.length !== b.length) return false; + for (let i = 0, n = a.length; i < n; i++) { + const left = a[i]; + const right = b[i]; + if (left.name !== right.name || left.defined !== right.defined) return false; + if (!sameCondition(left.condition, right.condition)) return false; + const leftPreceding = left.precedingConditions; + const rightPreceding = right.precedingConditions; + if ((leftPreceding?.length ?? 0) !== (rightPreceding?.length ?? 0)) return false; + for (let j = 0, m = leftPreceding?.length ?? 0; j < m; j++) { + if (!sameCondition(leftPreceding![j], rightPreceding![j])) return false; + } + } + return true; +} + +/** + * Compares two optional branch conditions structurally. + * @param left - First condition, or no condition. + * @param right - Second condition, or no condition. + * @returns Whether both conditions encode the same predicate. + * @internal + */ +export function sameCondition(left?: BranchCondition, right?: BranchCondition): boolean { + if (!left || !right) return left === right; + if (left.kind !== right.kind) return false; + if (left.kind === "constant") return right.kind === "constant" && left.value === right.value; + if (right.kind === "constant") return false; + if (left.kind === "expression") + return right.kind === "expression" && sameExpression(left, right) && left.negated === right.negated; + if (right.kind === "expression") return false; + if (left.name !== right.name || left.version !== right.version) return false; + if (left.kind === "defined" && right.kind === "defined") return left.defined === right.defined; + return ( + left.kind === "comparison" && + right.kind === "comparison" && + left.operator === right.operator && + left.value === right.value + ); +} + +/** + * Compares the normalized expression and macro-version dependencies of two expression conditions. + * @param left - First expression condition. + * @param right - Second expression condition. + * @returns Whether both expression conditions have identical inputs. + * @internal + */ +export function sameExpression( + left: Extract, + right: Extract +): boolean { + if ( + left.expression !== right.expression || + left.opaque !== right.opaque || + left.names.length !== right.names.length + ) { + return false; + } + for (let i = 0, n = left.names.length; i < n; i++) { + if (left.names[i] !== right.names[i] || left.versions[i] !== right.versions[i]) return false; + } + return true; +} diff --git a/packages/shader-parser/src/common/BranchSemantics.ts b/packages/shader-parser/src/common/BranchSemantics.ts new file mode 100644 index 0000000000..182af7b770 --- /dev/null +++ b/packages/shader-parser/src/common/BranchSemantics.ts @@ -0,0 +1,55 @@ +import type { BranchCoverage, BranchSignature, DeclarationCoexistence } from "./BaseToken"; + +/** + * Branch-reasoning operations supplied only to analyzer parser instances. + * @internal + */ +export interface BranchSemantics { + /** + * Determines whether two branches may coexist. + * @param left - First branch signature. + * @param right - Second branch signature. + * @returns Whether the branches overlap. + * @internal + */ + canBranchesOverlap(left: BranchSignature, right: BranchSignature): boolean; + /** + * Determines whether two declarations may coexist. + * @param earlier - Earlier declaration branch. + * @param later - Later declaration branch. + * @returns Whether coexistence has not been disproven. + * @internal + */ + canDeclarationsCoexist(earlier: BranchSignature, later: BranchSignature): boolean; + /** + * Classifies declaration coverage at a reference. + * @param candidates - Declaration branches. + * @param callSiteBranch - Reference branch. + * @returns Proven coverage, proven absence, or unknown coverage. + * @internal + */ + getBranchCoverage(candidates: readonly BranchSignature[], callSiteBranch: BranchSignature): BranchCoverage; + /** + * Classifies whether two declarations can coexist. + * @param earlier - Earlier declaration branch. + * @param later - Later declaration branch. + * @returns Proven coexistence, proven exclusivity, or an unknown relation. + * @internal + */ + getDeclarationCoexistence(earlier: BranchSignature, later: BranchSignature): DeclarationCoexistence; + /** + * Determines whether a branch has a satisfying macro configuration. + * @param branch - Branch signature to test. + * @returns Whether the branch is reachable. + * @internal + */ + isBranchReachable(branch: BranchSignature): boolean; + /** + * Determines whether a declaration is guaranteed at a reference. + * @param defBranch - Declaration branch. + * @param callSiteBranch - Reference branch. + * @returns Whether every reachable call-site configuration includes the declaration. + * @internal + */ + isBranchVisibleFrom(defBranch: BranchSignature, callSiteBranch: BranchSignature): boolean; +} diff --git a/packages/shader-compiler/src/common/IBaseSymbol.ts b/packages/shader-parser/src/common/IBaseSymbol.ts similarity index 60% rename from packages/shader-compiler/src/common/IBaseSymbol.ts rename to packages/shader-parser/src/common/IBaseSymbol.ts index d108ff083e..9c29afc607 100644 --- a/packages/shader-compiler/src/common/IBaseSymbol.ts +++ b/packages/shader-parser/src/common/IBaseSymbol.ts @@ -1,5 +1,8 @@ +import { BranchSignature } from "./BaseToken"; + export interface IBaseSymbol { isInMacroBranch: boolean; + branchSignature: BranchSignature; readonly ident: string; diff --git a/packages/shader-parser/src/common/PreprocessorCondition.ts b/packages/shader-parser/src/common/PreprocessorCondition.ts new file mode 100644 index 0000000000..d1a5b3e107 --- /dev/null +++ b/packages/shader-parser/src/common/PreprocessorCondition.ts @@ -0,0 +1,203 @@ +import type { BoolCondition, CompareCondition, DefinedCondition } from "@galacean/engine-design"; + +/** + * Parsed condition accepted by `#if` and `#elif` directives. + * + * `#ifndef` is represented by its own directive and is therefore outside this expression grammar. + */ +export type PreprocessorCondition = + | BoolCondition + | CompareCondition + | DefinedCondition + | { t: "and"; l: PreprocessorCondition; r: PreprocessorCondition } + | { t: "or"; l: PreprocessorCondition; r: PreprocessorCondition } + | { t: "not"; c: PreprocessorCondition }; + +const NUMBER_RE = /[-+]?(?:0[xX][0-9a-fA-F]+|\d+(?:\.\d+)?)/y; + +interface ParserContext { + source: string; + index: number; +} + +/** + * Parse the subset of shader-preprocessor conditions used for branch reasoning. + * + * The result is shared by lexical branch analysis and runtime instruction encoding, + * so both paths accept and interpret the same expressions. + * + * @param expression - Text following an `#if` or `#elif` directive. + * @returns The parsed condition tree. + * @throws Error when the expression cannot be represented by this limited reasoning model. + */ +export function parsePreprocessorCondition(expression: string): PreprocessorCondition { + const context: ParserContext = { source: expression.trim(), index: 0 }; + const condition = parseOr(context); + skipWhitespace(context); + if (context.index !== context.source.length) throwMalformedPreprocessorCondition(expression); + return condition; +} + +function parseOr(context: ParserContext): PreprocessorCondition { + let condition = parseAnd(context); + skipWhitespace(context); + while (consume(context, "||")) { + skipWhitespace(context); + condition = { t: "or", l: condition, r: parseAnd(context) }; + skipWhitespace(context); + } + return condition; +} + +function parseAnd(context: ParserContext): PreprocessorCondition { + let condition = parseUnary(context); + skipWhitespace(context); + while (consume(context, "&&")) { + skipWhitespace(context); + condition = { t: "and", l: condition, r: parseUnary(context) }; + skipWhitespace(context); + } + return condition; +} + +function parseUnary(context: ParserContext): PreprocessorCondition { + skipWhitespace(context); + if (consume(context, "!")) return { t: "not", c: parseUnary(context) }; + return parsePrimary(context); +} + +function parsePrimary(context: ParserContext): PreprocessorCondition { + skipWhitespace(context); + if (consume(context, "(")) { + const condition = parseOr(context); + skipWhitespace(context); + if (!consume(context, ")")) throwMalformedPreprocessorCondition(context.source); + return condition; + } + + const number = scanNumber(context); + if (number !== undefined) { + skipWhitespace(context); + const operator = scanComparisonOperator(context); + if (!operator) return { t: "bool", v: number !== 0 }; + const value = scanRequiredNumber(context); + return { t: "bool", v: evaluateNumericComparison(number, operator, value) }; + } + + const identifier = scanIdentifier(context); + if (!identifier) throwMalformedPreprocessorCondition(context.source); + if (identifier === "defined") return parseDefined(context); + + skipWhitespace(context); + const operator = scanComparisonOperator(context); + if (!operator) return { t: "cmp", m: identifier, op: "!=", v: 0 }; + return { t: "cmp", m: identifier, op: operator, v: scanRequiredNumber(context) }; +} + +function parseDefined(context: ParserContext): PreprocessorCondition { + skipWhitespace(context); + if (consume(context, "(")) { + skipWhitespace(context); + const identifier = scanIdentifier(context); + if (!identifier) throwMalformedPreprocessorCondition(context.source); + skipWhitespace(context); + if (!consume(context, ")")) throwMalformedPreprocessorCondition(context.source); + return { t: "def", m: identifier }; + } + + const identifier = scanIdentifier(context); + if (!identifier) throwMalformedPreprocessorCondition(context.source); + return { t: "def", m: identifier }; +} + +function scanRequiredNumber(context: ParserContext): number { + skipWhitespace(context); + const value = scanNumber(context); + if (value === undefined) throwMalformedPreprocessorCondition(context.source); + return value; +} + +function scanNumber(context: ParserContext): number | undefined { + const source = context.source; + NUMBER_RE.lastIndex = context.index; + const value = NUMBER_RE.exec(source)?.[0]; + if (!value) return undefined; + + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < -0x80000000 || parsed > 0x7fffffff) { + throwMalformedPreprocessorCondition(source); + } + context.index += value.length; + return parsed; +} + +function scanIdentifier(context: ParserContext): string | undefined { + const source = context.source; + const start = context.index; + const first = source.charCodeAt(start); + if (!isIdentifierStart(first)) return undefined; + + context.index++; + while (isIdentifierPart(source.charCodeAt(context.index))) context.index++; + return source.slice(start, context.index); +} + +function scanComparisonOperator(context: ParserContext): "==" | "!=" | ">" | ">=" | "<" | "<=" | undefined { + if (consume(context, "==")) return "=="; + if (consume(context, "!=")) return "!="; + if (consume(context, ">=")) return ">="; + if (consume(context, "<=")) return "<="; + if (consume(context, ">")) return ">"; + if (consume(context, "<")) return "<"; + return undefined; +} + +function evaluateNumericComparison( + left: number, + operator: "==" | "!=" | ">" | ">=" | "<" | "<=", + right: number +): boolean { + switch (operator) { + case "==": + return left === right; + case "!=": + return left !== right; + case ">": + return left > right; + case ">=": + return left >= right; + case "<": + return left < right; + case "<=": + return left <= right; + } +} + +function skipWhitespace(context: ParserContext): void { + const source = context.source; + while (source.charCodeAt(context.index) === 32 /* space */ || source.charCodeAt(context.index) === 9 /* tab */) { + context.index++; + } +} + +function consume(context: ParserContext, token: string): boolean { + if (!context.source.startsWith(token, context.index)) return false; + context.index += token.length; + return true; +} + +function isIdentifierStart(charCode: number): boolean { + return ( + (charCode >= 65 /* A */ && charCode <= 90) /* Z */ || + (charCode >= 97 /* a */ && charCode <= 122) /* z */ || + charCode === 95 /* _ */ + ); +} + +function isIdentifierPart(charCode: number): boolean { + return isIdentifierStart(charCode) || (charCode >= 48 /* 0 */ && charCode <= 57); /* 9 */ +} + +function throwMalformedPreprocessorCondition(expression: string): never { + throw new Error(`Unsupported or malformed preprocessor condition '${expression}'.`); +} diff --git a/packages/shader-compiler/src/common/ShaderPosition.ts b/packages/shader-parser/src/common/ShaderPosition.ts similarity index 62% rename from packages/shader-compiler/src/common/ShaderPosition.ts rename to packages/shader-parser/src/common/ShaderPosition.ts index 52b865827b..3880cb6aa5 100644 --- a/packages/shader-compiler/src/common/ShaderPosition.ts +++ b/packages/shader-parser/src/common/ShaderPosition.ts @@ -2,30 +2,18 @@ import type { IPoolElement } from "@galacean/engine-core"; export class ShaderPosition implements IPoolElement { index: number; - // #if _VERBOSE line: number; column: number; - // #endif - set( - index: number, - // #if _VERBOSE - line: number, - column: number - // #endif - ) { + set(index: number, line: number, column: number) { this.index = index; - // #if _VERBOSE this.line = line; this.column = column; - // #endif } dispose(): void { this.index = 0; - // #if _VERBOSE this.line = 0; this.column = 0; - // #endif } } diff --git a/packages/shader-compiler/src/common/ShaderRange.ts b/packages/shader-parser/src/common/ShaderRange.ts similarity index 100% rename from packages/shader-compiler/src/common/ShaderRange.ts rename to packages/shader-parser/src/common/ShaderRange.ts diff --git a/packages/shader-parser/src/common/SymbolTable.ts b/packages/shader-parser/src/common/SymbolTable.ts new file mode 100644 index 0000000000..ebbd00094c --- /dev/null +++ b/packages/shader-parser/src/common/SymbolTable.ts @@ -0,0 +1,143 @@ +import { EMPTY_BRANCH } from "./BaseToken"; +import type { BranchSignature, DeclarationCoexistence } from "./BaseToken"; +import type { BranchSemantics } from "./BranchSemantics"; +import { IBaseSymbol } from "./IBaseSymbol"; + +export class SymbolTable { + private _table: Map = new Map(); + + /** + * Insert a symbol and report whether it conflicts with an existing declaration. + * Branch declarations are retained even on conflict because codegen needs every arm. + * @param symbol - Symbol to insert. + * @param isInMacroBranch - Whether the declaration is inside a macro branch. + * @param branchSignature - Macro conditions at the declaration site. + * @returns Whether an equal declaration conflicts, is exclusive, or has unresolved branch overlap. + */ + insert( + symbol: T, + isInMacroBranch = false, + branchSignature: BranchSignature = EMPTY_BRANCH, + branchSemantics?: BranchSemantics + ): Exclude | "none" { + symbol.isInMacroBranch = isInMacroBranch; + symbol.branchSignature = branchSignature; + + const entry = this._table.get(symbol.ident) ?? []; + if (!branchSemantics) { + return this._insertWithoutBranchAnalysis(entry, symbol); + } + + let conflict: Exclude | "none" = "none"; + for (let i = 0, n = entry.length; i < n; i++) { + const existing = entry[i]; + if (!existing.equal(symbol)) continue; + + const existingBranch = existing.branchSignature ?? EMPTY_BRANCH; + if (existingBranch.length === 0 && branchSignature.length === 0) { + entry[i] = symbol; + return "coexist"; + } + + const coexistence = branchSemantics.getDeclarationCoexistence(existingBranch, branchSignature); + if (coexistence === "coexist") conflict = "coexist"; + else if (coexistence === "unknown" && conflict === "none") conflict = "unknown"; + } + + entry.push(symbol); + this._table.set(symbol.ident, entry); + return conflict; + } + + private _insertWithoutBranchAnalysis(entry: T[], symbol: T): Exclude | "none" { + for (let i = 0, n = entry.length; i < n; i++) { + if (entry[i].isInMacroBranch || !entry[i].equal(symbol)) continue; + entry[i] = symbol; + return "coexist"; + } + entry.push(symbol); + this._table.set(symbol.ident, entry); + return "none"; + } + + /** + * Look up a symbol visible from `callsiteBranch`. A candidate `item` is visible when + * `isBranchVisibleFrom(item.branchSignature, callsiteBranch)` — same or nested branch, or item is + * unconditional. Without a callsite branch, `includeMacro` controls whether macro-branch entries + * are eligible. Iterates from latest inserted to first visible match. + */ + getSymbol( + symbol: T, + includeMacro = false, + callsiteBranch?: BranchSignature, + branchSemantics?: BranchSemantics + ): T | undefined { + const entry = this._table.get(symbol.ident); + if (entry) { + for (let i = entry.length - 1; i >= 0; i--) { + const item = entry[i]; + let visible = includeMacro || !item.isInMacroBranch; + if (branchSemantics && callsiteBranch !== undefined) { + visible = branchSemantics.isBranchVisibleFrom(item.branchSignature ?? EMPTY_BRANCH, callsiteBranch); + } + if (!visible) continue; + if (item.equal(symbol)) return item; + } + } + } + + getSymbols(symbol: T, includeMacro = false, out: T[]): T[] { + out.length = 0; + this._getSymbols(symbol, includeMacro, out); + + return out; + } + + /** Whether this scope contains an equal symbol without applying macro-branch visibility rules. */ + hasSymbol(symbol: T): boolean { + const entry = this._table.get(symbol.ident); + if (!entry) return false; + for (let i = 0, n = entry.length; i < n; i++) { + if (entry[i].equal(symbol)) return true; + } + return false; + } + + /** + * @internal + * Collect every matching declaration that can coexist with the callsite. Consumers combine this + * candidate set with `canBranchesCoverCallsite` before accepting an unconditional reference. + */ + _getSymbols( + symbol: T, + includeMacro = false, + out: T[], + callsiteBranch?: BranchSignature, + branchSemantics?: BranchSemantics + ): T[] { + const entry = this._table.get(symbol.ident); + + if (entry) { + for (let i = entry.length - 1; i >= 0; i--) { + const item = entry[i]; + let visible = includeMacro || !item.isInMacroBranch; + if (branchSemantics && callsiteBranch !== undefined) { + visible = branchSemantics.canBranchesOverlap(item.branchSignature ?? EMPTY_BRANCH, callsiteBranch); + } + if (!visible) continue; + if (item.equal(symbol)) out.push(item); + } + } + + return out; + } + + /** Iterate every registered symbol. Order within a name bucket is insertion order. */ + forEach(callback: (symbol: T) => void): void { + for (const entries of this._table.values()) { + for (let i = 0, n = entries.length; i < n; i++) { + callback(entries[i]); + } + } + } +} diff --git a/packages/shader-parser/src/common/SymbolTableStack.ts b/packages/shader-parser/src/common/SymbolTableStack.ts new file mode 100644 index 0000000000..064dc1faee --- /dev/null +++ b/packages/shader-parser/src/common/SymbolTableStack.ts @@ -0,0 +1,96 @@ +import { BranchSignature, DeclarationCoexistence, EMPTY_BRANCH } from "./BaseToken"; +import type { BranchSemantics } from "./BranchSemantics"; +import { IBaseSymbol } from "./IBaseSymbol"; +import { SymbolTable } from "./SymbolTable"; + +export class SymbolTableStack> { + stack: T[] = []; + + /** + * @internal + */ + _macroLevel = 0; + + /** + * Branch signature stamped on declarations at the current parser position. Lookups receive their + * callsite branch explicitly. + */ + _currentBranch: BranchSignature = EMPTY_BRANCH; + + /** Analyzer-only branch operations; absent on the runtime compiler path. */ + branchSemantics?: BranchSemantics; + + get scope(): T { + return this.stack[this.stack.length - 1]; + } + + get isInMacroBranch(): boolean { + return this._macroLevel > 0; + } + + pushScope(scope: T): void { + this.stack.push(scope); + } + + clear(): void { + this.stack.length = 0; + // Working state, not just the stack: a parse that bails inside a macro branch leaves this + // non-zero, so a failed compile would make the next one think it is in a macro branch. + this._macroLevel = 0; + this._currentBranch = EMPTY_BRANCH; + } + + popScope(): T | undefined { + return this.stack.pop(); + } + + /** + * Insert a symbol into the current lexical scope. + * @param symbol - Symbol to insert. + * @param branchSignature - Macro branch at the declaration token. + * @returns Whether the declaration conflicts, is exclusive, or has unresolved branch overlap. + */ + insert( + symbol: S, + branchSignature: BranchSignature = this._currentBranch + ): Exclude | "none" { + return this.scope.insert(symbol, this.isInMacroBranch, branchSignature, this.branchSemantics); + } + + lookup(symbol: S, includeMacro = false, callsiteBranch?: BranchSignature): S | undefined { + for (let i = this.stack.length - 1; i >= 0; i--) { + const symbolTable = this.stack[i]; + const result = symbolTable.getSymbol(symbol, includeMacro, callsiteBranch, this.branchSemantics); + if (result) return result; + } + return undefined; + } + + /** Whether any lexical scope contains an equal symbol, regardless of macro-branch visibility. */ + hasSymbol(symbol: S): boolean { + for (let i = this.stack.length - 1; i >= 0; i--) { + if (this.stack[i].hasSymbol(symbol)) return true; + } + return false; + } + + /** + * Collect every matching symbol from the nearest lexical scope. + * @param symbol - Symbol shape used for name and kind matching. + * @param includeMacro - Whether lookups include declarations from macro branches without a callsite branch. + * @param out - Reusable output array. + * @param callsiteBranch - Branch signature used for branch-aware visibility filtering. + * @returns The supplied output array containing visible matches. + */ + lookupAll(symbol: S, includeMacro = false, out: S[], callsiteBranch?: BranchSignature): S[] { + out.length = 0; + for (let i = this.stack.length - 1; i >= 0; i--) { + const symbolTable = this.stack[i]; + symbolTable._getSymbols(symbol, includeMacro, out, callsiteBranch, this.branchSemantics); + // Match `lookup`: lexical shadowing stops at the nearest scope, while branch/overload + // alternatives inside that scope remain available for ambiguity checks. + if (out.length > 0) break; + } + return out; + } +} diff --git a/packages/shader-compiler/src/common/enums/Keyword.ts b/packages/shader-parser/src/common/enums/Keyword.ts similarity index 100% rename from packages/shader-compiler/src/common/enums/Keyword.ts rename to packages/shader-parser/src/common/enums/Keyword.ts diff --git a/packages/shader-compiler/src/common/enums/ShaderStage.ts b/packages/shader-parser/src/common/enums/ShaderStage.ts similarity index 100% rename from packages/shader-compiler/src/common/enums/ShaderStage.ts rename to packages/shader-parser/src/common/enums/ShaderStage.ts diff --git a/packages/shader-compiler/src/common/index.ts b/packages/shader-parser/src/common/index.ts similarity index 100% rename from packages/shader-compiler/src/common/index.ts rename to packages/shader-parser/src/common/index.ts diff --git a/packages/shader-compiler/src/common/types.ts b/packages/shader-parser/src/common/types.ts similarity index 100% rename from packages/shader-compiler/src/common/types.ts rename to packages/shader-parser/src/common/types.ts diff --git a/packages/shader-parser/src/formatDiagnostic.ts b/packages/shader-parser/src/formatDiagnostic.ts new file mode 100644 index 0000000000..4497066c8e --- /dev/null +++ b/packages/shader-parser/src/formatDiagnostic.ts @@ -0,0 +1,35 @@ +/** + * Formats a diagnostic with a source excerpt and caret markers. + * @param source - Source text containing the diagnostic. + * @param range - Zero-based source range containing the diagnostic. + * @param header - Text displayed before the source excerpt. + * @param contextLines - Number of context lines shown on either side of the range. + * @returns Formatted diagnostic text. + */ +export function formatDiagnosticSource( + source: string | undefined, + range: { start: { line: number; column: number }; end: { line: number; column: number } }, + header: string, + contextLines = 5 +): string { + if (!source) return header; + + const lines = source.split("\n"); + const { start, end } = range; + + const from = Math.max(0, start.line - contextLines); + const to = Math.min(lines.length - 1, end.line + contextLines); + const gutterWidth = String(to + 1).length; + const gutterPad = " ".repeat(gutterWidth); + + let out = header + "\n"; + for (let i = from; i <= to; i++) { + out += `${String(i + 1).padStart(gutterWidth)} | ${lines[i]}\n`; + if (start.line <= i && i <= end.line) { + const cs = i === start.line ? start.column : 0; + const ce = i === end.line ? end.column : lines[i].length; + out += `${gutterPad} | ${" ".repeat(cs)}${"^".repeat(Math.max(ce - cs, 1))}\n`; + } + } + return out; +} diff --git a/packages/shader-parser/src/index.ts b/packages/shader-parser/src/index.ts new file mode 100644 index 0000000000..21c0b93342 --- /dev/null +++ b/packages/shader-parser/src/index.ts @@ -0,0 +1,38 @@ +export * from "./common"; +export * from "./common/BaseToken"; +export * from "./common/BranchAnalysis"; +export * from "./common/BranchSemantics"; +export * from "./common/BaseLexer"; +export * from "./common/PreprocessorCondition"; +export * from "./common/SymbolTable"; +export * from "./common/SymbolTableStack"; +export * from "./common/IBaseSymbol"; +export * from "./common/enums/ShaderStage"; + +export * from "./lexer"; +export * from "./lexer/AnalyzerLexer"; +export * from "./lalr"; + +export * from "./parser"; +export * from "./parser/AST"; +export * from "./parser/types"; +export * from "./parser/GrammarSymbol"; +export * from "./parser/ShaderInfo"; +export * from "./parser/PassParser"; +export * from "./parser/AnalyzerSemanticDiagnostics"; +export * from "./parser/SemanticDiagnostics"; +export * from "./parser/ICodeGenVisitor"; +export * from "./parser/symbolTable"; +export * from "./parser/builtin"; +export * from "./parser/TypeSystem"; + +export * from "./ir"; + +export * from "./sourceParser"; +export * from "./sourceParser/ShaderSourceFactory"; + +export * from "./Preprocessor"; +export * from "./ParserUtils"; +export * from "./GSError"; +export * from "./formatDiagnostic"; +export * from "./ShaderCompilerUtils"; diff --git a/packages/shader-parser/src/ir/ShaderClueIR.ts b/packages/shader-parser/src/ir/ShaderClueIR.ts new file mode 100644 index 0000000000..00f24ad718 --- /dev/null +++ b/packages/shader-parser/src/ir/ShaderClueIR.ts @@ -0,0 +1,46 @@ +import type { ASTNode } from "../parser/AST"; +import type { ShaderData } from "../parser/ShaderInfo"; + +/** + * Maps a range in the preprocessed shader pass back to its source chunk. + * @internal + */ +export interface ShaderSourceMapSegment { + /** Start offset in the preprocessed pass. */ + readonly generatedStart: number; + /** Exclusive end offset in the preprocessed pass. */ + readonly generatedEnd: number; + /** Start offset in the original source chunk. */ + readonly sourceStart: number; + /** Original source chunk. */ + readonly source: string; + /** Include key when the segment came from an included chunk. */ + readonly file?: string; +} + +/** + * Read-only, backend-neutral view of a parsed shader pass. + * + * The existing typed AST and symbol table are the backing store; creating this view does not clone + * the syntax tree. Backends and analysis passes consume this object instead of depending on each + * other. + * @internal + */ +export class ShaderClueIR { + /** Semantic facts collected while parsing the pass. */ + readonly shaderData: ShaderData; + + /** + * Creates a neutral view over a parsed shader program. + * @param program - Existing typed AST used as the IR backing store. + * @param source - Preprocessed pass source represented by the AST. + * @param sourceMap - Mapping from preprocessed offsets to original source chunks. + */ + constructor( + readonly program: ASTNode.GLShaderProgram, + readonly source: string, + readonly sourceMap: readonly ShaderSourceMapSegment[] = [] + ) { + this.shaderData = program.shaderData; + } +} diff --git a/packages/shader-parser/src/ir/ShaderCoreInfo.ts b/packages/shader-parser/src/ir/ShaderCoreInfo.ts new file mode 100644 index 0000000000..89a2f27a6d --- /dev/null +++ b/packages/shader-parser/src/ir/ShaderCoreInfo.ts @@ -0,0 +1,332 @@ +import { BaseToken } from "../common/BaseToken"; +import { EShaderStage } from "../common/enums/ShaderStage"; +import { ASTNode, TreeNode } from "../parser/AST"; +import { ESymbolType, FnSymbol, StructSymbol, SymbolInfo, SymbolTable, VarSymbol } from "../parser/symbolTable"; +import type { StructProp } from "../parser/types"; +import type { ShaderClueIR } from "./ShaderClueIR"; + +/** Role a struct type plays in backend stage IO. @internal */ +export enum ShaderStructRole { + Varying = "varying", + Attribute = "attribute", + Mrt = "mrt" +} + +/** Backend-relevant stage entry and its matching declarations. @internal */ +export interface ShaderEntryPointInfo { + /** Pipeline stage represented by this entry. */ + readonly stage: EShaderStage; + /** Entry function name from the ShaderLab pass. */ + readonly name: string; + /** Matching function declarations retained across macro branches. */ + readonly functions: readonly FnSymbol[]; +} + +/** A struct assigned incompatible IO roles. The analyzer decides how to diagnose this fact. @internal */ +export interface ShaderStructRoleConflict { + /** Conflicting struct declaration. */ + readonly struct: ASTNode.StructSpecifier; + /** Roles inferred from the two entry signatures. */ + readonly roles: readonly ShaderStructRole[]; +} + +/** Backend-required shader input/output facts. @internal */ +export interface ShaderIOInfo { + readonly attributeStructs: readonly ASTNode.StructSpecifier[]; + readonly attributeList: readonly StructProp[]; + readonly varyingStructs: readonly ASTNode.StructSpecifier[]; + readonly varyingList: readonly StructProp[]; + readonly mrtStructs: readonly ASTNode.StructSpecifier[]; + readonly mrtList: readonly StructProp[]; + readonly vertexStructVarMap: Readonly>; + readonly fragmentStructVarMap: Readonly>; +} + +/** + * Lightweight semantic information required by shader backends. + * + * It derives entry and IO facts from `ShaderClueIR` without creating diagnostics or depending on an + * analyzer. Invalid-role facts are retained separately so emitters can stay deterministic while an + * analyzer chooses the diagnostic policy. + * @internal + */ +export class ShaderCoreInfo { + /** Vertex entry facts. */ + readonly vertexEntry: ShaderEntryPointInfo; + /** Fragment entry facts. */ + readonly fragmentEntry: ShaderEntryPointInfo; + /** Valid, unambiguous stage IO consumed by backends. */ + readonly io: ShaderIOInfo; + /** IO role conflicts excluded from `io`. */ + readonly roleConflicts: readonly ShaderStructRoleConflict[]; + /** Global preprocessor declarations that backends may reproduce. */ + readonly outerGlobalMacroDeclarations: readonly ASTNode.GlobalDeclaration[]; + + /** + * Derives backend-required facts from a neutral shader IR. + * @param ir - Neutral shader IR. + * @param vertexEntry - Vertex entry function name. + * @param fragmentEntry - Fragment entry function name. + * @returns Lightweight backend information with no diagnostics. + */ + static create(ir: ShaderClueIR, vertexEntry: string, fragmentEntry: string): ShaderCoreInfo { + return new ShaderCoreInfo(ir, vertexEntry, fragmentEntry); + } + + private constructor(ir: ShaderClueIR, vertexEntry: string, fragmentEntry: string) { + const symbolTable = ir.shaderData.symbolTable; + const vertexFunctions = findFunctions(symbolTable, vertexEntry); + const fragmentFunctions = findFunctions(symbolTable, fragmentEntry); + this.vertexEntry = { stage: EShaderStage.VERTEX, name: vertexEntry, functions: vertexFunctions }; + this.fragmentEntry = { stage: EShaderStage.FRAGMENT, name: fragmentEntry, functions: fragmentFunctions }; + + const mutableIO = createIOInfo(); + collectEntryIO(symbolTable, vertexFunctions, fragmentFunctions, mutableIO); + this.roleConflicts = removeRoleConflicts(mutableIO); + const conflictingStructNames = new Set(); + for (const conflict of this.roleConflicts) { + const name = conflict.struct.ident?.lexeme; + if (name) conflictingStructNames.add(name); + } + deriveStructVariableRoles(symbolTable, vertexFunctions, fragmentFunctions, mutableIO, conflictingStructNames); + this.io = mutableIO; + this.outerGlobalMacroDeclarations = ir.shaderData.getOuterGlobalMacroDeclarations(); + } +} + +interface MutableShaderIOInfo { + attributeStructs: ASTNode.StructSpecifier[]; + attributeList: StructProp[]; + varyingStructs: ASTNode.StructSpecifier[]; + varyingList: StructProp[]; + mrtStructs: ASTNode.StructSpecifier[]; + mrtList: StructProp[]; + vertexStructVarMap: Record; + fragmentStructVarMap: Record; +} + +const lookupSymbol = new SymbolInfo("", null); + +function createIOInfo(): MutableShaderIOInfo { + return { + attributeStructs: [], + attributeList: [], + varyingStructs: [], + varyingList: [], + mrtStructs: [], + mrtList: [], + vertexStructVarMap: Object.create(null), + fragmentStructVarMap: Object.create(null) + }; +} + +function findFunctions(symbolTable: SymbolTable, entry: string): FnSymbol[] { + lookupSymbol.set(entry, ESymbolType.FN); + return symbolTable.getSymbols(lookupSymbol, true, []); +} + +function findStructs(symbolTable: SymbolTable, name: string): StructSymbol[] { + lookupSymbol.set(name, ESymbolType.STRUCT); + return symbolTable.getSymbols(lookupSymbol, true, []); +} + +function appendStructs( + symbols: readonly StructSymbol[], + structs: ASTNode.StructSpecifier[], + props: StructProp[] +): void { + for (const symbol of symbols) { + const node = symbol.astNode; + structs.push(node); + props.push(...node.propList); + } +} + +function collectEntryIO( + symbolTable: SymbolTable, + vertexFunctions: readonly FnSymbol[], + fragmentFunctions: readonly FnSymbol[], + io: MutableShaderIOInfo +): void { + for (const fn of vertexFunctions) { + const proto = fn.astNode.protoType; + if (typeof proto.returnType.type === "string") { + appendStructs(findStructs(symbolTable, proto.returnType.type), io.varyingStructs, io.varyingList); + } + const attributeType = proto.parameterList?.[0]?.typeInfo.type; + if (typeof attributeType === "string") { + appendStructs(findStructs(symbolTable, attributeType), io.attributeStructs, io.attributeList); + } + } + + for (const fn of fragmentFunctions) { + const returnType = fn.astNode.protoType.returnType.type; + if (typeof returnType === "string") { + appendStructs(findStructs(symbolTable, returnType), io.mrtStructs, io.mrtList); + } + } +} + +function removeRoleConflicts(io: MutableShaderIOInfo): ShaderStructRoleConflict[] { + const roles = new Map(); + const register = (nodes: readonly ASTNode.StructSpecifier[], role: ShaderStructRole): void => { + for (const node of nodes) { + const nodeRoles = roles.get(node) ?? []; + if (nodeRoles.indexOf(role) === -1) nodeRoles.push(role); + roles.set(node, nodeRoles); + } + }; + register(io.attributeStructs, ShaderStructRole.Attribute); + register(io.varyingStructs, ShaderStructRole.Varying); + register(io.mrtStructs, ShaderStructRole.Mrt); + + const conflicts: ShaderStructRoleConflict[] = []; + const conflictingStructs = new Set(); + for (const [struct, structRoles] of roles) { + if (structRoles.length > 1) { + conflicts.push({ struct, roles: structRoles }); + conflictingStructs.add(struct); + } + } + if (!conflictingStructs.size) return conflicts; + + const droppedProps = new Set(); + const filterStructs = (structs: ASTNode.StructSpecifier[]): void => { + for (let index = structs.length - 1; index >= 0; index--) { + if (conflictingStructs.has(structs[index])) { + for (const prop of structs[index].propList) droppedProps.add(prop); + structs.splice(index, 1); + } + } + }; + filterStructs(io.attributeStructs); + filterStructs(io.varyingStructs); + filterStructs(io.mrtStructs); + const filterProps = (props: StructProp[]): void => { + for (let index = props.length - 1; index >= 0; index--) { + if (droppedProps.has(props[index])) props.splice(index, 1); + } + }; + filterProps(io.attributeList); + filterProps(io.varyingList); + filterProps(io.mrtList); + return conflicts; +} + +function deriveStructVariableRoles( + symbolTable: SymbolTable, + vertexFunctions: readonly FnSymbol[], + fragmentFunctions: readonly FnSymbol[], + io: MutableShaderIOInfo, + excludedStructNames: ReadonlySet +): void { + const structRoles: Record = Object.create(null); + registerEntryStructRoles( + vertexFunctions, + ShaderStructRole.Attribute, + ShaderStructRole.Varying, + structRoles, + excludedStructNames + ); + registerEntryStructRoles( + fragmentFunctions, + ShaderStructRole.Varying, + ShaderStructRole.Mrt, + structRoles, + excludedStructNames + ); + populateStageVariables(io.vertexStructVarMap, vertexFunctions, structRoles); + populateStageVariables(io.fragmentStructVarMap, fragmentFunctions, structRoles); + + symbolTable.forEach((symbol) => { + if (symbol.type !== ESymbolType.VAR || !(symbol instanceof VarSymbol) || !symbol.isGlobalVariable) return; + registerVariableRole(io.vertexStructVarMap, symbol.dataType?.typeLexeme, symbol.ident, structRoles); + registerVariableRole(io.fragmentStructVarMap, symbol.dataType?.typeLexeme, symbol.ident, structRoles); + }); +} + +function registerEntryStructRoles( + functions: readonly FnSymbol[], + parameterRole: ShaderStructRole, + returnRole: ShaderStructRole, + roles: Record, + excludedStructNames: ReadonlySet +): void { + for (const fn of functions) { + const proto = fn.astNode.protoType; + const firstParameter = proto.parameterList?.[0]; + if ( + firstParameter && + typeof firstParameter.typeInfo.type === "string" && + !excludedStructNames.has(firstParameter.typeInfo.typeLexeme) + ) { + roles[firstParameter.typeInfo.typeLexeme] = parameterRole; + } + if (typeof proto.returnType.type === "string" && !excludedStructNames.has(proto.returnType.type)) { + roles[proto.returnType.type] = returnRole; + } + } +} + +function populateStageVariables( + target: Record, + functions: readonly FnSymbol[], + roles: Record +): void { + for (const fn of functions) { + const parameters = fn.astNode.protoType.parameterList; + if (parameters) { + for (const parameter of parameters) { + if (parameter.ident && typeof parameter.typeInfo.type === "string") { + registerVariableRole(target, parameter.typeInfo.typeLexeme, parameter.ident.lexeme, roles); + } + } + } + walkLocalVariables(target, fn.astNode.statements, roles); + } +} + +function walkLocalVariables( + target: Record, + node: TreeNode, + roles: Record +): void { + for (const child of node.children) { + if (child instanceof ASTNode.InitDeclaratorList) { + const typeLexeme = child.typeInfo?.typeLexeme; + const role = typeLexeme && roles[typeLexeme]; + if (role) appendLocalVariableNames(target, child, role); + } else if (child instanceof TreeNode) { + walkLocalVariables(target, child, roles); + } + } +} + +function appendLocalVariableNames( + target: Record, + node: ASTNode.InitDeclaratorList, + role: ShaderStructRole +): void { + const children = node.children; + if (children.length === 1) { + const declarationChildren = (children[0] as ASTNode.SingleDeclaration).children; + if (declarationChildren.length >= 2 && declarationChildren[1] instanceof BaseToken) { + target[declarationChildren[1].lexeme] = role; + } + } else if (children.length >= 3) { + const previous = children[0]; + if (previous instanceof ASTNode.InitDeclaratorList) appendLocalVariableNames(target, previous, role); + if (children[2] instanceof BaseToken) target[children[2].lexeme] = role; + } +} + +function registerVariableRole( + target: Record, + typeLexeme: string | undefined, + variableName: string, + roles: Record +): void { + if (!typeLexeme) return; + const role = roles[typeLexeme]; + if (role) target[variableName] = role; +} diff --git a/packages/shader-parser/src/ir/index.ts b/packages/shader-parser/src/ir/index.ts new file mode 100644 index 0000000000..53fdd05be4 --- /dev/null +++ b/packages/shader-parser/src/ir/index.ts @@ -0,0 +1,2 @@ +export * from "./ShaderClueIR"; +export * from "./ShaderCoreInfo"; diff --git a/packages/shader-compiler/src/lalr/CFG.ts b/packages/shader-parser/src/lalr/CFG.ts similarity index 93% rename from packages/shader-compiler/src/lalr/CFG.ts rename to packages/shader-parser/src/lalr/CFG.ts index 8b8636c49f..cb1b3d489c 100644 --- a/packages/shader-compiler/src/lalr/CFG.ts +++ b/packages/shader-parser/src/lalr/CFG.ts @@ -264,36 +264,28 @@ const productionAndRules: [GrammarSymbol[], TranslationRule | undefined][] = [ ASTNode.SingleTypeQualifier.pool ), - ...GrammarUtils.createProductionWithOptions( + ...GrammarUtils.createAnalyzerProductionWithOptions( NoneTerminal.storage_qualifier, [[Keyword.CONST], [Keyword.IN], [Keyword.INOUT], [Keyword.OUT], [Keyword.CENTROID]], - // #if _VERBOSE ASTNode.StorageQualifier.pool - // #endif ), - ...GrammarUtils.createProductionWithOptions( + ...GrammarUtils.createAnalyzerProductionWithOptions( NoneTerminal.interpolation_qualifier, [[Keyword.SMOOTH], [Keyword.FLAT]], - // #if _VERBOSE ASTNode.InterpolationQualifier.pool - // #endif ), - ...GrammarUtils.createProductionWithOptions( + ...GrammarUtils.createAnalyzerProductionWithOptions( NoneTerminal.invariant_qualifier, [[Keyword.INVARIANT]], - // #if _VERBOSE ASTNode.InvariantQualifier.pool - // #endif ), - ...GrammarUtils.createProductionWithOptions( + ...GrammarUtils.createAnalyzerProductionWithOptions( NoneTerminal.precision_qualifier, [[Keyword.HIGHP], [Keyword.MEDIUMP], [Keyword.LOWP]], - // #if _VERBOSE ASTNode.PrecisionQualifier.pool - // #endif ), ...GrammarUtils.createProductionWithOptions( @@ -430,7 +422,7 @@ const productionAndRules: [GrammarSymbol[], TranslationRule | undefined][] = [ ASTNode.IntegerConstantExpression.pool ), - ...GrammarUtils.createProductionWithOptions( + ...GrammarUtils.createAnalyzerProductionWithOptions( NoneTerminal.conditional_expression, [ [NoneTerminal.logical_or_expression], @@ -442,90 +434,74 @@ const productionAndRules: [GrammarSymbol[], TranslationRule | undefined][] = [ NoneTerminal.assignment_expression ] ], - // #if _VERBOSE ASTNode.ConditionalExpression.pool - // #endif ), - ...GrammarUtils.createProductionWithOptions( + ...GrammarUtils.createAnalyzerProductionWithOptions( NoneTerminal.logical_or_expression, [ [NoneTerminal.logical_xor_expression], [NoneTerminal.logical_or_expression, ETokenType.OR_OP, NoneTerminal.logical_xor_expression] ], - // #if _VERBOSE ASTNode.LogicalOrExpression.pool - // #endif ), - ...GrammarUtils.createProductionWithOptions( + ...GrammarUtils.createAnalyzerProductionWithOptions( NoneTerminal.logical_xor_expression, [ [NoneTerminal.logical_and_expression], [NoneTerminal.logical_xor_expression, ETokenType.XOR_OP, NoneTerminal.logical_and_expression] ], - // #if _VERBOSE ASTNode.LogicalXorExpression.pool - // #endif ), - ...GrammarUtils.createProductionWithOptions( + ...GrammarUtils.createAnalyzerProductionWithOptions( NoneTerminal.logical_and_expression, [ [NoneTerminal.inclusive_or_expression], [NoneTerminal.logical_and_expression, ETokenType.AND_OP, NoneTerminal.inclusive_or_expression] ], - // #if _VERBOSE ASTNode.LogicalAndExpression.pool - // #endif ), - ...GrammarUtils.createProductionWithOptions( + ...GrammarUtils.createAnalyzerProductionWithOptions( NoneTerminal.inclusive_or_expression, [ [NoneTerminal.exclusive_or_expression], [NoneTerminal.inclusive_or_expression, ETokenType.VERTICAL_BAR, NoneTerminal.exclusive_or_expression] ], - // #if _VERBOSE ASTNode.InclusiveOrExpression.pool - // #endif ), - ...GrammarUtils.createProductionWithOptions( + ...GrammarUtils.createAnalyzerProductionWithOptions( NoneTerminal.exclusive_or_expression, [ [NoneTerminal.and_expression], [NoneTerminal.exclusive_or_expression, ETokenType.CARET, NoneTerminal.and_expression] ], - // #if _VERBOSE ASTNode.ExclusiveOrExpression.pool - // #endif ), - ...GrammarUtils.createProductionWithOptions( + ...GrammarUtils.createAnalyzerProductionWithOptions( NoneTerminal.and_expression, [ [NoneTerminal.equality_expression], [NoneTerminal.and_expression, ETokenType.AMPERSAND, NoneTerminal.equality_expression] ], - // #if _VERBOSE ASTNode.AndExpression.pool - // #endif ), - ...GrammarUtils.createProductionWithOptions( + ...GrammarUtils.createAnalyzerProductionWithOptions( NoneTerminal.equality_expression, [ [NoneTerminal.relational_expression], [NoneTerminal.equality_expression, ETokenType.EQ_OP, NoneTerminal.relational_expression], [NoneTerminal.equality_expression, ETokenType.NE_OP, NoneTerminal.relational_expression] ], - // #if _VERBOSE ASTNode.EqualityExpression.pool - // #endif ), - ...GrammarUtils.createProductionWithOptions( + ...GrammarUtils.createAnalyzerProductionWithOptions( NoneTerminal.relational_expression, [ [NoneTerminal.shift_expression], @@ -534,36 +510,30 @@ const productionAndRules: [GrammarSymbol[], TranslationRule | undefined][] = [ [NoneTerminal.relational_expression, ETokenType.LE_OP, NoneTerminal.shift_expression], [NoneTerminal.relational_expression, ETokenType.GE_OP, NoneTerminal.shift_expression] ], - // #if _VERBOSE ASTNode.RelationalExpression.pool - // #endif ), - ...GrammarUtils.createProductionWithOptions( + ...GrammarUtils.createAnalyzerProductionWithOptions( NoneTerminal.shift_expression, [ [NoneTerminal.additive_expression], [NoneTerminal.shift_expression, ETokenType.LEFT_OP, NoneTerminal.additive_expression], [NoneTerminal.shift_expression, ETokenType.RIGHT_OP, NoneTerminal.additive_expression] ], - // #if _VERBOSE ASTNode.ShiftExpression.pool - // #endif ), - ...GrammarUtils.createProductionWithOptions( + ...GrammarUtils.createAnalyzerProductionWithOptions( NoneTerminal.additive_expression, [ [NoneTerminal.multiplicative_expression], [NoneTerminal.additive_expression, ETokenType.PLUS, NoneTerminal.multiplicative_expression], [NoneTerminal.additive_expression, ETokenType.DASH, NoneTerminal.multiplicative_expression] ], - // #if _VERBOSE ASTNode.AdditiveExpression.pool - // #endif ), - ...GrammarUtils.createProductionWithOptions( + ...GrammarUtils.createAnalyzerProductionWithOptions( NoneTerminal.multiplicative_expression, [ [NoneTerminal.unary_expression], @@ -571,12 +541,10 @@ const productionAndRules: [GrammarSymbol[], TranslationRule | undefined][] = [ [NoneTerminal.multiplicative_expression, ETokenType.SLASH, NoneTerminal.unary_expression], [NoneTerminal.multiplicative_expression, ETokenType.PERCENT, NoneTerminal.unary_expression] ], - // #if _VERBOSE ASTNode.MultiplicativeExpression.pool - // #endif ), - ...GrammarUtils.createProductionWithOptions( + ...GrammarUtils.createAnalyzerProductionWithOptions( NoneTerminal.unary_expression, [ [NoneTerminal.postfix_expression], @@ -584,17 +552,13 @@ const productionAndRules: [GrammarSymbol[], TranslationRule | undefined][] = [ [ETokenType.DEC_OP, NoneTerminal.unary_expression], [NoneTerminal.unary_operator, NoneTerminal.unary_expression] ], - // #if _VERBOSE ASTNode.UnaryExpression.pool - // #endif ), - ...GrammarUtils.createProductionWithOptions( + ...GrammarUtils.createAnalyzerProductionWithOptions( NoneTerminal.unary_operator, [[ETokenType.PLUS], [ETokenType.DASH], [ETokenType.BANG], [ETokenType.TILDE]], - // #if _VERBOSE ASTNode.UnaryOperator.pool - // #endif ), ...GrammarUtils.createProductionWithOptions( @@ -642,7 +606,7 @@ const productionAndRules: [GrammarSymbol[], TranslationRule | undefined][] = [ ASTNode.AssignmentExpression.pool ), - ...GrammarUtils.createProductionWithOptions( + ...GrammarUtils.createAnalyzerProductionWithOptions( NoneTerminal.assignment_operator, [ [ETokenType.EQUAL], @@ -657,9 +621,7 @@ const productionAndRules: [GrammarSymbol[], TranslationRule | undefined][] = [ [ETokenType.XOR_ASSIGN], [ETokenType.OR_ASSIGN] ], - // #if _VERBOSE ASTNode.AssignmentOperator.pool - // #endif ), ...GrammarUtils.createProductionWithOptions( @@ -827,12 +789,10 @@ const productionAndRules: [GrammarSymbol[], TranslationRule | undefined][] = [ ASTNode.StatementList.pool ), - ...GrammarUtils.createProductionWithOptions( + ...GrammarUtils.createAnalyzerProductionWithOptions( NoneTerminal.statement, [[NoneTerminal.compound_statement], [NoneTerminal.simple_statement]], - // #if _VERBOSE ASTNode.Statement.pool - // #endif ), ...GrammarUtils.createProductionWithOptions( @@ -844,18 +804,16 @@ const productionAndRules: [GrammarSymbol[], TranslationRule | undefined][] = [ ASTNode.CompoundStatementNoScope.pool ), - ...GrammarUtils.createProductionWithOptions( + ...GrammarUtils.createAnalyzerProductionWithOptions( NoneTerminal.compound_statement, [ [ETokenType.LEFT_BRACE, ETokenType.RIGHT_BRACE], [NoneTerminal.scope_brace, NoneTerminal.statement_list, NoneTerminal.scope_end_brace] ], - // #if _VERBOSE ASTNode.CompoundStatement.pool - // #endif ), - ...GrammarUtils.createProductionWithOptions( + ...GrammarUtils.createAnalyzerProductionWithOptions( NoneTerminal.simple_statement, [ [NoneTerminal.declaration], @@ -868,9 +826,7 @@ const productionAndRules: [GrammarSymbol[], TranslationRule | undefined][] = [ [NoneTerminal.macro_define], [Keyword.MACRO_DEFINE_EXPRESSION] ], - // #if _VERBOSE ASTNode.SimpleStatement.pool - // #endif ), ...GrammarUtils.createProductionWithOptions( @@ -941,35 +897,29 @@ const productionAndRules: [GrammarSymbol[], TranslationRule | undefined][] = [ ASTNode.SingleDeclaration.pool ), - ...GrammarUtils.createProductionWithOptions( + ...GrammarUtils.createAnalyzerProductionWithOptions( NoneTerminal.initializer, [ [NoneTerminal.assignment_expression], [ETokenType.LEFT_BRACE, NoneTerminal.initializer_list, ETokenType.RIGHT_BRACE] ], - // #if _VERBOSE ASTNode.Initializer.pool - // #endif ), - ...GrammarUtils.createProductionWithOptions( + ...GrammarUtils.createAnalyzerProductionWithOptions( NoneTerminal.initializer_list, [[NoneTerminal.initializer], [NoneTerminal.initializer_list, ETokenType.COMMA, NoneTerminal.initializer]], - // #if _VERBOSE ASTNode.InitializerList.pool - // #endif ), - ...GrammarUtils.createProductionWithOptions( + ...GrammarUtils.createAnalyzerProductionWithOptions( NoneTerminal.expression_statement, [[ETokenType.SEMICOLON], [NoneTerminal.expression, ETokenType.SEMICOLON]], - // #if _VERBOSE ASTNode.ExpressionStatement.pool - // #endif ), // dangling else ambiguity - ...GrammarUtils.createProductionWithOptions( + ...GrammarUtils.createAnalyzerProductionWithOptions( NoneTerminal.selection_statement, [ [Keyword.IF, ETokenType.LEFT_PAREN, NoneTerminal.expression, ETokenType.RIGHT_PAREN, NoneTerminal.statement], @@ -983,12 +933,10 @@ const productionAndRules: [GrammarSymbol[], TranslationRule | undefined][] = [ NoneTerminal.statement ] ], - // #if _VERBOSE ASTNode.SelectionStatement.pool - // #endif ), - ...GrammarUtils.createProductionWithOptions( + ...GrammarUtils.createAnalyzerProductionWithOptions( NoneTerminal.iteration_statement, [ [Keyword.WHILE, ETokenType.LEFT_PAREN, NoneTerminal.condition, ETokenType.RIGHT_PAREN, NoneTerminal.statement], @@ -1001,9 +949,7 @@ const productionAndRules: [GrammarSymbol[], TranslationRule | undefined][] = [ NoneTerminal.statement ] ], - // #if _VERBOSE ASTNode.IterationStatement.pool - // #endif ), ...GrammarUtils.createProductionWithOptions( @@ -1019,42 +965,34 @@ const productionAndRules: [GrammarSymbol[], TranslationRule | undefined][] = [ ASTNode.PrecisionSpecifier.pool ), - ...GrammarUtils.createProductionWithOptions( + ...GrammarUtils.createAnalyzerProductionWithOptions( NoneTerminal.for_init_statement, [[NoneTerminal.expression_statement], [NoneTerminal.declaration]], - // #if _VERBOSE ASTNode.ForInitStatement.pool - // #endif ), - ...GrammarUtils.createProductionWithOptions( + ...GrammarUtils.createAnalyzerProductionWithOptions( NoneTerminal.condition, [ [NoneTerminal.expression], [NoneTerminal.fully_specified_type, ETokenType.ID, ETokenType.EQUAL, NoneTerminal.initializer] ], - // #if _VERBOSE ASTNode.Condition.pool - // #endif ), - ...GrammarUtils.createProductionWithOptions( + ...GrammarUtils.createAnalyzerProductionWithOptions( NoneTerminal.for_rest_statement, [ [NoneTerminal.conditionopt, ETokenType.SEMICOLON], [NoneTerminal.conditionopt, ETokenType.SEMICOLON, NoneTerminal.expression] ], - // #if _VERBOSE ASTNode.ForRestStatement.pool - // #endif ), - ...GrammarUtils.createProductionWithOptions( + ...GrammarUtils.createAnalyzerProductionWithOptions( NoneTerminal.conditionopt, [[ETokenType.EPSILON], [NoneTerminal.condition]], - // #if _VERBOSE ASTNode.ConditionOpt.pool - // #endif ), ...GrammarUtils.createProductionWithOptions( diff --git a/packages/shader-compiler/src/lalr/LALR1.ts b/packages/shader-parser/src/lalr/LALR1.ts similarity index 96% rename from packages/shader-compiler/src/lalr/LALR1.ts rename to packages/shader-parser/src/lalr/LALR1.ts index 7a19e86d34..9486918a06 100644 --- a/packages/shader-compiler/src/lalr/LALR1.ts +++ b/packages/shader-parser/src/lalr/LALR1.ts @@ -62,13 +62,17 @@ export class LALR1 { const productionList = this.grammar.getProductionList(item.curSymbol); - if (item.nextSymbol) { - let newLookaheadSet = new Set(); + if (item.nextSymbol !== undefined) { + const newLookaheadSet = new Set(); let lastFirstSet: Set | undefined; let terminalExist = false; // when A :=> a.BC, a; ==》 B :=> .xy, First(Ca) // newLookAhead = First(Ca) - for (let i = 1, nextSymbol = item.symbolByOffset(1); !!nextSymbol; nextSymbol = item.symbolByOffset(++i)) { + for ( + let i = 1, nextSymbol = item.symbolByOffset(1); + nextSymbol !== undefined; + nextSymbol = item.symbolByOffset(++i) + ) { if (GrammarUtils.isTerminal(nextSymbol)) { newLookaheadSet.add(nextSymbol); terminalExist = true; @@ -168,14 +172,12 @@ export class LALR1 { if (LALR1._isKnownShiftPreferred(terminal, exist, action)) { if (exist.action === EAction.Shift && action.action === EAction.Reduce) return; } else { - // #if _VERBOSE Logger.warn( `conflict detect: \n`, Utils.printAction(exist), "\n", Utils.printAction(action) ); - // #endif } } table.set(terminal, action); @@ -183,7 +185,7 @@ export class LALR1 { // Catalog of expected shift/reduce conflicts. Each entry must correspond to // one of TargetParser.y's `%expect`-ed conflicts; any new conflict not in - // this list falls through to the verbose `conflict detect` warning so the + // this list falls through to the grammar `conflict detect` warning so the // grammar/runtime drift is loud rather than silent. // - ELSE: dangling-else, bind to nearest `if` // - '(' + `type_specifier_nonarray → macro_call_symbol`: macro-as-type-alias diff --git a/packages/shader-compiler/src/lalr/Production.ts b/packages/shader-parser/src/lalr/Production.ts similarity index 100% rename from packages/shader-compiler/src/lalr/Production.ts rename to packages/shader-parser/src/lalr/Production.ts diff --git a/packages/shader-compiler/src/lalr/State.ts b/packages/shader-parser/src/lalr/State.ts similarity index 97% rename from packages/shader-compiler/src/lalr/State.ts rename to packages/shader-parser/src/lalr/State.ts index 6ef782249e..c00b5ac26e 100644 --- a/packages/shader-compiler/src/lalr/State.ts +++ b/packages/shader-parser/src/lalr/State.ts @@ -40,7 +40,6 @@ export default class State { return newState; } - // TODO: any optimization? static getMapKey(cores: StateItem[]) { return cores.map((item) => `${item.production.id},${item.position}`).join(";"); } @@ -56,7 +55,6 @@ export default class State { State.pool.set(this.id, this); } - // TODO: any optimization? getStateItemMapKey(production: Production, position: number) { return `${production.id},${position}`; } diff --git a/packages/shader-compiler/src/lalr/StateItem.ts b/packages/shader-parser/src/lalr/StateItem.ts similarity index 97% rename from packages/shader-compiler/src/lalr/StateItem.ts rename to packages/shader-parser/src/lalr/StateItem.ts index c065e7d1c3..c0f3f1b135 100644 --- a/packages/shader-compiler/src/lalr/StateItem.ts +++ b/packages/shader-parser/src/lalr/StateItem.ts @@ -59,13 +59,10 @@ export default class StateItem { } advance() { - // #if _VERBOSE if (this.canReduce()) throw `Error: advance reduce-able parsing state item`; - // #endif return new StateItem(this.production, this.position + 1, this.lookaheadSet); } - // #if _VERBOSE toString() { const coreItem = this.production.derivation.map((item) => GrammarUtils.toString(item)); coreItem[this.position] = "." + (coreItem[this.position] ?? ""); @@ -74,5 +71,4 @@ export default class StateItem { .map((item) => GrammarUtils.toString(item)) .join("/")}`; } - // #endif } diff --git a/packages/shader-compiler/src/lalr/Utils.ts b/packages/shader-parser/src/lalr/Utils.ts similarity index 60% rename from packages/shader-compiler/src/lalr/Utils.ts rename to packages/shader-parser/src/lalr/Utils.ts index 766952320f..e2946d6c69 100644 --- a/packages/shader-compiler/src/lalr/Utils.ts +++ b/packages/shader-parser/src/lalr/Utils.ts @@ -4,11 +4,15 @@ import { TranslationRule } from "../parser/SemanticAnalyzer"; import { NoneTerminal, GrammarSymbol } from "../parser/GrammarSymbol"; import Production from "./Production"; import { ActionInfo, EAction } from "./types"; -import { ShaderCompiler } from "../ShaderCompiler"; +import { ShaderCompilerUtils } from "../ShaderCompilerUtils"; import { ClearableObjectPool, type IPoolElement } from "@galacean/engine-core"; import { NodeChild } from "../parser/types"; import { Keyword } from "../common/enums/Keyword"; +type ASTNodePool = ClearableObjectPool< + { set: (loc: ShaderRange, children: NodeChild[], trackAnalysis?: boolean) => void } & IPoolElement & TreeNode +>; + export default class GrammarUtils { static isTerminal(sm: GrammarSymbol) { return sm < NoneTerminal.START; @@ -25,12 +29,35 @@ export default class GrammarUtils { goal: NoneTerminal, options: GrammarSymbol[][], /** the ast node */ - astTypePool?: ClearableObjectPool< - { set: (loc: ShaderRange, children: NodeChild[]) => void } & IPoolElement & TreeNode - > + astTypePool?: ASTNodePool + ) { + return this._createProductionWithOptions(goal, options, astTypePool, astTypePool); + } + + /** + * Creates productions whose typed AST nodes are needed only by analyzer diagnostics. + * @param goal - Production goal symbol. + * @param options - Alternative right-hand sides. + * @param analyzerPool - Typed node pool used by analyzer parses. + * @returns Grammar productions with mode-specific translation rules. + * @internal + */ + static createAnalyzerProductionWithOptions( + goal: NoneTerminal, + options: GrammarSymbol[][], + analyzerPool: ASTNodePool + ) { + return this._createProductionWithOptions(goal, options, undefined, analyzerPool); + } + + private static _createProductionWithOptions( + goal: NoneTerminal, + options: GrammarSymbol[][], + runtimePool?: ASTNodePool, + analyzerPool?: ASTNodePool ) { - // Resolve the AST pool once per grammar production (not per reduce). - const pool = astTypePool ?? ASTNode.TrivialNode.pool; + const runtimeResolvedPool = runtimePool ?? ASTNode.TrivialNode.pool; + const analyzerResolvedPool = analyzerPool ?? ASTNode.TrivialNode.pool; const ret: [GrammarSymbol[], TranslationRule | undefined][] = []; for (const opt of options) { // Single-`NonTerminal` RHS + no typed class → this production reduces @@ -39,18 +66,21 @@ export default class GrammarUtils { // the parser's GOTO runs off `reduceProduction.goal`, not off the node // type on the stack. Single-Terminal RHS (e.g. `unary_operator → PLUS`) // isn't eligible — a `BaseToken` can't stand in for an AST node. - const canElide = !astTypePool && opt.length === 1 && !GrammarUtils.isTerminal(opt[0]); + const runtimeCanElide = !runtimePool && opt.length === 1 && !GrammarUtils.isTerminal(opt[0]); + const analyzerCanElide = !analyzerPool && opt.length === 1 && !GrammarUtils.isTerminal(opt[0]); ret.push([ [goal, ...opt], function (sa, ...children) { if (!children[0]) return; + const analyzerMode = sa.diagnosticsEnabled; + const canElide = analyzerMode ? analyzerCanElide : runtimeCanElide; if (canElide) { sa.semanticStack.push(children[0] as TreeNode); } else { const start = children[0].location.start; const end = children[children.length - 1].location.end; - const location = ShaderCompiler.createRange(start, end); - ASTNode.get(pool, sa, location, children); + const location = ShaderCompilerUtils.createRange(start, end); + ASTNode.get(analyzerMode ? analyzerResolvedPool : runtimeResolvedPool, sa, location, children); } } ]); @@ -75,7 +105,6 @@ export default class GrammarUtils { return a.action === b.action && a.target === b.target; } - // #if _VERBOSE static printAction(actionInfo: ActionInfo) { const production = Production.pool.get(actionInfo.target!); return ` ${this.printProduction(production)}>`; @@ -85,5 +114,4 @@ export default class GrammarUtils { const deriv = production.derivation.map((gs) => GrammarUtils.toString(gs)).join("|"); return `${NoneTerminal[production.goal]} :=> ${deriv}`; } - // #endif } diff --git a/packages/shader-compiler/src/lalr/index.ts b/packages/shader-parser/src/lalr/index.ts similarity index 100% rename from packages/shader-compiler/src/lalr/index.ts rename to packages/shader-parser/src/lalr/index.ts diff --git a/packages/shader-compiler/src/lalr/types.ts b/packages/shader-parser/src/lalr/types.ts similarity index 100% rename from packages/shader-compiler/src/lalr/types.ts rename to packages/shader-parser/src/lalr/types.ts diff --git a/packages/shader-parser/src/lexer/AnalyzerLexer.ts b/packages/shader-parser/src/lexer/AnalyzerLexer.ts new file mode 100644 index 0000000000..44ce6c0776 --- /dev/null +++ b/packages/shader-parser/src/lexer/AnalyzerLexer.ts @@ -0,0 +1,816 @@ +import { ETokenType } from "../common"; +import { parsePreprocessorCondition, type PreprocessorCondition } from "../common/PreprocessorCondition"; +import { + type BranchCondition, + type BranchConstraint, + type BranchSignature, + EOF, + sameBranch +} from "../common/BaseToken"; +import { canBranchesOverlap, isBranchReachable, isConditionalChainExhaustive } from "../common/BranchAnalysis"; +import { Keyword } from "../common/enums/Keyword"; +import { Lexer } from "./Lexer"; + +interface MacroState { + defined: boolean | undefined; + definedCondition: BranchCondition; + value: number | undefined; + version: number; +} + +type MacroStateMap = Record; + +interface ConditionalFrame { + entryState: MacroStateMap; + armStates: ConditionalArmState[]; + constraints: BranchConstraint[]; + priorConditions: BranchCondition[]; + hasElse: boolean; + definitelyMatched: boolean; + mutatedNames: Set; + guardName?: string; + guardDefined?: boolean; + selfGuarding: boolean; +} + +interface ConditionalArmState { + branch: BranchSignature; + state: MacroStateMap; +} + +/** + * Lexer variant that retains conditional facts required by the standalone analyzer. + * @internal + */ +export class AnalyzerLexer extends Lexer { + private _conditionalFrames: ConditionalFrame[] = []; + private _guardUndefBranches: Record = Object.create(null); + private _macroStates: MacroStateMap = Object.create(null); + private _macroVersions: Record = Object.create(null); + private _pendingGuardUndef = false; + private _pendingOpaqueConditional: "push" | "advance" | null = null; + + override *tokenize() { + while (!this.isEnd()) { + const tok = this.scanToken(); + tok.inMacroDefinition = this._inMacroDefineValue; + + // Resolve a pending #ifdef/#ifndef push using the flag-name token that + // immediately follows the keyword. Grammar allows the name to be either + // a plain `id` or a `MACRO_CALL` when the macro is already defined. + const isMacroName = tok.type === ETokenType.ID || tok.type === Keyword.MACRO_CALL; + if (this._pendingBranchPushDefined !== null && isMacroName) { + const conditionalGroup = ++this._conditionalGroup; + const guardUndefBranches = this._guardUndefBranches[tok.lexeme] ?? (this._guardUndefBranches[tok.lexeme] = []); + this._openConditional({ + name: tok.lexeme, + defined: this._pendingBranchPushDefined, + conditionalGroup, + conditionalArm: 0, + condition: { + kind: "defined", + name: tok.lexeme, + defined: this._pendingBranchPushDefined, + version: this._macroVersion(tok.lexeme) + }, + guardUndefBranches: this._pendingBranchPushDefined ? undefined : guardUndefBranches, + guardUndefStart: this._pendingBranchPushDefined ? undefined : guardUndefBranches.length, + selfGuarding: false + }); + this._pendingBranchPushDefined = null; + } + if (this._pendingGuardUndef && isMacroName) { + this._recordGuardUndef(tok.lexeme); + this._applyMacroUndef(tok.lexeme); + this._pendingGuardUndef = false; + } + if (this._pendingOpaqueConditional && tok.type === Keyword.MACRO_CONDITIONAL_EXPRESSION) { + const condition = this._parseSimpleCondition(tok.lexeme); + if (this._pendingOpaqueConditional === "push") this._pushOpaqueConditional(condition); + else this._advanceOpaqueConditionalArm(condition); + this._pendingOpaqueConditional = null; + } + + // Stamp the branch onto the token only when inside an `#ifdef`. The + // top-level case keeps the BaseToken default (shared empty signature), + // so the hot path stays allocation-free. + if (this._branchStack.length > 0) tok.branch = this._branchStack.slice(); + + // Update stack state based on the just-emitted token, so the *next* + // token sees the correct snapshot. `#if expr` opens a level after its + // expression is scanned so recognized atoms can annotate that arm; every + // expression still consumes exactly one stack slot for its matching `#endif`. + switch (tok.type as Keyword) { + case Keyword.MACRO_IFDEF: + this._pendingBranchPushDefined = true; + break; + case Keyword.MACRO_IFNDEF: + this._pendingBranchPushDefined = false; + break; + case Keyword.MACRO_IF: + this._pendingOpaqueConditional = "push"; + break; + case Keyword.MACRO_ELIF: + this._pendingOpaqueConditional = "advance"; + break; + case Keyword.MACRO_ELSE: { + this._advanceElseArm(); + break; + } + case Keyword.MACRO_UNDEF: + this._pendingGuardUndef = true; + break; + case Keyword.MACRO_ENDIF: + this._closeConditional(); + break; + } + + yield tok; + } + return EOF; + } + + private _pushOpaqueConditional(condition?: BranchCondition): void { + const conditionalGroup = ++this._conditionalGroup; + this._openConditional({ + name: `__if_${conditionalGroup}_0`, + defined: true, + conditionalGroup, + conditionalArm: 0, + condition + }); + } + + private _advanceOpaqueConditionalArm(condition?: BranchCondition): void { + const frame = this._conditionalFrames[this._conditionalFrames.length - 1]; + const index = this._branchStack.length - 1; + const top = this._branchStack[index]; + if (!frame || !top) return; + this._finishCurrentArm(frame); + this._macroStates = AnalyzerLexer._cloneMacroStates(frame.entryState); + const conditionalArm = (top.conditionalArm ?? 0) + 1; + const precedingConditions = frame.priorConditions.slice(); + const resolved = this._resolveCondition(condition); + const armCondition: BranchCondition | undefined = frame.definitelyMatched + ? { kind: "constant", value: false } + : resolved; + if (armCondition?.kind === "constant" && armCondition.value) frame.definitelyMatched = true; + const nextConstraint: BranchConstraint = { + name: `__if_${top.conditionalGroup}_${conditionalArm}`, + defined: true, + conditionalGroup: top.conditionalGroup, + conditionalArm, + condition: armCondition, + precedingConditions + }; + this._branchStack[index] = nextConstraint; + frame.constraints.push(nextConstraint); + if (resolved) frame.priorConditions.push(AnalyzerLexer._negateSimpleCondition(resolved)!); + this._assumeCondition(armCondition); + } + + private _advanceElseArm(): void { + const frame = this._conditionalFrames[this._conditionalFrames.length - 1]; + const index = this._branchStack.length - 1; + const top = this._branchStack[index]; + if (!frame || !top) return; + this._finishCurrentArm(frame); + this._macroStates = AnalyzerLexer._cloneMacroStates(frame.entryState); + const conditionalArm = (top.conditionalArm ?? 0) + 1; + const precedingConditions = frame.priorConditions.slice(); + const condition: BranchCondition | undefined = frame.definitelyMatched + ? { kind: "constant", value: false } + : undefined; + for (let i = 0, n = frame.constraints.length; i < n; i++) frame.constraints[i].conditionalComplete = true; + const nextConstraint: BranchConstraint = { + name: `__if_${top.conditionalGroup}_${conditionalArm}`, + defined: true, + conditionalGroup: top.conditionalGroup, + conditionalArm, + condition, + precedingConditions, + conditionalComplete: true + }; + this._branchStack[index] = nextConstraint; + frame.constraints.push(nextConstraint); + frame.hasElse = true; + frame.definitelyMatched = true; + } + + private _openConditional(constraint: BranchConstraint): void { + const resolved = this._resolveCondition(constraint.condition); + const activeConstraint: BranchConstraint = { ...constraint, condition: resolved }; + const frame: ConditionalFrame = { + entryState: AnalyzerLexer._cloneMacroStates(this._macroStates), + armStates: [], + constraints: [activeConstraint], + priorConditions: resolved ? [AnalyzerLexer._negateSimpleCondition(resolved)!] : [], + hasElse: false, + definitelyMatched: resolved?.kind === "constant" && resolved.value, + mutatedNames: new Set(), + guardName: constraint.guardUndefBranches ? constraint.name : undefined, + guardDefined: constraint.guardUndefBranches ? constraint.defined : undefined, + selfGuarding: false + }; + this._conditionalFrames.push(frame); + this._branchStack.push(activeConstraint); + this._assumeCondition(resolved); + } + + private _closeConditional(): void { + const frame = this._conditionalFrames.pop(); + const branch = this._branchStack.pop(); + if (!frame || !branch) return; + this._finishCurrentArm(frame, [...this._branchStack, branch]); + const conditionalComplete = frame.hasElse || isConditionalChainExhaustive(frame.constraints); + if (conditionalComplete) { + const conditionalReachableArms = frame.constraints.map((constraint) => isBranchReachable([constraint])); + for (let i = 0, n = frame.constraints.length; i < n; i++) { + frame.constraints[i].conditionalComplete = true; + frame.constraints[i].conditionalArmCount = n; + frame.constraints[i].conditionalReachableArms = conditionalReachableArms; + } + } + if (!conditionalComplete) { + frame.armStates.push({ + branch: [ + ...this._branchStack, + { + name: `__if_${branch.conditionalGroup}_implicit`, + defined: true, + condition: undefined, + precedingConditions: frame.priorConditions.slice() + } + ], + state: AnalyzerLexer._cloneMacroStates(frame.entryState) + }); + } + this._macroStates = this._mergeMacroStates(frame); + if (frame.guardName && frame.guardDefined === false && frame.selfGuarding) { + this._setMacroState(frame.guardName, true, undefined); + } + } + + private _finishCurrentArm(frame: ConditionalFrame, branch = this._branchStack): void { + if (isBranchReachable(branch)) { + frame.armStates.push({ branch: branch.slice(), state: AnalyzerLexer._cloneMacroStates(this._macroStates) }); + } + } + + private _mergeMacroStates(frame: ConditionalFrame): MacroStateMap { + const merged = AnalyzerLexer._cloneMacroStates(frame.entryState); + for (const name of frame.mutatedNames) { + const first = frame.armStates[0]?.state[name] ?? frame.entryState[name] ?? this._defaultMacroState(name); + let matches = true; + for (let i = 1, n = frame.armStates.length; i < n; i++) { + const candidate = frame.armStates[i].state[name] ?? frame.entryState[name] ?? this._defaultMacroState(name); + if (!AnalyzerLexer._sameMacroState(first, candidate)) { + matches = false; + break; + } + } + if (matches) { + merged[name] = { ...first }; + } else { + const definitionConditions: BranchCondition[] = []; + for (let i = 0, n = frame.armStates.length; i < n; i++) { + const arm = frame.armStates[i]; + const state = arm.state[name] ?? frame.entryState[name] ?? this._defaultMacroState(name); + definitionConditions.push( + AnalyzerLexer._combineConditions("&&", [this._branchCondition(arm.branch), state.definedCondition]) + ); + } + merged[name] = { + defined: undefined, + definedCondition: AnalyzerLexer._combineConditions("||", definitionConditions), + value: undefined, + version: this._nextMacroVersion(name) + }; + } + } + return merged; + } + + private _resolveCondition(condition?: BranchCondition): BranchCondition | undefined { + if (!condition || condition.kind === "constant") return condition; + const bound = this._bindCondition(condition); + const value = this._evaluateCondition(bound); + if (value !== undefined) return { kind: "constant", value }; + return this._expandDefinedMacroConditions(bound); + } + + private _expandDefinedMacroConditions(condition: BranchCondition): BranchCondition { + if (condition.kind === "defined") return this._resolveDefinedMacroCondition(condition); + if (condition.kind !== "expression") return condition; + if (condition.opaque) return condition; + const expanded = AnalyzerLexer._combineConditions( + condition.operator, + condition.operands.map((operand) => this._expandDefinedMacroConditions(operand)) + ); + return condition.negated ? AnalyzerLexer._negateSimpleCondition(expanded)! : expanded; + } + + /** Resolve a macro test from the symbolic state produced by preceding define and undef directives. */ + private _resolveDefinedMacroCondition(condition: Extract): BranchCondition { + let macroDefined = this._macroState(condition.name).definedCondition; + const definitions = this.macroDefineList[condition.name]; + if ( + definitions?.some( + (definition) => + !definition.branch.some((constraint) => constraint.name === condition.name && constraint.selfGuarding) + ) + ) { + macroDefined = AnalyzerLexer._substituteExternalMacroState(macroDefined, condition.name); + } + return condition.defined ? macroDefined : AnalyzerLexer._negateSimpleCondition(macroDefined)!; + } + + private _branchCondition(branch: BranchSignature): BranchCondition { + const conditions: BranchCondition[] = []; + for (let i = 0, n = branch.length; i < n; i++) { + const constraint = branch[i]; + if (constraint.selfGuarding) continue; + if (constraint.precedingConditions) conditions.push(...constraint.precedingConditions); + if (constraint.condition) conditions.push(constraint.condition); + } + return AnalyzerLexer._combineConditions("&&", conditions); + } + + private _bindCondition(condition: Exclude): BranchCondition { + if (condition.kind === "expression") { + return { + ...condition, + operands: condition.operands.map((operand) => + operand.kind === "constant" ? operand : this._bindCondition(operand) + ), + versions: condition.names.map((name) => this._macroVersion(name)) + }; + } + return { ...condition, version: this._macroVersion(condition.name) }; + } + + private _evaluateCondition(condition: BranchCondition): boolean | undefined { + if (condition.kind === "constant") return condition.value; + if (condition.kind === "expression") { + if (condition.opaque) return undefined; + const values = condition.operands.map((operand) => this._evaluateCondition(operand)); + let value: boolean | undefined; + if (condition.operator === "&&") { + value = values.some((candidate) => candidate === false) + ? false + : values.every((candidate) => candidate === true) + ? true + : undefined; + } else { + value = values.some((candidate) => candidate === true) + ? true + : values.every((candidate) => candidate === false) + ? false + : undefined; + } + return value === undefined ? undefined : condition.negated ? !value : value; + } + const state = this._macroState(condition.name); + if (condition.kind === "defined") { + return state.defined === undefined ? undefined : state.defined === condition.defined; + } + if (state.value !== undefined) return AnalyzerLexer._matchesComparison(state.value, condition); + if (state.defined === false) return AnalyzerLexer._matchesComparison(0, condition); + return undefined; + } + + private _assumeCondition(condition?: BranchCondition): void { + if (!condition || condition.kind === "constant") return; + if (condition.kind === "expression") return; + const current = this._macroState(condition.name); + if (condition.kind === "defined") { + this._macroStates[condition.name] = { + defined: condition.defined, + definedCondition: { kind: "constant", value: condition.defined }, + value: condition.defined ? current.value : 0, + version: current.version + }; + return; + } + if (condition.operator === "==") { + this._macroStates[condition.name] = { + defined: true, + definedCondition: { kind: "constant", value: true }, + value: condition.value, + version: current.version + }; + } else if (condition.operator === "!=" && condition.value === 0) { + this._macroStates[condition.name] = { + defined: true, + definedCondition: { kind: "constant", value: true }, + value: current.value, + version: current.version + }; + } + } + + private _applyMacroUndef(name: string): void { + if (!isBranchReachable(this._branchStack)) return; + this._markMacroMutation(name); + this._setMacroState(name, false, 0); + } + + private _applyMacroDefine( + name: string, + paramsLexeme: string | undefined, + valueStart: number, + valueEnd: number + ): void { + if (!isBranchReachable(this._branchStack)) return; + this._markMacroMutation(name); + const value = + paramsLexeme === undefined + ? AnalyzerLexer._parseNumericLiteral(AnalyzerLexer._normalizeValueText(this._source, valueStart, valueEnd)) + : undefined; + this._setMacroState(name, true, value); + } + + private _markMacroMutation(name: string): void { + const state = this._macroState(name); + for (let i = 0, n = this._conditionalFrames.length; i < n; i++) { + const frame = this._conditionalFrames[i]; + if (!frame.entryState[name]) frame.entryState[name] = { ...state }; + frame.mutatedNames.add(name); + } + } + + private _setMacroState(name: string, defined: boolean, value: number | undefined): void { + this._macroStates[name] = { + defined, + definedCondition: { kind: "constant", value: defined }, + value, + version: this._nextMacroVersion(name) + }; + } + + private _macroState(name: string): MacroState { + return this._macroStates[name] ?? this._defaultMacroState(name); + } + + private _defaultMacroState(name: string): MacroState { + const version = this._macroVersion(name); + return { + defined: undefined, + definedCondition: { kind: "defined", name, defined: true, version }, + value: undefined, + version + }; + } + + private _macroVersion(name: string): number { + return this._macroVersions[name] ?? 0; + } + + private _nextMacroVersion(name: string): number { + const version = this._macroVersion(name) + 1; + this._macroVersions[name] = version; + return version; + } + + private _recordGuardUndef(name: string): void { + const events = this._guardUndefBranches[name] ?? (this._guardUndefBranches[name] = []); + events.push( + this._branchStack.map(({ name, defined, conditionalGroup, conditionalArm, condition, precedingConditions }) => ({ + name, + defined, + conditionalGroup, + conditionalArm, + condition, + precedingConditions + })) + ); + } + + private _parseSimpleCondition(expression: string): BranchCondition | undefined { + try { + return this._toBranchCondition(parsePreprocessorCondition(expression)); + } catch { + return AnalyzerLexer._parseOpaqueComparisonCondition(expression); + } + } + + private static _parseOpaqueComparisonCondition(expression: string): BranchCondition | undefined { + const source = AnalyzerLexer._unwrapConditionParentheses(expression.trim()); + let depth = 0; + let comparisonIndex = -1; + let comparisonOperator: "==" | "!=" | ">" | ">=" | "<" | "<=" | undefined; + + for (let i = 0; i < source.length; i++) { + const char = source[i]; + if (char === "(") { + depth++; + continue; + } + if (char === ")") { + if (--depth < 0) return undefined; + continue; + } + if (depth !== 0) continue; + const pair = source.slice(i, i + 2); + if (pair === "&&" || pair === "||" || char === "?" || char === ",") return undefined; + if (pair === "<<" || pair === ">>") { + i++; + continue; + } + const operator = + pair === "==" || pair === "!=" || pair === ">=" || pair === "<=" + ? pair + : char === ">" || char === "<" + ? char + : undefined; + if (!operator) continue; + if (comparisonOperator) return undefined; + comparisonIndex = i; + comparisonOperator = operator; + i += operator.length - 1; + } + if (depth !== 0 || comparisonIndex < 0 || !comparisonOperator) return undefined; + + const left = source.slice(0, comparisonIndex).replace(/\s+/g, ""); + const right = source.slice(comparisonIndex + comparisonOperator.length).replace(/\s+/g, ""); + if (!left || !right) return undefined; + const names = Array.from(new Set(`${left} ${right}`.match(/[A-Za-z_]\w*/g) ?? [])).sort(); + const [baseOperator, negated] = + comparisonOperator === "!=" + ? (["==", true] as const) + : comparisonOperator === "<=" + ? ([">", true] as const) + : comparisonOperator === "<" + ? ([">=", true] as const) + : ([comparisonOperator, false] as const); + return { + kind: "expression", + expression: `${baseOperator}(${left},${right})`, + operator: "&&", + operands: [], + names, + versions: names.map(() => 0), + negated, + opaque: true + }; + } + + private static _unwrapConditionParentheses(expression: string): string { + let source = expression; + while (source.startsWith("(") && source.endsWith(")")) { + let depth = 0; + let wrapsAll = true; + for (let i = 0; i < source.length; i++) { + if (source[i] === "(") depth++; + else if (source[i] === ")") depth--; + if (depth === 0 && i < source.length - 1) { + wrapsAll = false; + break; + } + if (depth < 0) return source; + } + if (!wrapsAll || depth !== 0) break; + source = source.slice(1, -1).trim(); + } + return source; + } + + private _toBranchCondition(condition: PreprocessorCondition): BranchCondition { + switch (condition.t) { + case "bool": + return { kind: "constant", value: condition.v }; + case "def": + return { kind: "defined", name: condition.m, defined: true, version: 0 }; + case "cmp": + return { + kind: "comparison", + name: condition.m, + operator: condition.op as Extract["operator"], + value: condition.v, + version: 0 + }; + case "not": + return AnalyzerLexer._negateSimpleCondition(this._toBranchCondition(condition.c))!; + case "and": + case "or": { + const operands = [this._toBranchCondition(condition.l), this._toBranchCondition(condition.r)]; + const names = Array.from(new Set(operands.flatMap((operand) => AnalyzerLexer._conditionNames(operand)))).sort(); + return { + kind: "expression", + expression: `${condition.t === "and" ? "&&" : "||"}(${operands.map(AnalyzerLexer._conditionKey).sort().join(",")})`, + operator: condition.t === "and" ? "&&" : "||", + operands, + names, + versions: names.map(() => 0), + negated: false + }; + } + } + } + + private static _conditionNames(condition: BranchCondition): readonly string[] { + if (condition.kind === "constant") return []; + if (condition.kind === "expression") return condition.names; + return [condition.name]; + } + + private static _conditionKey(condition: BranchCondition): string { + if (condition.kind === "constant") return `constant:${condition.value}`; + if (condition.kind === "defined") return `defined:${condition.name}:${condition.defined}`; + if (condition.kind === "expression") return `${condition.negated ? "!" : ""}${condition.expression}`; + return `comparison:${condition.name}:${condition.operator}:${condition.value}`; + } + + private static _sameCondition(left: BranchCondition, right: BranchCondition): boolean { + if (left.kind !== right.kind) return false; + if (left.kind === "constant") return right.kind === "constant" && left.value === right.value; + if (left.kind === "defined") { + return ( + right.kind === "defined" && + left.name === right.name && + left.defined === right.defined && + left.version === right.version + ); + } + if (left.kind === "comparison") { + return ( + right.kind === "comparison" && + left.name === right.name && + left.operator === right.operator && + left.value === right.value && + left.version === right.version + ); + } + if (right.kind !== "expression" || left.operator !== right.operator || left.negated !== right.negated) return false; + if (left.opaque || right.opaque) { + return left.opaque === right.opaque && left.expression === right.expression; + } + if (left.operands.length !== right.operands.length) return false; + for (let i = 0, n = left.operands.length; i < n; i++) { + if (!AnalyzerLexer._sameCondition(left.operands[i], right.operands[i])) return false; + } + return true; + } + + private static _substituteExternalMacroState(condition: BranchCondition, macroName: string): BranchCondition { + if (condition.kind === "constant" || condition.kind === "comparison") return condition; + if (condition.kind === "defined") { + return condition.name === macroName ? { kind: "constant", value: !condition.defined } : condition; + } + if (condition.opaque) return condition; + const substituted = AnalyzerLexer._combineConditions( + condition.operator, + condition.operands.map((operand) => AnalyzerLexer._substituteExternalMacroState(operand, macroName)) + ); + return condition.negated ? AnalyzerLexer._negateSimpleCondition(substituted)! : substituted; + } + + private static _combineConditions(operator: "&&" | "||", conditions: readonly BranchCondition[]): BranchCondition { + const operands: BranchCondition[] = []; + for (let i = 0, n = conditions.length; i < n; i++) { + const condition = conditions[i]; + if (condition.kind === "constant") { + if ((operator === "&&" && !condition.value) || (operator === "||" && condition.value)) return condition; + continue; + } + operands.push(condition); + } + if (!operands.length) return { kind: "constant", value: operator === "&&" }; + if (operands.length === 1) return operands[0]; + + const versions = new Map(); + const names = new Set(); + for (let i = 0, n = operands.length; i < n; i++) + AnalyzerLexer._collectConditionVersions(operands[i], names, versions); + const sortedNames = Array.from(names).sort(); + return { + kind: "expression", + expression: `${operator}(${operands.map(AnalyzerLexer._conditionKey).sort().join(",")})`, + operator, + operands, + names: sortedNames, + versions: sortedNames.map((name) => versions.get(name) ?? 0), + negated: false + }; + } + + private static _collectConditionVersions( + condition: BranchCondition, + names: Set, + versions: Map + ): void { + if (condition.kind === "constant") return; + if (condition.kind === "expression") { + if (condition.opaque) { + for (let i = 0; i < condition.names.length; i++) { + names.add(condition.names[i]); + versions.set(condition.names[i], condition.versions[i]); + } + return; + } + for (let i = 0, n = condition.operands.length; i < n; i++) { + AnalyzerLexer._collectConditionVersions(condition.operands[i], names, versions); + } + return; + } + names.add(condition.name); + versions.set(condition.name, condition.version); + } + + private static _negateSimpleCondition(condition?: BranchCondition): BranchCondition | undefined { + if (!condition) return undefined; + if (condition.kind === "constant") return { kind: "constant", value: !condition.value }; + if (condition.kind === "defined") return { ...condition, defined: !condition.defined }; + if (condition.kind === "expression") return { ...condition, negated: !condition.negated }; + + const operator = + condition.operator === "==" + ? "!=" + : condition.operator === "!=" + ? "==" + : condition.operator === ">" + ? "<=" + : condition.operator === ">=" + ? "<" + : condition.operator === "<" + ? ">=" + : ">"; + return { ...condition, operator }; + } + + private static _parseNumericLiteral(source: string): number | undefined { + if (!/^[-+]?(?:0[xX][0-9a-fA-F]+|\d+(?:\.\d+)?)$/.test(source)) return undefined; + const value = Number(source); + return Number.isFinite(value) ? value : undefined; + } + + private static _matchesComparison( + value: number, + comparison: Extract + ): boolean { + switch (comparison.operator) { + case "==": + return value === comparison.value; + case "!=": + return value !== comparison.value; + case ">": + return value > comparison.value; + case ">=": + return value >= comparison.value; + case "<": + return value < comparison.value; + case "<=": + return value <= comparison.value; + } + } + + private static _cloneMacroStates(states: MacroStateMap): MacroStateMap { + const clone: MacroStateMap = Object.create(null); + for (const name in states) clone[name] = { ...states[name] }; + return clone; + } + + private static _sameMacroState(left: MacroState, right: MacroState): boolean { + return ( + left.defined === right.defined && + left.value === right.value && + left.version === right.version && + AnalyzerLexer._sameCondition(left.definedCondition, right.definedCondition) + ); + } + + protected override _isBranchReachable(branch: BranchSignature): boolean { + return isBranchReachable(branch); + } + + protected override _beforeRegisterMacroDefine(name: string): void { + const branchIndex = this._branchStack.length - 1; + const branch = this._branchStack[branchIndex]; + if (branch?.guardUndefBranches && branch.name === name && !branch.defined) { + this._branchStack[branchIndex] = { + ...branch, + selfGuarding: true, + guardUndefStart: branch.guardUndefBranches.length + }; + const frame = this._conditionalFrames[this._conditionalFrames.length - 1]; + if (frame?.guardName === name) frame.selfGuarding = true; + } + } + + protected override _sameDefinitionBranch(left: BranchSignature, right: BranchSignature): boolean { + return sameBranch(left, right); + } + + protected override _afterRegisterMacroDefine( + name: string, + paramsLexeme: string | undefined, + valueStart: number, + valueEnd: number + ): void { + this._applyMacroDefine(name, paramsLexeme, valueStart, valueEnd); + } + + protected override _branchesOverlap(left: BranchSignature, right: BranchSignature): boolean { + return canBranchesOverlap(left, right); + } +} diff --git a/packages/shader-compiler/src/lexer/Lexer.ts b/packages/shader-parser/src/lexer/Lexer.ts similarity index 81% rename from packages/shader-compiler/src/lexer/Lexer.ts rename to packages/shader-parser/src/lexer/Lexer.ts index c497aba832..bd2e08b7ba 100644 --- a/packages/shader-compiler/src/lexer/Lexer.ts +++ b/packages/shader-parser/src/lexer/Lexer.ts @@ -1,9 +1,9 @@ import { ETokenType } from "../common"; import { BaseLexer } from "../common/BaseLexer"; -import { BaseToken, BranchConstraint, BranchSignature, EMPTY_BRANCH, EOF } from "../common/BaseToken"; +import { BaseToken, BranchCondition, BranchConstraint, BranchSignature, EMPTY_BRANCH, EOF } from "../common/BaseToken"; import { Keyword } from "../common/enums/Keyword"; import { MacroDefineInfo, MacroDefineList } from "../Preprocessor"; -import { ShaderCompiler } from "../ShaderCompiler"; +import { ShaderCompilerUtils } from "../ShaderCompilerUtils"; /** * The Lexer of Shader Compiler @@ -40,6 +40,12 @@ export class Lexer extends BaseLexer { mat2: Keyword.MAT2, mat3: Keyword.MAT3, mat4: Keyword.MAT4, + mat2x3: Keyword.MAT2X3, + mat2x4: Keyword.MAT2X4, + mat3x2: Keyword.MAT3X2, + mat3x4: Keyword.MAT3X4, + mat4x2: Keyword.MAT4X2, + mat4x3: Keyword.MAT4X3, in: Keyword.IN, out: Keyword.OUT, inout: Keyword.INOUT, @@ -85,44 +91,6 @@ export class Lexer extends BaseLexer { "#undef": Keyword.MACRO_UNDEF }; - // Synthetic `__if_` per `#if` — polarity flip makes `#else` mutually exclusive in `isVisibleFrom`. - private static _ifCounter = 0; - - /** Two branch signatures are equal iff they have the same constraints in the - * same order with the same polarity. Used by `#define` dedup and AST upgrade - * matching. */ - static sameBranch(a: BranchSignature, b: BranchSignature): boolean { - if (a.length !== b.length) return false; - for (let i = 0, n = a.length; i < n; i++) { - if (a[i].name !== b[i].name || a[i].defined !== b[i].defined) return false; - } - return true; - } - - /** - * Returns true if a `#define` registered under `defBranch` is reachable from - * a call site under `callSiteBranch`. Two signatures are mutually exclusive - * iff some flag appears in both with opposite `defined` polarity. Anything - * else is compatible — the same flag with the same polarity, or flags that - * simply don't intersect. - * - * Conservative for `#if expr` (not modeled — `tokenize` pushes a sentinel - * with `name === ""` to keep the `#endif` stack depth correct; that - * sentinel never matches a real flag in the polarity check below, so it - * stays visible from everywhere). Exact for the common `#ifdef` / - * `#ifndef` / `#else` cases that drive real shader code. - */ - static isVisibleFrom(defBranch: BranchSignature, callSiteBranch: BranchSignature): boolean { - for (let i = 0, n = defBranch.length; i < n; i++) { - const d = defBranch[i]; - for (let j = 0, m = callSiteBranch.length; j < m; j++) { - const c = callSiteBranch[j]; - if (d.name === c.name && d.defined !== c.defined) return false; - } - } - return true; - } - private _needScanMacroConditionExpression = false; // --- `#define` scanning state machine --- @@ -139,7 +107,7 @@ export class Lexer extends BaseLexer { // so the CFG doesn't need its own parameter-list // non-terminal (which would conflict with // function_call_parameter_list under LALR(1)) - private _inMacroDefineValue = false; + protected _inMacroDefineValue = false; private _macroDefineExpectsNameToken = false; private _macroDefineExpectsParamsToken = false; @@ -153,33 +121,69 @@ export class Lexer extends BaseLexer { // emitting tokens; read by `_registerMacroDefine` (when it registers a // legacy entry mid-scan) and stamped onto every emitted token's `branch` // field so AST nodes know which branch they're inside. - private _branchStack: BranchConstraint[] = []; + protected _branchStack: BranchConstraint[] = []; + protected _conditionalGroup = 0; // True when the previous token was `#ifdef`/`#ifndef` and we're waiting on // the next ID token (the flag name) to actually push onto the stack. - private _pendingBranchPushDefined: boolean | null = null; + protected _pendingBranchPushDefined: boolean | null = null; + private _pendingCodegenConditional: "push" | "advance" | null = null; + private _codegenDefinitelyMatched: boolean[] = []; *tokenize() { + yield* this._tokenizeForCodegen(); + return EOF; + } + + private *_tokenizeForCodegen() { while (!this.isEnd()) { const tok = this.scanToken(); - - // Resolve a pending #ifdef/#ifndef push using the flag-name token that - // immediately follows the keyword. Grammar allows the name to be either - // a plain `id` or a `MACRO_CALL` (for `#ifdef `). - if (this._pendingBranchPushDefined !== null && (tok.type === ETokenType.ID || tok.type === Keyword.MACRO_CALL)) { - this._branchStack.push({ name: tok.lexeme, defined: this._pendingBranchPushDefined }); + if (this._pendingCodegenConditional && tok.type === Keyword.MACRO_CONDITIONAL_EXPRESSION) { + const parsedCondition = Lexer._parseCodegenConstantCondition(tok.lexeme); + if (this._pendingCodegenConditional === "push") { + const conditionalGroup = ++this._conditionalGroup; + this._branchStack.push({ + name: `__if_${conditionalGroup}`, + defined: true, + conditionalGroup, + conditionalArm: 0, + condition: parsedCondition + }); + this._codegenDefinitelyMatched.push(parsedCondition?.kind === "constant" && parsedCondition.value); + } else { + const index = this._branchStack.length - 1; + const previous = this._branchStack[index]; + if (previous) { + const definitelyMatched = this._codegenDefinitelyMatched[index]; + const condition = definitelyMatched ? { kind: "constant" as const, value: false } : parsedCondition; + this._branchStack[index] = { + name: previous.name, + defined: true, + conditionalGroup: previous.conditionalGroup, + conditionalArm: (previous.conditionalArm ?? 0) + 1, + condition + }; + if (!definitelyMatched && parsedCondition?.kind === "constant" && parsedCondition.value) { + this._codegenDefinitelyMatched[index] = true; + } + } + } + this._pendingCodegenConditional = null; + } + const isMacroName = tok.type === ETokenType.ID || tok.type === Keyword.MACRO_CALL; + if (this._pendingBranchPushDefined !== null && isMacroName) { + const conditionalGroup = ++this._conditionalGroup; + this._branchStack.push({ + name: tok.lexeme, + defined: this._pendingBranchPushDefined, + conditionalGroup, + conditionalArm: 0 + }); + this._codegenDefinitelyMatched.push(false); this._pendingBranchPushDefined = null; } - // Stamp the branch onto the token only when inside an `#ifdef`. The - // top-level case keeps the BaseToken default (shared empty signature), - // so the hot path stays allocation-free. if (this._branchStack.length > 0) tok.branch = this._branchStack.slice(); - // Update stack state based on the just-emitted token, so the *next* - // token sees the correct snapshot. `#if expr` opens a level we can't - // address (we don't model expressions), but must consume a stack slot - // so the matching `#endif` pops the right depth — without it, an - // outer `#ifdef A`'s constraint would be wrongly popped. switch (tok.type as Keyword) { case Keyword.MACRO_IFDEF: this._pendingBranchPushDefined = true; @@ -188,25 +192,32 @@ export class Lexer extends BaseLexer { this._pendingBranchPushDefined = false; break; case Keyword.MACRO_IF: - this._branchStack.push({ name: `__if_${++Lexer._ifCounter}`, defined: true }); + this._pendingCodegenConditional = "push"; break; case Keyword.MACRO_ELIF: - // Each `#elif` link gets a fresh tag so it's exclusive with earlier arms. - if (this._branchStack.length > 0) { - this._branchStack[this._branchStack.length - 1] = { - name: `__if_${++Lexer._ifCounter}`, - defined: true - }; - } + this._pendingCodegenConditional = "advance"; break; case Keyword.MACRO_ELSE: { - // Flip polarity: `#ifdef X` → `[X=true]` becomes `[X=false]`; `__if_n` likewise. - const top = this._branchStack[this._branchStack.length - 1]; - if (top) this._branchStack[this._branchStack.length - 1] = { name: top.name, defined: !top.defined }; + const index = this._branchStack.length - 1; + const previous = this._branchStack[index]; + if (previous) { + const condition = this._codegenDefinitelyMatched[index] + ? { kind: "constant" as const, value: false } + : undefined; + this._branchStack[index] = { + name: previous.name, + defined: tok.type === Keyword.MACRO_ELSE ? !previous.defined : true, + conditionalGroup: previous.conditionalGroup, + conditionalArm: (previous.conditionalArm ?? 0) + 1, + condition + }; + this._codegenDefinitelyMatched[index] = true; + } break; } case Keyword.MACRO_ENDIF: this._branchStack.pop(); + this._codegenDefinitelyMatched.pop(); break; } @@ -215,6 +226,46 @@ export class Lexer extends BaseLexer { return EOF; } + private static _parseCodegenConstantCondition(expression: string): BranchCondition | undefined { + const source = expression.trim(); + if (!/^[+-]?(?:0[xX][0-9a-fA-F]+|\d+)$/.test(source)) return undefined; + return { kind: "constant", value: Number(source) !== 0 }; + } + + private static _isCodegenBranchReachable(branch: BranchSignature): boolean { + for (let i = 0; i < branch.length; i++) { + const condition = branch[i].condition; + if (condition?.kind === "constant" && !condition.value) return false; + } + return true; + } + + /** @internal */ + protected _isBranchReachable(branch: BranchSignature): boolean { + return Lexer._isCodegenBranchReachable(branch); + } + + /** @internal */ + protected _beforeRegisterMacroDefine(_name: string): void {} + + /** @internal */ + protected _sameDefinitionBranch(left: BranchSignature, right: BranchSignature): boolean { + return Lexer._sameCodegenBranch(left, right); + } + + /** @internal */ + protected _afterRegisterMacroDefine( + _name: string, + _paramsLexeme: string | undefined, + _valueStart: number, + _valueEnd: number + ): void {} + + /** @internal */ + protected _branchesOverlap(left: BranchSignature, right: BranchSignature): boolean { + return Lexer._canCodegenBranchesOverlap(left, right); + } + constructor( source: string, public macroDefineList: MacroDefineList @@ -518,7 +569,7 @@ export class Lexer extends BaseLexer { this.advance(1); } this.advance(1); - const range = ShaderCompiler.createRange(start, this.getShaderPosition()); + const range = ShaderCompilerUtils.createRange(start, this.getShaderPosition()); const token = BaseToken.pool.get(); token.set(ETokenType.STRING_CONST, buffer.join(""), range); @@ -567,6 +618,14 @@ export class Lexer extends BaseLexer { const word = buffer.join(""); if (word === "#define") { + const branchReachable = this._isBranchReachable(this._branchStack); + if (!branchReachable) { + // GLSL preprocessors ignore replacement-list syntax in a statically inactive arm. + // Keep the original directive for downstream preprocessing without registering or parsing it. + this._scanUtilBreakLine(buffer); + token.set(Keyword.MACRO_DEFINE_EXPRESSION, "\n" + buffer.join("") + "\n", start); + return token; + } const peek = this._peekMacroDefine(); if (peek && peek.isExpression) { // AST path: the value will be tokenized by the lexer's `_inMacroDefineValue` @@ -611,39 +670,16 @@ export class Lexer extends BaseLexer { * and registration need: name range, optional params range, value range, * and whether the value parses as an `expression`. * - * The replacement list is split three ways: + * The replacement list is split into two paths: * * - **AST path** (`isExpression = true`): value parses as `expression`. * Covers identifiers, literals, parenthesized sub-expressions, operator * expressions, function calls, top-level comma lists (per C99 §6.10.3). * - * - **Legacy opaque path** (`isExpression = false`, no throw): the three - * GLSL-ES-§3.4-legal-but-not-an-expression shapes with real-world use — - * 1. empty value e.g. `#define COMMON_INCLUDED` - * 2. single type/qualifier keyword e.g. `#define FxaaFloat float` - * 3. type-qualifier list e.g. `#define TEX_PARAM(s) mediump sampler2D s` - * - * Note on X-macro support: the classical C X-macro pattern (a list - * macro re-expanded with redefined `X(...)`) works fine — it uses - * function-like macros + `\` line-continuation + `#undef`, all of - * which Galacean supports. It does NOT require unbalanced parens. - * - * - **Authoring error** (throws): every other shape that's not a valid - * `expression`. Legal token sequences in theory but not used in real - * GLSL — almost always author mistakes. We surface one uniform - * diagnostic with the macro name and value text and let the user fix - * their code instead of routing politely. - * - * Unsupported shapes that throw (non-exhaustive — the predicate is - * "the value doesn't reduce as `expression` and isn't one of the three - * legacy shapes above"): - * - leading bare punctuation `,` `;` `:` `?` `)` `]` - * - trailing `,` or `;` e.g. `#define X a, b,` - * - trailing binary / unary op `+` `-` `*` `/` `%` `&` `|` `^` - * `<` `>` `=` `!` `~` - * - trailing ternary fragment `?` or `:` - * - unbalanced `[` / `]` e.g. `#define X a[b` - * - unbalanced `(` / `)` e.g. `#define PAREN (` + * - **Opaque path** (`isExpression = false`): every replacement list that + * cannot safely enter the expression grammar. Preprocessor replacement + * lists are token sequences, not GLSL expressions; fragments such as + * `#define ADD +` or `#define OPEN (` are valid and must be preserved. * * Returns `null` if the directive is malformed before the name. `cursor` is * the position past the last non-newline char (caller advances from there). @@ -719,8 +755,7 @@ export class Lexer extends BaseLexer { i++; } const result = { name, paramsLexeme, valueStart, valueEnd: i, cursor: i, isExpression: false }; - // Real-world legacy shapes: - // 1. empty value, 2. single type/qualifier kw, 3. type-qualifier list. + // Empty and declaration-oriented replacement lists stay opaque. if (firstStart === -1) return result; if ( firstEnd !== -1 && @@ -729,12 +764,6 @@ export class Lexer extends BaseLexer { ) { return result; } - // Authoring errors. Anything that's neither a legal `expression` nor one - // of the three legacy shapes above gets a single uniform diagnostic — - // the user sees the macro name and the value text, that's enough to - // locate and fix. We don't categorize further; the rule for users is - // simply "value must be a valid GLSL expression". - // // Legal expression starts: alnum (identifier / literal), `(` (group), // `.` (GLSL ES §4.1.4 leading-dot float literal like `.5`), `-`/`+`/`!`/`~` // (unary). Legal expression ends: alnum (identifier / literal), `)` (group @@ -751,13 +780,7 @@ export class Lexer extends BaseLexer { head !== 33 /* ! */ && head !== 126; /* ~ */ const tailIllegal = !BaseLexer.isAlnum(tail) && tail !== 41 /* ) */ && tail !== 93; /* ] */ - if (parenDepth !== 0 || bracketDepth !== 0 || headIllegal || tailIllegal) { - const valueText = src.slice(firstStart, i).replace(/\s+/g, " ").trim(); - this.throwError( - this.getShaderPosition(0), - `#define ${name}: invalid replacement list — not a valid GLSL expression ("${valueText}")` - ); - } + if (parenDepth !== 0 || bracketDepth !== 0 || headIllegal || tailIllegal) return result; result.isExpression = true; return result; } @@ -826,8 +849,8 @@ export class Lexer extends BaseLexer { /** Consuming wrapper around `_skipNonSemantic` operating on the lexer's * current position. Used by the `#define`-value scan state machine. Goes - * through `advance(diff)` so verbose builds keep their line/column counters - * in sync (advance walks the consumed slice and bumps `_line` on `\n`). */ + * through `advance(diff)` to keep the line/column counters in sync + * (advance walks the consumed slice and bumps `_line` on `\n`). */ private _skipInlineSpaceAndComments(): void { const next = Lexer._skipNonSemantic(this._source, this._currentIndex, this._source.length); if (next > this._currentIndex) this.advance(next - this._currentIndex); @@ -911,6 +934,8 @@ export class Lexer extends BaseLexer { valueStart: number, valueEnd: number ): void { + this._beforeRegisterMacroDefine(name); + const params = paramsLexeme ? paramsLexeme .slice(1, -1) // strip enclosing `(` `)` @@ -932,21 +957,26 @@ export class Lexer extends BaseLexer { const arr = this.macroDefineList[name]; if (!arr) { this.macroDefineList[name] = [info]; - return; - } - // Same key + same branch → duplicate (re-include). Different branches stay - // separate so the visibility filter picks the right entry at each call site. - for (let i = 0, n = arr.length; i < n; i++) { - const e = arr[i]; - if (e.dedupKey === dedupKey && Lexer.sameBranch(e.branch, info.branch)) return; + } else { + // Same key + same branch → duplicate (re-include). Different branches stay + // separate so the visibility filter picks the right entry at each call site. + let duplicate = false; + for (let i = 0, n = arr.length; i < n; i++) { + const e = arr[i]; + if (e.dedupKey === dedupKey && this._sameDefinitionBranch(e.branch, info.branch)) { + duplicate = true; + break; + } + } + if (!duplicate) arr.push(info); } - arr.push(info); + this._afterRegisterMacroDefine(name, paramsLexeme, valueStart, valueEnd); } /** Render a `[start, end)` value range as space-separated significant chars, * using the same comment / line-continuation rules `_skipNonSemantic` * applies on the token-stream path. Used as the dedup key body. */ - private static _normalizeValueText(src: string, start: number, end: number): string { + protected static _normalizeValueText(src: string, start: number, end: number): string { let out = ""; let i = start; let pendingSpace = false; @@ -1028,11 +1058,39 @@ export class Lexer extends BaseLexer { if (!defs || defs.length === 0) return false; const callSiteBranch = this._branchStack; for (let i = 0, n = defs.length; i < n; i++) { - if (Lexer.isVisibleFrom(defs[i].branch, callSiteBranch)) return true; + if (this._branchesOverlap(defs[i].branch, callSiteBranch)) { + return true; + } } return false; } + private static _sameCodegenBranch(left: BranchSignature, right: BranchSignature): boolean { + if (left.length !== right.length) return false; + for (let i = 0; i < left.length; i++) { + if (left[i].name !== right[i].name || left[i].defined !== right[i].defined) return false; + } + return true; + } + + private static _canCodegenBranchesOverlap(left: BranchSignature, right: BranchSignature): boolean { + for (let i = 0; i < left.length; i++) { + const leftConstraint = left[i]; + for (let j = 0; j < right.length; j++) { + const rightConstraint = right[j]; + if ( + (leftConstraint.conditionalGroup !== undefined && + leftConstraint.conditionalGroup === rightConstraint.conditionalGroup && + leftConstraint.conditionalArm !== rightConstraint.conditionalArm) || + (leftConstraint.name === rightConstraint.name && leftConstraint.defined !== rightConstraint.defined) + ) { + return false; + } + } + } + return true; + } + private _scanNum(): BaseToken { const buffer: string[] = []; diff --git a/packages/shader-compiler/src/lexer/index.ts b/packages/shader-parser/src/lexer/index.ts similarity index 100% rename from packages/shader-compiler/src/lexer/index.ts rename to packages/shader-parser/src/lexer/index.ts diff --git a/packages/shader-compiler/src/parser/AST.ts b/packages/shader-parser/src/parser/AST.ts similarity index 58% rename from packages/shader-compiler/src/parser/AST.ts rename to packages/shader-parser/src/parser/AST.ts index 9c56ce41f1..8bac02c656 100644 --- a/packages/shader-compiler/src/parser/AST.ts +++ b/packages/shader-parser/src/parser/AST.ts @@ -1,19 +1,37 @@ import { ClearableObjectPool, type IPoolElement } from "@galacean/engine-core"; -import { CodeGenVisitor } from "../codeGen"; +import type { ICodeGenVisitor } from "./ICodeGenVisitor"; import { ETokenType, GalaceanDataType, ShaderRange, TokenType, TypeAny } from "../common"; -import { BaseToken } from "../common/BaseToken"; +import { BaseToken, BranchSignature, EMPTY_BRANCH, sameBranch } from "../common/BaseToken"; import { Keyword } from "../common/enums/Keyword"; import { ParserUtils } from "../ParserUtils"; -import { Lexer } from "../lexer/Lexer"; +import { TypeSystem } from "./TypeSystem"; import { MacroDefineInfo } from "../Preprocessor"; import { ShaderCompilerUtils } from "../ShaderCompilerUtils"; import { BuiltinFunction, BuiltinVariable, NonGenericGalaceanType } from "./builtin"; import { NoneTerminal } from "./GrammarSymbol"; import SemanticAnalyzer from "./SemanticAnalyzer"; import { ShaderData } from "./ShaderInfo"; -import { ESymbolType, FnSymbol, StructSymbol, VarSymbol } from "./symbolTable"; +import { ESymbolType, FnSymbol, StructSymbol, SymbolInfo, VarSymbol } from "./symbolTable"; import { IParamInfo, NodeChild, StructProp, SymbolType } from "./types"; +/** Texture-sampling builtins whose first argument is a sampler — used to flag a non-sampler arg0. */ +const TEXTURE_SAMPLING_BUILTINS = new Set([ + "texture", + "texture2D", + "texture2DLod", + "texture2DLodEXT", + "textureCube", + "textureCubeLod", + "textureCubeLodEXT", + "textureLod", + "textureOffset", + "textureProj", + "textureProjLod", + "textureProjOffset", + "textureSize", + "texelFetch" +]); + function ASTNodeDecorator(nonTerminal: NoneTerminal) { return function (ASTNode: T) { ASTNode.prototype.nt = nonTerminal; @@ -22,7 +40,9 @@ function ASTNodeDecorator(nonTerminal: NoneTerminal) { } export abstract class TreeNode implements IPoolElement { - static pool: ClearableObjectPool void }>; + static pool: ClearableObjectPool< + TreeNode & { set: (loc: ShaderRange, children: NodeChild[], trackAnalysis?: boolean) => void } + >; /** The non-terminal in grammar. */ nt: NoneTerminal; @@ -31,6 +51,16 @@ export abstract class TreeNode implements IPoolElement { private _location: ShaderRange; private _codeCache: string; + /** + * Snapshot of the `#ifdef` stack at this node's source position, inherited from its first + * terminal descendant (tokens carry `.branch` from the Lexer). Empty = unconditional. Used as + * the callsite branch for `symbolTableStack.lookup/insert` so a reference inside `#ifdef X` + * resolves against declarations visible from that branch, and a declaration inside `#ifdef X` + * is stamped with that branch. Mirrors codegen's per-branch visibility model. + */ + _branch: BranchSignature = EMPTY_BRANCH; + _inMacroDefinition = false; + /** * Parent pointer for AST traversal. * @remarks @@ -50,14 +80,36 @@ export abstract class TreeNode implements IPoolElement { return this._location; } - set(loc: ShaderRange, children: NodeChild[]): void { + set(loc: ShaderRange, children: NodeChild[], trackAnalysis = false): void { this._location = loc; this._children = children; + if (!trackAnalysis) { + for (const child of children) { + if (child instanceof TreeNode) child._parent = this; + } + this.init(); + return; + } + + let branch: BranchSignature = EMPTY_BRANCH; + let inheritedBranch = false; + let inMacroDefinition = false; for (const child of children) { if (child instanceof TreeNode) { child._parent = this; + if (!inheritedBranch) { + branch = child._branch; + inMacroDefinition = child._inMacroDefinition; + inheritedBranch = true; + } + } else if (!inheritedBranch && child instanceof BaseToken) { + branch = child.branch; + inMacroDefinition = child.inMacroDefinition; + inheritedBranch = true; } } + this._branch = branch; + this._inMacroDefinition = inMacroDefinition; this.init(); } @@ -76,7 +128,7 @@ export abstract class TreeNode implements IPoolElement { } // Visitor pattern interface for code generation - codeGen(visitor: CodeGenVisitor) { + codeGen(visitor: ICodeGenVisitor) { const code = visitor.defaultCodeGen(this.children); this.setCache(code); return code; @@ -88,7 +140,12 @@ export abstract class TreeNode implements IPoolElement { semanticAnalyze(sa: SemanticAnalyzer) {} } -export namespace ASTNode { +namespace ASTNodes { + interface MacroReference { + name: string; + branch: BranchSignature; + } + type MacroExpression = | MacroPushContext | MacroPopContext @@ -99,7 +156,7 @@ export namespace ASTNode { | BaseToken; export type ASTNodePool = ClearableObjectPool< - { set: (loc: ShaderRange, children: NodeChild[]) => void } & IPoolElement & TreeNode + { set: (loc: ShaderRange, children: NodeChild[], trackAnalysis?: boolean) => void } & IPoolElement & TreeNode >; export function _unwrapToken(node: NodeChild) { @@ -111,8 +168,20 @@ export namespace ASTNode { export function get(pool: ASTNodePool, sa: SemanticAnalyzer, loc: ShaderRange, children: NodeChild[]) { const node = pool.get(); - node.set(loc, children); + node.set(loc, children, sa.diagnosticsEnabled); + if (!sa.diagnosticsEnabled) { + node.semanticAnalyze(sa); + sa.semanticStack.push(node); + return; + } + + const prev = sa.symbolTableStack._currentBranch; + const previousMacroDefinition = sa.inMacroDefinition; + sa.symbolTableStack._currentBranch = node._branch; + sa.inMacroDefinition = node._inMacroDefinition; node.semanticAnalyze(sa); + sa.symbolTableStack._currentBranch = prev; + sa.inMacroDefinition = previousMacroDefinition; sa.semanticStack.push(node); } @@ -142,17 +211,17 @@ export namespace ASTNode { } override semanticAnalyze(sa: SemanticAnalyzer): void { - if (ASTNode._unwrapToken(this.children![0]).type === Keyword.RETURN) { + const children = this.children!; + if (ASTNodes._unwrapToken(children[0]).type === Keyword.RETURN) { sa.curFunctionInfo.returnStatement = this; } } - override codeGen(visitor: CodeGenVisitor): string { + override codeGen(visitor: ICodeGenVisitor): string { return this.setCache(visitor.visitJumpStatement(this)); } } - // #if _VERBOSE @ASTNodeDecorator(NoneTerminal.conditionopt) export class ConditionOpt extends TreeNode {} @@ -166,14 +235,17 @@ export namespace ASTNode { export class ForInitStatement extends TreeNode {} @ASTNodeDecorator(NoneTerminal.iteration_statement) - export class IterationStatement extends TreeNode {} + export class IterationStatement extends TreeNode { + override semanticAnalyze(sa: SemanticAnalyzer): void { + if (sa.diagnosticsEnabled) sa.popScope(); + } + } @ASTNodeDecorator(NoneTerminal.selection_statement) export class SelectionStatement extends TreeNode {} @ASTNodeDecorator(NoneTerminal.expression_statement) export class ExpressionStatement extends TreeNode {} - // #endif export abstract class ExpressionAstNode extends TreeNode { protected _type?: GalaceanDataType; @@ -189,7 +261,6 @@ export namespace ASTNode { } } - // #if _VERBOSE @ASTNodeDecorator(NoneTerminal.initializer_list) export class InitializerList extends ExpressionAstNode { override semanticAnalyze(sa: SemanticAnalyzer): void { @@ -208,16 +279,40 @@ export namespace ASTNode { } } } - // #endif + + /** + * Canonical semantic description of one variable declarator. + * + * A comma-separated declaration owns one instance per identifier so array and initializer state + * cannot leak between siblings. Parser symbol registration and analyzer validation consume this + * same description instead of decoding grammar child counts independently. + */ + export interface VariableDeclaratorInfo { + /** Identifier introduced by this declarator. */ + identifier: BaseToken; + /** Fully resolved base and array type for this identifier. */ + typeInfo: SymbolType; + /** Initializer attached to this identifier, when present. */ + initializer?: Initializer; + /** Whether the shared declaration type is `const`-qualified. */ + isConst: boolean; + /** Whether the declaration belongs to shader-global scope. */ + isGlobal: boolean; + } @ASTNodeDecorator(NoneTerminal.single_declaration) export class SingleDeclaration extends TreeNode { typeSpecifier: TypeSpecifier; arraySpecifier?: ArraySpecifier; + isConst: boolean; + /** Canonical information for the first declarator in a declaration list. */ + declarator: VariableDeclaratorInfo; override init(): void { this.typeSpecifier = undefined; this.arraySpecifier = undefined; + this.isConst = false; + this.declarator = undefined; } override semanticAnalyze(sa: SemanticAnalyzer): void { @@ -229,30 +324,32 @@ export namespace ASTNode { this.arraySpecifier = typeSpecifier.arraySpecifier; const id = children[1] as BaseToken; + const isConst = fullyType.isConst; + this.isConst = isConst; - let sm: VarSymbol; + let symbolType: SymbolType; + let initializer: Initializer | undefined; if (childrenLen === 2 || childrenLen === 4) { - const symbolType = new SymbolType(fullyType.type, typeSpecifier.lexeme, this.arraySpecifier); - const initializer = children[3] as Initializer; - - sm = new VarSymbol(id.lexeme, symbolType, false, initializer); + symbolType = new SymbolType(fullyType.type, typeSpecifier.lexeme, this.arraySpecifier); + initializer = children[3] as Initializer; } else { + // Array-of-array is target-divergent (GLSL ES 3.00 / WGSL allow it, ES 1.00 doesn't) and the + // backend can always emit it, so it's left to codegen/driver — not flagged here. The nested + // array structure stays as the neutral clue. const arraySpecifier = children[2] as ArraySpecifier; - // #if _VERBOSE - if (arraySpecifier && this.arraySpecifier) { - sa.reportError(arraySpecifier.location, "Array of array is not supported."); - } - // #endif this.arraySpecifier = arraySpecifier; - const symbolType = new SymbolType(fullyType.type, typeSpecifier.lexeme, this.arraySpecifier); - const initializer = children[4] as Initializer; - - sm = new VarSymbol(id.lexeme, symbolType, false, initializer); + symbolType = new SymbolType(fullyType.type, typeSpecifier.lexeme, this.arraySpecifier); + initializer = children[4] as Initializer; } - sa.symbolTableStack.insert(sm); + this.declarator = { identifier: id, typeInfo: symbolType, initializer, isConst, isGlobal: false }; + const sm = new VarSymbol(id.lexeme, symbolType, false, initializer, isConst); + // Equal declarations that can coexist are errors. Macro-branch alternatives remain registered + // so codegen can preserve every arm; unconditional collisions retain the legacy replacement behavior. + const insertResult = sa.symbolTableStack.insert(sm, id.branch); + sa.reportRedefinition(id.location, id.lexeme, insertResult); } - override codeGen(visitor: CodeGenVisitor): string { + override codeGen(visitor: ICodeGenVisitor): string { return this.setCache(visitor.visitSingleDeclaration(this)); } } @@ -261,9 +358,12 @@ export namespace ASTNode { export class FullySpecifiedType extends TreeNode { typeSpecifier: TypeSpecifier; type: GalaceanDataType; + /** Whether the declaration is `const`-qualified — drives the const-initializer / array-size checks. */ + isConst: boolean; override semanticAnalyze(_: SemanticAnalyzer): void { const children = this.children; + this.isConst = children.length === 2 && ParserUtils.hasQualifier(children[0] as TreeNode, Keyword.CONST); this.typeSpecifier = (children.length === 1 ? children[0] : children[1]) as TypeSpecifier; this.type = this.typeSpecifier.type; } @@ -300,7 +400,6 @@ export namespace ASTNode { } } - // #if _VERBOSE @ASTNodeDecorator(NoneTerminal.storage_qualifier) export class StorageQualifier extends BasicTypeQualifier {} @@ -312,7 +411,6 @@ export namespace ASTNode { @ASTNodeDecorator(NoneTerminal.invariant_qualifier) export class InvariantQualifier extends BasicTypeQualifier {} - // #endif @ASTNodeDecorator(NoneTerminal.type_specifier) export class TypeSpecifier extends TreeNode { @@ -340,16 +438,37 @@ export namespace ASTNode { @ASTNodeDecorator(NoneTerminal.array_specifier) export class ArraySpecifier extends TreeNode { + private static _symbolScratch: SymbolInfo[] = []; size: number | undefined; override semanticAnalyze(sa: SemanticAnalyzer): void { - const integerConstantExpr = this.children[1] as IntegerConstantExpression; + const integerConstantExpr = this.children[1]; + if (!(integerConstantExpr instanceof IntegerConstantExpression)) return; // `[ ]` — unsized this.size = integerConstantExpr.value; + // A non-literal size must be a constant. Only a single bare `variable_identifier` is checked, and + // only when it resolves to a known non-const var. Unknown identifiers may be runtime macros. + const exprChildren = integerConstantExpr.children; + if (this.size === undefined && exprChildren.length === 1 && exprChildren[0] instanceof VariableIdentifier) { + const bare = exprChildren[0].children[0]; + if (bare instanceof BaseToken && !sa.macroDefineList[bare.lexeme]) { + const lookup = SemanticAnalyzer._lookupSymbol; + lookup.set(bare.lexeme, ESymbolType.VAR); + const symbols = sa.symbolTableStack.lookupAll(lookup, true, ArraySpecifier._symbolScratch, this._branch); + if (!symbols.length) return; + const firstIsConst = (symbols[0] as VarSymbol).isConst; + const divergent = symbols.some((symbol) => (symbol as VarSymbol).isConst !== firstIsConst); + if (divergent) { + sa.reportBranchAmbiguity(exprChildren[0].location, bare.lexeme, "const-qualification", bare.lexeme); + } else if (!firstIsConst) { + sa.reportNonConstArraySize(exprChildren[0].location); + } + } + } } } @ASTNodeDecorator(NoneTerminal.integer_constant_expression_operator) export class IntegerConstantExpressionOperator extends TreeNode { - compute: (a: number, b: number) => number; + compute?: (a: number, b: number) => number; lexeme: string; override semanticAnalyze(sa: SemanticAnalyzer): void { @@ -371,8 +490,6 @@ export namespace ASTNode { case ETokenType.PERCENT: this.compute = (a, b) => a % b; break; - default: - sa.reportError(operator.location, `not implemented operator ${operator.lexeme}`); } } } @@ -391,15 +508,6 @@ export namespace ASTNode { if (child instanceof BaseToken) { this.value = Number(child.lexeme); } - // #if _VERBOSE - else { - const id = child as VariableIdentifier; - if (!ParserUtils.typeCompatible(Keyword.INT, id.typeInfo)) { - sa.reportError(id.location, "Invalid integer."); - return; - } - } - // #endif } } } @@ -441,35 +549,60 @@ export namespace ASTNode { @ASTNodeDecorator(NoneTerminal.init_declarator_list) export class InitDeclaratorList extends TreeNode { typeInfo: SymbolType; + isConst: boolean; + /** Canonical information for the declarator appended by this list node. */ + declarator?: VariableDeclaratorInfo; + + override init(): void { + this.isConst = false; + this.declarator = undefined; + } override semanticAnalyze(sa: SemanticAnalyzer): void { let sm: VarSymbol; const children = this.children; const childrenLength = children.length; if (childrenLength === 1) { - const { typeSpecifier, arraySpecifier } = children[0] as SingleDeclaration; + const { typeSpecifier, arraySpecifier, isConst } = children[0] as SingleDeclaration; this.typeInfo = new SymbolType(typeSpecifier.type, typeSpecifier.lexeme, arraySpecifier); + this.isConst = isConst; } else { const initDeclList = children[0] as InitDeclaratorList; this.typeInfo = initDeclList.typeInfo; + this.isConst = initDeclList.isConst; } if (childrenLength === 3 || childrenLength === 5) { const id = children[2] as BaseToken; - sm = new VarSymbol(id.lexeme, this.typeInfo, false, this); - sa.symbolTableStack.insert(sm); + const initializer = childrenLength === 5 ? (children[4] as Initializer) : undefined; + const typeInfo = new SymbolType(this.typeInfo.type, this.typeInfo.typeLexeme); + this.declarator = { + identifier: id, + typeInfo, + initializer, + isConst: this.isConst, + isGlobal: false + }; + sm = new VarSymbol(id.lexeme, typeInfo, false, this, this.isConst); + const insertResult = sa.symbolTableStack.insert(sm, id.branch); + sa.reportRedefinition(id.location, id.lexeme, insertResult); } else if (childrenLength === 4 || childrenLength === 6) { - const typeInfo = this.typeInfo; + // Array-of-array is target-divergent — left to codegen/driver, not flagged here (see SingleDeclaration). + const typeInfo = new SymbolType(this.typeInfo.type, this.typeInfo.typeLexeme); const arraySpecifier = this.children[3] as ArraySpecifier; - // #if _VERBOSE - if (typeInfo.arraySpecifier && arraySpecifier) { - sa.reportError(arraySpecifier.location, "Array of array is not supported."); - } - // #endif typeInfo.arraySpecifier = arraySpecifier; const id = children[2] as BaseToken; - sm = new VarSymbol(id.lexeme, typeInfo, false, this); - sa.symbolTableStack.insert(sm); + const initializer = childrenLength === 6 ? (children[5] as Initializer) : undefined; + this.declarator = { + identifier: id, + typeInfo, + initializer, + isConst: this.isConst, + isGlobal: false + }; + sm = new VarSymbol(id.lexeme, typeInfo, false, this, this.isConst); + const insertResult = sa.symbolTableStack.insert(sm, id.branch); + sa.reportRedefinition(id.location, id.lexeme, insertResult); } } } @@ -502,7 +635,7 @@ export namespace ASTNode { @ASTNodeDecorator(NoneTerminal.declaration) export class Declaration extends TreeNode { - override codeGen(visitor: CodeGenVisitor): string { + override codeGen(visitor: ICodeGenVisitor): string { return this.setCache(visitor.visitDeclaration(this)); } } @@ -556,7 +689,7 @@ export namespace ASTNode { this.returnType = children[0] as FullySpecifiedType; } - override codeGen(visitor: CodeGenVisitor): string { + override codeGen(visitor: ICodeGenVisitor): string { return this.setCache(visitor.visitFunctionHeader(this)); } } @@ -598,7 +731,7 @@ export namespace ASTNode { } } - override codeGen(visitor: CodeGenVisitor): string { + override codeGen(visitor: ICodeGenVisitor): string { return this.setCache(visitor.visitFunctionParameterList(this)); } } @@ -643,7 +776,7 @@ export namespace ASTNode { false, parameterDeclarator ); - sa.symbolTableStack.insert(varSymbol); + sa.symbolTableStack.insert(varSymbol, parameterDeclarator.ident.branch); } } } @@ -662,32 +795,28 @@ export namespace ASTNode { } } - // #if _VERBOSE @ASTNodeDecorator(NoneTerminal.simple_statement) export class SimpleStatement extends TreeNode {} @ASTNodeDecorator(NoneTerminal.compound_statement) export class CompoundStatement extends TreeNode {} - // #endif @ASTNodeDecorator(NoneTerminal.compound_statement_no_scope) export class CompoundStatementNoScope extends TreeNode {} - // #if _VERBOSE @ASTNodeDecorator(NoneTerminal.statement) export class Statement extends TreeNode {} - // #endif @ASTNodeDecorator(NoneTerminal.statement_list) export class StatementList extends TreeNode { - override codeGen(visitor: CodeGenVisitor): string { + override codeGen(visitor: ICodeGenVisitor): string { return this.setCache(visitor.visitStatementList(this)); } } @ASTNodeDecorator(NoneTerminal.function_definition) export class FunctionDefinition extends TreeNode { - returnStatement?: ASTNode.JumpStatement; + returnStatement?: ASTNodes.JumpStatement; protoType: FunctionProtoType; statements: CompoundStatementNoScope; isInMacroBranch: boolean; @@ -703,27 +832,28 @@ export namespace ASTNode { sa.popScope(); const sm = new FnSymbol(this.protoType.ident.lexeme, this); - sa.symbolTableStack.insert(sm); + // Preserve the legacy keep-first behavior for unconditional duplicates. Branch declarations + // must all be inserted even when they conflict: codegen needs every macro arm to reproduce + // the source, while `insert` independently reports whether two declarations can coexist. + const unconditionalDuplicate = this.protoType.ident.branch.length === 0 && sa.symbolTableStack.lookup(sm); + const conflict = unconditionalDuplicate ? "coexist" : sa.symbolTableStack.insert(sm, this.protoType.ident.branch); + sa.reportRedefinition(this.protoType.ident.location, this.protoType.ident.lexeme, conflict); this.isInMacroBranch = sa.symbolTableStack.isInMacroBranch; const { curFunctionInfo } = sa; - const { header, returnStatement } = curFunctionInfo; - if (header.returnType.type === Keyword.VOID) { - if (returnStatement) { - sa.reportError(header.returnType.location, "Return in void function."); - } - } else { - if (!returnStatement) { - sa.reportError(header.returnType.location, `No return statement found.`); - } else { - this.returnStatement = returnStatement; - } - } + // Codegen invariant: `returnStatement` is set ONLY for non-void functions with a value return. + // The fragment-entry rewrite (GLES100/300 visitJumpStatement) reads it as an Expression at + // children[1] — a bare `return;` in a void function has no expression there and would emit + // malformed GLSL if recorded. + this.returnStatement = + this.protoType.returnType.type === Keyword.VOID || curFunctionInfo.returnStatement?.children.length !== 3 + ? undefined + : curFunctionInfo.returnStatement; curFunctionInfo.header = undefined; curFunctionInfo.returnStatement = undefined; } - override codeGen(visitor: CodeGenVisitor): string { + override codeGen(visitor: ICodeGenVisitor): string { return this.setCache(visitor.visitFunctionDefinition(this)); } } @@ -734,7 +864,7 @@ export namespace ASTNode { this.type = (this.children[0] as FunctionCallGeneric).type; } - override codeGen(visitor: CodeGenVisitor): string { + override codeGen(visitor: ICodeGenVisitor): string { return this.setCache(visitor.visitFunctionCall(this)); } } @@ -742,6 +872,8 @@ export namespace ASTNode { @ASTNodeDecorator(NoneTerminal.function_call_generic) export class FunctionCallGeneric extends ExpressionAstNode { fnSymbol: FnSymbol | StructSymbol | undefined; + /** Scratch storage for the ambiguity-guard overload probe. */ + private static _overloadScratch: SymbolInfo[] = []; override init(): void { super.init(); @@ -762,28 +894,128 @@ export namespace ASTNode { paramSig = paramList.paramSig as any; } } - // #if _VERBOSE - const builtinFn = BuiltinFunction.resolveOverload(fnIdent, paramSig); - if (builtinFn) { - this.type = builtinFn.realReturnType; + if (sa.diagnosticsEnabled) { + // GLSL forbids recursion. A self-call — same name AND same parameter signature as the + // enclosing function (i.e. the same overload) — is short-circuited here: the function symbol + // isn't inserted until after its body, so the lookup below would otherwise mis-report it as + // Undefined / NoMatchingOverload. The validator reports RecursiveFunction; the exact-signature + // match avoids short-circuiting a call to a *different* overload of the same name. + const header = sa.curFunctionInfo.header; + if (header?.ident?.lexeme === fnIdent) { + const hSig = header.paramSig ?? []; + const cSig = paramSig ?? []; + if (hSig.length === cSig.length && hSig.every((t, i) => t === cSig[i])) { + return; + } + } + // A texture-sampling builtin's first argument must be a sampler. Flag a known non-sampler arg0 + // here (specific) rather than letting it fall through to the generic NoMatchingOverload below. + if (TEXTURE_SAMPLING_BUILTINS.has(fnIdent)) { + const arg0 = paramSig?.[0]; + if (arg0 !== undefined && arg0 !== TypeAny && !TypeSystem.isSamplerType(arg0)) { + sa.reportExpectedSampler(this.location, fnIdent, arg0); + return; + } + } + const builtinFn = BuiltinFunction.resolveOverload(fnIdent, paramSig); + if (builtinFn) { + this.type = builtinFn.realReturnType; + return; + } + + const lookupSymbol = SemanticAnalyzer._lookupSymbol; + lookupSymbol.set(fnIdent, ESymbolType.FN, undefined, undefined, paramSig); + + // Keep every macro-compatible overload, then require one declaration to be guaranteed or + // matching declarations in every arm of a complete conditional chain. This is the same + // contract as variable lookup: a helper hidden behind only `#ifdef X` cannot satisfy an + // unconditional call. + const allMatches = FunctionCallGeneric._overloadScratch; + sa.symbolTableStack.lookupAll(lookupSymbol, true, allMatches, this._branch); + const branchCoverage = sa.getBranchCoverage( + allMatches.map((symbol) => symbol.branchSignature ?? EMPTY_BRANCH), + this._branch + ); + const branchCovered = + branchCoverage === "covered" || FunctionCallGeneric._hasConflictingBranches(sa, allMatches); + const fnSymbol = branchCovered ? (allMatches[0] as FnSymbol | undefined) : undefined; + + // Ambiguity guard: when the call has TypeAny args, `SymbolInfo.equal` treats them as + // wildcards, so the first overload in reverse-insertion order wins and fixes a specific + // return type from what may be several equally-valid candidates (e.g. `permute` ships as + // `float`/`vec3`/`vec4` overloads; a TypeAny-typed arg matched all three but committed + // to the last-inserted return type). If multiple overloads match and their return types + // diverge, keep the reference resolved (`fnSymbol` stays set) but drop the call's type + // to TypeAny — the caller's downstream inference stays open instead of committing. + let overloadTypeAmbiguous = false; + if (fnSymbol && paramSig?.some((t) => t === TypeAny)) { + if (allMatches.length > 1) { + const firstType = (allMatches[0] as FnSymbol).dataType?.type; + overloadTypeAmbiguous = allMatches.some((s) => (s as FnSymbol).dataType?.type !== firstType); + } + } + + if (!fnSymbol) { + if (allMatches.length) { + sa.reportBranchAvailability(this.location, "Function", fnIdent, branchCoverage); + return; + } + // The lookup above is keyed by argument signature, so a miss conflates an unknown + // name with a known function called with the wrong arguments; re-probe by name + // alone (and the builtin registry) to report whichever it actually is. + lookupSymbol.set(fnIdent, ESymbolType.FN); + const nameDeclared = + sa.symbolTableStack.lookupAll(lookupSymbol, true, allMatches, this._branch).length > 0 || + BuiltinFunction.isExist(fnIdent); + // NoMatchingOverload = name is known, arg types are wrong → real type error. + // UndefinedFunction = name is unknown at precompile. `#include` is already expanded by + // the time the AST is built, so the only remaining "provided later" path is a runtime + // macro that the material system supplies at bind time — hand responsibility back to the + // author instead of hard-failing. + if (nameDeclared) { + sa.reportNoMatchingOverload(this.location, fnIdent); + } else { + sa.reportUndefinedFunction(this.location, fnIdent); + } + return; + } + this.type = overloadTypeAmbiguous ? TypeAny : fnSymbol?.dataType?.type; + this.fnSymbol = fnSymbol; return; } - // #endif const lookupSymbol = SemanticAnalyzer._lookupSymbol; lookupSymbol.set(fnIdent, ESymbolType.FN, undefined, undefined, paramSig); + const fnSymbol = sa.symbolTableStack.lookup(lookupSymbol, true) as FnSymbol | undefined; + if (!fnSymbol) return; + this.type = fnSymbol.dataType?.type; + this.fnSymbol = fnSymbol; + } + } - const fnSymbol = sa.symbolTableStack.lookup(lookupSymbol, true) as FnSymbol; - - if (!fnSymbol) { - // #if _VERBOSE - sa.reportError(this.location, `No overload function type found: ${functionIdentifier.ident}`); - // #endif - return; + private static _hasConflictingBranches(sa: SemanticAnalyzer, symbols: readonly SymbolInfo[]): boolean { + for (let i = 0, n = symbols.length; i < n; i++) { + for (let j = i + 1; j < n; j++) { + const left = symbols[i] as FnSymbol; + const right = symbols[j] as FnSymbol; + if ( + FunctionCallGeneric._hasSameParameterSignature(left, right) && + sa.canDeclarationsCoexist(left.branchSignature ?? EMPTY_BRANCH, right.branchSignature ?? EMPTY_BRANCH) + ) { + return true; + } } - this.type = fnSymbol?.dataType?.type; - this.fnSymbol = fnSymbol; } + return false; + } + + private static _hasSameParameterSignature(left: FnSymbol, right: FnSymbol): boolean { + const leftSignature = left.astNode.protoType.paramSig ?? []; + const rightSignature = right.astNode.protoType.paramSig ?? []; + return ( + leftSignature.length === rightSignature.length && + leftSignature.every((type, index) => type === rightSignature[index]) + ); } } @@ -848,7 +1080,7 @@ export namespace ASTNode { this.isBuiltin = typeof this.ident !== "string"; } - override codeGen(visitor: CodeGenVisitor): string { + override codeGen(visitor: ICodeGenVisitor): string { return this.setCache(visitor.visitFunctionIdentifier(this)); } } @@ -860,16 +1092,15 @@ export namespace ASTNode { const expr = this.children[0] as ConditionalExpression; this.type = expr.type ?? TypeAny; } else { - const expr = this.children[2] as AssignmentExpression; - this.type = expr.type ?? TypeAny; + const rhs = this.children[2] as AssignmentExpression; + this.type = rhs.type ?? TypeAny; } } } - // #if _VERBOSE @ASTNodeDecorator(NoneTerminal.assignment_operator) + /** Assignment operator syntax retained for post-parse validation. @internal */ export class AssignmentOperator extends TreeNode {} - // #endif @ASTNodeDecorator(NoneTerminal.expression) export class Expression extends ExpressionAstNode { @@ -914,6 +1145,8 @@ export namespace ASTNode { @ASTNodeDecorator(NoneTerminal.postfix_expression) export class PostfixExpression extends ExpressionAstNode { + private static _structScratch: SymbolInfo[] = []; + override init(): void { super.init(); if (this.children.length === 1) { @@ -922,12 +1155,91 @@ export namespace ASTNode { } } - override codeGen(visitor: CodeGenVisitor): string { + override semanticAnalyze(sa: SemanticAnalyzer): void { + // `struct.field` reads the symbol table, so it stays inline; the stateless swizzle/index checks + // (InvalidSwizzle, GlFragData, NonIndexableType, NonIntegerIndex, IndexOutOfBounds) moved to ShaderValidator. + const children = this.children; + if (children.length === 3 && children[2] instanceof BaseToken) { + const base = children[0] as ExpressionAstNode; + if (typeof base.type === "string") { + PostfixExpression._checkStructField(sa, base.type, children[2], this._branch); + } + } + } + + /** A `struct.field` access where the struct type is resolvable: the field must be a declared member. */ + private static _checkStructField( + sa: SemanticAnalyzer, + structName: string, + field: BaseToken, + callsiteBranch: BranchSignature + ): void { + const lookup = SemanticAnalyzer._lookupSymbol; + lookup.set(structName, ESymbolType.STRUCT); + const structs = sa.symbolTableStack.lookupAll(lookup, true, PostfixExpression._structScratch, callsiteBranch); + // Unresolved struct (e.g. a built-in or out-of-scope type) — skip rather than risk a false positive. + if (!structs.length) return; + const coverage = sa.getBranchCoverage( + structs.map((struct) => struct.branchSignature ?? EMPTY_BRANCH), + callsiteBranch + ); + if (coverage !== "covered") { + sa.reportBranchAvailability(field.location, "Struct", structName, coverage); + return; + } + const firstProp = (structs[0] as StructSymbol).astNode.propList.find( + (prop) => prop.ident.lexeme === field.lexeme + ); + let memberPresenceDivergent = false; + let memberTypeDivergent = false; + for (let i = 1; i < structs.length; i++) { + const prop = (structs[i] as StructSymbol).astNode.propList.find((item) => item.ident.lexeme === field.lexeme); + if (!!prop !== !!firstProp) { + memberPresenceDivergent = true; + break; + } + if (prop && firstProp) { + const firstArray = firstProp.typeInfo.arraySpecifier; + const array = prop.typeInfo.arraySpecifier; + if ( + prop.typeInfo.type !== firstProp.typeInfo.type || + !!array !== !!firstArray || + array?.size !== firstArray?.size + ) { + memberTypeDivergent = true; + break; + } + } + } + if (memberPresenceDivergent) { + sa.reportBranchAmbiguity( + field.location, + `${structName}.${field.lexeme}`, + "struct-member-presence", + field.lexeme, + structName + ); + return; + } + if (memberTypeDivergent) { + sa.reportBranchAmbiguity( + field.location, + `${structName}.${field.lexeme}`, + "struct-member-type", + field.lexeme, + structName + ); + return; + } + if (firstProp) return; + sa.reportUndeclaredStructMember(field.location, structName, field.lexeme); + } + + override codeGen(visitor: ICodeGenVisitor): string { return this.setCache(visitor.visitPostfixExpression(this)); } } - // #if _VERBOSE @ASTNodeDecorator(NoneTerminal.unary_operator) export class UnaryOperator extends TreeNode {} @@ -944,13 +1256,12 @@ export namespace ASTNode { super.init(); if (this.children.length === 1) { this.type = (this.children[0] as UnaryExpression).type; - // TODO: Temporarily remove type deduce due to generic function type issue. - // } else { - // const exp1 = this.children[0] as MultiplicativeExpression; - // const exp2 = this.children[2] as UnaryExpression; - // if (exp1.type === exp2.type) { - // this.type = exp1.type; - // } + } else { + this.type = TypeSystem.arithmeticResultType( + (this.children[0] as ExpressionAstNode).type, + (this.children[2] as ExpressionAstNode).type, + (this.children[1] as BaseToken).lexeme + ); } } } @@ -961,13 +1272,12 @@ export namespace ASTNode { super.init(); if (this.children.length === 1) { this.type = (this.children[0] as MultiplicativeExpression).type; - // TODO: Temporarily remove type deduce due to generic function type issue. - // } else { - // const exp1 = this.children[0] as AdditiveExpression; - // const exp2 = this.children[2] as MultiplicativeExpression; - // if (exp1.type === exp2.type) { - // this.type = exp1.type; - // } + } else { + this.type = TypeSystem.arithmeticResultType( + (this.children[0] as ExpressionAstNode).type, + (this.children[2] as ExpressionAstNode).type, + (this.children[1] as BaseToken).lexeme + ); } } } @@ -1076,7 +1386,6 @@ export namespace ASTNode { } } } - // #endif @ASTNodeDecorator(NoneTerminal.struct_specifier) export class StructSpecifier extends TreeNode { @@ -1094,7 +1403,8 @@ export namespace ASTNode { this.isInMacroBranch = sa.symbolTableStack.isInMacroBranch; if (children.length === 6) { this.ident = children[1] as BaseToken; - sa.symbolTableStack.insert(new StructSymbol(this.ident.lexeme, this)); + const insertResult = sa.symbolTableStack.insert(new StructSymbol(this.ident.lexeme, this), this.ident.branch); + sa.reportRedefinition(this.ident.location, this.ident.lexeme, insertResult); this.propList = (children[3] as StructDeclarationList).propList; this.macroExpressions = (children[3] as StructDeclarationList).macroExpressions; @@ -1104,7 +1414,7 @@ export namespace ASTNode { } } - override codeGen(visitor: CodeGenVisitor) { + override codeGen(visitor: ICodeGenVisitor) { return this.setCache(visitor.visitStructSpecifier(this)); } } @@ -1173,7 +1483,6 @@ export namespace ASTNode { this._typeSpecifier = children[1] as TypeSpecifier; this._declaratorList = children[2] as StructDeclaratorList; } - const firstChild = children[0]; const { type, lexeme } = this._typeSpecifier; const isInMacroBranch = sa.symbolTableStack.isInMacroBranch; @@ -1183,13 +1492,16 @@ export namespace ASTNode { const prop = new StructProp(typeInfo, declarator.ident, firstChild.index, isInMacroBranch); props.push(prop); } else { + // `type_qualifier type_specifier struct_declarator_list ;` — the qualifier (children[0]) may carry + // `flat`, which integer varyings need; the 3-child form has no qualifier so it can't be flat. + const isFlat = children.length === 4 && ParserUtils.hasQualifier(children[0] as TreeNode, Keyword.FLAT); const declaratorList = this._declaratorList.declaratorList; const declaratorListLength = declaratorList.length; props.length = declaratorListLength; for (let i = 0; i < declaratorListLength; i++) { const declarator = declaratorList[i]; const typeInfo = new SymbolType(type, lexeme, declarator.arraySpecifier); - const prop = new StructProp(typeInfo, declarator.ident, undefined, isInMacroBranch); + const prop = new StructProp(typeInfo, declarator.ident, undefined, isInMacroBranch, isFlat); props[i] = prop; } } @@ -1309,9 +1621,12 @@ export namespace ASTNode { export class VariableDeclaration extends TreeNode { type: FullySpecifiedType; isStatic: boolean; + /** Canonical information for this shader-global declarator. */ + declarator: VariableDeclaratorInfo; override init(): void { this.isStatic = false; + this.declarator = undefined; } override semanticAnalyze(sa: SemanticAnalyzer): void { @@ -1319,16 +1634,34 @@ export namespace ASTNode { const type = children[0] as FullySpecifiedType; const ident = children[1] as BaseToken; this.type = type; - const sm = new VarSymbol(ident.lexeme, new SymbolType(type.type, type.typeSpecifier.lexeme), true, this); - - sa.symbolTableStack.insert(sm); + // A global variable without an initializer is Galacean's implicit uniform (bound at runtime + // by the material system). With an initializer, `this.isStatic` becomes true — it's a + // compile-time value, not a uniform. `const`-qualified is a separate read-only path. + const hasInitializer = children.length === 4; + // Grammar `fully_specified_type ID array_specifier` — children[2] is the array specifier. + // Without it, `float arr[3]` at global scope stored as scalar `float`, then every `arr[i]` + // ref misfires `NonIndexableType`. Recover the array-ness at symbol level. + const arraySpecifier = children.length === 3 ? (children[2] as ArraySpecifier) : undefined; + const initializer = hasInitializer ? (children[3] as Initializer) : undefined; + const typeInfo = new SymbolType(type.type, type.typeSpecifier.lexeme, arraySpecifier); + this.declarator = { + identifier: ident, + typeInfo, + initializer, + isConst: type.isConst, + isGlobal: true + }; + const sm = new VarSymbol(ident.lexeme, typeInfo, true, this, type.isConst, !hasInitializer && !type.isConst); + + const insertResult = sa.symbolTableStack.insert(sm, ident.branch); + sa.reportRedefinition(ident.location, ident.lexeme, insertResult); if (children.length === 4) { this.isStatic = true; } } - override codeGen(visitor: CodeGenVisitor): string { + override codeGen(visitor: ICodeGenVisitor): string { if (this.isStatic) { return super.codeGen(visitor); } else { @@ -1376,68 +1709,218 @@ export namespace ASTNode { @ASTNodeDecorator(NoneTerminal.variable_identifier) export class VariableIdentifier extends TreeNode { - // @todo: typeInfo may be multiple types typeInfo: GalaceanDataType; + /** Whether the resolved symbol is an array — `typeInfo` alone drops array-ness, but indexing rules need it. */ + isArray: boolean; + /** A fixed array's element count, for constant index bounds-checking; `undefined` if unsized/unknown. */ + arraySize?: number; referenceGlobalSymbolNames: string[] = []; private _symbols: Array = []; + /** + * Returns the symbols retained when this reference was resolved. + * @returns Branch-visible variable or function candidates for the reference. + * @internal + */ + resolvedSymbols(): readonly (VarSymbol | FnSymbol)[] { + return this._symbols; + } + override init(): void { this.typeInfo = TypeAny; + this.isArray = false; + this.arraySize = undefined; this.referenceGlobalSymbolNames.length = 0; this._symbols.length = 0; } override semanticAnalyze(sa: SemanticAnalyzer): void { - const child = this.children[0] as BaseToken | MacroCallSymbol | MacroCallFunction; - const referenceGlobalSymbolNames = this.referenceGlobalSymbolNames; - const symbols = this._symbols; + if (sa.diagnosticsEnabled) { + const child = this.children[0] as BaseToken | MacroCallSymbol | MacroCallFunction; + const referenceGlobalSymbolNames = this.referenceGlobalSymbolNames; + const symbols = this._symbols; - // Real references — every name must resolve; miss is an authoring error. - const needFindNames = child instanceof BaseToken ? [child.lexeme] : child.referenceSymbolNames; + // Real references — every name must resolve; miss is an authoring error. + const references: readonly MacroReference[] = + child instanceof BaseToken ? [{ name: child.lexeme, branch: this._branch }] : child.referenceSymbols; + const needFindNames = references.map((reference) => reference.name); - for (let i = 0; i < needFindNames.length; i++) { - const name = needFindNames[i]; + for (let i = 0; i < references.length; i++) { + const { name, branch } = references[i]; - if (sa.macroDefineList[name]) continue; + if (sa.macroDefineList[name]) continue; - // only `macro_call` CFG can reference fnSymbols, others fnSymbols are referenced in `function_call_generic` CFG - if (!(child instanceof BaseToken) && BuiltinFunction.isExist(name)) { - continue; + // only `macro_call` CFG can reference fnSymbols, others fnSymbols are referenced in `function_call_generic` CFG + if (!(child instanceof BaseToken) && BuiltinFunction.isExist(name)) { + continue; + } + + const builtinVar = BuiltinVariable.getVar(name); + if (builtinVar) { + this.typeInfo = builtinVar.type; + continue; + } + + const hit = VariableIdentifier._lookupAndMarkGlobalReference( + sa, + name, + symbols, + referenceGlobalSymbolNames, + this.location, + branch + ); + // Expression-style macros have their own value AST; its real type isn't + // the type of any single `referenceSymbolNames` entry (`v` in `v.v_uv` + // is a `Varyings` struct but the macro call site's type should be the + // member type). Skip type inference for those and keep TypeAny. + if (hit && (child instanceof BaseToken || !child.hasAstValue)) { + // Divergence guard: lookupAll returns every branch-visible decl; if their type identity + // (base type + isArray + arraySize) diverges, we can't confidently pick one — commit to + // TypeAny + isArray=false + arraySize=undefined so downstream checks self-disable rather + // than acting on an arbitrary last-inserted decl. Symmetric with FunctionCallGeneric's + // `overloadTypeAmbiguous` guard. + const first = symbols[0]; + const firstType = first.dataType?.type; + const firstIsArray = !!first.dataType?.arraySpecifier; + const firstArraySize = first.dataType?.arraySpecifier?.size; + let divergent = false; + let arraySizeDivergent = false; + if (symbols.length > 1) { + for (let s = 1; s < symbols.length; s++) { + const d = symbols[s].dataType; + if (d?.type !== firstType || !!d?.arraySpecifier !== firstIsArray) { + divergent = true; + break; + } + if (d?.arraySpecifier?.size !== firstArraySize) arraySizeDivergent = true; + } + } + if (divergent) { + this.typeInfo = TypeAny; + this.isArray = false; + this.arraySize = undefined; + if (!symbols.every((symbol) => TypeSystem.isSamplerType(symbol.dataType?.type))) { + // Report once per (pass, symbol name) — a single divergent symbol may be referenced + // dozens of times (e.g. `renderer_BlendShapeWeights[0..7]`); flooding the editor UI + // with identical warnings buries the signal. + sa.reportBranchAmbiguity(this.location, name, "symbol-type", name); + } + } else { + this.typeInfo = firstType; + this.isArray = firstIsArray; + this.arraySize = arraySizeDivergent ? undefined : firstArraySize; + } + } + } + + // FXAA-style cross-arm shadowing: at a MACRO_CALL use site, silently + // probe the macro name itself so any sibling-arm `var` declaration is + // marked as referenced and codegen keeps it. Miss is the common + // single-arm case — no warning, no type inference. Grammar half of the + // cross-arm fix is in 87cb2b5f0. + if (!(child instanceof BaseToken)) { + VariableIdentifier._probeCrossArmShadowing(sa, child, needFindNames, symbols, referenceGlobalSymbolNames); + } + return; + } + + this._semanticAnalyzeForCodegen(sa); + } + + private _semanticAnalyzeForCodegen(sa: SemanticAnalyzer): void { + const child = this.children[0] as BaseToken | MacroCallSymbol | MacroCallFunction; + if (child instanceof BaseToken) { + const name = child.lexeme; + if (sa.macroDefineList[name]) return; + const builtinVar = BuiltinVariable.getVar(name); + if (builtinVar) { + this.typeInfo = builtinVar.type; + return; } + this._resolveCodegenReference(sa, name, true); + return; + } + + const references = child.referenceSymbolNames; + for (let i = 0; i < references.length; i++) { + const name = references[i]; + if (sa.macroDefineList[name] || BuiltinFunction.isExist(name)) continue; const builtinVar = BuiltinVariable.getVar(name); if (builtinVar) { this.typeInfo = builtinVar.type; continue; } + this._resolveCodegenReference(sa, name, !child.hasAstValue); + } - const hit = VariableIdentifier._lookupAndMarkGlobalReference( - sa, - name, - symbols, - referenceGlobalSymbolNames, - this.location - ); - // Expression-style macros have their own value AST; its real type isn't - // the type of any single `referenceSymbolNames` entry (`v` in `v.v_uv` - // is a `Varyings` struct but the macro call site's type should be the - // member type). Skip type inference for those and keep TypeAny. - if (hit && (child instanceof BaseToken || !child.hasAstValue)) { - this.typeInfo = symbols[0].dataType?.type; + const macroName = child.macroName; + if (!macroName || BuiltinFunction.isExist(macroName) || BuiltinVariable.getVar(macroName)) return; + for (let i = 0; i < references.length; i++) { + if (references[i] === macroName) return; + } + const lookupSymbol = SemanticAnalyzer._lookupSymbol; + lookupSymbol.set(macroName, ESymbolType.Any); + const symbol = sa.symbolTableStack.lookup(lookupSymbol, true) as VarSymbol | FnSymbol | undefined; + if ( + symbol && + (symbol instanceof FnSymbol || symbol.isGlobalVariable) && + this.referenceGlobalSymbolNames.indexOf(macroName) === -1 + ) { + this.referenceGlobalSymbolNames.push(macroName); + } + } + + private _resolveCodegenReference(sa: SemanticAnalyzer, name: string, inferType: boolean): void { + const lookupSymbol = SemanticAnalyzer._lookupSymbol; + lookupSymbol.set(name, ESymbolType.Any); + const symbols = this._symbols; + sa.symbolTableStack.lookupAll(lookupSymbol, true, symbols); + if (!symbols.length) return; + + const currentScopeSymbol = sa.symbolTableStack.scope.getSymbol(lookupSymbol, true) as + | VarSymbol + | FnSymbol + | undefined; + if (currentScopeSymbol) { + this._markCodegenReference(name, currentScopeSymbol); + } else { + for (let i = 0; i < symbols.length; i++) { + const symbol = symbols[i]; + if (symbol instanceof FnSymbol || symbol.isGlobalVariable) { + this._markCodegenReference(name, symbol); + break; + } } } + if (!inferType) return; + + const first = symbols[0].dataType; + let arraySizeDivergent = false; + for (let i = 1; i < symbols.length; i++) { + const current = symbols[i].dataType; + if (current?.type !== first?.type || !!current?.arraySpecifier !== !!first?.arraySpecifier) return; + if (current?.arraySpecifier?.size !== first?.arraySpecifier?.size) arraySizeDivergent = true; + } + this._setCodegenType(first, arraySizeDivergent); + } - // FXAA-style cross-arm shadowing: at a MACRO_CALL use site, silently - // probe the macro name itself so any sibling-arm `var` declaration is - // marked as referenced and codegen keeps it. Miss is the common - // single-arm case — no warning, no type inference. Grammar half of the - // cross-arm fix is in 87cb2b5f0. - if (!(child instanceof BaseToken)) { - VariableIdentifier._probeCrossArmShadowing(sa, child, needFindNames, symbols, referenceGlobalSymbolNames); + private _markCodegenReference(name: string, symbol: VarSymbol | FnSymbol): void { + if ( + (symbol instanceof FnSymbol || symbol.isGlobalVariable) && + this.referenceGlobalSymbolNames.indexOf(name) === -1 + ) { + this.referenceGlobalSymbolNames.push(name); } } + private _setCodegenType(dataType: SymbolType | undefined, arraySizeDivergent = false): void { + this.typeInfo = dataType?.type; + this.isArray = !!dataType?.arraySpecifier; + this.arraySize = arraySizeDivergent ? undefined : dataType?.arraySpecifier?.size; + } + /** Run the cross-arm shadowing probe for a MACRO_CALL site. No-op when the * probe isn't meaningful: no macro name, name already resolved as a real * reference, or name is a builtin (builtins can't be shadowed by a @@ -1453,14 +1936,26 @@ export namespace ASTNode { if (!macroName) return; if (needFindNames.indexOf(macroName) !== -1) return; // already looked up as a real reference if (BuiltinFunction.isExist(macroName) || BuiltinVariable.getVar(macroName)) return; // builtins can't be shadowed - VariableIdentifier._lookupAndMarkGlobalReference(sa, macroName, symbols, referenceGlobalSymbolNames, null); + // Cross-arm probes intentionally collect candidates from every compatible branch. They are + // only used to retain declarations for codegen, never for author-facing type resolution. + VariableIdentifier._lookupAndMarkGlobalReference( + sa, + macroName, + symbols, + referenceGlobalSymbolNames, + null, + EMPTY_BRANCH, + true + ); } /** Look up `name` in the symbol stack and, if a global var/fn declaration * exists, push it into `referenceGlobalSymbolNames`. Returns `true` iff - * the lookup hit (caller can then derive type info). When `missWarnLoc` - * is non-null, a miss reports a "declared before used" warning; pass + * the lookup hit (caller can then derive type info). When `missErrorLoc` + * is non-null, a miss reports an "undeclared identifier" error; pass * `null` for silent probes (e.g. FXAA-style cross-arm shadowing). + * `retainPartialBranchCandidates` is reserved for codegen-only probes: they retain every + * branch-local declaration even though none is guaranteed at the probe's synthetic call site. * * Mutation contract: `symbols` is used as scratch storage — `lookupAll` * clears and refills it. On hit, the caller may read `symbols[0]` for @@ -1470,21 +1965,66 @@ export namespace ASTNode { name: string, symbols: (VarSymbol | FnSymbol)[], referenceGlobalSymbolNames: string[], - missWarnLoc: ShaderRange | null + missErrorLoc: ShaderRange | null, + callsiteBranch: BranchSignature, + retainPartialBranchCandidates = false ): boolean { const lookupSymbol = SemanticAnalyzer._lookupSymbol; lookupSymbol.set(name, ESymbolType.Any); - sa.symbolTableStack.lookupAll(lookupSymbol, true, symbols); + // Branch-aware: filter to declarations visible from the reference's own `#ifdef` branch. + // A `float u_a` inside `#ifdef X` is invisible to a reference in `#else` (as it should be) + // and visible to a reference in the same branch (so type inference recovers). + sa.symbolTableStack.lookupAll(lookupSymbol, true, symbols, callsiteBranch); + let directlyVisibleCount = 0; + for (let i = 0, n = symbols.length; i < n; i++) { + const symbol = symbols[i]; + if (sa.isBranchVisibleFrom(symbol.branchSignature ?? EMPTY_BRANCH, callsiteBranch)) { + symbols[directlyVisibleCount++] = symbol; + } + } + if (directlyVisibleCount) symbols.length = directlyVisibleCount; if (!symbols.length) { - // #if _VERBOSE - if (missWarnLoc) { - sa.reportWarning(missWarnLoc, `Please sure the identifier "${name}" will be declared before used.`); + if (missErrorLoc) { + if (sa.symbolTableStack.hasSymbol(lookupSymbol)) { + sa.symbolTableStack.lookupAll(lookupSymbol, true, symbols); + sa.reportBranchAvailability( + missErrorLoc, + "Identifier", + name, + sa.getBranchCoverage( + symbols.map((symbol) => symbol.branchSignature ?? EMPTY_BRANCH), + callsiteBranch + ) + ); + symbols.length = 0; + return false; + } + // `#include` is already expanded by the time the AST is built, so the only remaining + // "provided later" path is a runtime macro that the material system supplies at bind + // time (`RENDERER_JOINTS_NUM` etc.). Report as a warning — the author is responsible for + // confirming the runtime path supplies this identifier. + sa.reportUnknownVariable(missErrorLoc, name); + } + return false; + } + const coverage = sa.getBranchCoverage( + symbols.map((symbol) => symbol.branchSignature ?? EMPTY_BRANCH), + callsiteBranch + ); + if ( + !retainPartialBranchCandidates && + coverage !== "covered" && + !VariableIdentifier._hasConflictingGlobalBranches(sa, symbols) + ) { + if (missErrorLoc) { + sa.reportBranchAvailability(missErrorLoc, "Identifier", name, coverage); } - // #endif return false; } - const currentScopeSymbol = sa.symbolTableStack.scope.getSymbol(lookupSymbol, true); + const currentScopeSymbol = ( + sa.symbolTableStack.scope.getSymbol(lookupSymbol, true, callsiteBranch, sa.branchSemantics) + ); const isGlobal = currentScopeSymbol ? currentScopeSymbol instanceof FnSymbol || currentScopeSymbol.isGlobalVariable : symbols.some((s) => s instanceof FnSymbol || s.isGlobalVariable); @@ -1494,11 +2034,28 @@ export namespace ASTNode { return true; } - override codeGen(visitor: CodeGenVisitor): string { + private static _hasConflictingGlobalBranches( + sa: SemanticAnalyzer, + symbols: readonly (VarSymbol | FnSymbol)[] + ): boolean { + for (let i = 0, n = symbols.length; i < n; i++) { + const left = symbols[i]; + if (!(left instanceof FnSymbol) && !left.isGlobalVariable) continue; + for (let j = i + 1; j < n; j++) { + const right = symbols[j]; + if (!(right instanceof FnSymbol) && !right.isGlobalVariable) continue; + if (sa.canDeclarationsCoexist(left.branchSignature ?? EMPTY_BRANCH, right.branchSignature ?? EMPTY_BRANCH)) + return true; + } + } + return false; + } + + override codeGen(visitor: ICodeGenVisitor): string { return this.setCache(visitor.visitVariableIdentifier(this)); } - getLexeme(visitor: CodeGenVisitor): string { + getLexeme(visitor: ICodeGenVisitor): string { const child = this.children[0] as BaseToken | MacroCallSymbol | MacroCallFunction; if (child instanceof BaseToken) { return child.lexeme; @@ -1548,7 +2105,7 @@ export namespace ASTNode { @ASTNodeDecorator(NoneTerminal.macro_undef) export class MacroUndef extends TreeNode { - override codeGen(visitor: CodeGenVisitor) { + override codeGen(visitor: ICodeGenVisitor) { return this.setCache(super.codeGen(visitor) + "\n"); } } @@ -1559,7 +2116,7 @@ export namespace ASTNode { sa.symbolTableStack._macroLevel++; } - override codeGen(visitor: CodeGenVisitor) { + override codeGen(visitor: ICodeGenVisitor) { return this.setCache("\n" + super.codeGen(visitor) + "\n"); } } @@ -1570,21 +2127,21 @@ export namespace ASTNode { sa.symbolTableStack._macroLevel--; } - override codeGen(visitor: CodeGenVisitor) { + override codeGen(visitor: ICodeGenVisitor) { return this.setCache("\n" + super.codeGen(visitor) + "\n"); } } @ASTNodeDecorator(NoneTerminal.macro_elif_expression) export class MacroElifExpression extends TreeNode { - override codeGen(visitor: CodeGenVisitor) { + override codeGen(visitor: ICodeGenVisitor) { return this.setCache("\n" + super.codeGen(visitor) + "\n"); } } @ASTNodeDecorator(NoneTerminal.macro_else_expression) export class MacroElseExpression extends TreeNode { - override codeGen(visitor: CodeGenVisitor) { + override codeGen(visitor: ICodeGenVisitor) { return this.setCache("\n" + super.codeGen(visitor) + "\n"); } } @@ -1609,7 +2166,7 @@ export namespace ASTNode { } } - override codeGen(visitor: CodeGenVisitor) { + override codeGen(visitor: ICodeGenVisitor) { const children = this.children as TreeNode[]; if (children.length === 1) { return this.setCache(children[0].codeGen(visitor)); @@ -1680,6 +2237,7 @@ export namespace ASTNode { @ASTNodeDecorator(NoneTerminal.macro_call_symbol) export class MacroCallSymbol extends TreeNode { referenceSymbolNames: string[] = []; + referenceSymbols: MacroReference[] = []; macroName: string; /** True iff every `MacroDefineInfo` visible from this call site's branch * has a `valueAst` (i.e. was parsed via the `macro_define` CFG rule). @@ -1695,6 +2253,7 @@ export namespace ASTNode { override init(): void { this.referenceSymbolNames.length = 0; + this.referenceSymbols.length = 0; this.hasAstValue = false; this.isFunctionLikeMacro = false; this.aliasesNonBuiltinIdent = false; @@ -1705,68 +2264,120 @@ export namespace ASTNode { const macroName = nameToken.lexeme; this.macroName = macroName; - // Filter `defList` to only entries reachable from this call site's - // `#ifdef` branch (Issue #2980 nit fix). Without filtering, definitions - // in disjoint branches conflate at the call site and pollute type - // inference; with it, what remains is exactly what could substitute at - // this position. - const callSiteBranch = nameToken.branch; const defList = sa.macroDefineList[macroName]; const refs = this.referenceSymbolNames; refs.length = 0; - let visibleCount = 0; - let allAst = true; - let isFn = false; - let allAliasNonBuiltinIdent = true; - if (defList) { - for (let i = 0, n = defList.length; i < n; i++) { - const info = defList[i]; - if (!Lexer.isVisibleFrom(info.branch, callSiteBranch)) continue; - visibleCount++; - if (info.valueAst == null) allAst = false; - if (info.isFunction) isFn = true; - // Harvest references from the value AST. Legacy-form macros (no - // `valueAst`) hold non-expression token sequences with no user - // identifiers, so nothing to collect. - if (info.valueAst) { - MacroCallSymbol._collectIdentifierRefs(info.valueAst, info.params, refs); - } - // aliasesNonBuiltinIdent: macro replacement is a single non-builtin - // identifier — best-effort proxy for "this macro call site aliases a - // user fn", since uniform/const aliases would surface as a GLSL - // compile error later anyway. Keyword replacements (`vec3`, `mat4`) - // reach here with `valueAst === undefined` (opaque path), so they - // automatically fail without an explicit keyword guard. - if (info.isFunction || !info.valueAst) { - allAliasNonBuiltinIdent = false; - } else { - const leadingIdent = ParserUtils.unwrapBareIdentifier(info.valueAst, { allowParens: false }); - const leadingChild = leadingIdent?.children[0]; - const leadingId = leadingChild instanceof BaseToken ? leadingChild.lexeme : undefined; - if (!leadingId || BuiltinFunction.isExist(leadingId)) { + this.referenceSymbols.length = 0; + if (sa.diagnosticsEnabled) { + // Filter `defList` to only entries reachable from this call site's + // `#ifdef` branch. Without filtering, definitions + // in disjoint branches conflate at the call site and pollute type + // inference; with it, what remains is exactly what could substitute at + // this position. + const callSiteBranch = nameToken.branch; + const referenceSymbols = this.referenceSymbols; + let visibleCount = 0; + let allAst = true; + let isFn = false; + let allAliasNonBuiltinIdent = true; + if (defList) { + for (let i = 0, n = defList.length; i < n; i++) { + const info = defList[i]; + if (!sa.canBranchesOverlap(info.branch, callSiteBranch)) continue; + visibleCount++; + if (info.valueAst == null) allAst = false; + if (info.isFunction) isFn = true; + // Harvest references from the value AST. Legacy-form macros (no + // `valueAst`) hold non-expression token sequences with no user + // identifiers, so nothing to collect. + if (info.valueAst) { + MacroCallSymbol._collectIdentifierRefs( + info.valueAst, + info.params, + refs, + referenceSymbols, + callSiteBranch + ); + } + // aliasesNonBuiltinIdent: macro replacement is a single non-builtin + // identifier — best-effort proxy for "this macro call site aliases a + // user fn", since uniform/const aliases would surface as a GLSL + // compile error later anyway. Keyword replacements (`vec3`, `mat4`) + // reach here with `valueAst === undefined` (opaque path), so they + // automatically fail without an explicit keyword guard. + if (info.isFunction || !info.valueAst) { allAliasNonBuiltinIdent = false; + } else { + const leadingIdent = ParserUtils.unwrapBareIdentifier(info.valueAst, { allowParens: false }); + const leadingChild = leadingIdent?.children[0]; + const leadingId = leadingChild instanceof BaseToken ? leadingChild.lexeme : undefined; + if (!leadingId || BuiltinFunction.isExist(leadingId)) { + allAliasNonBuiltinIdent = false; + } } } } + // Require *every* visible entry to be AST-form before taking the AST + // shortcut: residual ambiguity (e.g. unmodeled `#if expr` letting both + // forms through) falls back to legacy `referenceSymbolNames` inference + // instead of polluting the call site with TypeAny. + this.hasAstValue = visibleCount > 0 && allAst; + this.isFunctionLikeMacro = isFn; + this.aliasesNonBuiltinIdent = visibleCount > 0 && allAliasNonBuiltinIdent; + return; } - // Require *every* visible entry to be AST-form before taking the AST - // shortcut: residual ambiguity (e.g. unmodeled `#if expr` letting both - // forms through) falls back to legacy `referenceSymbolNames` inference - // instead of polluting the call site with TypeAny. - this.hasAstValue = visibleCount > 0 && allAst; - this.isFunctionLikeMacro = isFn; - this.aliasesNonBuiltinIdent = visibleCount > 0 && allAliasNonBuiltinIdent; + + this._analyzeForCodegen(defList, refs); + } + + private _analyzeForCodegen(defList: MacroDefineInfo[] | undefined, refs: string[]): void { + let allAst = true; + let isFunctionLike = false; + let allAliasNonBuiltinIdent = true; + const count = defList?.length ?? 0; + for (let i = 0; i < count; i++) { + const info = defList![i]; + if (!info.valueAst) allAst = false; + if (info.isFunction) isFunctionLike = true; + if (info.valueAst) MacroCallSymbol._collectIdentifierRefs(info.valueAst, info.params, refs); + + if (info.isFunction || !info.valueAst) { + allAliasNonBuiltinIdent = false; + } else { + const leadingIdent = ParserUtils.unwrapBareIdentifier(info.valueAst, { allowParens: false }); + const leadingChild = leadingIdent?.children[0]; + const leadingId = leadingChild instanceof BaseToken ? leadingChild.lexeme : undefined; + if (!leadingId || BuiltinFunction.isExist(leadingId)) allAliasNonBuiltinIdent = false; + } + } + this.hasAstValue = count > 0 && allAst; + this.isFunctionLikeMacro = isFunctionLike; + this.aliasesNonBuiltinIdent = count > 0 && allAliasNonBuiltinIdent; } /** Push every leaf `VariableIdentifier`'s lexeme into `out`, skipping * function-like parameter names (local to the macro, not call-site refs) * and duplicates. */ - private static _collectIdentifierRefs(node: TreeNode, params: string[], out: string[]): void { + private static _collectIdentifierRefs( + node: TreeNode, + params: string[], + out: string[], + references?: MacroReference[], + callSiteBranch: BranchSignature = EMPTY_BRANCH + ): void { if (node instanceof VariableIdentifier) { const child = node.children[0]; if (child instanceof BaseToken) { const name = child.lexeme; - if (params.indexOf(name) === -1 && out.indexOf(name) === -1) out.push(name); + if (params.indexOf(name) === -1) { + if (out.indexOf(name) === -1) out.push(name); + if (references) { + const branch = [...callSiteBranch, ...node._branch]; + if (!references.some((reference) => reference.name === name && sameBranch(reference.branch, branch))) { + references.push({ name, branch }); + } + } + } } return; } @@ -1774,7 +2385,7 @@ export namespace ASTNode { if (!children) return; for (let i = 0, n = children.length; i < n; i++) { const c = children[i]; - if (c instanceof TreeNode) MacroCallSymbol._collectIdentifierRefs(c, params, out); + if (c instanceof TreeNode) MacroCallSymbol._collectIdentifierRefs(c, params, out, references, callSiteBranch); } } } @@ -1782,6 +2393,7 @@ export namespace ASTNode { @ASTNodeDecorator(NoneTerminal.macro_call_function) export class MacroCallFunction extends TreeNode { referenceSymbolNames: string[] = []; + referenceSymbols: MacroReference[] = []; macroName: string = ""; hasAstValue: boolean = false; isFunctionLikeMacro: boolean = false; @@ -1789,6 +2401,7 @@ export namespace ASTNode { override init(): void { this.referenceSymbolNames = []; + this.referenceSymbols = []; this.macroName = ""; this.hasAstValue = false; this.isFunctionLikeMacro = false; @@ -1799,13 +2412,14 @@ export namespace ASTNode { const child = this.children[0] as MacroCallSymbol; this.referenceSymbolNames = child.referenceSymbolNames; + this.referenceSymbols = child.referenceSymbols; this.macroName = child.macroName; this.hasAstValue = child.hasAstValue; this.isFunctionLikeMacro = child.isFunctionLikeMacro; this.aliasesNonBuiltinIdent = child.aliasesNonBuiltinIdent; } - override codeGen(visitor: CodeGenVisitor) { + override codeGen(visitor: ICodeGenVisitor) { return this.setCache(visitor.visitMacroCallFunction(this)); } } @@ -1870,7 +2484,7 @@ export namespace ASTNode { info.params.length === params.length && info.params.every((p, i) => p === params[i]); const upgradable = entries?.find( - (info) => !info.valueAst && sameArity(info) && Lexer.sameBranch(info.branch, definingBranch) + (info) => !info.valueAst && sameArity(info) && sameBranch(info.branch, definingBranch) ); if (upgradable) { upgradable.valueAst = this.valueExpression; @@ -1898,8 +2512,10 @@ export namespace ASTNode { } } - override codeGen(visitor: CodeGenVisitor): string { + override codeGen(visitor: ICodeGenVisitor): string { return this.setCache(visitor.visitMacroDefine(this)); } } } + +export import ASTNode = ASTNodes; diff --git a/packages/shader-parser/src/parser/AnalyzerSemanticDiagnostics.ts b/packages/shader-parser/src/parser/AnalyzerSemanticDiagnostics.ts new file mode 100644 index 0000000000..d711cee8d6 --- /dev/null +++ b/packages/shader-parser/src/parser/AnalyzerSemanticDiagnostics.ts @@ -0,0 +1,130 @@ +import type { GalaceanDataType, ShaderRange } from "../common"; +import type { BranchCoverage, DeclarationCoexistence } from "../common/BaseToken"; +import { GSErrorName } from "../GSError"; +import { ShaderCompilerUtils } from "../ShaderCompilerUtils"; +import { TypeSystem } from "./TypeSystem"; +import type { SemanticAmbiguityKind, SemanticDiagnostics } from "./SemanticDiagnostics"; + +class AnalyzerSemanticDiagnostics implements SemanticDiagnostics { + redefinition( + location: ShaderRange, + name: string, + conflict: Exclude | "none" + ): Error | undefined { + if (conflict === "coexist") { + return this._create(`Redefinition of '${name}'.`, location, "Redefinition"); + } + if (conflict === "unknown") { + return this._create( + `Declaration '${name}' may overlap another macro-guarded declaration; align their branch conditions.`, + location, + "Redefinition", + true + ); + } + } + + branchAvailability( + location: ShaderRange, + subjectKind: "Function" | "Struct" | "Identifier", + name: string, + coverage: BranchCoverage + ): Error | undefined { + if (coverage === "covered") return; + const subject = `${subjectKind} '${name}'`; + return coverage === "uncovered" + ? this._create( + `${subject} is unavailable under at least one macro configuration reaching this reference.`, + location, + "UseBeforeDeclaration" + ) + : this._create( + `${subject} may be unavailable under some macro configurations; align its declaration and reference conditions.`, + location, + "UseBeforeDeclaration", + true + ); + } + + branchAmbiguity(location: ShaderRange, kind: SemanticAmbiguityKind, name: string, owner?: string): Error { + switch (kind) { + case "const-qualification": + return this._create( + `Symbol '${name}' has conflicting const qualification across macro branches; constant-expression validation disabled at this reference.`, + location, + "AmbiguousMacroBranchResolution" + ); + case "struct-member-presence": + return this._create( + `Member '${name}' is missing from at least one reachable declaration of struct '${owner}'.`, + location, + "AmbiguousMacroBranchResolution" + ); + case "struct-member-type": + return this._create( + `Member '${name}' has divergent types across declarations of struct '${owner}'; type inference is disabled at this reference.`, + location, + "AmbiguousMacroBranchType", + true + ); + case "symbol-type": + return this._create( + `Symbol '${name}' resolves to multiple declarations with divergent types across macro branches; type inference disabled at this reference.`, + location, + "AmbiguousMacroBranchType", + true + ); + } + } + + nonConstArraySize(location: ShaderRange): Error { + return this._create("Array size must be a constant expression.", location, "NonConstArraySize"); + } + + expectedSampler(location: ShaderRange, functionName: string, actualType: GalaceanDataType): Error { + return this._create( + `'${functionName}' expects a sampler as its first argument, got '${TypeSystem.typeName(actualType)}'.`, + location, + "ExpectedSampler" + ); + } + + noMatchingOverload(location: ShaderRange, functionName: string): Error { + return this._create(`No overload function type found: ${functionName}`, location, "NoMatchingOverload"); + } + + undefinedFunction(location: ShaderRange, functionName: string): Error { + return this._create( + `Undefined function '${functionName}' — ensure it is provided at runtime as a macro.`, + location, + "UndefinedFunction", + true + ); + } + + undeclaredStructMember(location: ShaderRange, structName: string, memberName: string): Error { + return this._create(`'${memberName}' : no such field in '${structName}'`, location, "UndeclaredStructMember"); + } + + unknownVariable(location: ShaderRange, name: string): Error { + return this._create( + `Undeclared identifier '${name}' — ensure it is provided at runtime as a macro.`, + location, + "UnknownVariable", + true + ); + } + + private _create(message: string, location: ShaderRange, code: string, warning = false): Error { + return ShaderCompilerUtils.createGSError( + message, + warning ? GSErrorName.CompilationWarn : GSErrorName.CompilationError, + ShaderCompilerUtils.processingPassText, + location, + code + ); + } +} + +/** Analyzer-only diagnostic mapper supplied to the analyzer parser instance. @internal */ +export const analyzerSemanticDiagnostics: SemanticDiagnostics = new AnalyzerSemanticDiagnostics(); diff --git a/packages/shader-compiler/src/parser/Grammar.ts b/packages/shader-parser/src/parser/Grammar.ts similarity index 100% rename from packages/shader-compiler/src/parser/Grammar.ts rename to packages/shader-parser/src/parser/Grammar.ts diff --git a/packages/shader-compiler/src/parser/GrammarSymbol.ts b/packages/shader-parser/src/parser/GrammarSymbol.ts similarity index 100% rename from packages/shader-compiler/src/parser/GrammarSymbol.ts rename to packages/shader-parser/src/parser/GrammarSymbol.ts diff --git a/packages/shader-parser/src/parser/ICodeGenVisitor.ts b/packages/shader-parser/src/parser/ICodeGenVisitor.ts new file mode 100644 index 0000000000..a34acb54b6 --- /dev/null +++ b/packages/shader-parser/src/parser/ICodeGenVisitor.ts @@ -0,0 +1,22 @@ +import type { ASTNode } from "./AST"; +import type { NodeChild } from "./types"; + +/** AST nodes call back into the code generator through this interface, so AST stays decoupled from the concrete `CodeGenVisitor`. */ +export interface ICodeGenVisitor { + defaultCodeGen(children: NodeChild[]): string; + visitPostfixExpression(node: ASTNode.PostfixExpression): string; + visitVariableIdentifier(node: ASTNode.VariableIdentifier): string; + visitFunctionCall(node: ASTNode.FunctionCall): string; + visitMacroCallFunction(node: ASTNode.MacroCallFunction): string; + visitStatementList(node: ASTNode.StatementList): string; + visitMacroDefine(node: ASTNode.MacroDefine): string; + visitSingleDeclaration(node: ASTNode.SingleDeclaration): string; + visitGlobalVariableDeclaration(node: ASTNode.VariableDeclaration): string; + visitDeclaration(node: ASTNode.Declaration): string; + visitFunctionParameterList(node: ASTNode.FunctionParameterList): string; + visitFunctionHeader(node: ASTNode.FunctionHeader): string; + visitJumpStatement(node: ASTNode.JumpStatement): string; + visitFunctionIdentifier(node: ASTNode.FunctionIdentifier): string; + visitStructSpecifier(node: ASTNode.StructSpecifier): string; + visitFunctionDefinition(node: ASTNode.FunctionDefinition): string; +} diff --git a/packages/shader-parser/src/parser/PassParser.ts b/packages/shader-parser/src/parser/PassParser.ts new file mode 100644 index 0000000000..b8bbce4cf1 --- /dev/null +++ b/packages/shader-parser/src/parser/PassParser.ts @@ -0,0 +1,49 @@ +import { ShaderTargetParser } from "./ShaderTargetParser"; +import { Preprocessor, type ChunkOutputCache, type IncludeMap } from "../Preprocessor"; +import { AnalyzerLexer } from "../lexer/AnalyzerLexer"; +import { branchAnalysis } from "../common/BranchAnalysis"; +import { analyzerSemanticDiagnostics } from "./AnalyzerSemanticDiagnostics"; +import { ShaderCompilerUtils } from "../ShaderCompilerUtils"; +import { ShaderClueIR, type ShaderSourceMapSegment } from "../ir"; + +let _parser: ShaderTargetParser; + +/** Maps a range in expanded pass text back to its source chunk. */ +export type PreprocessSourceMapSegment = ShaderSourceMapSegment; + +/** + * Parses one shader pass into neutral IR and parse-stage diagnostics. + * @param source - GLSL source for the shader pass. + * @param includeMap - Include-path lookup table. + * @param cache - Cache for expanded include chunks. + * @param basePathForIncludeKey - Base URL for relative include paths. + * @returns Neutral IR, diagnostics, and preprocessed pass text. + */ +export function parseShaderPass( + source: string, + includeMap: IncludeMap, + cache: ChunkOutputCache, + basePathForIncludeKey = "" +): { + ir: ShaderClueIR | null; + errors: Error[]; + passText: string; + sourceMap: PreprocessSourceMapSegment[]; +} { + _parser ??= ShaderTargetParser.create(branchAnalysis, analyzerSemanticDiagnostics); + const macroDefineList = {}; + const { + content: passText, + errors: preprocessErrors, + sourceMap + } = Preprocessor.parseWithErrors(source, basePathForIncludeKey, includeMap, cache); + const tokens = new AnalyzerLexer(passText, macroDefineList).tokenize(); + ShaderCompilerUtils.processingPassText = passText; + try { + const program = _parser.parse(tokens, macroDefineList); + const ir = program ? new ShaderClueIR(program, passText, sourceMap) : null; + return { ir, errors: [...preprocessErrors, ..._parser.errors], passText, sourceMap }; + } finally { + ShaderCompilerUtils.processingPassText = undefined; + } +} diff --git a/packages/shader-parser/src/parser/SemanticAnalyzer.ts b/packages/shader-parser/src/parser/SemanticAnalyzer.ts new file mode 100644 index 0000000000..e5ee1c8b85 --- /dev/null +++ b/packages/shader-parser/src/parser/SemanticAnalyzer.ts @@ -0,0 +1,187 @@ +import type { GalaceanDataType, ShaderRange } from "../common"; +import type { BranchCoverage, BranchSignature, DeclarationCoexistence } from "../common/BaseToken"; +import type { BranchSemantics } from "../common/BranchSemantics"; +import { SymbolTable } from "../common/SymbolTable"; +import { SymbolTableStack } from "../common/SymbolTableStack"; +import { SymbolInfo } from "../parser/symbolTable"; +import { ASTNode, TreeNode } from "./AST"; +import { ShaderData } from "./ShaderInfo"; +import type { SemanticAmbiguityKind, SemanticDiagnostics } from "./SemanticDiagnostics"; +import { NodeChild } from "./types"; + +import { MacroDefineList } from "../Preprocessor"; + +export type TranslationRule = (sa: SemanticAnalyzer, ...tokens: NodeChild[]) => T; +type RedefinitionConflict = Exclude | "none"; + +/** + * @internal + * The semantic analyzer of `ShaderCompiler` compiler. + * - Build symbol table + * - Static analysis + */ +export default class SemanticAnalyzer { + /** + * @internal + */ + static _lookupSymbol: SymbolInfo = new SymbolInfo("", null); + + semanticStack: TreeNode[] = []; + acceptRule?: TranslationRule = undefined; + symbolTableStack: SymbolTableStack> = new SymbolTableStack(); + curFunctionInfo: { + header?: ASTNode.FunctionDeclarator; + returnStatement?: ASTNode.JumpStatement; + } = {}; + private _shaderData = new ShaderData(); + private _translationRuleTable: Map = new Map(); + + private _macroDefineList: MacroDefineList; + + readonly errors: Error[] = []; + readonly diagnosticsEnabled: boolean; + inMacroDefinition = false; + /** Ambiguity diagnostic keys already emitted in this pass. Reset in `reset()`. */ + readonly _ambiguousReported = new Set(); + + get shaderData() { + return this._shaderData; + } + + get macroDefineList(): MacroDefineList { + return this._macroDefineList; + } + + constructor( + readonly branchSemantics?: BranchSemantics, + private readonly _semanticDiagnostics?: SemanticDiagnostics + ) { + this.diagnosticsEnabled = _semanticDiagnostics !== undefined; + this.symbolTableStack.branchSemantics = branchSemantics; + this.pushScope(); + } + + reset(macroDefineList: MacroDefineList) { + this._macroDefineList = macroDefineList; + this.semanticStack.length = 0; + this._shaderData = new ShaderData(); + this.symbolTableStack.clear(); + this.pushScope(); + this.errors.length = 0; + this.inMacroDefinition = false; + this._ambiguousReported.clear(); + } + + pushScope() { + this.symbolTableStack.pushScope(new SymbolTable()); + } + + popScope() { + return this.symbolTableStack.popScope(); + } + + addTranslationRule(pid: number, rule: TranslationRule) { + this._translationRuleTable.set(pid, rule); + } + + getTranslationRule(pid: number) { + return this._translationRuleTable.get(pid); + } + + /** Report a proven duplicate as an error and unresolved branch overlap as a warning. */ + reportRedefinition(loc: ShaderRange, name: string, conflict: RedefinitionConflict): void { + this._report(this._semanticDiagnostics?.redefinition(loc, name, conflict)); + } + + /** Report a proven missing declaration as an error and uncertain coverage as a warning. */ + reportBranchAvailability( + loc: ShaderRange, + subjectKind: "Function" | "Struct" | "Identifier", + name: string, + coverage: BranchCoverage + ): void { + this._report(this._semanticDiagnostics?.branchAvailability(loc, subjectKind, name, coverage)); + } + + /** + * Emit one macro-branch ambiguity diagnostic per semantic projection and pass. + * @param loc - Source range of the ambiguous reference. + * @param key - Stable projection key, such as a variable name or `Struct.member`. + * @param kind - Structured ambiguity category. + * @param name - Symbol or member name. + * @param owner - Struct owner for member ambiguities. + */ + reportBranchAmbiguity( + loc: ShaderRange, + key: string, + kind: SemanticAmbiguityKind, + name: string, + owner?: string + ): void { + if (!this._semanticDiagnostics) return; + const dedupKey = `${kind}:${key}`; + if (this._ambiguousReported.has(dedupKey)) return; + this._ambiguousReported.add(dedupKey); + this._report(this._semanticDiagnostics.branchAmbiguity(loc, kind, name, owner)); + } + + /** @internal */ + reportNonConstArraySize(loc: ShaderRange): void { + this._report(this._semanticDiagnostics?.nonConstArraySize(loc)); + } + + /** @internal */ + reportExpectedSampler(loc: ShaderRange, functionName: string, actualType: GalaceanDataType): void { + this._report(this._semanticDiagnostics?.expectedSampler(loc, functionName, actualType)); + } + + /** @internal */ + reportNoMatchingOverload(loc: ShaderRange, functionName: string): void { + this._report(this._semanticDiagnostics?.noMatchingOverload(loc, functionName)); + } + + /** @internal */ + reportUndefinedFunction(loc: ShaderRange, functionName: string): void { + this._report(this._semanticDiagnostics?.undefinedFunction(loc, functionName)); + } + + /** @internal */ + reportUndeclaredStructMember(loc: ShaderRange, structName: string, memberName: string): void { + this._report(this._semanticDiagnostics?.undeclaredStructMember(loc, structName, memberName)); + } + + /** @internal */ + reportUnknownVariable(loc: ShaderRange, name: string): void { + this._report(this._semanticDiagnostics?.unknownVariable(loc, name)); + } + + /** @internal */ + canBranchesOverlap(left: BranchSignature, right: BranchSignature): boolean { + return this.branchSemantics?.canBranchesOverlap(left, right) ?? true; + } + + /** @internal */ + canDeclarationsCoexist(earlier: BranchSignature, later: BranchSignature): boolean { + return this.branchSemantics?.canDeclarationsCoexist(earlier, later) ?? true; + } + + /** @internal */ + getBranchCoverage(candidates: readonly BranchSignature[], callSiteBranch: BranchSignature): BranchCoverage { + return this.branchSemantics?.getBranchCoverage(candidates, callSiteBranch) ?? "covered"; + } + + /** @internal */ + isBranchVisibleFrom(defBranch: BranchSignature, callSiteBranch: BranchSignature): boolean { + return this.branchSemantics?.isBranchVisibleFrom(defBranch, callSiteBranch) ?? true; + } + + private _report(error?: Error): void { + if (!error || this.inMacroDefinition || !this._isCurrentBranchReachable()) return; + this.errors.push(error); + } + + /** Suppress diagnostics from paths the lexer has proven cannot reach the generated shader. */ + private _isCurrentBranchReachable(): boolean { + return this.branchSemantics?.isBranchReachable(this.symbolTableStack._currentBranch) ?? true; + } +} diff --git a/packages/shader-parser/src/parser/SemanticDiagnostics.ts b/packages/shader-parser/src/parser/SemanticDiagnostics.ts new file mode 100644 index 0000000000..56c10294f4 --- /dev/null +++ b/packages/shader-parser/src/parser/SemanticDiagnostics.ts @@ -0,0 +1,100 @@ +import type { GalaceanDataType, ShaderRange } from "../common"; +import type { BranchCoverage, DeclarationCoexistence } from "../common/BaseToken"; + +/** Analyzer-only ambiguity categories emitted while semantic facts are projected. @internal */ +export type SemanticAmbiguityKind = + | "const-qualification" + | "struct-member-presence" + | "struct-member-type" + | "symbol-type"; + +/** Structured parser facts that an analyzer may map to diagnostics. @internal */ +export interface SemanticDiagnostics { + /** + * Maps a declaration conflict to a diagnostic. + * @param location - Declaration range. + * @param name - Declared symbol name. + * @param conflict - Proven or unresolved coexistence state. + * @returns A diagnostic when the conflict is reportable. + * @internal + */ + redefinition( + location: ShaderRange, + name: string, + conflict: Exclude | "none" + ): Error | undefined; + /** + * Maps declaration coverage to a reference diagnostic. + * @param location - Reference range. + * @param subjectKind - Referenced declaration category. + * @param name - Referenced symbol name. + * @param coverage - Proven or unresolved coverage state. + * @returns A diagnostic when coverage is not proven. + * @internal + */ + branchAvailability( + location: ShaderRange, + subjectKind: "Function" | "Struct" | "Identifier", + name: string, + coverage: BranchCoverage + ): Error | undefined; + /** + * Creates an ambiguity diagnostic for one semantic projection. + * @param location - Ambiguous reference range. + * @param kind - Ambiguity category. + * @param name - Symbol or member name. + * @param owner - Optional owning struct name. + * @returns The mapped ambiguity diagnostic. + * @internal + */ + branchAmbiguity(location: ShaderRange, kind: SemanticAmbiguityKind, name: string, owner?: string): Error; + /** + * Creates a non-constant array-size diagnostic. + * @param location - Array-size range. + * @returns The mapped diagnostic. + * @internal + */ + nonConstArraySize(location: ShaderRange): Error; + /** + * Creates a sampler-argument diagnostic. + * @param location - Call range. + * @param functionName - Texture function name. + * @param actualType - First argument type. + * @returns The mapped diagnostic. + * @internal + */ + expectedSampler(location: ShaderRange, functionName: string, actualType: GalaceanDataType): Error; + /** + * Creates an overload-resolution diagnostic. + * @param location - Call range. + * @param functionName - Called function name. + * @returns The mapped diagnostic. + * @internal + */ + noMatchingOverload(location: ShaderRange, functionName: string): Error; + /** + * Creates an undefined-function diagnostic. + * @param location - Call range. + * @param functionName - Called function name. + * @returns The mapped diagnostic. + * @internal + */ + undefinedFunction(location: ShaderRange, functionName: string): Error; + /** + * Creates an unknown-struct-member diagnostic. + * @param location - Member range. + * @param structName - Struct type name. + * @param memberName - Referenced member name. + * @returns The mapped diagnostic. + * @internal + */ + undeclaredStructMember(location: ShaderRange, structName: string, memberName: string): Error; + /** + * Creates an unknown-variable diagnostic. + * @param location - Reference range. + * @param name - Referenced variable name. + * @returns The mapped diagnostic. + * @internal + */ + unknownVariable(location: ShaderRange, name: string): Error; +} diff --git a/packages/shader-compiler/src/parser/ShaderInfo.ts b/packages/shader-parser/src/parser/ShaderInfo.ts similarity index 100% rename from packages/shader-compiler/src/parser/ShaderInfo.ts rename to packages/shader-parser/src/parser/ShaderInfo.ts diff --git a/packages/shader-compiler/src/parser/ShaderTargetParser.ts b/packages/shader-parser/src/parser/ShaderTargetParser.ts similarity index 71% rename from packages/shader-compiler/src/parser/ShaderTargetParser.ts rename to packages/shader-parser/src/parser/ShaderTargetParser.ts index 461b47248c..f5438c55ad 100644 --- a/packages/shader-compiler/src/parser/ShaderTargetParser.ts +++ b/packages/shader-parser/src/parser/ShaderTargetParser.ts @@ -1,18 +1,19 @@ import { ETokenType } from "../common"; import { BaseToken } from "../common/BaseToken"; +import type { BranchSemantics } from "../common/BranchSemantics"; import { Keyword } from "../common/enums/Keyword"; -import { GSError, GSErrorName } from "../GSError"; +import { GSErrorName } from "../GSError"; +import type { GSError } from "../GSError"; import { LALR1 } from "../lalr"; import { addTranslationRule, createGrammar } from "../lalr/CFG"; import { EAction, StateActionTable, StateGotoTable } from "../lalr/types"; import { MacroDefineList } from "../Preprocessor"; import { ParserUtils } from "../ParserUtils"; -import { ShaderCompiler } from "../ShaderCompiler"; import { ShaderCompilerUtils } from "../ShaderCompilerUtils"; import { ASTNode, TreeNode } from "./AST"; import { Grammar } from "./Grammar"; -import { GrammarSymbol, NoneTerminal } from "./GrammarSymbol"; import SematicAnalyzer from "./SemanticAnalyzer"; +import type { SemanticDiagnostics } from "./SemanticDiagnostics"; import { ESymbolType, SymbolInfo } from "./symbolTable"; import { TraceStackItem } from "./types"; @@ -36,37 +37,54 @@ export class ShaderTargetParser { return this.gotoTable.get(this.curState); } - // #if _VERBOSE /** @internal */ get errors() { return this.sematicAnalyzer.errors; } - // #endif - static _singleton: ShaderTargetParser; + private static _runtimeSingleton: ShaderTargetParser; + private static _analyzerSingleton: ShaderTargetParser; - static create() { - if (!this._singleton) { + static create(branchSemantics?: BranchSemantics, semanticDiagnostics?: SemanticDiagnostics) { + const singletonKey = semanticDiagnostics ? "_analyzerSingleton" : "_runtimeSingleton"; + if (!this[singletonKey]) { const grammar = createGrammar(); const generator = new LALR1(grammar); generator.generate(); - this._singleton = new ShaderTargetParser(generator.actionTable, generator.gotoTable, grammar); - addTranslationRule(this._singleton.sematicAnalyzer); + const parser = new ShaderTargetParser( + generator.actionTable, + generator.gotoTable, + grammar, + branchSemantics, + semanticDiagnostics + ); + addTranslationRule(parser.sematicAnalyzer); + this[singletonKey] = parser; } - return this._singleton; + return this[singletonKey]; } - private constructor(actionTable: StateActionTable, gotoTable: StateGotoTable, grammar: Grammar) { + private constructor( + actionTable: StateActionTable, + gotoTable: StateGotoTable, + grammar: Grammar, + branchSemantics?: BranchSemantics, + semanticDiagnostics?: SemanticDiagnostics + ) { this.actionTable = actionTable; this.gotoTable = gotoTable; this.grammar = grammar; - this.sematicAnalyzer = new SematicAnalyzer(); + this.sematicAnalyzer = new SematicAnalyzer(branchSemantics, semanticDiagnostics); } parse(tokens: Generator, macroDefineList: MacroDefineList): ASTNode.GLShaderProgram | null { this.sematicAnalyzer.reset(macroDefineList); const { _traceBackStack: traceBackStack, sematicAnalyzer } = this; + // A prior parse that bailed early (syntax error -> `return null` below) leaves this working + // stack dirty; the parser is a shared singleton, so start every parse from a clean stack or a + // failed compile corrupts the next one. + traceBackStack.length = 0; traceBackStack.push(0); let nextToken = tokens.next(); @@ -88,10 +106,14 @@ export class ShaderTargetParser { sematicAnalyzer.symbolTableStack.insert(new SymbolInfo(p, ESymbolType.VAR)); } } + if (sematicAnalyzer.diagnosticsEnabled && (token.type === Keyword.FOR || token.type === Keyword.WHILE)) { + sematicAnalyzer.pushScope(); + } nextToken = tokens.next(); } else if (actionInfo?.action === EAction.Accept) { sematicAnalyzer.acceptRule?.(sematicAnalyzer); - return sematicAnalyzer.semanticStack.pop() as ASTNode.GLShaderProgram; + const program = sematicAnalyzer.semanticStack.pop() as ASTNode.GLShaderProgram; + return program; } else if (actionInfo?.action === EAction.Reduce) { const target = actionInfo.target!; const reduceProduction = this.grammar.getProductionByID(target)!; @@ -122,27 +144,12 @@ export class ShaderTargetParser { const error = ShaderCompilerUtils.createGSError( `Unexpected token ${token.lexeme}`, GSErrorName.CompilationError, - ShaderCompiler._processingPassText, + ShaderCompilerUtils.processingPassText, token.location ); - // #if _VERBOSE this.sematicAnalyzer.errors.push(error); - // #endif return null; } } } - - // #if _VERBOSE - private _printStack(nextToken: BaseToken) { - let str = ""; - for (let i = 0; i < this._traceBackStack.length - 1; i++) { - const state = this._traceBackStack[i++]; - const token = this._traceBackStack[i]; - str += `State${state} - ${(token).lexeme ?? ParserUtils.toString(token as GrammarSymbol)}; `; - } - str += `State${this._traceBackStack[this._traceBackStack.length - 1]} --- ${nextToken.lexeme}`; - console.info(str); - } - // #endif } diff --git a/packages/shader-compiler/src/parser/TargetParser.y b/packages/shader-parser/src/parser/TargetParser.y similarity index 98% rename from packages/shader-compiler/src/parser/TargetParser.y rename to packages/shader-parser/src/parser/TargetParser.y index 47da78e57c..dad863ee31 100644 --- a/packages/shader-compiler/src/parser/TargetParser.y +++ b/packages/shader-parser/src/parser/TargetParser.y @@ -519,10 +519,7 @@ function_call: function_call_generic: function_identifier '(' function_call_parameter_list ')' | function_identifier '(' ')' - // Mirrors CFG.ts:681 verbatim. This alt is unreachable from any legal - // GLSL token stream (lexer never produces `f VOID )` without `(` between) - // — present since the LALR refactor (#2113, 2024-07). Tracked as latent - // bug; fix belongs in a separate PR with a `f(void)` regression test. + // Kept in sync with CFG.ts; the lexer does not emit this sequence for legal GLSL. | function_identifier void ')' ; diff --git a/packages/shader-parser/src/parser/TypeSystem.ts b/packages/shader-parser/src/parser/TypeSystem.ts new file mode 100644 index 0000000000..4226ec4f34 --- /dev/null +++ b/packages/shader-parser/src/parser/TypeSystem.ts @@ -0,0 +1,314 @@ +import { GalaceanDataType, TypeAny } from "../common"; +import { Keyword } from "../common/enums/Keyword"; + +export type { GalaceanDataType } from "../common/types"; + +/** Proven reason that a binary arithmetic operation is invalid. */ +export type ArithmeticFailureReason = "non-arithmetic" | "family-mismatch" | "shape-mismatch" | "integer-required"; + +/** + * Shared result of arithmetic type inference and validation. + * + * `valid` is undefined when an operand type is unresolved. A false value is therefore proof of an + * invalid operation, while unresolved macro-provided types remain non-blocking. + */ +export interface ArithmeticTypeResult { + /** Inferred result type, or `TypeAny` when it cannot be determined. */ + resultType: GalaceanDataType | undefined; + /** Whether the operation is proven valid, proven invalid, or unresolved. */ + valid: boolean | undefined; + /** Invalidity reason when `valid` is false. */ + reason?: ArithmeticFailureReason; +} + +/** Utility functions for GLSL type classification and compatibility. */ +export class TypeSystem { + /** + * Tests whether a value type can be assigned without an implicit conversion. + * @param target - Type of the assignment target. + * @param source - Type of the assigned value. + * @returns Whether the assignment is valid or cannot yet be resolved. + */ + static isAssignable(target: GalaceanDataType | undefined, source: GalaceanDataType | undefined): boolean { + if (target == undefined || source == undefined || target === TypeAny || source === TypeAny) return true; + // Struct types compare by name (§4.1.8: types are equal only if they are the same struct). + // Mixed struct-vs-primitive is a conflict; struct-vs-struct with different names is a conflict. + return target === source; + } + + /** + * Returns a human-readable GLSL type name. + * @param type - Type to format. + * @returns GLSL name or `unknown` when the type is unresolved. + */ + static typeName(type: GalaceanDataType | undefined): string { + if (typeof type === "string") return type; + if (type == undefined) return "unknown"; + return (Keyword[type] ?? String(type)).toLowerCase(); + } + + /** + * Tests whether a type is an opaque GLSL sampler. + * @param type - Type to classify. + * @returns Whether the type is a sampler. + */ + static isSamplerType(type: GalaceanDataType | undefined): boolean { + switch (type) { + case Keyword.SAMPLER2D: + case Keyword.SAMPLER3D: + case Keyword.SAMPLER_CUBE: + case Keyword.SAMPLER2D_SHADOW: + case Keyword.SAMPLER_CUBE_SHADOW: + case Keyword.SAMPLER2D_ARRAY: + case Keyword.SAMPLER2D_ARRAY_SHADOW: + case Keyword.I_SAMPLER2D: + case Keyword.I_SAMPLER3D: + case Keyword.I_SAMPLER_CUBE: + case Keyword.I_SAMPLER2D_ARRAY: + case Keyword.U_SAMPLER2D: + case Keyword.U_SAMPLER3D: + case Keyword.U_SAMPLER_CUBE: + case Keyword.U_SAMPLER2D_ARRAY: + return true; + default: + return false; + } + } + + /** + * Tests whether a type is a boolean scalar or vector. + * @param type - Type to classify. + * @returns Whether the type belongs to the boolean family. + */ + static isBoolType(type: GalaceanDataType | undefined): boolean { + return type === Keyword.BOOL || type === Keyword.BVEC2 || type === Keyword.BVEC3 || type === Keyword.BVEC4; + } + + /** + * Tests whether a type is a signed or unsigned integer scalar or vector. + * @param type - Type to classify. + * @returns Whether the type belongs to an integer family. + */ + static isIntegerType(type: GalaceanDataType | undefined): boolean { + switch (type) { + case Keyword.INT: + case Keyword.UINT: + case Keyword.IVEC2: + case Keyword.IVEC3: + case Keyword.IVEC4: + case Keyword.UVEC2: + case Keyword.UVEC3: + case Keyword.UVEC4: + return true; + default: + return false; + } + } + + /** + * Tests whether a resolved type cannot participate in arithmetic. + * @param type - Type to classify. + * @returns Whether the type is a boolean, sampler, or struct. Unresolved types return false. + */ + static nonArithmeticOperand(type: GalaceanDataType | undefined): boolean { + return ( + type != undefined && + type !== TypeAny && + (this.isBoolType(type) || this.isSamplerType(type) || typeof type === "string") + ); + } + + /** + * Tests whether a type is a numeric or boolean scalar. + * @param type - Type to classify. + * @returns Whether the type is a scalar. + */ + static isScalarType(type: GalaceanDataType | undefined): boolean { + return type === Keyword.FLOAT || type === Keyword.INT || type === Keyword.UINT || type === Keyword.BOOL; + } + + /** + * Infers and validates one GLSL arithmetic operation from a single rule table. + * @param left - Left operand type. + * @param right - Right operand type. + * @param operator - Arithmetic operator lexeme. + * @returns Shared inference and validity result. + */ + static arithmeticOperation( + left: GalaceanDataType | undefined, + right: GalaceanDataType | undefined, + operator: string + ): ArithmeticTypeResult { + if (left == undefined || right == undefined || left === TypeAny || right === TypeAny) { + return { resultType: TypeAny, valid: undefined }; + } + + const leftFamily = this._arithmeticFamily(left); + const rightFamily = this._arithmeticFamily(right); + if (!leftFamily || !rightFamily) { + return { resultType: TypeAny, valid: false, reason: "non-arithmetic" }; + } + if (leftFamily !== rightFamily) { + return { resultType: TypeAny, valid: false, reason: "family-mismatch" }; + } + if (operator === "%" && leftFamily === "float") { + return { resultType: TypeAny, valid: false, reason: "integer-required" }; + } + + const leftScalar = this.isScalarType(left); + const rightScalar = this.isScalarType(right); + if (leftScalar || rightScalar) { + return { resultType: leftScalar ? right : left, valid: true }; + } + + const leftVectorSize = this.vectorComponentCount(left); + const rightVectorSize = this.vectorComponentCount(right); + const leftMatrix = this.matrixDimensions(left); + const rightMatrix = this.matrixDimensions(right); + + if (leftVectorSize && rightVectorSize) { + return leftVectorSize === rightVectorSize + ? { resultType: left, valid: true } + : { resultType: TypeAny, valid: false, reason: "shape-mismatch" }; + } + + if (leftMatrix && rightMatrix) { + if (operator === "*") { + return leftMatrix.columns === rightMatrix.rows + ? { resultType: this._matrixType(rightMatrix.columns, leftMatrix.rows), valid: true } + : { resultType: TypeAny, valid: false, reason: "shape-mismatch" }; + } + return leftMatrix.columns === rightMatrix.columns && leftMatrix.rows === rightMatrix.rows + ? { resultType: left, valid: true } + : { resultType: TypeAny, valid: false, reason: "shape-mismatch" }; + } + + if (operator === "*" && leftMatrix && rightVectorSize) { + return leftMatrix.columns === rightVectorSize + ? { resultType: this._vectorType(leftMatrix.rows), valid: true } + : { resultType: TypeAny, valid: false, reason: "shape-mismatch" }; + } + if (operator === "*" && leftVectorSize && rightMatrix) { + return leftVectorSize === rightMatrix.rows + ? { resultType: this._vectorType(rightMatrix.columns), valid: true } + : { resultType: TypeAny, valid: false, reason: "shape-mismatch" }; + } + + return { resultType: TypeAny, valid: false, reason: "shape-mismatch" }; + } + + /** + * Result type compatibility wrapper for existing inference consumers. + * @param a - Left operand type. + * @param b - Right operand type. + * @param operator - Arithmetic operator lexeme. + * @returns Inferred type or `TypeAny` when invalid or unresolved. + */ + static arithmeticResultType( + a: GalaceanDataType | undefined, + b: GalaceanDataType | undefined, + operator = "+" + ): GalaceanDataType | undefined { + return this.arithmeticOperation(a, b, operator).resultType; + } + + private static _arithmeticFamily(type: GalaceanDataType): "float" | "int" | "uint" | undefined { + if (typeof type === "string" || this.isBoolType(type) || this.isSamplerType(type)) return undefined; + if (this.matrixDimensions(type)) return "float"; + switch (type) { + case Keyword.FLOAT: + case Keyword.VEC2: + case Keyword.VEC3: + case Keyword.VEC4: + return "float"; + case Keyword.INT: + case Keyword.IVEC2: + case Keyword.IVEC3: + case Keyword.IVEC4: + return "int"; + case Keyword.UINT: + case Keyword.UVEC2: + case Keyword.UVEC3: + case Keyword.UVEC4: + return "uint"; + default: + return undefined; + } + } + + private static _vectorType(size: number): GalaceanDataType { + return size === 2 ? Keyword.VEC2 : size === 3 ? Keyword.VEC3 : size === 4 ? Keyword.VEC4 : TypeAny; + } + + private static _matrixType(columns: number, rows: number): GalaceanDataType { + const key = `MAT${columns}${columns === rows ? "" : `X${rows}`}` as keyof typeof Keyword; + return (Keyword[key] as GalaceanDataType | undefined) ?? TypeAny; + } + + /** + * Returns the component count of a vector type. + * @param type - Type to inspect. + * @returns Two, three, or four for vectors; otherwise zero. + */ + static vectorComponentCount(type: GalaceanDataType | undefined): number { + switch (type) { + case Keyword.VEC2: + case Keyword.IVEC2: + case Keyword.UVEC2: + case Keyword.BVEC2: + return 2; + case Keyword.VEC3: + case Keyword.IVEC3: + case Keyword.UVEC3: + case Keyword.BVEC3: + return 3; + case Keyword.VEC4: + case Keyword.IVEC4: + case Keyword.UVEC4: + case Keyword.BVEC4: + return 4; + default: + return 0; + } + } + + /** + * Returns the total component count of a matrix type. + * @param type - Type to inspect. + * @returns Row count multiplied by column count, or zero for non-matrix types. + */ + static matrixComponentCount(type: GalaceanDataType | undefined): number { + const dimensions = this.matrixDimensions(type); + return dimensions ? dimensions.columns * dimensions.rows : 0; + } + + /** + * Returns the dimensions of a matrix type. + * @param type - Type to inspect. + * @returns Column and row counts, or `undefined` for non-matrix types. + */ + static matrixDimensions(type: GalaceanDataType | undefined): { columns: number; rows: number } | undefined { + switch (type) { + case Keyword.MAT2: + return { columns: 2, rows: 2 }; + case Keyword.MAT3: + return { columns: 3, rows: 3 }; + case Keyword.MAT4: + return { columns: 4, rows: 4 }; + case Keyword.MAT2X3: + return { columns: 2, rows: 3 }; + case Keyword.MAT3X2: + return { columns: 3, rows: 2 }; + case Keyword.MAT2X4: + return { columns: 2, rows: 4 }; + case Keyword.MAT4X2: + return { columns: 4, rows: 2 }; + case Keyword.MAT3X4: + return { columns: 3, rows: 4 }; + case Keyword.MAT4X3: + return { columns: 4, rows: 3 }; + default: + return undefined; + } + } +} diff --git a/packages/shader-compiler/src/parser/builtin/functions.ts b/packages/shader-parser/src/parser/builtin/functions.ts similarity index 95% rename from packages/shader-compiler/src/parser/builtin/functions.ts rename to packages/shader-parser/src/parser/builtin/functions.ts index d31917374d..b53bf3cba2 100644 --- a/packages/shader-compiler/src/parser/builtin/functions.ts +++ b/packages/shader-parser/src/parser/builtin/functions.ts @@ -156,13 +156,27 @@ export class BuiltinFunction { let sizeLock = -1; let scalarTypeLock = -1; let matched = true; + // Track whether any Size- / ScalarType-family arg was TypeAny: those positions could match + // *any* member of the family, so we can't fully commit the family index. Later, if the + // return family shares the ambiguous dimension, we fall through to TypeAny rather than + // guessing from the other arg. Example: `max(TypeAny, 0.0)` — locking Size to 0.0's `float` + // makes the whole call look scalar, but the TypeAny could be a `vec3` matching the + // `max(vec3, vec3)` overload. + let sizeAmbiguous = false; + let scalarTypeAmbiguous = false; for (let j = 0; j < n; j++) { const declaredType = declaredArgs[j]; const actualType = callArgTypes![j]; - if (actualType === TypeAny) continue; - const paramFamily = FamilyMembers[declaredType]; + if (actualType === TypeAny) { + if (paramFamily) { + if (paramFamily.dimension === GenericDimension.Size) sizeAmbiguous = true; + else scalarTypeAmbiguous = true; + } + continue; + } + if (paramFamily) { // Families have at most 4 members; linear indexOf beats Map.get on this size const memberIdx = paramFamily.members.indexOf(actualType); @@ -196,11 +210,13 @@ export class BuiltinFunction { candidate._realReturnType = candidate._returnType as NonGenericGalaceanType; return candidate; } - const returnIdx = returnFamily.dimension === GenericDimension.Size ? sizeLock : scalarTypeLock; - // No argument locked the dimension (all relevant args were TypeAny): fall - // through as TypeAny so downstream overload resolution treats the result - // as a wildcard rather than a specific guess - candidate._realReturnType = returnIdx === -1 ? TypeAny : returnFamily.members[returnIdx]; + const returnIsSize = returnFamily.dimension === GenericDimension.Size; + const ambiguous = returnIsSize ? sizeAmbiguous : scalarTypeAmbiguous; + const returnIdx = returnIsSize ? sizeLock : scalarTypeLock; + // Two paths to TypeAny: (1) no arg locked the dimension (all relevant args were TypeAny); + // (2) at least one arg in the return's dimension was TypeAny, so committing to the + // other-arg lock would over-specialise the result. + candidate._realReturnType = returnIdx === -1 || ambiguous ? TypeAny : returnFamily.members[returnIdx]; return candidate; } diff --git a/packages/shader-compiler/src/parser/builtin/index.ts b/packages/shader-parser/src/parser/builtin/index.ts similarity index 100% rename from packages/shader-compiler/src/parser/builtin/index.ts rename to packages/shader-parser/src/parser/builtin/index.ts diff --git a/packages/shader-compiler/src/parser/builtin/variables.ts b/packages/shader-parser/src/parser/builtin/variables.ts similarity index 100% rename from packages/shader-compiler/src/parser/builtin/variables.ts rename to packages/shader-parser/src/parser/builtin/variables.ts diff --git a/packages/shader-compiler/src/parser/index.ts b/packages/shader-parser/src/parser/index.ts similarity index 100% rename from packages/shader-compiler/src/parser/index.ts rename to packages/shader-parser/src/parser/index.ts diff --git a/packages/shader-compiler/src/parser/symbolTable/FnSymbol.ts b/packages/shader-parser/src/parser/symbolTable/FnSymbol.ts similarity index 100% rename from packages/shader-compiler/src/parser/symbolTable/FnSymbol.ts rename to packages/shader-parser/src/parser/symbolTable/FnSymbol.ts diff --git a/packages/shader-compiler/src/parser/symbolTable/StructSymbol.ts b/packages/shader-parser/src/parser/symbolTable/StructSymbol.ts similarity index 100% rename from packages/shader-compiler/src/parser/symbolTable/StructSymbol.ts rename to packages/shader-parser/src/parser/symbolTable/StructSymbol.ts diff --git a/packages/shader-compiler/src/parser/symbolTable/SymbolDataType.ts b/packages/shader-parser/src/parser/symbolTable/SymbolDataType.ts similarity index 100% rename from packages/shader-compiler/src/parser/symbolTable/SymbolDataType.ts rename to packages/shader-parser/src/parser/symbolTable/SymbolDataType.ts diff --git a/packages/shader-compiler/src/parser/symbolTable/SymbolInfo.ts b/packages/shader-parser/src/parser/symbolTable/SymbolInfo.ts similarity index 74% rename from packages/shader-compiler/src/parser/symbolTable/SymbolInfo.ts rename to packages/shader-parser/src/parser/symbolTable/SymbolInfo.ts index 7160171fa2..e4e7789f18 100644 --- a/packages/shader-compiler/src/parser/symbolTable/SymbolInfo.ts +++ b/packages/shader-parser/src/parser/symbolTable/SymbolInfo.ts @@ -1,4 +1,5 @@ import { IBaseSymbol } from "../../common/IBaseSymbol"; +import { BranchSignature, EMPTY_BRANCH } from "../../common/BaseToken"; import { GalaceanDataType, TypeAny } from "../../common/types"; import { ASTNode } from "../AST"; import { SymbolDataType } from "./SymbolDataType"; @@ -19,6 +20,16 @@ export type SymbolAstNode = | ASTNode.VariableDeclaration; export class SymbolInfo implements IBaseSymbol { + /** + * Snapshot of the `#ifdef` stack at the declaration site. Empty means the declaration is + * unconditional (top-level). Non-empty means the declaration is only active when every + * constraint holds. `SymbolTable.getSymbol` filters candidates by + * `isBranchVisibleFrom(this.branchSignature, callsiteBranch)` — a reference inside a mutually + * exclusive branch never sees this symbol; a reference inside the same or a nested branch + * does. Mirrors `codegen`'s per-branch symbol visibility. + */ + branchSignature: BranchSignature = EMPTY_BRANCH; + constructor( public ident: string, public type: ESymbolType, diff --git a/packages/shader-compiler/src/parser/symbolTable/VarSymbol.ts b/packages/shader-parser/src/parser/symbolTable/VarSymbol.ts similarity index 56% rename from packages/shader-compiler/src/parser/symbolTable/VarSymbol.ts rename to packages/shader-parser/src/parser/symbolTable/VarSymbol.ts index 4d34843e09..543fb12569 100644 --- a/packages/shader-compiler/src/parser/symbolTable/VarSymbol.ts +++ b/packages/shader-parser/src/parser/symbolTable/VarSymbol.ts @@ -10,6 +10,14 @@ export class VarSymbol extends SymbolInfo { | ASTNode.VariableDeclaration; readonly isGlobalVariable: boolean; + /** `const`-qualified — lets a const-expression check resolve identifier references to constants. */ + readonly isConst: boolean; + /** + * Global variable without an initializer — Galacean's implicit uniform. The material system + * binds it at runtime. Read-only from the shader's perspective, so writes must be flagged as + * `InvalidAssignmentTarget`. + */ + readonly isUniform: boolean; constructor( ident: string, @@ -19,9 +27,13 @@ export class VarSymbol extends SymbolInfo { | ASTNode.Initializer | ASTNode.ParameterDeclarator | ASTNode.InitDeclaratorList - | ASTNode.VariableDeclaration + | ASTNode.VariableDeclaration, + isConst = false, + isUniform = false ) { super(ident, ESymbolType.VAR, initAst, dataType); this.isGlobalVariable = isGlobalVariable; + this.isConst = isConst; + this.isUniform = isUniform; } } diff --git a/packages/shader-compiler/src/parser/symbolTable/index.ts b/packages/shader-parser/src/parser/symbolTable/index.ts similarity index 100% rename from packages/shader-compiler/src/parser/symbolTable/index.ts rename to packages/shader-parser/src/parser/symbolTable/index.ts diff --git a/packages/shader-compiler/src/parser/types.ts b/packages/shader-parser/src/parser/types.ts similarity index 82% rename from packages/shader-compiler/src/parser/types.ts rename to packages/shader-parser/src/parser/types.ts index df31908816..64273fba2d 100644 --- a/packages/shader-compiler/src/parser/types.ts +++ b/packages/shader-parser/src/parser/types.ts @@ -18,7 +18,9 @@ export class StructProp implements IParamInfo { public typeInfo: SymbolType, public ident: BaseToken, public mrtIndex?: number, - public isInMacroBranch = false + public isInMacroBranch = false, + /** Whether the member carries the `flat` interpolation qualifier — integer varyings require it. */ + public isFlat = false ) {} } diff --git a/packages/shader-parser/src/runtime.ts b/packages/shader-parser/src/runtime.ts new file mode 100644 index 0000000000..1eb6e66504 --- /dev/null +++ b/packages/shader-parser/src/runtime.ts @@ -0,0 +1,30 @@ +export * from "./common"; +export { BaseToken, EMPTY_BRANCH, EOF, sameBranch } from "./common/BaseToken"; +export type { BranchCondition, BranchConstraint, BranchSignature } from "./common/BaseToken"; +export * from "./common/BaseLexer"; +export * from "./common/PreprocessorCondition"; +export * from "./common/SymbolTable"; +export * from "./common/SymbolTableStack"; +export * from "./common/IBaseSymbol"; +export * from "./common/enums/ShaderStage"; + +export * from "./lexer"; +export * from "./lalr"; + +export * from "./parser"; +export * from "./parser/AST"; +export * from "./parser/types"; +export * from "./parser/GrammarSymbol"; +export * from "./parser/ShaderInfo"; +export * from "./parser/ICodeGenVisitor"; +export * from "./parser/symbolTable"; + +export * from "./ir"; + +export * from "./sourceParser"; +export * from "./sourceParser/ShaderSourceFactory"; + +export * from "./Preprocessor"; +export * from "./ParserUtils"; +export * from "./GSError"; +export * from "./ShaderCompilerUtils"; diff --git a/packages/shader-compiler/src/sourceParser/ShaderSourceFactory.ts b/packages/shader-parser/src/sourceParser/ShaderSourceFactory.ts similarity index 100% rename from packages/shader-compiler/src/sourceParser/ShaderSourceFactory.ts rename to packages/shader-parser/src/sourceParser/ShaderSourceFactory.ts diff --git a/packages/shader-compiler/src/sourceParser/ShaderSourceParser.ts b/packages/shader-parser/src/sourceParser/ShaderSourceParser.ts similarity index 78% rename from packages/shader-compiler/src/sourceParser/ShaderSourceParser.ts rename to packages/shader-parser/src/sourceParser/ShaderSourceParser.ts index 273f8266e9..18b1a4770b 100644 --- a/packages/shader-compiler/src/sourceParser/ShaderSourceParser.ts +++ b/packages/shader-parser/src/sourceParser/ShaderSourceParser.ts @@ -5,6 +5,7 @@ import { ColorWriteMask, CompareFunction, CullMode, + Logger, RenderQueueType, RenderStateElementKey, StencilOperation @@ -20,9 +21,6 @@ import { ETokenType, ShaderPosition, ShaderRange } from "../common"; import { BaseToken } from "../common/BaseToken"; import { SymbolTableStack } from "../common/SymbolTableStack"; import { GSErrorName } from "../GSError"; -// #if _VERBOSE -import { GSError } from "../GSError"; -// #endif import { BaseLexer } from "../common/BaseLexer"; import { Keyword } from "../common/enums/Keyword"; import { SymbolTable } from "../common/SymbolTable"; @@ -31,11 +29,20 @@ import { ShaderSourceFactory } from "./ShaderSourceFactory"; import { ShaderSourceSymbol } from "./ShaderSourceSymbol"; import SourceLexer from "./SourceLexer"; +/** Result of parsing one ShaderLab source document. */ +export interface ShaderSourceParseResult { + /** Parsed source structure, including subshaders, passes, entries, and render states. */ + shaderSource: IShaderSource; + /** Source-structure diagnostics captured during this parse. */ + errors: readonly Error[]; +} + /** * @internal */ export class ShaderSourceParser { - static readonly errors = new Array(); + /** @deprecated Consume the `errors` snapshot returned by `parseWithErrors`. */ + static readonly errors = new Array(); private static _renderStateConstMap = >>{ RenderQueueType, @@ -50,7 +57,21 @@ export class ShaderSourceParser { private static _lexer = new SourceLexer(); private static _lookupSymbol = new ShaderSourceSymbol("", null); + /** + * Parses ShaderLab source and returns the source object for compatibility callers. + * @param sourceCode - Complete ShaderLab source. + * @returns Parsed source structure. + */ static parse(sourceCode: string): IShaderSource { + return this.parseWithErrors(sourceCode).shaderSource; + } + + /** + * Parses ShaderLab source with a parse-local diagnostic snapshot. + * @param sourceCode - Complete ShaderLab source. + * @returns Parsed source structure and diagnostics from the same parse. + */ + static parseWithErrors(sourceCode: string): ShaderSourceParseResult { // Clear previous data this.errors.length = 0; this._symbolTableStack.clear(); @@ -87,7 +108,7 @@ export class ShaderSourceParser { } } - return shaderSource; + return { shaderSource, errors: this.errors.slice() }; } private static _parseShader(lexer: SourceLexer): IShaderSource { @@ -158,10 +179,14 @@ export class ShaderSourceParser { lookupSymbol.set(nextToken.lexeme, stateToken.type); const sm = this._symbolTableStack.lookup(lookupSymbol); if (!sm?.value) { - this._createCompileError(`Invalid "${stateToken.lexeme}" variable: ${nextToken.lexeme}`, nextToken.location); - // #if _VERBOSE + // Partial-application: the syntax-sugar assignment path takes an early return here — the + // outRenderStates never sees the intended merge, so the runtime silently gets nothing. + this._createCompileError( + `Invalid "${stateToken.lexeme}" variable: ${nextToken.lexeme} — property will not be applied.`, + nextToken.location, + "InvalidRenderStateVariable" + ); return; - // #endif } renderState = sm.value as IRenderStates; } @@ -205,19 +230,17 @@ export class ShaderSourceParser { private static _parseRenderStateProperties(state: string): IRenderStates { const lexer = this._lexer; const renderStates = ShaderSourceFactory.createRenderStates(); - while (lexer.getCurChar() !== "}") { + while (lexer.getCurChar() !== "}" && !lexer.isEnd()) { this._parseRenderStateProperty(state, renderStates); lexer.skipCommentsAndSpace(); } - lexer.advance(1); + if (lexer.getCurChar() === "}") lexer.advance(1); return renderStates; } - private static _createCompileError(message: string, location?: ShaderPosition | ShaderRange): void { - const error = this._lexer.createCompileError(message, location); - // #if _VERBOSE - this.errors.push(error); - // #endif + private static _createCompileError(message: string, location?: ShaderPosition | ShaderRange, code?: string): void { + const error = this._lexer.createCompileError(message, location, code); + this.errors.push(error); } private static _scanEnumConstValue(enumName: string): number | undefined { @@ -226,13 +249,14 @@ export class ShaderSourceParser { const constValueToken = lexer.scanToken(); const value = this._renderStateConstMap[enumName]?.[constValueToken.lexeme] as number; if (value == undefined) { + // Partial-application: the enclosing property is skipped after this error, so the render state + // never receives it — say so explicitly instead of silently dropping the write. this._createCompileError( - `Invalid engine constant: ${enumName}.${constValueToken.lexeme}`, - constValueToken.location + `Invalid engine constant: ${enumName}.${constValueToken.lexeme} — property will not be applied.`, + constValueToken.location, + "InvalidEnumValue" ); - // #if _VERBOSE lexer.scanToCharacter(";"); - // #endif } return value; } @@ -250,11 +274,13 @@ export class ShaderSourceParser { lexer.scanLexeme("]"); lexer.scanLexeme("="); } else if (scannedLexeme !== "=") { - this._createCompileError(`Invalid syntax, expect '[' or '=', but got unexpected token`); - // #if _VERBOSE + this._createCompileError( + `Invalid syntax, expect '[' or '=', but got unexpected token`, + undefined, + "SyntaxError" + ); lexer.scanToCharacter(";"); return; - // #endif } stateElementKey += keyIndex; } else { @@ -263,11 +289,14 @@ export class ShaderSourceParser { const renderStateElementKey = RenderStateElementKey[stateLexeme + stateElementKey]; if (renderStateElementKey === undefined) { - this._createCompileError(`Invalid render state property ${propertyLexeme}`); - // #if _VERBOSE + // Unknown properties are skipped, so the diagnostic must make the missing write explicit. + this._createCompileError( + `Invalid render state property ${propertyLexeme} — property will not be applied.`, + undefined, + "InvalidRenderStateProperty" + ); lexer.scanToCharacter(";"); return; - // #endif } lexer.skipCommentsAndSpace(); @@ -294,33 +323,35 @@ export class ShaderSourceParser { lexer.skipCommentsAndSpace(); if (lexer.getCurChar() === "|") { if (valueToken.lexeme !== "ColorWriteMask") { + // Partial-application: the whole property is dropped after this error. this._createCompileError( - `Bitwise OR '|' is not supported for '${valueToken.lexeme}', only bitmask enums like 'ColorWriteMask' support this`, - valueToken.location + `Bitwise OR '|' is not supported for '${valueToken.lexeme}', only bitmask enums like 'ColorWriteMask' support this — property will not be applied.`, + valueToken.location, + "BitwiseOrOnNonBitmask" ); - // #if _VERBOSE lexer.scanToCharacter(";"); - // #endif return; } while (lexer.getCurChar() === "|") { lexer.advance(1); const nextEnumToken = lexer.scanToken(); if (nextEnumToken == undefined || lexer.getCurChar() !== ".") { - this._createCompileError(`Invalid syntax after '|', expect 'EnumType.Value'`, nextEnumToken?.location); - // #if _VERBOSE + this._createCompileError( + `Invalid syntax after '|', expect 'EnumType.Value'`, + nextEnumToken?.location, + "SyntaxError" + ); lexer.scanToCharacter(";"); - // #endif return; } if (nextEnumToken.lexeme !== valueToken.lexeme) { + // Partial-application: the whole property is dropped after this error. this._createCompileError( - `Cannot mix enum types in bitwise OR: expected '${valueToken.lexeme}' but got '${nextEnumToken.lexeme}'`, - nextEnumToken.location + `Cannot mix enum types in bitwise OR: expected '${valueToken.lexeme}' but got '${nextEnumToken.lexeme}' — property will not be applied.`, + nextEnumToken.location, + "MixedEnumTypes" ); - // #if _VERBOSE lexer.scanToCharacter(";"); - // #endif return; } const nextValue = this._scanEnumConstValue(nextEnumToken.lexeme); @@ -334,11 +365,14 @@ export class ShaderSourceParser { const lookupSymbol = this._lookupSymbol; lookupSymbol.set(valueToken.lexeme, ETokenType.ID); if (!this._symbolTableStack.lookup(lookupSymbol)) { - this._createCompileError(`Invalid ${stateLexeme} variable: ${valueToken.lexeme}`, valueToken.location); - // #if _VERBOSE + // Partial-application: unknown variable binding → skip the write; the runtime never sees this state. + this._createCompileError( + `Invalid ${stateLexeme} variable: ${valueToken.lexeme} — property will not be applied.`, + valueToken.location, + "InvalidRenderStateVariable" + ); lexer.scanToCharacter(";"); return; - // #endif } } } @@ -362,26 +396,30 @@ export class ShaderSourceParser { } if (token.lexeme !== "=") { - this._createCompileError(`Invalid syntax, expect character '=', but got ${token.lexeme}`, token.location); - // #if _VERBOSE + this._createCompileError( + `Invalid syntax, expect character '=', but got ${token.lexeme}`, + token.location, + "SyntaxError" + ); return; - // #endif } const word = lexer.scanToken(); lexer.scanLexeme(";"); const value = this._renderStateConstMap.RenderQueueType[word.lexeme]; const key = RenderStateElementKey.RenderQueueType; if (value == undefined) { - renderStates.variableMap[key] = word.lexeme; const lookupSymbol = this._lookupSymbol; lookupSymbol.set(word.lexeme, Keyword.GSRenderQueueType); const sm = this._symbolTableStack.lookup(lookupSymbol); if (!sm) { - this._createCompileError(`Invalid RenderQueueType variable: ${word.lexeme}`, word.location); - // #if _VERBOSE + this._createCompileError( + `Invalid RenderQueueType variable: ${word.lexeme} — property will not be applied at runtime.`, + word.location, + "InvalidRenderQueueVariable" + ); return; - // #endif } + renderStates.variableMap[key] = word.lexeme; } else { renderStates.constantMap[key] = value; } @@ -474,6 +512,7 @@ export class ShaderSourceParser { private static _parsePass(): IShaderPassSource { this._pushScope(); const lexer = this._lexer; + const passStart = lexer.getShaderPosition(0); const name = lexer.scanPairedChar('"', '"', false, false); const passSource = ShaderSourceFactory.createShaderPassSource(name); @@ -491,20 +530,24 @@ export class ShaderSourceParser { this._addPendingContents(start, token.lexeme.length, passSource.pendingContents); lexer.scanLexeme("="); const entry = lexer.scanToken(); - if (passSource[token.lexeme]) { - const error = ShaderCompilerUtils.createGSError( - "Reassign main entry", - GSErrorName.CompilationError, - lexer.source, - lexer.getShaderPosition(0) + const isVertex = token.type === Keyword.GSVertexShader; + const key = isVertex ? "vertexEntry" : "fragmentEntry"; + if (passSource[key]) { + // Collect + continue (sibling MissingEntry uses the same collect flow). Keeps the first + // binding — codegen sees the same entry the driver would if this diagnostic were + // absent. Skips the reassignment so subsequent shader-body issues remain reachable in + // the same parse. + this._createCompileError( + `Reassignment of ${isVertex ? "VertexShader" : "FragmentShader"} entry — the first binding is kept.`, + entry.location, + "DuplicateEntryAssignment" ); - // #if _VERBOSE - console.error(error.toString()); - throw error; - // #endif + lexer.scanLexeme(";"); + start = lexer.getShaderPosition(0); + break; } - const key = token.type === Keyword.GSVertexShader ? "vertexEntry" : "fragmentEntry"; passSource[key] = entry.lexeme; + passSource[isVertex ? "vertexEntryLocation" : "fragmentEntryLocation"] = entry.location; lexer.scanLexeme(";"); start = lexer.getShaderPosition(0); break; @@ -514,6 +557,13 @@ export class ShaderSourceParser { case Keyword.RightBrace: if (--braceLevel === 0) { this._addPendingContents(start, token.lexeme.length, passSource.pendingContents); + if (!passSource.vertexEntry || !passSource.fragmentEntry) { + this._createCompileError( + "Pass must bind both VertexShader and FragmentShader entries.", + passStart, + "MissingEntry" + ); + } this._popScope(); return passSource; } diff --git a/packages/shader-compiler/src/sourceParser/ShaderSourceParser.y b/packages/shader-parser/src/sourceParser/ShaderSourceParser.y similarity index 83% rename from packages/shader-compiler/src/sourceParser/ShaderSourceParser.y rename to packages/shader-parser/src/sourceParser/ShaderSourceParser.y index 462110ea8a..26f554e810 100644 --- a/packages/shader-compiler/src/sourceParser/ShaderSourceParser.y +++ b/packages/shader-parser/src/sourceParser/ShaderSourceParser.y @@ -1,4 +1,5 @@ -// For cft conflict test, used by bison +// Conflict-free Bison mirror for the ShaderLab source parser. +%expect 0 %token shader %token subshader @@ -19,7 +20,7 @@ %token render_state_prop_type %token UsePass %token Color_init -%token VertextShader +%token VertexShader %token FragmentShader %token plain_statements @@ -53,14 +54,19 @@ subshader_statement: ; pass_statements: + /** empty */ + | pass_statements pass_statement + ; + +pass_statement: global_declaration | plain_statements | main_shader_assignment ; main_shader_assignment: - VertextShader '=' id ';' - FragmentShader '=' id ';' + VertexShader '=' id ';' + | FragmentShader '=' id ';' ; global_declaration_in_shader: @@ -137,12 +143,12 @@ render_state_prop_list: render_state_prop_assignment: render_state_prop '=' id ';' - render_state_prop '=' true ';' - render_state_prop '=' false ';' - render_state_prop '=' INT_CONSTANT ';' - render_state_prop '=' FLOAT_CONSTANT ';' - render_state_prop '=' id '.' id ';' - render_state_prop '=' Color_init; + | render_state_prop '=' true ';' + | render_state_prop '=' false ';' + | render_state_prop '=' INT_CONSTANT ';' + | render_state_prop '=' FLOAT_CONSTANT ';' + | render_state_prop '=' id '.' id ';' + | render_state_prop '=' Color_init; ; render_state_prop: diff --git a/packages/shader-compiler/src/sourceParser/ShaderSourceSymbol.ts b/packages/shader-parser/src/sourceParser/ShaderSourceSymbol.ts similarity index 78% rename from packages/shader-compiler/src/sourceParser/ShaderSourceSymbol.ts rename to packages/shader-parser/src/sourceParser/ShaderSourceSymbol.ts index cbdb196dd0..33019f9fda 100644 --- a/packages/shader-compiler/src/sourceParser/ShaderSourceSymbol.ts +++ b/packages/shader-parser/src/sourceParser/ShaderSourceSymbol.ts @@ -1,7 +1,9 @@ +import { BranchSignature, EMPTY_BRANCH } from "../common/BaseToken"; import { IBaseSymbol } from "../common/IBaseSymbol"; export class ShaderSourceSymbol implements IBaseSymbol { public isInMacroBranch: boolean = false; + public branchSignature: BranchSignature = EMPTY_BRANCH; constructor( public ident: string, diff --git a/packages/shader-compiler/src/sourceParser/SourceLexer.ts b/packages/shader-parser/src/sourceParser/SourceLexer.ts similarity index 94% rename from packages/shader-compiler/src/sourceParser/SourceLexer.ts rename to packages/shader-parser/src/sourceParser/SourceLexer.ts index d1748adff2..c0a5172378 100644 --- a/packages/shader-compiler/src/sourceParser/SourceLexer.ts +++ b/packages/shader-parser/src/sourceParser/SourceLexer.ts @@ -4,7 +4,6 @@ import { BaseLexer } from "../common/BaseLexer"; import { BaseToken } from "../common/BaseToken"; import { Keyword } from "../common/enums/Keyword"; import { GSErrorName } from "../GSError"; -import { ShaderCompiler } from "../ShaderCompiler"; import { ShaderCompilerUtils } from "../ShaderCompilerUtils"; export default class SourceLexer extends BaseLexer { @@ -152,21 +151,22 @@ export default class SourceLexer extends BaseLexer { } } - // #if _VERBOSE - scanToCharacter(char: string): void { - while (this.getCurChar() !== char && !this.isEnd()) { + scanToCharacter(char: string): boolean { + while (this.getCurChar() !== char && this.getCurChar() !== "}" && !this.isEnd()) { this.advance(1); } + if (this.getCurChar() !== char) return false; this.advance(1); + return true; } - // #endif - createCompileError(message: string, location?: ShaderPosition | ShaderRange) { + createCompileError(message: string, location?: ShaderPosition | ShaderRange, code?: string) { return ShaderCompilerUtils.createGSError( message, GSErrorName.CompilationError, this.source, - location ?? this.getShaderPosition(0) + location ?? this.getShaderPosition(0), + code ); } @@ -184,7 +184,7 @@ export default class SourceLexer extends BaseLexer { const lexeme = this._source.substring(start.index, end.index); const tokenType = SourceLexer._keywordLexemeTable[lexeme] ?? ETokenType.ID; - const range = ShaderCompiler.createRange(start, end); + const range = ShaderCompilerUtils.createRange(start, end); const token = BaseToken.pool.get(); token.set(tokenType, lexeme, range); return token; diff --git a/packages/shader-parser/src/sourceParser/index.ts b/packages/shader-parser/src/sourceParser/index.ts new file mode 100644 index 0000000000..cb1cc07646 --- /dev/null +++ b/packages/shader-parser/src/sourceParser/index.ts @@ -0,0 +1 @@ +export { ShaderSourceParser, type ShaderSourceParseResult } from "./ShaderSourceParser"; diff --git a/packages/shader-parser/tsconfig.json b/packages/shader-parser/tsconfig.json new file mode 100644 index 0000000000..4d319bf4c1 --- /dev/null +++ b/packages/shader-parser/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "module": "esnext", + "target": "esnext", + "declaration": true, + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "experimentalDecorators": true, + "declarationDir": "types", + "emitDeclarationOnly": true, + "noImplicitOverride": true, + "sourceMap": true, + "incremental": false, + "skipLibCheck": true + }, + "include": ["src/**/*"] +} diff --git a/packages/shader/src/ShaderLibrary/Common/Fog.glsl b/packages/shader/src/ShaderLibrary/Common/Fog.glsl index a9d920c9ad..c8710594b7 100644 --- a/packages/shader/src/ShaderLibrary/Common/Fog.glsl +++ b/packages/shader/src/ShaderLibrary/Common/Fog.glsl @@ -18,6 +18,8 @@ // exp(-(z * density)^2) = exp2(-(z * density)^2/ln(2)) = exp2(-(z * density/sprt(ln(2)))^2) float factor = fogDepth * scene_FogParams.w; float fogIntensity = clamp(exp2(-factor * factor), 0.0, 1.0); + #else + float fogIntensity = 1.0; #endif color.rgb = mix(scene_FogColor.rgb, color.rgb, fogIntensity); @@ -27,4 +29,4 @@ #endif -#endif \ No newline at end of file +#endif diff --git a/packages/shader/src/ShaderLibrary/Lighting/AmbientOcclusion/BilateralBlur.glsl b/packages/shader/src/ShaderLibrary/Lighting/AmbientOcclusion/BilateralBlur.glsl index 15e5e26fea..97dab0efbc 100644 --- a/packages/shader/src/ShaderLibrary/Lighting/AmbientOcclusion/BilateralBlur.glsl +++ b/packages/shader/src/ShaderLibrary/Lighting/AmbientOcclusion/BilateralBlur.glsl @@ -13,6 +13,8 @@ float material_farPlaneOverEdgeDistance; #define BLUR_SAMPLE_COUNT 6 #elif SSAO_QUALITY == 2 #define BLUR_SAMPLE_COUNT 12 +#else + #define BLUR_SAMPLE_COUNT 3 #endif float material_kernel[12]; diff --git a/packages/shader/src/ShaderLibrary/Lighting/AmbientOcclusion/ScalableAmbientOcclusion.glsl b/packages/shader/src/ShaderLibrary/Lighting/AmbientOcclusion/ScalableAmbientOcclusion.glsl index 07349ee722..1873c39351 100644 --- a/packages/shader/src/ShaderLibrary/Lighting/AmbientOcclusion/ScalableAmbientOcclusion.glsl +++ b/packages/shader/src/ShaderLibrary/Lighting/AmbientOcclusion/ScalableAmbientOcclusion.glsl @@ -25,6 +25,10 @@ highp sampler2D renderer_BlitTexture; // Camera_DepthTexture #define SAMPLE_COUNT 16.0 #define SPIRAL_TURNS 7.0 const vec2 angleIncCosSin = vec2(-0.966846, 0.255311); +#else + #define SAMPLE_COUNT 7.0 + #define SPIRAL_TURNS 3.0 + const vec2 angleIncCosSin = vec2(-0.971148, 0.238227); #endif float material_invRadiusSquared; // Inverse of the squared radius diff --git a/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl b/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl index 56ea060838..861b1993ed 100644 --- a/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl +++ b/packages/shader/src/ShaderLibrary/Particle/ParticleVert.glsl @@ -49,9 +49,7 @@ mediump vec3 material_EmissiveColor; struct Attributes { #if defined(RENDERER_MODE_SPHERE_BILLBOARD) || defined(RENDERER_MODE_STRETCHED_BILLBOARD) || defined(RENDERER_MODE_HORIZONTAL_BILLBOARD) || defined(RENDERER_MODE_VERTICAL_BILLBOARD) vec4 a_CornerTextureCoordinate; - #endif - - #ifdef RENDERER_MODE_MESH + #elif defined(RENDERER_MODE_MESH) vec3 POSITION; #ifdef RENDERER_ENABLE_VERTEXCOLOR vec4 COLOR_0; @@ -93,7 +91,7 @@ struct Varyings { #ifdef MATERIAL_HAS_BASETEXTURE vec2 v_TextureCoordinate; #endif - #ifdef RENDERER_MODE_MESH + #if defined(RENDERER_MODE_MESH) && !defined(RENDERER_MODE_SPHERE_BILLBOARD) && !defined(RENDERER_MODE_STRETCHED_BILLBOARD) && !defined(RENDERER_MODE_HORIZONTAL_BILLBOARD) && !defined(RENDERER_MODE_VERTICAL_BILLBOARD) vec4 v_MeshColor; #endif }; @@ -271,9 +269,7 @@ vec3 computeParticleCenter(Attributes attr, float age, float normalizedAge, inou center += renderer_SizeScale.xzy * (corner.x * sideVector + corner.y * upVector); } #endif - #endif - - #ifdef RENDERER_MODE_STRETCHED_BILLBOARD + #elif defined(RENDERER_MODE_STRETCHED_BILLBOARD) vec2 corner = attr.a_CornerTextureCoordinate.xy + renderer_PivotOffset.xy; vec3 velocity = rotationByQuaternions(renderer_SizeScale * visualLocalVelocity, worldRotation) + visualWorldVelocity; vec3 cameraUpVector = normalize(velocity); @@ -292,9 +288,7 @@ vec3 computeParticleCenter(Attributes attr, float age, float normalizedAge, inou float speed = length(velocity); center += sign(renderer_SizeScale.x) * (sign(renderer_StretchedBillboardLengthScale) * size.x * corner.x * sideVector + (speed * renderer_StretchedBillboardSpeedScale + size.y * renderer_StretchedBillboardLengthScale) * corner.y * cameraUpVector); - #endif - - #ifdef RENDERER_MODE_HORIZONTAL_BILLBOARD + #elif defined(RENDERER_MODE_HORIZONTAL_BILLBOARD) vec2 corner = attr.a_CornerTextureCoordinate.xy + renderer_PivotOffset.xy; const vec3 sideVector = vec3(1.0, 0.0, 0.0); const vec3 upVector = vec3(0.0, 0.0, -1.0); @@ -312,9 +306,7 @@ vec3 computeParticleCenter(Attributes attr, float age, float normalizedAge, inou mat2 rotation = mat2(c, -s, s, c); corner = rotation * corner; center += renderer_SizeScale.xzy * (corner.x * sideVector + corner.y * upVector); - #endif - - #ifdef RENDERER_MODE_VERTICAL_BILLBOARD + #elif defined(RENDERER_MODE_VERTICAL_BILLBOARD) vec2 corner = attr.a_CornerTextureCoordinate.xy + renderer_PivotOffset.xy; const vec3 cameraUpVector = vec3(0.0, 1.0, 0.0); vec3 sideVector = normalize(cross(camera_Forward, cameraUpVector)); @@ -326,9 +318,7 @@ vec3 computeParticleCenter(Attributes attr, float age, float normalizedAge, inou corner = rotation * corner * cos(0.78539816339744830961566084581988); corner *= computeParticleSizeBillboard(attr, attr.a_StartSize.xy, normalizedAge); center += renderer_SizeScale.xzy * (corner.x * sideVector + corner.y * cameraUpVector); - #endif - - #ifdef RENDERER_MODE_MESH + #elif defined(RENDERER_MODE_MESH) #if defined(RENDERER_ROL_CONSTANT_MODE) || defined(RENDERER_ROL_CURVE_MODE) #define RENDERER_ROL_ENABLED #endif @@ -382,8 +372,7 @@ vec2 computeParticleVaryingUV(Attributes attr, float normalizedAge) { vec2 simulateUV; #if defined(RENDERER_MODE_SPHERE_BILLBOARD) || defined(RENDERER_MODE_STRETCHED_BILLBOARD) || defined(RENDERER_MODE_HORIZONTAL_BILLBOARD) || defined(RENDERER_MODE_VERTICAL_BILLBOARD) simulateUV = attr.a_CornerTextureCoordinate.zw * attr.a_SimulationUV.xy + attr.a_SimulationUV.zw; - #endif - #ifdef RENDERER_MODE_MESH + #elif defined(RENDERER_MODE_MESH) simulateUV = attr.a_SimulationUV.zw + attr.TEXCOORD_0 * attr.a_SimulationUV.xy; #endif return computeParticleUV(attr, simulateUV, normalizedAge); diff --git a/packages/shader/src/Shaders/Effect/Particle.shader b/packages/shader/src/Shaders/Effect/Particle.shader index 862ecdb85b..cf4cf6159c 100644 --- a/packages/shader/src/Shaders/Effect/Particle.shader +++ b/packages/shader/src/Shaders/Effect/Particle.shader @@ -73,7 +73,7 @@ Shader "Effect/Particle" { void frag(Varyings v) { vec4 color = material_BaseColor * v.v_Color; - #if defined(RENDERER_MODE_MESH) && defined(RENDERER_ENABLE_VERTEXCOLOR) + #if defined(RENDERER_MODE_MESH) && !defined(RENDERER_MODE_SPHERE_BILLBOARD) && !defined(RENDERER_MODE_STRETCHED_BILLBOARD) && !defined(RENDERER_MODE_HORIZONTAL_BILLBOARD) && !defined(RENDERER_MODE_VERTICAL_BILLBOARD) && defined(RENDERER_ENABLE_VERTEXCOLOR) color *= v.v_MeshColor; #endif diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c82cc287c..fcc2fa1ddf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,9 +89,6 @@ importers: rollup: specifier: ^2.36.1 version: 2.79.2 - rollup-plugin-jscc: - specifier: ^2.0.0 - version: 2.0.0(rollup@2.79.2) rollup-plugin-serve: specifier: ^1.1.0 version: 1.1.1 @@ -176,6 +173,9 @@ importers: '@galacean/engine-shader': specifier: workspace:* version: link:../packages/shader + '@galacean/engine-shader-analyzer': + specifier: workspace:* + version: link:../packages/shader-analyzer '@galacean/engine-shader-compiler': specifier: workspace:* version: link:../packages/shader-compiler @@ -272,6 +272,16 @@ importers: specifier: workspace:* version: link:../design + packages/shader-analyzer: + dependencies: + '@galacean/engine-shader-parser': + specifier: workspace:* + version: link:../shader-parser + devDependencies: + '@galacean/engine-design': + specifier: workspace:* + version: link:../design + packages/shader-compiler: dependencies: '@galacean/engine-core': @@ -280,6 +290,9 @@ importers: '@galacean/engine-math': specifier: workspace:* version: link:../math + '@galacean/engine-shader-parser': + specifier: workspace:* + version: link:../shader-parser '@rollup/pluginutils': specifier: ^5.0.0 version: 5.2.0(rollup@4.27.2) @@ -288,6 +301,19 @@ importers: specifier: workspace:* version: link:../design + packages/shader-parser: + dependencies: + '@galacean/engine-core': + specifier: workspace:* + version: link:../core + '@galacean/engine-math': + specifier: workspace:* + version: link:../math + devDependencies: + '@galacean/engine-design': + specifier: workspace:* + version: link:../design + packages/ui: devDependencies: '@galacean/engine': @@ -342,9 +368,15 @@ importers: '@galacean/engine-shader': specifier: workspace:* version: link:../packages/shader + '@galacean/engine-shader-analyzer': + specifier: workspace:* + version: link:../packages/shader-analyzer '@galacean/engine-shader-compiler': specifier: workspace:* version: link:../packages/shader-compiler + '@galacean/engine-shader-parser': + specifier: workspace:* + version: link:../packages/shader-parser '@galacean/engine-ui': specifier: workspace:* version: link:../packages/ui @@ -977,14 +1009,6 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} - '@jsbits/escape-regex-str@1.0.3': - resolution: {integrity: sha512-0800vYI2fg1nuUq/T9Tqv8DMOLLNiRAltxFbKIbR7szrvW6qTuI2+zGK51hV7NAAmUr4G83Kvpj2R6Yyg07iIw==} - engines: {node: '>=4.2'} - - '@jsbits/get-package-version@1.0.3': - resolution: {integrity: sha512-IJy1jRL01x7p6UEpgKa1lVLstMUx8EiIR8pPoS5sBfsHEoeLkzYiNpAfxPx8zLDUJyS1yBbChJjcWdPqyH285w==} - engines: {node: '>=4.2'} - '@jsdevtools/ez-spawn@3.0.4': resolution: {integrity: sha512-f5DRIOZf7wxogefH03RjMPMdBF7ADTWUMoOs9kaJo06EfwF+aFhMZMDZxHg/Xe12hptN9xoZjGso2fdjapBRIA==} engines: {node: '>=10'} @@ -2278,9 +2302,6 @@ packages: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} - estree-walker@0.6.1: - resolution: {integrity: sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==} - estree-walker@1.0.1: resolution: {integrity: sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==} @@ -2454,6 +2475,7 @@ packages: git-raw-commits@2.0.11: resolution: {integrity: sha512-VnctFhw+xfj8Va1xtfEqCUD2XDrbAPSJx+hSrE5K7fGdjZruW7XV+QOrN7LF/RJyvspRiD2I0asWsxFp0ya26A==} engines: {node: '>=10'} + deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead. hasBin: true glob-parent@5.1.2: @@ -2721,10 +2743,6 @@ packages: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true - jscc@1.1.1: - resolution: {integrity: sha512-anpZkTXwZbxfxLEBMciKxXMHx2xOLK2qhynIhTnoSyC+wGOEPrAoofxnADgblbarn0kijVMt1U71cQGmRF/1Og==} - engines: {node: '>=6.0'} - jsesc@3.0.2: resolution: {integrity: sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==} engines: {node: '>=6'} @@ -3156,10 +3174,6 @@ packages: pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} - perf-regexes@1.0.1: - resolution: {integrity: sha512-L7MXxUDtqr4PUaLFCDCXBfGV/6KLIuSEccizDI7JxT+c9x1G1v04BQ4+4oag84SHaCdrBgQAIs/Cqn+flwFPng==} - engines: {node: '>=6.14'} - perfect-debounce@1.0.0: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} @@ -3351,12 +3365,6 @@ packages: resolution: {integrity: sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A==} engines: {node: '>=8.0'} - rollup-plugin-jscc@2.0.0: - resolution: {integrity: sha512-5jG9q79K2u5uRBTKA+GA4gqt1zA7qHQRpcabZMoVs913gr75s428O7K3r58n2vADDzwIhiOKMo7rCMhOyks6dw==} - engines: {node: '>=10.12.0'} - peerDependencies: - rollup: '>=2' - rollup-plugin-serve@1.1.1: resolution: {integrity: sha512-H0VarZRtFR0lfiiC9/P8jzCDvtFf1liOX4oSdIeeYqUCKrmFA7vNiQ0rg2D+TuoP7leaa/LBR8XBts5viF6lnw==} @@ -3367,9 +3375,6 @@ packages: '@swc/core': '>=1.2.165' rollup: ^2.0.0 || ^3.0.0 || ^4.0.0 - rollup-pluginutils@2.8.2: - resolution: {integrity: sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==} - rollup-swc-preserve-directives@0.6.0: resolution: {integrity: sha512-MkKETpYF2ml5p15IxqbvLdFxWV6b99ALT6qL/okXYhaaiK8Mqu95nyk90+m58Ye+jMOpnhQsdK3JGMIPUV7i5g==} peerDependencies: @@ -3473,10 +3478,6 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} - skip-regex@1.0.2: - resolution: {integrity: sha512-pEjMUbwJ5Pl/6Vn6FsamXHXItJXSRftcibixDmNCWbWhic0hzHrwkMZo0IZ7fMRH9KxcWDFSkzhccB4285PutA==} - engines: {node: '>=4.2'} - slice-ansi@7.1.2: resolution: {integrity: sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==} engines: {node: '>=18'} @@ -3606,6 +3607,7 @@ packages: tar@6.2.1: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me terser@5.44.1: resolution: {integrity: sha512-t/R3R/n0MSwnnazuPpPNVO60LX0SKL45pyl9YlvxIdkH0Of7D5qM2EVe+yASRIlY5pZ73nclYJfNANGWPwFDZw==} @@ -3796,6 +3798,7 @@ packages: uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true v8-compile-cache-lib@3.0.1: @@ -4605,10 +4608,6 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.0 - '@jsbits/escape-regex-str@1.0.3': {} - - '@jsbits/get-package-version@1.0.3': {} - '@jsdevtools/ez-spawn@3.0.4': dependencies: call-me-maybe: 1.0.2 @@ -5886,8 +5885,6 @@ snapshots: estraverse@5.3.0: {} - estree-walker@0.6.1: {} - estree-walker@1.0.1: {} estree-walker@2.0.2: {} @@ -6343,14 +6340,6 @@ snapshots: dependencies: argparse: 2.0.1 - jscc@1.1.1: - dependencies: - '@jsbits/escape-regex-str': 1.0.3 - '@jsbits/get-package-version': 1.0.3 - magic-string: 0.25.9 - perf-regexes: 1.0.1 - skip-regex: 1.0.2 - jsesc@3.0.2: {} json-buffer@3.0.0: {} @@ -6798,8 +6787,6 @@ snapshots: pend@1.2.0: {} - perf-regexes@1.0.1: {} - perfect-debounce@1.0.0: {} picocolors@1.1.1: {} @@ -6985,13 +6972,6 @@ snapshots: sprintf-js: 1.1.3 optional: true - rollup-plugin-jscc@2.0.0(rollup@2.79.2): - dependencies: - '@jsbits/get-package-version': 1.0.3 - jscc: 1.1.1 - rollup: 2.79.2 - rollup-pluginutils: 2.8.2 - rollup-plugin-serve@1.1.1: dependencies: mime: 2.6.0 @@ -7006,10 +6986,6 @@ snapshots: rollup: 2.79.2 rollup-swc-preserve-directives: 0.6.0(@swc/core@1.9.2(@swc/helpers@0.5.15))(rollup@2.79.2) - rollup-pluginutils@2.8.2: - dependencies: - estree-walker: 0.6.1 - rollup-swc-preserve-directives@0.6.0(@swc/core@1.9.2(@swc/helpers@0.5.15))(rollup@2.79.2): dependencies: '@napi-rs/magic-string': 0.3.4 @@ -7110,8 +7086,6 @@ snapshots: sisteransi@1.0.5: {} - skip-regex@1.0.2: {} - slice-ansi@7.1.2: dependencies: ansi-styles: 6.2.1 diff --git a/rollup.config.js b/rollup.config.js index 1831d6fb4e..4a10cd1c43 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -7,7 +7,6 @@ import { shaderCompiler } from "@galacean/engine-shader-compiler/bundler/rollup" import serve from "rollup-plugin-serve"; import replace from "@rollup/plugin-replace"; import { swc, defineRollupSwcOption, minify } from "rollup-plugin-swc3"; -import jscc from "rollup-plugin-jscc"; const { BUILD_TYPE, NODE_ENV } = process.env; @@ -24,8 +23,8 @@ const pkgs = fs }; }); -const shaderCompilerPkg = pkgs.find((item) => item.pkgJson.name === "@galacean/engine-shader-compiler"); -pkgs.push({ ...shaderCompilerPkg, verboseMode: true }); +const shaderParserPkg = pkgs.find((item) => item.pkgJson.name === "@galacean/engine-shader-parser"); +if (shaderParserPkg) pkgs.push({ ...shaderParserPkg, parserEntry: "analyzer" }); // toGlobalName const extensions = [".js", ".jsx", ".ts", ".tsx"]; @@ -62,39 +61,44 @@ const commonPlugins = [ : null ]; -function config({ location, pkgJson, verboseMode }) { - const input = path.join(location, "src", "index.ts"); +function config({ location, pkgJson, parserEntry = "runtime" }) { + const isShaderParser = pkgJson.name === "@galacean/engine-shader-parser"; + const entry = isShaderParser && parserEntry === "runtime" ? "runtime.ts" : "index.ts"; + const input = path.join(location, "src", entry); const dependencies = Object.assign({}, pkgJson.dependencies ?? {}, pkgJson.peerDependencies ?? {}); const curPlugins = Array.from(commonPlugins); - curPlugins.push( - jscc({ - values: { _VERBOSE: verboseMode } - }) - ); - const external = Object.keys(dependencies); + const isExternal = (id) => external.some((dependency) => id === dependency || id.startsWith(`${dependency}/`)); curPlugins.push( replace({ preventAssignment: true, __buildVersion: pkgJson.version }) ); + if (isShaderParser && parserEntry === "runtime") { + // The analyzer-support artifact remains readable; the runtime entry keeps names and control + // flow but omits authoring comments so splitting parser/compiler does not increase shipped size. + curPlugins.push( + minify({ + compress: false, + mangle: false, + module: true, + sourceMap: true, + format: { beautify: true, comments: false } + }) + ); + } return { umd: (compress) => { const umdConfig = pkgJson.umd; - let file = path.join(location, "dist", "browser.js"); if (compress) { curPlugins.push(minify({ sourceMap: true })); } - if (verboseMode) { - file = path.join(location, "dist", compress ? "browser.verbose.min.js" : "browser.verbose.js"); - } else { - file = path.join(location, "dist", compress ? "browser.min.js" : "browser.js"); - } + const file = path.join(location, "dist", compress ? "browser.min.js" : "browser.js"); const umdExternal = Object.keys(umdConfig.globals ?? {}); @@ -114,15 +118,25 @@ function config({ location, pkgJson, verboseMode }) { }; }, module: () => { - let esFile = path.join(location, pkgJson.module); - let mainFile = path.join(location, pkgJson.main); - if (verboseMode) { - esFile = path.join(location, "dist", "module.verbose.js"); - mainFile = path.join(location, "dist", "main.verbose.js"); - } + const esFile = path.join( + location, + isShaderParser && parserEntry === "analyzer" + ? "dist/module.analyzer.js" + : isShaderParser + ? "dist/module.js" + : pkgJson.module + ); + const mainFile = path.join( + location, + isShaderParser && parserEntry === "analyzer" + ? "dist/main.analyzer.js" + : isShaderParser + ? "dist/main.js" + : pkgJson.main + ); return { input, - external, + external: isExternal, output: [ { file: esFile, @@ -144,7 +158,7 @@ function config({ location, pkgJson, verboseMode }) { const sourcesInput = path.join(location, "src", "sources.ts"); return { input: sourcesInput, - external, + external: isExternal, output: [ { file: path.join(location, "dist", "sources.module.js"), @@ -160,6 +174,27 @@ function config({ location, pkgJson, verboseMode }) { plugins: curPlugins }; }, + analyzerCli: () => { + const cliFile = path.join(location, "dist", "cli.js"); + return { + input: path.join(location, "src", "cli.ts"), + external: (id) => isExternal(id) || id === "node:fs" || id === "node:path", + output: { + file: cliFile, + format: "commonjs", + banner: "#!/usr/bin/env node" + }, + plugins: [ + ...curPlugins, + { + name: "executable-analyzer-cli", + writeBundle() { + if (process.platform !== "win32") fs.chmodSync(cliFile, 0o755); + } + } + ] + }; + }, bundled: (compress) => { // ES module format with no external dependencies (bundled) const bundledFile = path.join(location, "dist", compress ? "bundled.module.min.js" : "bundled.module.js"); @@ -240,6 +275,11 @@ function getModule() { result.push(makeRollupConfig({ ...shaderPkg, type: "sources" })); } + const analyzerPkg = pkgs.find((pkg) => pkg.pkgJson.name === "@galacean/engine-shader-analyzer"); + if (analyzerPkg) { + result.push(makeRollupConfig({ ...analyzerPkg, type: "analyzerCli" })); + } + return result; } diff --git a/tests/package.json b/tests/package.json index d5bb1b1ae3..069c3c52ae 100644 --- a/tests/package.json +++ b/tests/package.json @@ -21,7 +21,9 @@ "@galacean/engine-design": "workspace:*", "@galacean/engine-math": "workspace:*", "@galacean/engine-rhi-webgl": "workspace:*", + "@galacean/engine-shader-parser": "workspace:*", "@galacean/engine-shader-compiler": "workspace:*", + "@galacean/engine-shader-analyzer": "workspace:*", "@galacean/engine-physics-physx": "workspace:*", "@galacean/engine-ui": "workspace:*", "@galacean/engine-shader": "workspace:*" diff --git a/tests/src/shader-analyzer/BranchAwareLookup.test.ts b/tests/src/shader-analyzer/BranchAwareLookup.test.ts new file mode 100644 index 0000000000..a695ca1100 --- /dev/null +++ b/tests/src/shader-analyzer/BranchAwareLookup.test.ts @@ -0,0 +1,552 @@ +/** Branch-aware symbol lookup resolves only declarations visible from the reference branch. */ + +import { ShaderAnalyzer } from "@galacean/engine-shader-analyzer"; +import { + areConditionsComplementary, + getBranchCoverage, + type BranchSignature +} from "@galacean/engine-shader-parser/internal/analyzer"; +import { describe, expect, it } from "vitest"; + +const analyzer = new ShaderAnalyzer(); + +const HEADER = `Shader "cov" { SubShader "s" { Pass "p" {\n`; +const FOOTER = `\n} } }`; + +function pass(body: string): string { + return HEADER + body + FOOTER; +} + +function errorsOf(source: string, code?: string) { + const { diagnostics } = analyzer.analyze(source); + const errs = diagnostics.filter((d) => d.severity === "error"); + return code ? errs.filter((d) => d.code === code) : errs; +} + +function warningsOf(source: string, code?: string) { + const { diagnostics } = analyzer.analyze(source); + const warnings = diagnostics.filter((diagnostic) => diagnostic.severity === "warning"); + return code ? warnings.filter((diagnostic) => diagnostic.code === code) : warnings; +} + +// Type-mismatch diagnostics are the cleanest signal that lookup resolved: a hit gives a concrete +// type; a miss yields TypeAny which suppresses `AssignTypeMismatch`. So we assert its presence / +// absence to prove the resolver did or didn't find a same-branch declaration. +describe("branch-aware SymbolTable lookup", () => { + it("classifies adjacent integer ranges as complementary", () => { + const left: BranchSignature = [ + { + name: "MODE", + defined: true, + condition: { kind: "comparison", name: "MODE", operator: "<=", value: 0, version: 0 } + } + ]; + const right: BranchSignature = [ + { + name: "MODE", + defined: true, + condition: { kind: "comparison", name: "MODE", operator: ">=", value: 1, version: 0 } + } + ]; + expect(areConditionsComplementary(left[0].condition, right[0].condition)).toBe(true); + expect(getBranchCoverage([left, right], [])).toBe("covered"); + }); + + it("same-branch reference resolves same-branch declaration (assign type checks)", () => { + // `u_a` declared inside `#ifdef X`, assigned an `int` inside the same branch. Type must resolve + // to `float` — otherwise TypeAny suppresses the mismatch and the test never catches the miss. + const src = pass( + `#ifdef X + void frag() { + float u_a; + u_a = 1; + gl_FragColor = vec4(u_a); + } + #endif + void vert() { gl_Position = vec4(0.0); } + VertexShader = vert; FragmentShader = frag;` + ); + const errs = errorsOf(src, "AssignTypeMismatch"); + expect(errs.length, "same-branch decl → assign-mismatch fires").to.be.greaterThan(0); + }); + + it("outer-scope declaration is visible from inner branch (type propagates)", () => { + // Top-level `float u_t` — unconditional. Reference inside `#ifdef X` writes an int to it. + // The mismatch must be reported, proving the branch lookup resolved to the outer decl. + const src = pass( + `void frag() { + float u_t; + #ifdef X + u_t = 1; + #endif + gl_FragColor = vec4(u_t); + } + void vert() { gl_Position = vec4(0.0); } + VertexShader = vert; FragmentShader = frag;` + ); + const errs = errorsOf(src, "AssignTypeMismatch"); + expect(errs.length, "outer decl visible in nested #ifdef").to.be.greaterThan(0); + }); + + it("preserves type check inside macro branch — `float a = 1;` still errors", () => { + const src = pass( + `#ifdef X + void frag() { float a = 1; gl_FragColor = vec4(a); } + #endif + void vert() { gl_Position = vec4(0.0); } + VertexShader = vert; FragmentShader = frag;` + ); + expect(errorsOf(src, "AssignTypeMismatch").length, "no implicit int→float even inside #ifdef").to.be.greaterThan(0); + }); + + it("accepts a declaration guarded by a macro defined earlier in the same arm", () => { + const src = pass( + `#ifndef G + #define G + #ifdef G + float u_value; + #endif + void frag() { gl_FragColor = vec4(u_value); } + #endif + void vert() { gl_Position = vec4(0.0); } + VertexShader = vert; FragmentShader = frag;` + ); + expect(errorsOf(src, "UseBeforeDeclaration")).to.be.empty; + }); + + it("covers an unconditional reference with #ifdef and #elif !MACRO", () => { + const src = pass( + `#ifdef USE_BRANCH_VALUE + float branchValue; + #elif !USE_BRANCH_VALUE + float branchValue; + #endif + void frag() { gl_FragColor = vec4(branchValue); } + void vert() { gl_Position = vec4(0.0); } + VertexShader = vert; FragmentShader = frag;` + ); + expect(errorsOf(src, "UseBeforeDeclaration")).to.be.empty; + }); + + it("rejects a reference that is not guaranteed by its declaration branch", () => { + const src = pass( + `#ifdef A + #ifdef B + float u_value; + #endif + void frag() { gl_FragColor = vec4(u_value); } + #endif + void vert() { gl_Position = vec4(0.0); } + VertexShader = vert; FragmentShader = frag;` + ); + const errors = errorsOf(src, "UseBeforeDeclaration"); + expect(errors).to.have.lengthOf(1); + expect(errors[0].message).to.contain("unavailable under at least one macro configuration"); + }); + + it("reports a tangent reference guarded less strictly than its declaration", () => { + const src = pass( + `void frag() { + #ifdef RENDERER_HAS_NORMAL + vec3 normal = vec3(0.0); + #ifdef RENDERER_HAS_TANGENT + vec4 tangent = vec4(0.0); + #endif + #endif + #ifdef RENDERER_HAS_TANGENT + gl_FragColor = tangent; + #else + gl_FragColor = vec4(0.0); + #endif + } + void vert() { gl_Position = vec4(0.0); } + VertexShader = vert; FragmentShader = frag;` + ); + expect(errorsOf(src, "UseBeforeDeclaration")).to.have.lengthOf(1); + }); + + it("reports when a blend-shape tangent argument can outlive its declaration", () => { + const src = pass( + `void calculateBlendShape(inout vec4 position + #ifdef RENDERER_HAS_NORMAL + , inout vec3 normal + #ifdef RENDERER_HAS_TANGENT + , inout vec4 tangent + #endif + #endif + ) {} + void frag() { + vec4 position = vec4(0.0); + #ifdef RENDERER_HAS_NORMAL + vec3 normal = vec3(0.0); + #ifdef RENDERER_HAS_TANGENT + vec4 tangent = vec4(0.0); + #endif + #endif + #ifdef RENDERER_HAS_BLENDSHAPE + calculateBlendShape(position + #ifdef RENDERER_HAS_NORMAL + , normal + #endif + #ifdef RENDERER_HAS_TANGENT + , tangent + #endif + ); + #endif + gl_FragColor = vec4(0.0); + } + void vert() { gl_Position = vec4(0.0); } + VertexShader = vert; FragmentShader = frag;` + ); + const result = analyzer.analyze(src); + expect(result.diagnostics.filter((diagnostic) => diagnostic.code === "UseBeforeDeclaration")).to.have.lengthOf(1); + }); + + it("recognizes a simple condition implied by a conjunction", () => { + const src = pass( + `#ifdef A + float branchValue; + #endif + #if defined(A) && defined(B) + void frag() { gl_FragColor = vec4(branchValue); } + #endif + void vert() { gl_Position = vec4(0.0); } + VertexShader = vert; FragmentShader = frag;` + ); + expect(errorsOf(src, "UseBeforeDeclaration")).to.be.empty; + }); + + it("recognizes a disjunction implied by one of its operands", () => { + const src = pass( + `#if defined(A) || defined(B) + float branchValue; + #endif + #ifdef A + void frag() { gl_FragColor = vec4(branchValue); } + #endif + void vert() { gl_Position = vec4(0.0); } + VertexShader = vert; FragmentShader = frag;` + ); + expect(errorsOf(src, "UseBeforeDeclaration")).to.be.empty; + }); + + it("combines branch facts to prove a remaining disjunct", () => { + const src = pass( + `#ifdef B + float branchValue; + #endif + #if defined(A) || defined(B) + #ifndef A + void frag() { gl_FragColor = vec4(branchValue); } + #endif + #endif + void vert() { gl_Position = vec4(0.0); } + VertexShader = vert; FragmentShader = frag;` + ); + expect(errorsOf(src, "UseBeforeDeclaration")).to.be.empty; + }); + + it("proves local boolean facts with unrelated outer conditions", () => { + const src = pass( + `#ifdef B + float branchValue; + #endif + #if defined(C) || defined(D) || defined(E) || defined(F) || defined(G) || defined(H) + #if defined(A) || defined(B) + #ifndef A + void frag() { gl_FragColor = vec4(branchValue); } + #endif + #endif + #endif + void vert() { gl_Position = vec4(0.0); } + VertexShader = vert; FragmentShader = frag;` + ); + expect(errorsOf(src, "UseBeforeDeclaration")).to.be.empty; + }); + + it("covers a reference from independent declarations across many macros", () => { + const src = pass( + `#ifdef A + float branchValue; + #endif + #ifdef B + float branchValue; + #endif + #ifdef C + float branchValue; + #endif + #ifdef D + float branchValue; + #endif + #ifdef E + float branchValue; + #endif + #ifdef F + float branchValue; + #endif + #ifdef G + float branchValue; + #endif + #if defined(A) || defined(B) || defined(C) || defined(D) || defined(E) || defined(F) || defined(G) + void frag() { gl_FragColor = vec4(branchValue); } + #endif + void vert() { gl_Position = vec4(0.0); } + VertexShader = vert; FragmentShader = frag;` + ); + expect(errorsOf(src, "UseBeforeDeclaration")).to.be.empty; + }); + + it("does not treat selected numeric values as an exhaustive macro domain", () => { + const src = pass( + `#if MODE == 1 + float branchValue; + #elif MODE == 2 + float branchValue; + #endif + void frag() { + #if MODE != 0 + gl_FragColor = vec4(branchValue); + #else + gl_FragColor = vec4(0.0); + #endif + } + void vert() { gl_Position = vec4(0.0); } + VertexShader = vert; FragmentShader = frag;` + ); + + expect(errorsOf(src, "UseBeforeDeclaration")).to.have.lengthOf(1); + }); + + it("recognizes adjacent integer ranges as exhaustive", () => { + const src = pass( + `#if MODE <= 0 + float branchValue; + #endif + #if MODE >= 1 + float branchValue; + #endif + void frag() { gl_FragColor = vec4(branchValue); } + void vert() { gl_Position = vec4(0.0); } + VertexShader = vert; FragmentShader = frag;` + ); + + expect(errorsOf(src, "UseBeforeDeclaration")).to.be.empty; + expect(warningsOf(src, "UseBeforeDeclaration")).to.be.empty; + }); + + it("does not report an integer-only coverage gap as an error", () => { + // These ranges cover every integer, but the non-backtracking witness search intentionally leaves coverage unknown. + const src = pass( + `#if MODE <= 0 + float branchValue; + #endif + #if MODE == 1 + float branchValue; + #endif + #if MODE >= 2 + float branchValue; + #endif + void frag() { gl_FragColor = vec4(branchValue); } + void vert() { gl_Position = vec4(0.0); } + VertexShader = vert; FragmentShader = frag;` + ); + + expect(errorsOf(src, "UseBeforeDeclaration")).to.be.empty; + expect(warningsOf(src, "UseBeforeDeclaration")).to.have.lengthOf(1); + }); + + it("reports a numeric branch gap without host-specific macro assumptions", () => { + const src = pass( + `#if MODE == 1 + float branchValue; + #endif + void frag() { + #if MODE != 0 + gl_FragColor = vec4(branchValue); + #else + gl_FragColor = vec4(0.0); + #endif + } + void vert() { gl_Position = vec4(0.0); } + VertexShader = vert; FragmentShader = frag;` + ); + expect(errorsOf(src, "UseBeforeDeclaration")).to.have.lengthOf(1); + }); + + it("propagates a derived macro's defining branch", () => { + const src = pass( + `#if defined(A) || defined(B) + #define DERIVED + #endif + #ifdef DERIVED + #define WRAPPED + #endif + #ifdef WRAPPED + float branchValue; + #endif + #ifdef A + void frag() { gl_FragColor = vec4(branchValue); } + #endif + void vert() { gl_Position = vec4(0.0); } + VertexShader = vert; FragmentShader = frag;` + ); + expect(errorsOf(src, "UseBeforeDeclaration")).to.be.empty; + }); + + it("does not retain a derived macro after a conditional #undef", () => { + const src = pass( + `#ifdef A + #define DERIVED + #endif + #ifdef B + #undef DERIVED + #endif + #ifdef DERIVED + float branchValue; + #endif + #ifdef A + void frag() { gl_FragColor = vec4(branchValue); } + #endif + void vert() { gl_Position = vec4(0.0); } + VertexShader = vert; FragmentShader = frag;` + ); + expect(errorsOf(src, "UseBeforeDeclaration")).to.be.empty; + expect(warningsOf(src, "UseBeforeDeclaration")).to.have.lengthOf(1); + }); + + it("applies a macro replacement's definition branch to its references", () => { + const src = pass( + `#ifdef USE_ALIAS + float branchValue; + #define VALUE branchValue + #else + float VALUE; + #endif + void frag() { gl_FragColor = vec4(VALUE); } + void vert() { gl_Position = vec4(0.0); } + VertexShader = vert; FragmentShader = frag;` + ); + expect(errorsOf(src, "UseBeforeDeclaration")).to.be.empty; + }); + + it("keeps outer constraints when a declaration is inside a canonical include guard", () => { + const result = analyzer.analyze( + pass( + `#ifdef FEATURE + #ifndef DATA_INCLUDED + #define DATA_INCLUDED + struct Data { float value; }; + float guardedValue; + float guardedHelper() { return 1.0; } + #endif + #endif + Data data; + void frag() { gl_FragColor = vec4(data.value + guardedValue + guardedHelper()); } + void vert() { gl_Position = vec4(0.0); } + VertexShader = vert; FragmentShader = frag;` + ) + ); + + const errors = result.diagnostics.filter( + (diagnostic) => diagnostic.severity === "error" && diagnostic.code === "UseBeforeDeclaration" + ); + expect(errors, JSON.stringify(result.diagnostics)).to.have.lengthOf(4); + }); + + it("accepts a helper implemented in every complete branch", () => { + const src = pass( + `#ifdef A + float branchValue() { return 0.0; } + #else + float branchValue() { return 1.0; } + #endif + void frag() { gl_FragColor = vec4(branchValue()); } + void vert() { gl_Position = vec4(0.0); } + VertexShader = vert; FragmentShader = frag;` + ); + expect(errorsOf(src, "UseBeforeDeclaration")).to.be.empty; + }); + + it("rejects an unconditional helper call missing a macro branch", () => { + const src = pass( + `#ifdef A + float branchValue() { return 0.0; } + #endif + void frag() { gl_FragColor = vec4(branchValue()); } + void vert() { gl_Position = vec4(0.0); } + VertexShader = vert; FragmentShader = frag;` + ); + expect(errorsOf(src, "UseBeforeDeclaration")).to.have.lengthOf(1); + }); + + it("rejects a struct type that is missing on a macro path", () => { + const result = analyzer.analyze( + pass( + `#ifdef A + struct Data { float value; }; + #endif + Data data; + void frag() { gl_FragColor = vec4(0.0); } + void vert() { gl_Position = vec4(0.0); } + VertexShader = vert; FragmentShader = frag;` + ) + ); + const errors = result.diagnostics.filter( + (diagnostic) => diagnostic.severity === "error" && diagnostic.code === "UseBeforeDeclaration" + ); + expect(errors).to.have.lengthOf(1); + expect(errors[0].message).to.contain("Type 'Data'"); + }); + + it("warns when an unknown type may be supplied as a runtime macro", () => { + const src = pass(`RUNTIME_TYPE u_value; + void vert() { gl_Position = vec4(0.0); } + void frag() { gl_FragColor = vec4(1.0); } + VertexShader = vert; FragmentShader = frag;`); + const diagnostics = analyzer.analyze(src).diagnostics; + const unknownType = diagnostics.find((diagnostic) => diagnostic.code === "UnknownType"); + expect(unknownType?.severity).to.equal("warning"); + expect(diagnostics.filter((diagnostic) => diagnostic.severity === "error")).to.be.empty; + }); + + it("accepts a struct type declared by every arm of an exhaustive macro chain", () => { + const result = analyzer.analyze( + pass( + `#ifdef A + struct Data { float value; }; + #else + struct Data { float value; }; + #endif + Data data; + void frag() { gl_FragColor = vec4(0.0); } + void vert() { gl_Position = vec4(0.0); } + VertexShader = vert; FragmentShader = frag;` + ) + ); + expect(result.diagnostics.filter((diagnostic) => diagnostic.severity === "error")).to.be.empty; + }); + + it.each([ + ["global variable", "Data globalValue;"], + ["local variable", "void helper() { Data localValue; }"], + ["function parameter", "void helper(Data parameter) { }"], + ["function return", "Data helper() { return; }"], + ["struct member", "struct Container { Data member; };"] + ])("rejects an uncovered struct type in a %s declaration", (_name, declaration) => { + const result = analyzer.analyze( + pass( + `#ifdef A + struct Data { float value; }; + #endif + ${declaration} + void frag() { gl_FragColor = vec4(0.0); } + void vert() { gl_Position = vec4(0.0); } + VertexShader = vert; FragmentShader = frag;` + ) + ); + const errors = result.diagnostics.filter( + (diagnostic) => diagnostic.severity === "error" && diagnostic.code === "UseBeforeDeclaration" + ); + expect(errors).to.have.lengthOf(1); + }); +}); diff --git a/tests/src/shader-analyzer/BranchDeclarationConflict.test.ts b/tests/src/shader-analyzer/BranchDeclarationConflict.test.ts new file mode 100644 index 0000000000..1193d0dd08 --- /dev/null +++ b/tests/src/shader-analyzer/BranchDeclarationConflict.test.ts @@ -0,0 +1,484 @@ +import { ShaderAnalyzer } from "@galacean/engine-shader-analyzer"; +import type { IncludeMap } from "@galacean/engine-shader-parser/internal"; +import { describe, expect, it } from "vitest"; + +function pass(body: string): string { + return `Shader "branch-declarations" { SubShader "s" { Pass "p" { +${body} +} } }`; +} + +function shader(declarations: string, fragmentExpression = "vec4(0.0)"): string { + return pass(`${declarations} + void vert() { gl_Position = vec4(0.0); } + void frag() { gl_FragColor = ${fragmentExpression}; } + VertexShader = vert; + FragmentShader = frag;`); +} + +function analyze(source: string, includeMap?: IncludeMap) { + return new ShaderAnalyzer().analyze(source, includeMap ? { includeMap } : undefined); +} + +function redefinitions(source: string, includeMap?: IncludeMap) { + return analyze(source, includeMap).diagnostics.filter((diagnostic) => diagnostic.code === "Redefinition"); +} + +describe("branch declaration conflicts", () => { + it.each([ + ["conditional then unconditional", `#ifdef A\nfloat u_value;\n#endif\nfloat u_value;`], + ["unconditional then conditional", `float u_value;\n#ifdef A\nfloat u_value;\n#endif`] + ])("reports %s as an error", (_name, declarations) => { + const diagnostics = redefinitions(shader(declarations)); + expect(diagnostics).to.have.lengthOf(1); + expect(diagnostics[0].severity).to.equal("error"); + }); + + it("reports declarations under independently configurable macros", () => { + const diagnostics = redefinitions( + shader(`#ifdef A +float u_value; +#endif +#ifdef B +float u_value; +#endif`) + ); + expect(diagnostics).to.have.lengthOf(1); + }); + + it("reports independently configurable local declarations", () => { + const diagnostics = redefinitions( + shader(`void localDeclarations() { +#ifdef A + float value; +#endif +#ifdef B + float value; +#endif +}`) + ); + expect(diagnostics).to.have.lengthOf(1); + expect(diagnostics[0].severity).to.equal("error"); + }); + + it("keeps unconditional local redefinition diagnostics", () => { + const diagnostics = redefinitions( + shader(`void localDeclarations() { + float value; + float value; +}`) + ); + expect(diagnostics).to.have.lengthOf(1); + }); + + it("does not report opposite arms of one conditional chain", () => { + const diagnostics = redefinitions( + shader(`#ifdef A +float u_value; +#else +float u_value; +#endif`) + ); + expect(diagnostics).to.be.empty; + }); + + it("treats #if/#elif/#else siblings as mutually exclusive", () => { + const source = shader(`void fog() { +#if MODE == 1 + float intensity = 1.0; +#elif MODE == 2 + float intensity = 2.0; +#elif MODE == 3 + float intensity = 3.0; +#else + float intensity = 4.0; +#endif +}`); + expect(redefinitions(source)).to.be.empty; + }); + + it("reports a true duplicate inside one macro arm", () => { + const diagnostics = redefinitions( + shader(`#ifdef A +float u_value; +float u_value; +#endif`) + ); + expect(diagnostics).to.have.lengthOf(1); + }); + + it("reports a true duplicate inside one canonical guard", () => { + const diagnostics = redefinitions( + shader(`#ifndef VALUE_INCLUDED +#define VALUE_INCLUDED +float u_value; +float u_value; +#endif`) + ); + expect(diagnostics).to.have.lengthOf(1); + }); + + it("reports duplicates in separate non-guard #ifdef blocks", () => { + const diagnostics = redefinitions( + shader(`#ifdef A +float u_value; +#endif +#ifdef A +float u_value; +#endif`) + ); + expect(diagnostics).to.have.lengthOf(1); + }); + + it("reports repeated #ifndef blocks that never define their guard", () => { + const diagnostics = redefinitions( + shader(`#ifndef VALUE_INCLUDED +float u_value; +#endif +#ifndef VALUE_INCLUDED +float u_value; +#endif`) + ); + expect(diagnostics).to.have.lengthOf(1); + }); + + it.each([ + ["different equality values", "MODE == 1", "MODE == 2"], + ["equality and its inequality complement", "MODE == 1", "MODE != 1"], + ["complementary numeric bounds", "MODE < 3", "MODE >= 3"], + ["defined and not-defined", "defined(MODE)", "!defined(MODE)"], + ["bare defined and negated bare defined", "MODE", "!MODE"] + ])("does not report independent #if branches with %s", (_name, first, second) => { + expect( + redefinitions( + shader(`#if ${first} +float u_value; +#endif +#if ${second} +float u_value; +#endif`) + ) + ).to.be.empty; + }); + + it("reports #ifndef and numeric-zero declarations as coexisting", () => { + expect( + redefinitions( + shader(`#if !defined(MODE) +float u_value; +#endif +#if MODE == 0 +float u_value; +#endif`) + ) + ).to.have.lengthOf(1); + }); + + it("does not reopen a guard through an unreachable conditional #undef", () => { + expect( + redefinitions( + shader(`#ifndef CONDITIONAL_GUARD +#define CONDITIONAL_GUARD +float u_value; +#endif +#if !defined(CONDITIONAL_GUARD) +#undef CONDITIONAL_GUARD +#endif +#ifndef CONDITIONAL_GUARD +#define CONDITIONAL_GUARD +float u_value; +#endif`) + ) + ).to.be.empty; + }); + + it("does not report declarations in constant-false branches", () => { + expect( + redefinitions( + shader(`#if 0 +float u_value; +#endif +#if 0 +float u_value; +#endif`) + ) + ).to.be.empty; + }); + + it("includes preceding #if negations in #elif branch constraints", () => { + expect( + redefinitions( + shader(`#if A +float u_first; +#elif B +float u_value; +#endif +#if A +float u_value; +#endif`) + ) + ).to.be.empty; + }); + + it.each([ + ["overlapping numeric ranges", "MODE >= 1", "MODE > 1"], + ["different macro names", "FIRST == 1", "SECOND == 2"] + ])("reports proven overlap for %s", (_name, first, second) => { + const diagnostics = redefinitions( + shader(`#if ${first} +float u_value; +#endif +#if ${second} +float u_value; +#endif`) + ); + expect(diagnostics).to.have.lengthOf(1); + expect(diagnostics[0].severity).to.equal("error"); + }); + + it.each([["compound and atomic", "MODE == 1 || MODE == 2", "MODE == 2"]])( + "warns when overlap cannot be proven for %s", + (_name, first, second) => { + const diagnostics = redefinitions( + shader(`#if ${first} +float u_value; +#endif +#if ${second} +float u_value; +#endif`) + ); + expect(diagnostics).to.have.lengthOf(1); + expect(diagnostics[0].severity).to.equal("warning"); + } + ); + + it("proves canonical arithmetic comparisons are complementary", () => { + const diagnostics = redefinitions( + shader(`#if A + B > 1 +float u_value; +#endif +#if A+B <= 1 +float u_value; +#endif`) + ); + expect(diagnostics).to.be.empty; + }); + + it("does not treat comparisons inside a disjunction as complementary", () => { + const diagnostics = redefinitions( + shader(`#if A + B > 1 || C +float u_value; +#endif +#if A + B <= 1 || C +float u_value; +#endif`) + ); + expect(diagnostics).to.have.lengthOf(1); + expect(diagnostics[0].severity).to.equal("warning"); + }); + + it("silences repeated canonical include guards", () => { + const diagnostics = redefinitions( + shader(`#ifndef VALUE_INCLUDED +#define VALUE_INCLUDED +float u_value; +#endif +#ifndef VALUE_INCLUDED +#define VALUE_INCLUDED +float u_value; +#endif`) + ); + expect(diagnostics).to.be.empty; + }); + + it("distinguishes guarded and unguarded repeated includes", () => { + const guarded: IncludeMap = { + "guarded.glsl": `#ifndef GUARDED_INCLUDED +#define GUARDED_INCLUDED +float u_value; +#endif` + }; + const unguarded: IncludeMap = { "unguarded.glsl": "float u_value;" }; + + expect( + redefinitions( + shader( + `#include "guarded.glsl" +#include "guarded.glsl"`, + "vec4(u_value)" + ), + guarded + ) + ).to.be.empty; + expect( + redefinitions( + shader( + `#include "unguarded.glsl" +#include "unguarded.glsl"`, + "vec4(u_value)" + ), + unguarded + ) + ).to.have.lengthOf(1); + }); + + it("reports guarded declarations separated by #undef", () => { + const includeMap: IncludeMap = { + "guarded.glsl": `#ifndef GUARDED_INCLUDED +#define GUARDED_INCLUDED +float u_value; +#endif` + }; + const diagnostics = redefinitions( + shader( + `#include "guarded.glsl" +#undef GUARDED_INCLUDED +#include "guarded.glsl"`, + "vec4(u_value)" + ), + includeMap + ); + expect(diagnostics).to.have.lengthOf(1); + }); + + it("keeps one guard state after an earlier #undef", () => { + const includeMap: IncludeMap = { + "guarded.glsl": `#ifndef GUARDED_INCLUDED +#define GUARDED_INCLUDED +float u_value; +#endif` + }; + const diagnostics = redefinitions( + shader( + `#undef GUARDED_INCLUDED +#include "guarded.glsl" +#include "guarded.glsl"`, + "vec4(u_value)" + ), + includeMap + ); + expect(diagnostics).to.be.empty; + }); + + it("does not let a mutually exclusive #undef reopen a canonical guard", () => { + expect( + redefinitions( + shader(`#ifdef FIRST_PATH + #ifndef CONDITIONAL_GUARD + #define CONDITIONAL_GUARD + float u_value; + #endif +#else + #undef CONDITIONAL_GUARD +#endif +#ifndef CONDITIONAL_GUARD + #define CONDITIONAL_GUARD + float u_value; +#endif`) + ) + ).to.be.empty; + }); + + it("silences direct and transitive includes of one guarded chunk", () => { + const includeMap: IncludeMap = { + "guarded.glsl": `#ifndef GUARDED_INCLUDED +#define GUARDED_INCLUDED +float u_value; +#endif`, + "wrapper.glsl": `#include "guarded.glsl"` + }; + const diagnostics = redefinitions( + shader( + `#include "guarded.glsl" +#include "wrapper.glsl"`, + "vec4(u_value)" + ), + includeMap + ); + expect(diagnostics).to.be.empty; + }); + + it("does not let an inner guard definition retroactively suppress an entered outer arm", () => { + const diagnostics = redefinitions( + shader(`#ifndef G + #ifndef G + #define G + float u_value; + #endif + #define G + float u_value; +#endif`) + ); + expect(diagnostics).to.have.lengthOf(1); + }); + + it.each([ + ["conditional then unconditional", `#ifdef A\nstruct S { float value; };\n#endif\nstruct S { float value; };`], + ["unconditional then conditional", `struct S { float value; };\n#ifdef A\nstruct S { float value; };\n#endif`] + ])("reports struct conflicts: %s", (_name, declarations) => { + expect(redefinitions(shader(declarations))).to.have.lengthOf(1); + }); + + it.each([ + [ + "function, unconditional first", + `float branchValue() { return 1.0; } +#ifdef A +float branchValue() { return 2.0; } +#endif`, + "vec4(branchValue())", + /float\s+branchValue\s*\(\s*\)/g + ], + [ + "function, conditional first", + `#ifdef A +float branchValue() { return 2.0; } +#endif +float branchValue() { return 1.0; }`, + "vec4(branchValue())", + /float\s+branchValue\s*\(\s*\)/g + ], + [ + "variable, unconditional first", + `float branchValue; +#ifdef A +float branchValue; +#endif`, + "vec4(branchValue)", + /float\s+branchValue\s*;/g + ], + [ + "variable, conditional first", + `#ifdef A +float branchValue; +#endif +float branchValue;`, + "vec4(branchValue)", + /float\s+branchValue\s*;/g + ], + [ + "struct, unconditional first", + `struct BranchData { float value; }; +#ifdef A +struct BranchData { float value; }; +#endif +BranchData branchData;`, + "vec4(branchData.value)", + /struct\s+BranchData\b/g + ], + [ + "struct, conditional first", + `#ifdef A +struct BranchData { float value; }; +#endif +struct BranchData { float value; }; +BranchData branchData;`, + "vec4(branchData.value)", + /struct\s+BranchData\b/g + ] + ])("reports conflicting declarations without acting as a codegen gate: %s", (_name, declarations, expression) => { + const source = shader(declarations, expression); + const { diagnostics } = analyze(source); + expect(diagnostics.filter((diagnostic) => diagnostic.code === "Redefinition")).to.have.lengthOf(1); + }); +}); diff --git a/tests/src/shader-analyzer/BranchResolutionAmbiguity.test.ts b/tests/src/shader-analyzer/BranchResolutionAmbiguity.test.ts new file mode 100644 index 0000000000..ce2769265c --- /dev/null +++ b/tests/src/shader-analyzer/BranchResolutionAmbiguity.test.ts @@ -0,0 +1,203 @@ +import { ShaderAnalyzer } from "@galacean/engine-shader-analyzer"; +import { describe, expect, it } from "vitest"; + +function pass(body: string): string { + return `Shader "branch-resolution" { SubShader "s" { Pass "p" { +${body} +} } }`; +} + +function diagnostics(body: string) { + return new ShaderAnalyzer().analyze(pass(body)).diagnostics; +} + +function codes(body: string): string[] { + return diagnostics(body).map((diagnostic) => diagnostic.code); +} + +const ENTRIES = ` + void vert() { gl_Position = vec4(0.0); } + VertexShader = vert; + FragmentShader = frag;`; + +describe("branch resolution ambiguity", () => { + it.each([ + [ + "const first", + `#ifdef A + const int N = 2; + #else + int N = 2; + #endif` + ], + [ + "non-const first", + `#ifdef A + int N = 2; + #else + const int N = 2; + #endif` + ] + ])("makes an array constness split order-independent: %s", (_name, declarations) => { + const result = diagnostics(`void frag() { + ${declarations} + float first[N]; + float second[N]; + gl_FragColor = vec4(0.0); + } + ${ENTRIES}`); + const ambiguity = result.filter((diagnostic) => diagnostic.code === "AmbiguousMacroBranchResolution"); + expect(ambiguity).to.have.lengthOf(1); + expect(ambiguity[0].severity).to.equal("error"); + expect(result.some((diagnostic) => diagnostic.code === "NonConstArraySize")).to.equal(false); + }); + + it("keeps the definitive array-size result when all visible branches agree", () => { + const nonConstCodes = codes(`void frag() { + #ifdef A + int N = 2; + #else + int N = 3; + #endif + float values[N]; + gl_FragColor = vec4(0.0); + } + ${ENTRIES}`); + expect(nonConstCodes).to.include("NonConstArraySize"); + expect(nonConstCodes).to.not.include("AmbiguousMacroBranchResolution"); + + const constCodes = codes(`void frag() { + #ifdef A + const int N = 2; + #else + const int N = 3; + #endif + float values[N]; + gl_FragColor = vec4(0.0); + } + ${ENTRIES}`); + expect(constCodes).to.not.include("NonConstArraySize"); + expect(constCodes).to.not.include("AmbiguousMacroBranchResolution"); + }); + + it("resolves an array-size symbol from the callsite arm", () => { + const result = codes(`void frag() { + #ifdef A + const int N = 2; + #else + int N = 3; + #endif + #ifdef A + float values[N]; + #endif + gl_FragColor = vec4(0.0); + } + ${ENTRIES}`); + expect(result).to.not.include("NonConstArraySize"); + expect(result).to.not.include("AmbiguousMacroBranchResolution"); + }); + + it("does not confuse lexical shadowing with macro ambiguity", () => { + const result = codes(`const int N = 2; + float outerValue; + void frag() { + int N = 3; + vec3 outerValue = vec3(0.0); + float values[N]; + gl_FragColor = vec4(outerValue.z); + } + ${ENTRIES}`); + expect(result).to.include("NonConstArraySize"); + expect(result).to.not.include("AmbiguousMacroBranchResolution"); + expect(result).to.not.include("AmbiguousMacroBranchType"); + }); + + it("errors when a struct member exists in only one visible branch", () => { + const result = diagnostics(`#ifdef A + struct S { float value; }; + #else + struct S { float other; }; + #endif + S s; + void frag() { gl_FragColor = vec4(s.value); } + ${ENTRIES}`); + const ambiguity = result.filter((diagnostic) => diagnostic.code === "AmbiguousMacroBranchResolution"); + expect(ambiguity).to.have.lengthOf(1); + expect(ambiguity[0].severity).to.equal("error"); + expect(result.some((diagnostic) => diagnostic.code === "UndeclaredStructMember")).to.equal(false); + }); + + it.each([ + ["base type", "float value;", "int value;"], + ["array shape", "float value;", "float value[2];"], + ["array size", "float value[2];", "float value[3];"] + ])("warns when a struct member has divergent %s", (_name, first, second) => { + const result = diagnostics(`#ifdef A + struct S { ${first} }; + #else + struct S { ${second} }; + #endif + S s; + void frag() { gl_FragColor = vec4(s.value); } + ${ENTRIES}`); + const ambiguity = result.filter((diagnostic) => diagnostic.code === "AmbiguousMacroBranchType"); + expect(ambiguity).to.have.lengthOf(1); + expect(ambiguity[0].severity).to.equal("warning"); + expect(result.filter((diagnostic) => diagnostic.severity === "error")).to.be.empty; + }); + + it("keeps definitive struct member results when branches agree", () => { + const missing = codes(`#ifdef A + struct S { float other; }; + #else + struct S { float other; }; + #endif + S s; + void frag() { gl_FragColor = vec4(s.value); } + ${ENTRIES}`); + expect(missing).to.include("UndeclaredStructMember"); + expect(missing).to.not.include("AmbiguousMacroBranchResolution"); + + const present = codes(`#ifdef A + struct S { float value; }; + #else + struct S { float value; }; + #endif + S s; + void frag() { gl_FragColor = vec4(s.value); } + ${ENTRIES}`); + expect(present).to.not.include("UndeclaredStructMember"); + expect(present).to.not.include("AmbiguousMacroBranchResolution"); + }); + + it("resolves a struct member from the callsite arm", () => { + const result = codes(`#ifdef A + struct S { float value; }; + #else + struct S { float other; }; + #endif + S s; + void frag() { + #ifdef A + gl_FragColor = vec4(s.value); + #else + gl_FragColor = vec4(s.other); + #endif + } + ${ENTRIES}`); + expect(result).to.not.include("UndeclaredStructMember"); + expect(result).to.not.include("AmbiguousMacroBranchResolution"); + }); + + it("does not confuse a local struct with its shadowed outer declaration", () => { + const result = codes(`struct S { float outerValue; }; + void frag() { + struct S { float localValue; }; + S s; + gl_FragColor = vec4(s.localValue); + } + ${ENTRIES}`); + expect(result).to.not.include("UndeclaredStructMember"); + expect(result).to.not.include("AmbiguousMacroBranchResolution"); + }); +}); diff --git a/tests/src/shader-analyzer/BuiltinShaderSmoke.test.ts b/tests/src/shader-analyzer/BuiltinShaderSmoke.test.ts new file mode 100644 index 0000000000..6b544269b8 --- /dev/null +++ b/tests/src/shader-analyzer/BuiltinShaderSmoke.test.ts @@ -0,0 +1,29 @@ +/** Built-in shaders must retain their reviewed diagnostic contract. */ + +import { ShaderFactory } from "@galacean/engine-core"; +import { WebGLEngine } from "@galacean/engine"; +import { DiagnosticSeverity, ShaderAnalyzer } from "@galacean/engine-shader-analyzer"; +import { shaders as builtinShaders } from "@galacean/engine-shader/sources"; +import { beforeAll, describe, expect, it } from "vitest"; + +beforeAll(async () => { + await WebGLEngine.create({ canvas: document.createElement("canvas") }); +}); + +const shipping = builtinShaders.filter((s) => s.path.endsWith(".shader")); + +describe("built-in shader analyze() smoke", () => { + it("bundles the built-in shader corpus", () => { + expect(shipping.length).to.be.greaterThan(5); + }); + + for (const shader of shipping) { + it(`${shader.path} — diagnostics match the reviewed contract`, () => { + const analyzer = new ShaderAnalyzer(); + const { diagnostics } = analyzer.analyze(shader.source, { includeMap: ShaderFactory.includeMap }); + const errors = diagnostics.filter((diagnostic) => diagnostic.severity === DiagnosticSeverity.Error); + expect(errors).to.deep.equal([]); + expect(diagnostics.some((diagnostic) => diagnostic.code === "AmbiguousMacroBranchResolution")).to.equal(false); + }); + } +}); diff --git a/tests/src/shader-analyzer/DiagnosticCoverage.test.ts b/tests/src/shader-analyzer/DiagnosticCoverage.test.ts new file mode 100644 index 0000000000..a7290a2b86 --- /dev/null +++ b/tests/src/shader-analyzer/DiagnosticCoverage.test.ts @@ -0,0 +1,323 @@ +import { ShaderAnalyzer, DiagnosticType, DIAGNOSTIC_CATEGORY } from "@galacean/engine-shader-analyzer"; +import { describe, expect, it } from "vitest"; + +/** + * Coverage map: every diagnostic code in the registry must have a shader that + * triggers it through the production analyzer. A code with no triggering case is + * either untested or dead — both are findings. Presence (not multiplicity) is + * asserted here; report-once is enforced in ShaderIOAnalyzer.test.ts. + */ + +const analyzer = new ShaderAnalyzer(); + +function pass(body: string): string { + return `Shader "cov" { SubShader "s" { Pass "p" {\n${body}\n} } }`; +} + +// Each case: a shader expected to produce `code`. `gap` marks codes with no +// analyzer-reachable trigger (dead / backend-specific) — documented, not dropped. +const cases: { code: string; source?: string; gap?: string }[] = [ + { + code: "DuplicateEntryAssignment", + source: pass(` + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void vert2(Attributes attr) { gl_Position = vec4(0.0); } + void frag() { gl_FragColor = vec4(0.0); } + VertexShader = vert; VertexShader = vert2; FragmentShader = frag;`) + }, + { + code: "UndeclaredStructMember", + source: pass(` + struct Varyings { vec4 v; }; + Varyings vert() { Varyings o; o.v = vec4(0.0); return o; } + void frag(Varyings i) { gl_FragColor = i.notAField; } + VertexShader = vert; FragmentShader = frag;`) + }, + // ── B: RenderState ── + { + code: "AmbiguousMacroBranchType", + source: pass( + `void frag() { + #ifdef X + vec3 v; + #else + vec4 v; + #endif + gl_FragColor = vec4(v.x); + } + void vert() { gl_Position = vec4(0.0); } + VertexShader = vert; FragmentShader = frag;` + ) + }, + { + code: "AmbiguousMacroBranchResolution", + source: pass( + `void frag() { + #ifdef X + const int N = 2; + #else + int N = 2; + #endif + float values[N]; + gl_FragColor = vec4(0.0); + } + void vert() { gl_Position = vec4(0.0); } + VertexShader = vert; FragmentShader = frag;` + ) + }, + { code: "InvalidRenderStateProperty", source: pass(`BlendState bs { NotARealProperty = true; }`) }, + { code: "InvalidEnumValue", source: pass(`BlendState bs { SourceColorBlendFactor = BlendFactor.NotReal; }`) }, + { + code: "BitwiseOrOnNonBitmask", + source: pass(`BlendState bs { SourceColorBlendFactor = BlendFactor.One | BlendFactor.Zero; }`) + }, + { code: "MixedEnumTypes", source: pass(`BlendState bs { ColorWriteMask = ColorWriteMask.Red | CullMode.Front; }`) }, + { code: "InvalidRenderStateVariable", source: pass(`DepthState = undefinedDepthVar;`) }, + { code: "InvalidRenderQueueVariable", source: pass(`RenderQueueType = undefinedQueueVar;`) }, + + // ── C0: GLSL semantics ── + { code: "InvalidReturnType", source: pass(`void frag() { return vec4(0.0); } FragmentShader = frag;`) }, + { + code: "MissingReturn", + source: pass(`float getX() { float a = 1.0; } void frag() { gl_FragColor = vec4(getX()); } FragmentShader = frag;`) + }, + { + code: "NonConstructibleReturnType", + source: pass( + `mediump sampler2D u_tex; sampler2D getTex() { return u_tex; } void frag() { gl_FragColor = vec4(0.0); } FragmentShader = frag;` + ) + }, + { + code: "NestedIOStruct", + source: pass( + `struct Inner { vec4 v; }; struct Varyings { Inner nested; }; Varyings vert() { Varyings o; return o; } void frag(Varyings i) { gl_FragColor = i.nested.v; } VertexShader = vert; FragmentShader = frag;` + ) + }, + { + code: "ConstDivideByZero", + source: pass(`void frag() { int x = 1 / 0; gl_FragColor = vec4(float(x)); } FragmentShader = frag;`) + }, + { + code: "ShiftOutOfRange", + source: pass(`void frag() { int x = 1 << 40; gl_FragColor = vec4(float(x)); } FragmentShader = frag;`) + }, + { + code: "IndexOutOfBounds", + source: pass(`void frag() { vec3 v = vec3(0.0); float y = v[5]; gl_FragColor = vec4(y); } FragmentShader = frag;`) + }, + { + code: "InvalidUnaryOperand", + source: pass(`void frag() { float u_f = 1.0; bool ok = !u_f; gl_FragColor = vec4(0.0); } FragmentShader = frag;`) + }, + { + code: "InvalidBinaryOperands", + source: pass(`void frag() { bool b = true; float x = b + 1.0; gl_FragColor = vec4(x); } FragmentShader = frag;`) + }, + { + code: "NonIntegerIndex", + source: pass(`void frag() { vec3 v = vec3(0.0); float y = v[1.5]; gl_FragColor = vec4(y); } FragmentShader = frag;`) + }, + { + code: "NonIndexableType", + source: pass(`void frag() { float f = 1.0; float y = f[0]; gl_FragColor = vec4(y); } FragmentShader = frag;`) + }, + { + code: "ExpectedSampler", + source: pass( + `void frag() { vec2 uv = vec2(0.0); vec4 c = texture(uv, uv); gl_FragColor = c; } FragmentShader = frag;` + ) + }, + { + code: "ConstructorArgType", + source: pass( + `mediump sampler2D u_tex; void frag() { vec2 v = vec2(u_tex, 1.0); gl_FragColor = vec4(v, 0.0, 1.0); } FragmentShader = frag;` + ) + }, + { + code: "ConstructorArgCount", + source: pass(`void frag() { vec3 v = vec3(1.0, 2.0); gl_FragColor = vec4(v, 1.0); } FragmentShader = frag;`) + }, + { + code: "NonConstInitializer", + source: pass( + `float u_scale; void frag() { const float c = u_scale; gl_FragColor = vec4(c); } FragmentShader = frag;` + ) + }, + { + code: "NonConstArraySize", + source: pass(`void frag() { int n = 3; float a[n]; gl_FragColor = vec4(a[0]); } FragmentShader = frag;`) + }, + { + code: "EntryNotFound", + source: pass(` + void vert() { gl_Position = vec4(0.0); } + void frag() { gl_FragColor = vec4(0.0); } + VertexShader = vrt; FragmentShader = frag;`) + }, + { + code: "MissingVertexPosition", + source: pass( + `void vert() { } void frag() { gl_FragColor = vec4(0.0); } VertexShader = vert; FragmentShader = frag;` + ) + }, + { + code: "NonFlatIntegerVarying", + source: pass( + `struct Varyings { vec4 pos; int id; }; Varyings vert() { Varyings o; gl_Position = vec4(0.0); return o; } void frag(Varyings i) { gl_FragColor = vec4(float(i.id)); } VertexShader = vert; FragmentShader = frag;` + ) + }, + { + code: "MisplacedControlFlow", + source: pass(`void frag() { gl_FragColor = vec4(0.0); break; } FragmentShader = frag;`) + }, + { + code: "NoMatchingOverload", + source: pass( + `float f(float a) { return a; } void frag() { gl_FragColor = vec4(f(vec3(0.0))); } FragmentShader = frag;` + ) + }, + { code: "SyntaxError", source: pass(`void frag() { vec3 = ; } FragmentShader = frag;`) }, + { + code: "PreprocessorError", + source: pass(`#if 123 defined(USE)\nfloat value;\n#endif`) + }, + { + code: "GlFragColorWithMrt", + source: pass(` + struct MRT { vec4 c0; }; + void vert() { gl_Position = vec4(0.0); } + MRT frag() { MRT o; o.c0 = vec4(0.0); gl_FragColor = vec4(0.0); return o; } + VertexShader = vert; FragmentShader = frag;`) + }, + { + code: "InvalidIOStruct", + source: pass(` + void vert() { gl_Position = vec4(0.0); } + Undefined frag() { Undefined o; return o; } + VertexShader = vert; FragmentShader = frag;`) + }, + { + code: "StructRoleConflict", + source: pass(` + struct IO { vec4 v; }; + IO vert() { IO o; return o; } + IO frag(IO i) { return i; } + VertexShader = vert; FragmentShader = frag;`) + }, + { + code: "StructRoleConflict", + source: pass(` + struct IO { vec4 v; }; + void vert(IO attr) { gl_Position = vec4(0.0); } + IO frag() { IO o; return o; } + VertexShader = vert; FragmentShader = frag;`) + }, + { + code: "DerivativeInVertexShader", + source: pass(` + void vert() { float d = dFdx(1.0); gl_Position = vec4(d); } + void frag() { gl_FragColor = vec4(0.0); } + VertexShader = vert; FragmentShader = frag;`) + }, + { + code: "NonFloatDerivativeArg", + source: pass(` + void vert() { gl_Position = vec4(0.0); } + void frag() { int i = 1; float d = dFdx(i); gl_FragColor = vec4(d); } + VertexShader = vert; FragmentShader = frag;`) + }, + { + code: "EmptyStruct", + source: pass(` + struct Empty { + #ifdef EMPTY_MEMBER + #endif + }; + void vert() { gl_Position = vec4(0.0); } + void frag() { gl_FragColor = vec4(1.0); } + VertexShader = vert; FragmentShader = frag;`) + }, + { + code: "InvalidArraySize", + source: pass(` + void vert() { gl_Position = vec4(0.0); } + void frag() { float a[0]; gl_FragColor = vec4(a[0]); } + VertexShader = vert; FragmentShader = frag;`) + }, + { + code: "BareGlFragData", + source: pass(` + void vert() { gl_Position = vec4(0.0); } + void frag() { vec4 c = gl_FragData; gl_FragColor = c; } + VertexShader = vert; FragmentShader = frag;`) + }, + { + code: "InvalidAssignmentTarget", + // Macro-as-LHS is now handled by the runtime driver — the analyzer no longer flags it + // because a macro's l-value-ness depends on its expansion (`#define X vec.z` is a legal + // swizzle l-value, e.g. FXAA `lumaN = lumaW;`). Trigger via a numeric literal instead. + source: pass(` + void vert() { gl_Position = vec4(0.0); } + void frag() { 1 = 2; gl_FragColor = vec4(1.0); } + VertexShader = vert; FragmentShader = frag;`) + }, + { + code: "LocalFunctionPrototype", + source: pass(` + void vert() { gl_Position = vec4(0.0); } + void frag() { int g(); gl_FragColor = vec4(1.0); } + VertexShader = vert; FragmentShader = frag;`) + }, + { + code: "InvalidVoidVariable", + source: pass(` + void vert() { gl_Position = vec4(0.0); } + void frag() { void x; gl_FragColor = vec4(0.0); } + VertexShader = vert; FragmentShader = frag;`) + } +]; + +describe("diagnostic coverage map", () => { + for (const c of cases) { + // Codes with no analyzer-reachable trigger are recorded as skips with a reason, not dropped. + if (c.gap) { + it.skip(`${c.code} — ${c.gap}`, () => {}); + continue; + } + it(`${c.code} is produced`, () => { + const codes = analyzer.analyze(c.source!).diagnostics.map((d) => d.code); + expect(codes, `expected ${c.code}, got [${[...new Set(codes)].join(", ")}]`).to.include(c.code); + }); + } + + // Every DiagnosticType must have a category entry in DIAGNOSTIC_CATEGORY — same + // exhaustiveness contract the Record type gives at compile time, + // asserted at runtime so a bogus `undefined` from a stale build still fails loudly. + it("every DiagnosticType has a DIAGNOSTIC_CATEGORY entry", () => { + const missing = Object.values(DiagnosticType).filter((t) => DIAGNOSTIC_CATEGORY[t] === undefined); + expect(missing, `DiagnosticTypes with no category mapping: ${missing.join(", ")}`).to.be.empty; + }); + + // Completeness gate: every DiagnosticType must have a triggering AB test. The richer per-rule tests + // live in ShaderAnalyzer.test.ts / ShaderIOAnalyzer.test.ts; those codes are registered here so a + // newly-added diagnostic that ships with no test anywhere fails this check. + it("every DiagnosticType has a triggering test", () => { + const coveredElsewhere = new Set([ + "AssignTypeMismatch", + "InvalidEntryReturnType", + "InvalidSwizzle", + "MissingEntry", + "NonBoolCondition", + "RecursiveFunction", + "Redefinition", + "UndefinedFunction", + "UnknownType", + "UnknownVariable", + "UseBeforeDeclaration" + ]); + const here = new Set(cases.map((c) => c.code)); + const uncovered = Object.values(DiagnosticType).filter((t) => !here.has(t) && !coveredElsewhere.has(t)); + expect(uncovered, `DiagnosticTypes with no triggering test: ${uncovered.join(", ")}`).to.be.empty; + }); +}); diff --git a/tests/src/shader-analyzer/DiagnosticSmoke.test.ts b/tests/src/shader-analyzer/DiagnosticSmoke.test.ts new file mode 100644 index 0000000000..c3ccb50182 --- /dev/null +++ b/tests/src/shader-analyzer/DiagnosticSmoke.test.ts @@ -0,0 +1,323 @@ +/** + * Smoke test — one run confirms each diagnostic fires (or omits on the negative case). + * Complements the fine-grained per-rule tests in ShaderAnalyzer.test.ts. + */ +import { ShaderAnalyzer } from "@galacean/engine-shader-analyzer"; +import { describe, expect, it } from "vitest"; + +const analyzer = new ShaderAnalyzer(); + +function pass(body: string): string { + return `Shader "s" { SubShader "s" { Pass "p" {\n${body}\n} } }`; +} +function codes(src: string): string[] { + return analyzer.analyze(src).diagnostics.map((d) => d.code); +} + +describe("diagnostic smoke", () => { + it("function redefinition same signature fires with severity=error", () => { + const src = pass(` + void foo() { } + void foo() { } + struct Attributes { vec3 POSITION; }; + void vert(Attributes a) { gl_Position = vec4(a.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(0.0); } + VertexShader = vert; FragmentShader = frag;`); + const d = analyzer.analyze(src).diagnostics.find((x) => x.code === "Redefinition"); + expect(d).toBeDefined(); + expect(d!.severity).to.equal("error"); + }); + + it("function overload with different signature does NOT flag Redefinition", () => { + const src = pass(` + float f(float a) { return a; } + float f(vec2 a) { return a.x; } + struct Attributes { vec3 POSITION; }; + void vert(Attributes a) { gl_Position = vec4(a.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(f(1.0) + f(vec2(0.0))); } + VertexShader = vert; FragmentShader = frag;`); + expect(codes(src)).to.not.include("Redefinition"); + }); + + it("ConstructorArgCount fires on too-many components", () => { + const src = pass(` + void frag() { gl_FragColor = vec4(1.0, 2.0, 3.0, 4.0, 5.0); } + FragmentShader = frag;`); + expect(codes(src)).to.include("ConstructorArgCount"); + }); + + it("ConstructorArgCount fires on too-few components", () => { + const src = pass(` + void frag() { vec3 v = vec3(1.0, 2.0); gl_FragColor = vec4(v, 1.0); } + FragmentShader = frag;`); + expect(codes(src)).to.include("ConstructorArgCount"); + }); + + it("gl_FragData[i] is a legal author-side output (no GlFragData diagnostic)", () => { + const src = pass(` + void vert() { gl_Position = vec4(0.0); } + void frag() { gl_FragData[0] = vec4(1.0); } + VertexShader = vert; FragmentShader = frag;`); + expect(codes(src)).to.not.include("GlFragData"); + }); + + it("NonBoolCondition fires on while", () => { + const src = pass(` + void frag() { float x = 1.0; while (x) { break; } gl_FragColor = vec4(0.0); } + FragmentShader = frag;`); + expect(codes(src)).to.include("NonBoolCondition"); + }); + + it("NonBoolCondition fires on for", () => { + const src = pass(` + void frag() { for (float i = 1.0; i;) {} gl_FragColor = vec4(0.0); } + FragmentShader = frag;`); + expect(codes(src)).to.include("NonBoolCondition"); + }); + + it("NonBoolCondition fires on ternary", () => { + const src = pass(` + void frag() { float x = 1.0; float y = x ? 1.0 : 2.0; gl_FragColor = vec4(y); } + FragmentShader = frag;`); + expect(codes(src)).to.include("NonBoolCondition"); + }); + + it("MissingReturn fires when one branch omits return", () => { + const src = pass(` + float f() { if (1 == 1) return 1.0; } + void frag() { gl_FragColor = vec4(f()); } + FragmentShader = frag;`); + expect(codes(src)).to.include("MissingReturn"); + }); + + it("MissingVertexPosition fires when vertex only reads gl_Position", () => { + const src = pass(` + void vert() { vec4 x = gl_Position; } + void frag() { gl_FragColor = vec4(0.0); } + VertexShader = vert; FragmentShader = frag;`); + expect(codes(src)).to.include("MissingVertexPosition"); + }); + + it("Redefinition severity is error", () => { + const src = pass(` + float u_a; float u_a; + struct Attributes { vec3 POSITION; }; + void vert(Attributes a) { gl_Position = vec4(a.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(u_a); } + VertexShader = vert; FragmentShader = frag;`); + const d = analyzer.analyze(src).diagnostics.find((x) => x.code === "Redefinition"); + expect(d).toBeDefined(); + expect(d!.severity).to.equal("error"); + }); + + it("RenderState error message states property will not be applied", () => { + const src = pass(`BlendState bs { NotARealProperty = true; }`); + const d = analyzer.analyze(src).diagnostics.find((x) => x.code === "InvalidRenderStateProperty"); + expect(d).toBeDefined(); + expect(d!.message).toMatch(/not\s+be\s+applied|will\s+not/i); + }); + + it("DerivativeInVertexShader fires", () => { + const src = pass(` + struct Attributes { vec3 POSITION; }; + void vert(Attributes a) { float d = dFdx(a.POSITION.x); gl_Position = vec4(a.POSITION, d); } + void frag() { gl_FragColor = vec4(0.0); } + VertexShader = vert; FragmentShader = frag;`); + expect(codes(src)).to.include("DerivativeInVertexShader"); + }); + + it("NonFloatDerivativeArg fires", () => { + const src = pass(` + void frag() { int x = 3; float d = dFdx(x); gl_FragColor = vec4(d); } + FragmentShader = frag;`); + expect(codes(src)).to.include("NonFloatDerivativeArg"); + }); + + it("valid vec4 splat vec4(1.0) does NOT flag ConstructorArgCount", () => { + const src = pass(` + void frag() { gl_FragColor = vec4(1.0); } + FragmentShader = frag;`); + expect(codes(src)).to.not.include("ConstructorArgCount"); + }); + + it("valid vertex writing gl_Position does NOT flag MissingVertexPosition", () => { + const src = pass(` + void vert() { gl_Position = vec4(0.0); } + void frag() { gl_FragColor = vec4(0.0); } + VertexShader = vert; FragmentShader = frag;`); + expect(codes(src)).to.not.include("MissingVertexPosition"); + }); + + it("function overload with different signature does NOT flag Redefinition", () => { + const src = pass(` + float f(float a) { return a; } + float f(vec2 a) { return a.x; } + void frag() { gl_FragColor = vec4(f(1.0) + f(vec2(0.0))); } + FragmentShader = frag;`); + expect(codes(src)).to.not.include("Redefinition"); + }); + + it("NonConstInitializer: `1.0 + 2.0` literal-only compound is NOT flagged", () => { + const src = pass(` + void frag() { const float c = 1.0 + 2.0; gl_FragColor = vec4(c); } + FragmentShader = frag;`); + expect(codes(src)).to.not.include("NonConstInitializer"); + }); + + it("NonConstInitializer: `sin(0.5)` builtin-on-const is NOT flagged", () => { + const src = pass(` + void frag() { const float c = sin(0.5); gl_FragColor = vec4(c); } + FragmentShader = frag;`); + expect(codes(src)).to.not.include("NonConstInitializer"); + }); + + it("NonConstInitializer: `u_uniform + sin(0.5)` (uniform mixed in) IS flagged", () => { + const src = pass(` + float u_scale; + void frag() { const float c = u_scale + sin(0.5); gl_FragColor = vec4(c); } + FragmentShader = frag;`); + expect(codes(src)).to.include("NonConstInitializer"); + }); + + it("helper called from vertex containing dFdx fires DerivativeInVertexShader", () => { + const src = pass(` + float helper(float x) { return dFdx(x); } + struct Attributes { vec3 POSITION; }; + void vert(Attributes a) { gl_Position = vec4(helper(a.POSITION.x), 0.0, 0.0, 1.0); } + void frag() { gl_FragColor = vec4(helper(1.0)); } + VertexShader = vert; FragmentShader = frag;`); + expect(codes(src)).to.include("DerivativeInVertexShader"); + }); + + it("helper called only from fragment does NOT flag DerivativeInVertexShader", () => { + const src = pass(` + float helper(float x) { return dFdx(x); } + void vert() { gl_Position = vec4(0.0); } + void frag() { gl_FragColor = vec4(helper(1.0)); } + VertexShader = vert; FragmentShader = frag;`); + expect(codes(src)).to.not.include("DerivativeInVertexShader"); + }); + + it("mutual recursion (f ↔ g) fires RecursiveFunction", () => { + // Forward declarations aren't accepted by our grammar; author both bodies. `g` references `f` + // which is defined later — that identifier resolution is deferred until walk-time, so the SCC + // pass still sees both edges once the whole program has been analyzed. + const src = pass(` + float g(float x); + float f(float x) { return g(x); } + float g(float x) { return f(x); } + void vert() { gl_Position = vec4(0.0); } + void frag() { gl_FragColor = vec4(f(1.0)); } + VertexShader = vert; FragmentShader = frag;`); + const c = codes(src); + // Either RecursiveFunction (mutual detected) is fine, or UndefinedFunction (grammar rejects + // forward decl). Accept RecursiveFunction as the primary signal; skip the assertion when the + // grammar refuses the forward-decl form to keep the test portable. + if (!c.includes("SyntaxError") && !c.includes("UndefinedFunction")) { + expect(c).to.include("RecursiveFunction"); + } + }); + + it("array size 0 fires InvalidArraySize", () => { + const src = pass(` + void vert() { gl_Position = vec4(0.0); } + void frag() { float a[0]; gl_FragColor = vec4(a[0]); } + VertexShader = vert; FragmentShader = frag;`); + expect(codes(src)).to.include("InvalidArraySize"); + }); + + it("array size 4 does NOT fire InvalidArraySize", () => { + const src = pass(` + void vert() { gl_Position = vec4(0.0); } + void frag() { float a[4]; gl_FragColor = vec4(a[0]); } + VertexShader = vert; FragmentShader = frag;`); + expect(codes(src)).to.not.include("InvalidArraySize"); + }); + + it("struct-with-sampler return fires NonConstructibleReturnType", () => { + const src = pass(` + struct Material { mediump sampler2D tex; }; + Material u_m; + Material getIt() { return u_m; } + struct Attributes { vec3 POSITION; }; + void vert(Attributes a) { gl_Position = vec4(a.POSITION, 1.0); } + void frag() { gl_FragColor = texture2D(getIt().tex, vec2(0.0)); } + VertexShader = vert; FragmentShader = frag;`); + expect(codes(src)).to.include("NonConstructibleReturnType"); + }); + + it("struct-without-sampler return does NOT fire NonConstructibleReturnType", () => { + const src = pass(` + struct Plain { vec3 v; }; + Plain make() { Plain p; p.v = vec3(0.0); return p; } + struct Attributes { vec3 POSITION; }; + void vert(Attributes a) { gl_Position = vec4(a.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(make().v, 1.0); } + VertexShader = vert; FragmentShader = frag;`); + expect(codes(src)).to.not.include("NonConstructibleReturnType"); + }); + + it("mat3 constructor with too-few floats fires ConstructorArgCount", () => { + const src = pass(` + void frag() { mat3 m = mat3(1.0, 2.0, 3.0, 4.0, 5.0); gl_FragColor = vec4(m[0], 1.0); } + FragmentShader = frag;`); + expect(codes(src)).to.include("ConstructorArgCount"); + }); + + it("mat3 constructor with exactly 9 floats does NOT fire ConstructorArgCount", () => { + const src = pass(` + void frag() { mat3 m = mat3(1.0,2.0,3.0,4.0,5.0,6.0,7.0,8.0,9.0); gl_FragColor = vec4(m[0], 1.0); } + FragmentShader = frag;`); + expect(codes(src)).to.not.include("ConstructorArgCount"); + }); + + it("struct A = struct B (different names) reports AssignTypeMismatch", () => { + const src = pass(` + struct A { vec3 v; }; + struct B { vec3 v; }; + struct Attributes { vec3 POSITION; }; + void vert(Attributes a) { gl_Position = vec4(a.POSITION, 1.0); } + void frag() { A x; B y; x = y; gl_FragColor = vec4(x.v, 1.0); } + VertexShader = vert; FragmentShader = frag;`); + expect(codes(src)).to.include("AssignTypeMismatch"); + }); + + it("struct A = struct A (same name) does NOT flag AssignTypeMismatch", () => { + const src = pass(` + struct A { vec3 v; }; + struct Attributes { vec3 POSITION; }; + void vert(Attributes a) { gl_Position = vec4(a.POSITION, 1.0); } + void frag() { A x; A y; x = y; gl_FragColor = vec4(x.v, 1.0); } + VertexShader = vert; FragmentShader = frag;`); + expect(codes(src)).to.not.include("AssignTypeMismatch"); + }); + + it("DuplicateEntryAssignment collects and lets siblings surface in the same pass", () => { + const src = pass(` + float u_a; + float u_a; + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void vert2(Attributes attr) { gl_Position = vec4(0.0); } + void frag() { gl_FragColor = vec4(u_a); } + VertexShader = vert; + VertexShader = vert2; + FragmentShader = frag;`); + const c = codes(src); + expect(c).to.include("DuplicateEntryAssignment"); + // A parser that threw on duplicate-assign would abort here; Redefinition for `u_a` + // must still be reported to prove parse continued past the duplicate site. + expect(c).to.include("Redefinition"); + }); + + it("sampler as struct member is legal per GLSL ES 3.00 §4.1.7", () => { + const src = pass(` + struct Material { mediump sampler2D tex; }; + struct Attributes { vec3 POSITION; }; + Material mat; + void vert(Attributes a) { gl_Position = vec4(a.POSITION, 1.0); } + void frag() { gl_FragColor = texture2D(mat.tex, vec2(0.0)); } + VertexShader = vert; FragmentShader = frag;`); + expect(codes(src)).to.not.include("SamplerInStruct"); + }); +}); diff --git a/tests/src/shader-analyzer/MacroBranchMatrix.test.ts b/tests/src/shader-analyzer/MacroBranchMatrix.test.ts new file mode 100644 index 0000000000..3419727513 --- /dev/null +++ b/tests/src/shader-analyzer/MacroBranchMatrix.test.ts @@ -0,0 +1,552 @@ +import { ShaderLanguage } from "@galacean/engine-core"; +import { ShaderAnalyzer } from "@galacean/engine-shader-analyzer"; +import { ShaderCompiler } from "@galacean/engine-shader-compiler"; +import { AnalyzerLexer, ShaderSourceParser, type IncludeMap } from "@galacean/engine-shader-parser/internal/analyzer"; +import { describe, expect, it } from "vitest"; + +function pass(body: string): string { + return `Shader "macro-branch-matrix" { SubShader "s" { Pass "p" { +${body} +} } }`; +} + +function shader(declarations: string, fragmentBody: string): string { + return pass(`${declarations} + void vert() { gl_Position = vec4(0.0); } + void frag() { +${fragmentBody} + } + VertexShader = vert; + FragmentShader = frag;`); +} + +function compile(source: string, includeMap?: IncludeMap) { + const result = new ShaderAnalyzer().analyze(source, includeMap ? { includeMap } : undefined); + const codes = result.diagnostics.map((diagnostic) => diagnostic.code); + const passSource = ShaderSourceParser.parse(source).subShaders[0].passes[0]; + const compiler = new ShaderCompiler(); + if (includeMap) compiler._setIncludeMap(includeMap); + const generated = compiler._parseShaderPass( + passSource.contents, + passSource.vertexEntry, + passSource.fragmentEntry, + ShaderLanguage.GLSLES100, + "" + ); + + return { + codes, + fragment: generated?.fragment + }; +} + +interface MacroCase { + name: string; + source: string; + codes: string[]; + fragments: string[]; + occurrences?: [string, number][]; + includeMap?: IncludeMap; +} + +const cases: MacroCase[] = [ + { + name: "object-like #define", + source: shader("#define BRANCH_SCALE 0.5", " gl_FragColor = vec4(BRANCH_SCALE);"), + codes: [], + fragments: ["#define BRANCH_SCALE", "BRANCH_SCALE"] + }, + { + name: "function-like #define", + source: shader("#define APPLY_SCALE(value) ((value) * 0.5)", " gl_FragColor = vec4(APPLY_SCALE(1.0));"), + codes: [], + fragments: ["#define APPLY_SCALE", "APPLY_SCALE"] + }, + { + name: "#ifdef/#else siblings", + source: shader( + `#ifdef USE_VALUE +float u_value; +#else +float u_value; +#endif`, + " gl_FragColor = vec4(u_value);" + ), + codes: [], + fragments: ["#ifdef USE_VALUE", "#else", "#endif", "uniform float u_value;"] + }, + { + name: "#ifndef/#else siblings", + source: shader( + `#ifndef DISABLE_VALUE +float u_value; +#else +float u_value; +#endif`, + " gl_FragColor = vec4(u_value);" + ), + codes: [], + fragments: ["#ifndef DISABLE_VALUE", "#else", "#endif", "uniform float u_value;"] + }, + { + name: "#ifndef/#elif defined siblings", + source: shader( + `#ifndef DISABLE_VALUE +float u_value; +#elif defined(DISABLE_VALUE) +float u_value; +#endif`, + " gl_FragColor = vec4(u_value);" + ), + codes: [], + fragments: ["#ifndef DISABLE_VALUE", "#elif defined(DISABLE_VALUE)", "#endif", "uniform float u_value;"] + }, + { + name: "#ifdef/#elif !defined siblings", + source: shader( + `#ifdef USE_VALUE +float u_value; +#elif !defined(USE_VALUE) +float u_value; +#endif`, + " gl_FragColor = vec4(u_value);" + ), + codes: [], + fragments: ["#ifdef USE_VALUE", "#elif !defined(USE_VALUE)", "#endif", "uniform float u_value;"] + }, + { + name: "#ifdef/#elif negated macro value siblings", + source: shader( + `#ifdef USE_VALUE +float u_value; +#elif !USE_VALUE +float u_value; +#endif`, + " gl_FragColor = vec4(u_value);" + ), + codes: [], + fragments: ["#ifdef USE_VALUE", "#elif !USE_VALUE", "#endif", "uniform float u_value;"] + }, + { + name: "#ifdef/#elif repeated condition has a declaration gap", + source: shader( + `#ifdef USE_VALUE +float u_value; +#elif defined(USE_VALUE) +float u_value; +#endif`, + " gl_FragColor = vec4(u_value);" + ), + codes: ["UseBeforeDeclaration"], + fragments: [] + }, + { + name: "#ifndef/#elif non-complementary gap", + source: shader( + `#ifndef DISABLE_VALUE +float u_value; +#elif A +float u_value; +#endif`, + " gl_FragColor = vec4(u_value);" + ), + codes: ["UseBeforeDeclaration"], + fragments: [] + }, + { + name: "malformed #elif condition", + source: shader( + `#ifdef USE_VALUE +float u_value; +#elif 123 defined(USE_VALUE) +float u_value; +#endif`, + " gl_FragColor = vec4(u_value);" + ), + codes: ["PreprocessorError"], + fragments: [] + }, + { + name: "disjoint but non-exhaustive #elif conditions", + source: shader( + `#if MODE == 1 +float u_value; +#elif MODE == 2 +float u_value; +#endif`, + " gl_FragColor = vec4(u_value);" + ), + codes: ["UseBeforeDeclaration"], + fragments: [] + }, + { + name: "first true #elif arm", + source: shader( + `#if 0 +float u_value; +#elif 0 +float u_value; +#elif 1 +float u_value; +#elif 1 +float u_value; +#else +float u_value; +#endif`, + " gl_FragColor = vec4(u_value);" + ), + codes: [], + fragments: ["#if 0", "#elif 0", "#elif 1", "#else", "#endif", "uniform float u_value;"] + }, + { + name: "inactive #if body ignores an otherwise invalid stringifying macro", + source: shader( + `#if 0 +#define STRINGIFY(X) #X +#endif +float u_value;`, + " gl_FragColor = vec4(u_value);" + ), + codes: [], + fragments: ["#if 0", "#define STRINGIFY(X) #X", "#endif", "uniform float u_value;"] + }, + { + name: "#if/#elif/#else siblings", + source: shader( + `#if MODE == 1 +float u_mode; +#elif MODE == 2 +float u_mode; +#else +float u_mode; +#endif`, + " gl_FragColor = vec4(u_mode);" + ), + codes: [], + fragments: ["#if MODE == 1", "#elif MODE == 2", "#else", "#endif", "uniform float u_mode;"] + }, + { + name: "repeated logical macro conditions", + source: shader( + `#if defined(HAS_NORMAL) && defined(HAS_TANGENT) +float u_value[2]; +#else + #if defined(HAS_NORMAL) || defined(HAS_TANGENT) + float u_value[4]; + #else + float u_value[8]; + #endif +#endif`, + ` #if defined(HAS_NORMAL) && defined(HAS_TANGENT) + gl_FragColor = vec4(u_value[0]); + #else + #if defined(HAS_NORMAL) || defined(HAS_TANGENT) + gl_FragColor = vec4(u_value[0]); + #else + gl_FragColor = vec4(u_value[0]); + #endif + #endif` + ), + codes: [], + fragments: ["#if defined(HAS_NORMAL)", "#endif"] + }, + { + name: "nested conditional siblings", + source: shader( + `#ifdef OUTER + #ifdef INNER + float u_nested; + #else + float u_nested; + #endif +#else + float u_nested; +#endif`, + " gl_FragColor = vec4(u_nested);" + ), + codes: [], + fragments: ["#ifdef OUTER", "#ifdef INNER", "#else", "#endif", "uniform float u_nested;"] + }, + { + name: "independent global macros", + source: shader( + `#ifdef FIRST_SOURCE +float u_conflict; +#endif +#ifdef SECOND_SOURCE +float u_conflict; +#endif`, + " gl_FragColor = vec4(u_conflict);" + ), + codes: ["Redefinition"], + fragments: ["#ifdef FIRST_SOURCE", "#ifdef SECOND_SOURCE", "uniform float u_conflict;"], + occurrences: [["uniform float u_conflict;", 2]] + }, + { + name: "repeated canonical guard", + source: shader( + `#ifndef MATRIX_INCLUDED +#define MATRIX_INCLUDED +float u_guarded; +#endif +#ifndef MATRIX_INCLUDED +#define MATRIX_INCLUDED +float u_guarded; +#endif`, + " gl_FragColor = vec4(u_guarded);" + ), + codes: [], + fragments: ["#ifndef MATRIX_INCLUDED", "#define MATRIX_INCLUDED", "uniform float u_guarded;"], + occurrences: [ + ["#ifndef MATRIX_INCLUDED", 2], + ["#define MATRIX_INCLUDED", 2], + ["uniform float u_guarded;", 2] + ] + }, + { + name: "#undef reopens a guard", + source: shader( + `#ifndef RESETTABLE_INCLUDED +#define RESETTABLE_INCLUDED +float u_resettable; +#endif +#undef RESETTABLE_INCLUDED +#ifndef RESETTABLE_INCLUDED +#define RESETTABLE_INCLUDED +float u_resettable; +#endif`, + " gl_FragColor = vec4(u_resettable);" + ), + codes: ["Redefinition"], + fragments: ["#ifndef RESETTABLE_INCLUDED", "#define RESETTABLE_INCLUDED", "#undef RESETTABLE_INCLUDED"], + occurrences: [ + ["#ifndef RESETTABLE_INCLUDED", 2], + ["#define RESETTABLE_INCLUDED", 2], + ["uniform float u_resettable;", 2] + ] + }, + { + name: "direct and transitive canonical includes", + source: shader( + `#include "guarded.glsl" +#include "wrapper.glsl"`, + " gl_FragColor = vec4(u_included);" + ), + includeMap: { + "guarded.glsl": `#ifndef MATRIX_INCLUDED +#define MATRIX_INCLUDED +float u_included; +#endif`, + "wrapper.glsl": `#include "guarded.glsl"` + }, + codes: [], + fragments: ["#ifndef MATRIX_INCLUDED", "#define MATRIX_INCLUDED", "uniform float u_included;"], + occurrences: [ + ["#ifndef MATRIX_INCLUDED", 2], + ["#define MATRIX_INCLUDED", 2], + ["uniform float u_included;", 2] + ] + }, + { + name: "repeated unguarded include", + source: shader( + `#include "unguarded.glsl" +#include "unguarded.glsl"`, + " gl_FragColor = vec4(u_included);" + ), + includeMap: { "unguarded.glsl": "float u_included;" }, + codes: ["Redefinition"], + fragments: ["uniform float u_included;"] + }, + { + name: "independent local macro relation", + source: shader( + "", + ` #ifdef CALLER_A + float localValue = 0.0; + #endif + #ifdef CALLER_B + float localValue = 1.0; + #endif + gl_FragColor = vec4(0.0);` + ), + codes: ["Redefinition"], + fragments: ["#ifdef CALLER_A", "#ifdef CALLER_B", "float localValue = 0.0", "float localValue = 1.0"] + }, + { + name: "same-arm duplicate", + source: shader( + `#ifdef BROKEN_ARM +float u_duplicate; +float u_duplicate; +#endif`, + " gl_FragColor = vec4(u_duplicate);" + ), + codes: ["Redefinition"], + fragments: ["#ifdef BROKEN_ARM", "uniform float u_duplicate;"], + occurrences: [["uniform float u_duplicate;", 2]] + }, + { + name: "divergent variable types", + source: shader( + `#ifdef USE_VEC3 +vec3 branchColor; +#else +vec4 branchColor; +#endif`, + " gl_FragColor = vec4(branchColor.x);" + ), + codes: ["AmbiguousMacroBranchType"], + fragments: ["#ifdef USE_VEC3", "uniform vec3 branchColor;", "uniform vec4 branchColor;"] + }, + { + name: "divergent array sizes preserve element type", + source: shader( + `#ifdef SHORT_ARRAY +float branchValues[2]; +#else +float branchValues[4]; +#endif`, + " gl_FragColor = vec4(branchValues[0]);" + ), + codes: [], + fragments: ["#ifdef SHORT_ARRAY", "#else", "#endif"] + }, + { + name: "independent local macro alternatives may coexist", + source: shader( + "", + ` #ifdef MODE_A + vec2 branchSize = vec2(1.0); + gl_FragColor = vec4(branchSize, 0.0, 1.0); + #endif + #ifdef MODE_B + vec3 branchSize = vec3(1.0); + gl_FragColor = vec4(branchSize, 1.0); + #endif + gl_FragColor = vec4(0.0);` + ), + codes: ["Redefinition"], + fragments: ["#ifdef MODE_A", "#ifdef MODE_B"] + }, + { + name: "divergent array-size constness", + source: shader( + "", + ` #ifdef USE_CONST_SIZE + const int N = 2; + #else + int N = 2; + #endif + float values[N]; + gl_FragColor = vec4(values[0]);` + ), + codes: ["AmbiguousMacroBranchResolution"], + fragments: ["#ifdef USE_CONST_SIZE", "const int N = 2", "int N = 2"] + }, + { + name: "divergent struct members", + source: shader( + `#ifdef HAS_VALUE +struct BranchData { float value; }; +#else +struct BranchData { float other; }; +#endif +BranchData data;`, + " gl_FragColor = vec4(data.value);" + ), + codes: ["AmbiguousMacroBranchResolution"], + fragments: ["#ifdef HAS_VALUE", "#else", "struct BranchData { float value", "struct BranchData { float other"] + }, + { + name: "struct with conditional members remains unconditional", + source: shader( + `struct BranchData { +float value; +#if FEATURE_LEVEL != 0 +vec3 detail; +#endif +}; +BranchData data;`, + ` #ifdef WRITE_VALUE + gl_FragColor = vec4(data.value); + #else + gl_FragColor = vec4(data.value); + #endif` + ), + codes: [], + fragments: ["struct BranchData", "#if FEATURE_LEVEL != 0", "uniform BranchData data;"] + } +]; + +describe("macro branch matrix", () => { + it("marks complementary #ifndef/#elif arms as complete", () => { + const tokens = Array.from( + new AnalyzerLexer( + `#ifndef DISABLE_VALUE +float u_value; +#elif defined(DISABLE_VALUE) +float u_value; +#endif`, + {} + ).tokenize() + ); + const branches = tokens.filter((token) => token.lexeme === "u_value").map((token) => token.branch[0]); + expect(branches.map((branch) => branch.conditionalComplete)).to.deep.equal([true, true]); + expect(branches.map((branch) => branch.conditionalReachableArms)).to.deep.equal([ + [true, true], + [true, true] + ]); + }); + + it("marks complementary #ifdef/#elif !defined arms as complete", () => { + const tokens = Array.from( + new AnalyzerLexer( + `#ifdef USE_VALUE +float u_value; +#elif !defined(USE_VALUE) +float u_value; +#endif`, + {} + ).tokenize() + ); + const branches = tokens.filter((token) => token.lexeme === "u_value").map((token) => token.branch[0]); + expect(branches.map((branch) => branch.conditionalComplete)).to.deep.equal([true, true]); + expect(branches.map((branch) => branch.conditionalReachableArms)).to.deep.equal([ + [true, true], + [true, true] + ]); + }); + + it("marks #ifdef/#elif !macro-value arms as complete", () => { + const tokens = Array.from( + new AnalyzerLexer( + `#ifdef USE_VALUE +float u_value; +#elif !USE_VALUE +float u_value; +#endif`, + {} + ).tokenize() + ); + const branches = tokens.filter((token) => token.lexeme === "u_value").map((token) => token.branch[0]); + expect(branches.map((branch) => branch.conditionalComplete)).to.deep.equal([true, true]); + expect(branches.map((branch) => branch.conditionalReachableArms)).to.deep.equal([ + [true, true], + [true, true] + ]); + }); + + for (const testCase of cases) { + it(`analyzes and generates ${testCase.name}`, () => { + const { codes, fragment } = compile(testCase.source, testCase.includeMap); + expect(codes).to.deep.equal(testCase.codes); + expect(fragment).to.not.be.undefined; + const generatedFragment = fragment!; + for (const fragmentPart of testCase.fragments) expect(generatedFragment).to.include(fragmentPart); + for (const [fragmentPart, expectedCount] of testCase.occurrences ?? []) { + expect(generatedFragment.split(fragmentPart).length - 1, fragmentPart).to.equal(expectedCount); + } + }); + } +}); diff --git a/tests/src/shader-analyzer/PreprocessorExpressionDiagnostics.test.ts b/tests/src/shader-analyzer/PreprocessorExpressionDiagnostics.test.ts new file mode 100644 index 0000000000..6e4eae8e6a --- /dev/null +++ b/tests/src/shader-analyzer/PreprocessorExpressionDiagnostics.test.ts @@ -0,0 +1,131 @@ +import { DiagnosticSeverity, ShaderAnalyzer } from "@galacean/engine-shader-analyzer"; +import { describe, expect, it } from "vitest"; + +function shader(condition: string): string { + return `Shader "condition" { + SubShader "Default" { + Pass "p" { + #if ${condition} + float branchValue; + #endif + void vert() { gl_Position = vec4(0.0); } + void frag() { gl_FragColor = vec4(1.0); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; +} + +describe("preprocessor expression diagnostics", () => { + for (const condition of ["A + B > 1", "defined(A) && (B << 2) >= 4", "A ? B : C", "~A & 0xffu", "A || B && C"]) { + it(`accepts valid ESSL syntax without evaluating '${condition}'`, () => { + const diagnostics = new ShaderAnalyzer().analyze(shader(condition)).diagnostics; + expect(diagnostics.filter((diagnostic) => diagnostic.code === "PreprocessorError")).to.be.empty; + }); + } + + for (const [condition, token] of [ + ["123 defined(A)", "defined"], + ["defined()", "macro name"], + ["A +", "operand"], + ["A + * B", "operand"] + ]) { + it(`reports provably malformed syntax '${condition}'`, () => { + const diagnostic = new ShaderAnalyzer() + .analyze(shader(condition), { file: "condition.shader" }) + .diagnostics.find((candidate) => candidate.code === "PreprocessorError"); + expect(diagnostic).to.be.ok; + expect(diagnostic!.message).to.include(token); + expect(diagnostic!.file).to.equal("condition.shader"); + expect(diagnostic!.range.start.line).to.equal(4); + }); + } + + it("points at the unexpected token in a malformed expression", () => { + const source = shader("123 defined(A)"); + const diagnostic = new ShaderAnalyzer() + .analyze(source) + .diagnostics.find((candidate) => candidate.code === "PreprocessorError"); + expect(diagnostic).to.be.ok; + expect(source.slice(diagnostic!.range.start.offset, diagnostic!.range.end.offset)).to.equal("defined"); + }); + + it("does not reject adjacent unknown macro tokens that expansion may make valid", () => { + const diagnostics = new ShaderAnalyzer().analyze(shader("A CONDITION_TAIL")).diagnostics; + expect( + diagnostics.filter((diagnostic) => diagnostic.code === "PreprocessorError"), + JSON.stringify(diagnostics) + ).to.be.empty; + }); + + it("does not reject a function-like macro invocation before expansion", () => { + const source = shader("IS_SET(A)").replace( + "#if IS_SET(A)", + "#define IS_SET(value) ((value) > 0)\n #if IS_SET(A)" + ); + const diagnostics = new ShaderAnalyzer().analyze(source).diagnostics; + expect( + diagnostics.filter((diagnostic) => diagnostic.code === "PreprocessorError"), + JSON.stringify(diagnostics) + ).to.be.empty; + }); + + it("contains an unexpected validator failure as a diagnostic", () => { + const nestedCondition = `${"(".repeat(20000)}1${")".repeat(20000)}`; + expect(() => new ShaderAnalyzer().analyze(shader(nestedCondition))).to.not.throw(); + }); + + it("ignores preprocessor-looking text inside comments", () => { + const source = shader("A") + .replace("#if A", "/* #if 123 defined(A) */\n #if A") + .replace("#endif", "#endif\n // #elif 123 defined(A)"); + const diagnostics = new ShaderAnalyzer().analyze(source).diagnostics; + expect(diagnostics.filter((diagnostic) => diagnostic.code === "PreprocessorError")).to.be.empty; + }); + + it("validates a backslash-continued expression as one logical line", () => { + const source = shader("A && \\\n defined(B)"); + const diagnostics = new ShaderAnalyzer().analyze(source).diagnostics; + expect( + diagnostics.filter((diagnostic) => diagnostic.code === "PreprocessorError"), + JSON.stringify(diagnostics) + ).to.be.empty; + }); + + it("does not diagnose valid token-fragment macro replacement lists", () => { + const source = shader("A").replace( + "#if A", + "#define ADD +\n #define OPEN (\n #define TRAILING value +\n #if A" + ); + const diagnostics = new ShaderAnalyzer().analyze(source).diagnostics; + expect( + diagnostics.filter((diagnostic) => diagnostic.severity === DiagnosticSeverity.Error), + JSON.stringify(diagnostics) + ).to.be.empty; + }); + + it("maps semantic diagnostics back to the full ShaderLab source", () => { + const source = `Shader "mapping" { + float headerValue; + SubShader "Default" { + float subValue; + Pass "p" { + float branchValue; + float branchValue; + void vert() { gl_Position = vec4(0.0); } + void frag() { gl_FragColor = vec4(branchValue); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diagnostic = new ShaderAnalyzer() + .analyze(source, { file: "mapping.shader" }) + .diagnostics.find((candidate) => candidate.code === "Redefinition"); + expect(diagnostic).to.be.ok; + expect(diagnostic!.range.start.line).to.equal(7); + expect(diagnostic!.range.start.column).to.equal(13); + expect(diagnostic!.file).to.equal("mapping.shader"); + }); +}); diff --git a/tests/src/shader-analyzer/ReviewRegression.test.ts b/tests/src/shader-analyzer/ReviewRegression.test.ts new file mode 100644 index 0000000000..3c03c90c51 --- /dev/null +++ b/tests/src/shader-analyzer/ReviewRegression.test.ts @@ -0,0 +1,468 @@ +import { ShaderAnalyzer } from "@galacean/engine-shader-analyzer"; +import { ShaderCompiler } from "@galacean/engine-shader-compiler"; +import { ShaderLanguage } from "@galacean/engine-core"; +import { + GSError, + GSErrorName, + parseShaderPass, + Preprocessor, + ShaderSourceParser +} from "@galacean/engine-shader-parser/internal/analyzer"; +import { describe, expect, it } from "vitest"; + +function shader(declarations: string, fragmentBody = "gl_FragColor = vec4(1.0);"): string { + return `Shader "analyzer-regression" { SubShader "s" { Pass "p" { +struct Attributes { vec3 POSITION; }; +${declarations} +void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } +void frag() { ${fragmentBody} } +VertexShader = vert; +FragmentShader = frag; +} } }`; +} + +function codes(source: string): string[] { + return new ShaderAnalyzer().analyze(source).diagnostics.map((diagnostic) => diagnostic.code); +} + +describe("shader analyzer regressions", () => { + it("accepts vector truncation constructors", () => { + expect(codes(shader("vec3 shortValue = vec3(vec4(1.0));"))).to.not.include("ConstructorArgCount"); + }); + + it("keeps loop declarations inside their lexical scope", () => { + const source = shader( + "", + ` + float sum = 0.0; + for (int i = 0; i < 2; i++) { sum += float(i); } + for (int i = 0; i < 2; i++) { sum += float(i); } + gl_FragColor = vec4(sum);` + ); + const result = codes(source); + expect(result).to.not.include("Redefinition"); + expect(result).to.not.include("UnknownVariable"); + }); + + it("does not leak a loop declaration into the enclosing scope", () => { + const result = codes( + shader( + "", + ` + for (int i = 0; i < 2; i++) { } + gl_FragColor = vec4(float(i));` + ) + ); + expect(result).to.include("UnknownVariable"); + }); + + it("rejects incompatible vector and matrix arithmetic shapes", () => { + const result = codes( + shader( + "", + ` + vec2 a = vec2(0.0); + vec3 b = vec3(0.0); + mat2 m = mat2(1.0); + a = a + b; + a = m + a; + gl_FragColor = vec4(a, 0.0, 1.0);` + ) + ); + expect(result.filter((code) => code === "InvalidBinaryOperands")).to.have.lengthOf(2); + }); + + it("keeps valid matrix-vector multiplication valid", () => { + expect( + codes( + shader( + "", + ` + vec2 value = mat2(1.0) * vec2(1.0); + gl_FragColor = vec4(value, 0.0, 1.0);` + ) + ) + ).to.not.include("InvalidBinaryOperands"); + }); + + it("uses matrix dimensions for non-square multiplication", () => { + const result = codes( + shader( + "", + ` + mat3 validProduct = mat2x3(1.0) * mat3x2(1.0); + mat2x3 invalidProduct = mat2x3(1.0) * mat2x3(1.0); + gl_FragColor = vec4(validProduct[0], 1.0);` + ) + ); + expect(result.filter((code) => code === "InvalidBinaryOperands")).to.have.lengthOf(1); + }); + + it("validates compound arithmetic through the shared type operation", () => { + const result = codes( + shader( + "", + ` + mat2x3 value = mat2x3(1.0); + value *= mat2x3(1.0); + gl_FragColor = vec4(1.0);` + ) + ); + expect(result).to.include("InvalidBinaryOperands"); + }); + + it("rejects a bare return from a non-void function", () => { + expect(codes(shader("float missingValue() { return; }"))).to.include("InvalidReturnType"); + }); + + it("recognizes bool literals as constant initializers", () => { + expect(codes(shader("const bool enabled = true;"))).to.not.include("NonConstInitializer"); + }); + + it("validates a global const initializer through the shared declarator facts", () => { + const result = codes(shader("float runtimeValue; const float invalidValue = runtimeValue;")); + expect(result.filter((code) => code === "NonConstInitializer")).to.have.lengthOf(1); + }); + + it("validates a local array initializer through the shared declarator facts", () => { + const result = codes( + shader( + "", + `float runtimeValue = 1.0; + const float invalidValues[2] = runtimeValue; + gl_FragColor = vec4(invalidValues[0]);` + ) + ); + expect(result).to.include("NonConstInitializer"); + }); + + it("preserves const qualification and initializer checks for every declarator", () => { + const result = codes( + shader( + "", + ` + float runtimeValue = 1.0; + const float first = 1.0, second = runtimeValue; + gl_FragColor = vec4(first + second);` + ) + ); + expect(result).to.include("NonConstInitializer"); + }); + + it("treats every const declarator as a constant for later initializers", () => { + const result = codes( + shader( + "", + ` + const int first = 1, second = 2; + const int third = second; + gl_FragColor = vec4(float(first + third));` + ) + ); + expect(result).to.not.include("NonConstInitializer"); + }); + + it("keeps array shape local to each comma-separated declarator", () => { + const result = codes(shader("float first, values[2], last;", "gl_FragColor = vec4(values[0] + last[0]);")); + expect(result.filter((code) => code === "NonIndexableType")).to.have.lengthOf(1); + }); + + it("uses strict comparison facts to satisfy inclusive declaration guards", () => { + expect( + codes( + shader( + ` +#if QUALITY >= 0 +float guardedValue; +#endif +`, + ` +#if QUALITY > 0 + gl_FragColor = vec4(guardedValue); +#else + gl_FragColor = vec4(0.0); +#endif` + ) + ) + ).to.not.include("UseBeforeDeclaration"); + }); + + it("does not infer mutual recursion from an unresolved overloaded call", () => { + const result = codes( + shader(`float first(float value) { return second(vec2(value)); } +float second(vec2 value) { return first(value.x); }`) + ); + expect(result).to.include("UndefinedFunction"); + expect(result).to.not.include("RecursiveFunction"); + }); + + it("reports missing includes as preprocessing errors", () => { + const result = new ShaderAnalyzer().analyze(shader('#include "missing.glsl"')); + expect(result.diagnostics.some((diagnostic) => diagnostic.severity === "error")).to.equal(true); + expect(result.diagnostics[0].message).to.include("was not found"); + }); + + it("resolves relative includes from the supplied shader base path", () => { + const result = new ShaderAnalyzer().analyze(shader('#include "./common.glsl"'), { + basePathForIncludeKey: "shaders://root/folder/main.shader", + includeMap: { "folder/common.glsl": "float includedValue;" } + }); + expect(result.diagnostics, JSON.stringify(result.diagnostics)).to.be.empty; + }); + + it("maps included diagnostics to the include source", () => { + const included = "float includedValue;\nfloat includedValue;"; + const result = new ShaderAnalyzer().analyze(shader('#include "folder/common.glsl"'), { + file: "main.shader", + includeMap: { "folder/common.glsl": included } + }); + const diagnostic = result.diagnostics.find((candidate) => candidate.code === "Redefinition"); + expect(diagnostic).to.be.ok; + expect(diagnostic!.file).to.equal("folder/common.glsl"); + expect(diagnostic!.relatedSource).to.equal(included); + expect(diagnostic!.range.start.line).to.equal(2); + expect(diagnostic!.range.start.column).to.equal(7); + }); + + it("does not retain include inputs between analyses", () => { + const analyzer = new ShaderAnalyzer(); + const source = shader('#include "shared.glsl"'); + expect(analyzer.analyze(source, { includeMap: { "shared.glsl": "float includedValue;" } }).diagnostics).to.be.empty; + + const diagnostics = analyzer.analyze(source).diagnostics; + expect(diagnostics.some((diagnostic) => diagnostic.message.includes("was not found"))).to.equal(true); + }); + + it("resolves a nested relative include from the included chunk path", () => { + const includeMap = { + "shared/chunk.glsl": '#include "./local.glsl"', + "shared/local.glsl": "float sharedValue;", + "left/local.glsl": "float leftValue;", + "right/local.glsl": "float rightValue;" + }; + const cache = new Map(); + const left = Preprocessor.parseWithErrors( + '#include "shared/chunk.glsl"', + "shaders://root/left/main.shader", + includeMap, + cache + ); + const right = Preprocessor.parseWithErrors( + '#include "shared/chunk.glsl"', + "shaders://root/right/main.shader", + includeMap, + cache + ); + expect(left.content).to.include("sharedValue"); + expect(right.content).to.include("sharedValue"); + }); + + it("reports an include cycle without recursing indefinitely", () => { + const includeMap = { + "cycle/a.glsl": '#include "./b.glsl"', + "cycle/b.glsl": '#include "./a.glsl"' + }; + const result = Preprocessor.parseWithErrors( + '#include "cycle/a.glsl"', + "shaders://root/main.shader", + includeMap, + new Map() + ); + expect(result.errors, result.content).to.have.lengthOf(1); + expect(result.errors[0].message).to.include('cycle detected at "cycle/a.glsl"'); + expect(result.errors[0].file).to.equal("cycle/b.glsl"); + }); + + it.each([ + ["left then right", ["left", "right"]], + ["right then left", ["right", "left"]] + ])("keeps canonical include cache entries independent: %s", (_name, roots) => { + const includeMap = { + "left/chunk.glsl": '#include "./local.glsl"', + "left/local.glsl": "float leftValue;", + "right/chunk.glsl": '#include "./local.glsl"', + "right/local.glsl": "float rightValue;" + }; + const cache = new Map(); + const outputs = new Map( + roots.map((root) => [ + root, + Preprocessor.parseWithErrors('#include "./chunk.glsl"', `shaders://root/${root}/main.shader`, includeMap, cache) + .content + ]) + ); + expect(outputs.get("left")).to.include("leftValue"); + expect(outputs.get("right")).to.include("rightValue"); + }); + + it("keeps analyzer include expansion identical to runtime preprocessing", () => { + const source = '#include "shared/chunk.glsl"\nvoid frag() { gl_FragColor = vec4(includedValue); }'; + const includeMap = { + "shared/chunk.glsl": '#include "./local.glsl"', + "shared/local.glsl": "float includedValue;" + }; + const basePath = "shaders://root/left/main.shader"; + const runtime = Preprocessor.parseWithErrors(source, basePath, includeMap, new Map()); + const analyzer = parseShaderPass(source, includeMap, new Map(), basePath); + expect(analyzer.passText).to.equal(runtime.content); + }); + + it("formats source-parser positions that are not pooled ShaderPosition instances", () => { + const error = new GSError( + GSErrorName.CompilationError, + "entry is missing", + { index: 0, line: 0, column: 0 }, + undefined + ); + expect(() => error.toString()).to.not.throw(); + }); + + it("does not retain an unresolved RenderQueueType binding", () => { + const result = ShaderSourceParser.parseWithErrors(`Shader "queue" { SubShader "s" { +RenderQueueType = MissingQueue; +Pass "p" { +void vert() { gl_Position = vec4(0.0); } +void frag() { gl_FragColor = vec4(1.0); } +VertexShader = vert; +FragmentShader = frag; +} +} }`); + expect(result.errors.some((error) => error.message.includes("MissingQueue"))).to.equal(true); + expect(Object.values(result.shaderSource.subShaders[0].renderStates.variableMap)).to.not.include("MissingQueue"); + }); + + it("keeps source-parser diagnostics attached to their parse result", () => { + const invalid = ShaderSourceParser.parseWithErrors(`Shader "bad" { SubShader "s" { Pass "p" { +void vert() { gl_Position = vec4(0.0); } +VertexShader = vert; +} } }`); + const valid = ShaderSourceParser.parseWithErrors(shader("")); + expect(invalid.errors.some((error) => error.message.includes("both VertexShader and FragmentShader"))).to.equal( + true + ); + expect(valid.errors).to.be.empty; + }); + + it("keeps the first entry binding and its source range", () => { + const source = `Shader "entry" { SubShader "s" { Pass "p" { +VertexShader = firstVert; +VertexShader = secondVert; +FragmentShader = frag; +void firstVert() { gl_Position = vec4(0.0); } +void secondVert() { gl_Position = vec4(0.0); } +void frag() { gl_FragColor = vec4(1.0); } +} } }`; + const pass = ShaderSourceParser.parse(source).subShaders[0].passes[0]; + expect(pass.vertexEntry).to.equal("firstVert"); + expect(pass.vertexEntryLocation!.start.index).to.equal(source.indexOf("firstVert")); + }); + + it("does not let a prior source-structure error suppress an independent pass compile", () => { + const compiler = new ShaderCompiler(); + expect(() => + compiler._parseShaderSource(`Shader "bad" { SubShader "s" { Pass "p" { +void vert() { gl_Position = vec4(0.0); } +VertexShader = vert; +} } }`) + ).to.throw("Pass must bind both VertexShader and FragmentShader entries"); + expect( + compiler._parseShaderPass( + "void vert() { gl_Position = vec4(0.0); } void frag() { gl_FragColor = vec4(1.0); }", + "vert", + "frag", + ShaderLanguage.GLSLES100, + "" + ) + ).to.not.be.undefined; + }); + + it("rejects precompilation when an invalid RenderState property was discarded", () => { + const source = `Shader "invalid-state" { SubShader "s" { Pass "p" { +BlendState blend { NotARealProperty = true; } +void vert() { gl_Position = vec4(0.0); } +void frag() { gl_FragColor = vec4(1.0); } +VertexShader = vert; +FragmentShader = frag; +} } }`; + expect(() => new ShaderCompiler()._precompile(source, ShaderLanguage.GLSLES100, "")).to.throw( + "Invalid render state property" + ); + }); + + it("rejects precompilation after a duplicate entry assignment", () => { + const source = `Shader "duplicate-entry" { SubShader "s" { Pass "p" { +void firstVert() { gl_Position = vec4(0.0); } +void secondVert() { gl_Position = vec4(1.0); } +void frag() { gl_FragColor = vec4(1.0); } +VertexShader = firstVert; +VertexShader = secondVert; +FragmentShader = frag; +} } }`; + expect(() => new ShaderCompiler()._precompile(source, ShaderLanguage.GLSLES100, "")).to.throw( + "Reassignment of VertexShader entry" + ); + }); + + it("does not let a dead macro branch satisfy the vertex-position requirement", () => { + const result = new ShaderAnalyzer().analyze(`Shader "dead-position" { SubShader "s" { Pass "p" { +struct Attributes { vec3 POSITION; }; +void vert(Attributes attr) { +#if 0 + gl_Position = vec4(attr.POSITION, 1.0); +#endif +} +void frag() { gl_FragColor = vec4(1.0); } +VertexShader = vert; +FragmentShader = frag; +} } }`); + expect(result.diagnostics.map((diagnostic) => diagnostic.code)).to.include("MissingVertexPosition"); + }); + + it("does not treat a dead gl_FragColor write as an MRT conflict", () => { + const result = new ShaderAnalyzer().analyze(`Shader "dead-frag-color" { SubShader "s" { Pass "p" { +struct MRT { vec4 color; }; +void vert() { gl_Position = vec4(0.0); } +MRT frag() { + MRT outputValue; + outputValue.color = vec4(1.0); +#if 0 + gl_FragColor = vec4(1.0); +#endif + return outputValue; +} +VertexShader = vert; +FragmentShader = frag; +} } }`); + expect(result.diagnostics.map((diagnostic) => diagnostic.code)).to.not.include("GlFragColorWithMrt"); + }); + + it("does not apply an opposite-stage struct role to a local with the same name", () => { + const source = `Shader "stage-local" { SubShader "s" { Pass "p" { +struct Attributes { vec3 POSITION; }; +struct Varyings { vec4 color; }; +Varyings vert(Attributes input) { + Varyings outputValue; + outputValue.color = vec4(input.POSITION, 1.0); + gl_Position = vec4(input.POSITION, 1.0); + return outputValue; +} +void frag(Varyings varyingInput) { + float input = varyingInput.color.x; + gl_FragColor = vec4(input.x); +} +VertexShader = vert; +FragmentShader = frag; +} } }`; + const result = new ShaderAnalyzer().analyze(source); + expect(result.diagnostics, JSON.stringify(result.diagnostics)).to.be.empty; + const pass = ShaderSourceParser.parse(source).subShaders[0].passes[0]; + const generated = new ShaderCompiler()._parseShaderPass( + pass.contents, + pass.vertexEntry, + pass.fragmentEntry, + ShaderLanguage.GLSLES100, + "" + ); + expect(generated!.fragment).to.include("input.x"); + }); +}); diff --git a/tests/src/shader-analyzer/ShaderAnalyzer.test.ts b/tests/src/shader-analyzer/ShaderAnalyzer.test.ts new file mode 100644 index 0000000000..db09e48d9c --- /dev/null +++ b/tests/src/shader-analyzer/ShaderAnalyzer.test.ts @@ -0,0 +1,1535 @@ +import { ShaderAnalyzer } from "@galacean/engine-shader-analyzer"; +import type { Diagnostic } from "@galacean/engine-shader-analyzer"; +import { server } from "@vitest/browser/context"; +import { describe, expect, it } from "vitest"; + +const { readFile } = server.commands; + +describe("ShaderAnalyzer", () => { + const analyzer = new ShaderAnalyzer(); + + it("accepts legal preprocessing-token fragments as macro replacement lists", async () => { + for (const name of ["trailing-comma", "unbalanced-bracket", "unbalanced-paren"]) { + const source = await readFile(`src/shader-compiler/shaders/macro-token-fragment-${name}.shader`); + const { diagnostics } = analyzer.analyze(source); + expect( + diagnostics.filter((diagnostic) => diagnostic.severity === "error"), + `${name} is legal until its expansion site forms an invalid shader` + ).to.be.empty; + } + }); + + it("defers macro replacement-list references to the expansion site", () => { + const unused = `Shader "macro" { SubShader "s" { Pass "p" { + #define VALUE value + float value; + void vert() { gl_Position = vec4(0.0); } + void frag() { gl_FragColor = vec4(1.0); } + VertexShader = vert; FragmentShader = frag; + } } }`; + expect(new ShaderAnalyzer().analyze(unused).diagnostics).to.be.empty; + + const expanded = unused.replace("vec4(1.0)", "vec4(VALUE)"); + expect(new ShaderAnalyzer().analyze(expanded).diagnostics).to.be.empty; + + const missing = expanded.replace("float value;", ""); + const diagnostic = new ShaderAnalyzer() + .analyze(missing) + .diagnostics.find((candidate) => candidate.code === "UnknownVariable"); + expect(diagnostic).to.be.ok; + expect(diagnostic!.range.start.line).to.equal(5); + expect(diagnostic!.range.start.column).to.equal(41); + }); + + it("continues checking a replacement list after a macro-defined reference", () => { + const source = `Shader "macro-references" { SubShader "s" { Pass "p" { + #define KNOWN_VALUE 1.0 + #define COMBINED_VALUE KNOWN_VALUE + missingValue + void vert() { gl_Position = vec4(0.0); } + void frag() { gl_FragColor = vec4(COMBINED_VALUE); } + VertexShader = vert; FragmentShader = frag; + } } }`; + const diagnostics = analyzer.analyze(source).diagnostics; + const unknown = diagnostics.filter((diagnostic) => diagnostic.code === "UnknownVariable"); + expect(unknown, JSON.stringify(diagnostics)).to.have.lengthOf(1); + expect(unknown[0].message).to.include("missingValue"); + }); + + it("yields no diagnostics for a valid self-contained shader", () => { + const source = `Shader "valid" { + SubShader "Default" { + Pass "test" { + mat4 renderer_MVPMat; + float u_a; + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = renderer_MVPMat * vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(0.0); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const { diagnostics } = analyzer.analyze(source); + expect(diagnostics).to.be.empty; + }); + + it("surfaces an undeclared identifier as a warning diagnostic", () => { + const source = `Shader "c2" { + SubShader "Default" { + Pass "test" { + mat4 renderer_MVPMat; + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = renderer_MVPMat * vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(undeclared_color, 1.0); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const { diagnostics } = analyzer.analyze(source); + const err = diagnostics.find((d: Diagnostic) => d.code === "UnknownVariable"); + expect(err, "expected a warning for the undeclared identifier").to.be.ok; + expect(err!.severity).to.equal("warning"); + expect(err!.message).to.include("undeclared_color"); + expect(err!.range.start.line).to.be.greaterThan(0); + }); + + it("reports an undefined function call distinctly from an overload mismatch", () => { + const source = `Shader "c0-09" { + SubShader "Default" { + Pass "test" { + mat4 renderer_MVPMat; + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = renderer_MVPMat * vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = doesNotExist(1.0); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const { diagnostics } = analyzer.analyze(source); + const undef = diagnostics.find((d: Diagnostic) => d.code === "UndefinedFunction"); + expect(undef, "expected a C0-09 undefined-function diagnostic").to.be.ok; + // Warning — an unknown function name may resolve to a builtin from a runtime macro / conditional + // #include that precompile doesn't see. Overload mismatch on a known name is still an error. + expect(undef!.severity).to.equal("warning"); + expect(undef!.message).to.include("doesNotExist"); + }); + + it("rejects a variable redeclared in the same scope (first-wins, spec alignment)", () => { + const source = `Shader "c0-10" { + SubShader "Default" { + Pass "test" { + mat4 renderer_MVPMat; + float u_a; + float u_a; + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = renderer_MVPMat * vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(u_a); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const { diagnostics } = analyzer.analyze(source); + const redef = diagnostics.find((d: Diagnostic) => d.code === "Redefinition"); + expect(redef, "expected a C0-10 redefinition error").to.be.ok; + expect(redef!.severity).to.equal("error"); + expect(redef!.message).to.include("u_a"); + }); + + it("reports redefinition without exposing a codegen gate", () => { + const source = `Shader "first-wins" { + SubShader "Default" { + Pass "test" { + mat4 renderer_MVPMat; + float u_a; + float u_a; + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = renderer_MVPMat * vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(u_a); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const { diagnostics } = analyzer.analyze(source); + const redef = diagnostics.find((d: Diagnostic) => d.code === "Redefinition"); + expect(redef).to.be.ok; + }); + + it("does not flag the same name across exclusive macro branches", () => { + const source = `Shader "macro-arms" { + SubShader "Default" { + Pass "test" { + mat4 renderer_MVPMat; + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = renderer_MVPMat * vec4(attr.POSITION, 1.0); } + void frag() { + #ifdef FOO + float c = 1.0; + #else + float c = 0.0; + #endif + gl_FragColor = vec4(c); + } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const { diagnostics } = analyzer.analyze(source); + const redef = diagnostics.find((d: Diagnostic) => d.code === "Redefinition"); + expect(redef, "macro-arm siblings must not be flagged as redefinition").to.be.undefined; + }); + + it("reports an out-of-range vector swizzle", () => { + const source = `Shader "c1-01" { + SubShader "Default" { + Pass "test" { + mat4 renderer_MVPMat; + vec2 u_uv; + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = renderer_MVPMat * vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(u_uv.z, 0.0, 0.0, 1.0); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const { diagnostics } = analyzer.analyze(source); + const sw = diagnostics.find((d: Diagnostic) => d.code === "InvalidSwizzle"); + expect(sw, "expected a C1-01 swizzle diagnostic").to.be.ok; + expect(sw!.message).to.include("out of range"); + }); + + it("reports an incompatible-type assignment (C1-02)", () => { + const source = `Shader "c1-02" { + SubShader "Default" { + Pass "test" { + mat4 renderer_MVPMat; + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = renderer_MVPMat * vec4(attr.POSITION, 1.0); } + void frag() { + float a = 1.0; + vec3 b = vec3(0.0, 0.0, 0.0); + a = b; + gl_FragColor = vec4(a, a, a, 1.0); + } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const { diagnostics } = analyzer.analyze(source); + const mismatch = diagnostics.find((d: Diagnostic) => d.code === "AssignTypeMismatch"); + expect(mismatch, "expected a C1-02 type-mismatch diagnostic").to.be.ok; + expect(mismatch!.message).to.include("float"); + }); + + it("flags an int-to-float assignment as AssignTypeMismatch (§4 no implicit conversions)", () => { + // GLSL ES §4 states the language has no implicit type conversions; §5.8 requires assignment + // operands to have the same type. A real driver rejects `a = i;` where a:float, i:int with + // "cannot convert from 'const int' to 'mediump float'". The analyzer must match. + const source = `Shader "implicit" { + SubShader "Default" { + Pass "test" { + mat4 renderer_MVPMat; + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = renderer_MVPMat * vec4(attr.POSITION, 1.0); } + void frag() { + float a = 0.0; + int i = 1; + a = i; + gl_FragColor = vec4(a, a, a, 1.0); + } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const { diagnostics } = analyzer.analyze(source); + const mismatch = diagnostics.find((d: Diagnostic) => d.code === "AssignTypeMismatch"); + expect(mismatch, "int -> float has no implicit conversion — must flag AssignTypeMismatch").to.be.ok; + }); + + it("reports a return type that does not match the function (C1-03)", () => { + const source = `Shader "c1-03" { + SubShader "Default" { + Pass "test" { + mat4 renderer_MVPMat; + struct Attributes { vec3 POSITION; }; + vec3 getColor() { return 1.0; } + void vert(Attributes attr) { gl_Position = renderer_MVPMat * vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(getColor(), 1.0); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const { diagnostics } = analyzer.analyze(source); + const ret = diagnostics.find((d: Diagnostic) => d.code === "InvalidReturnType"); + expect(ret, "expected a C1-03 return-type diagnostic").to.be.ok; + expect(ret!.message).to.include("vec3"); + }); + + it("flags an int-returning literal from a float-returning function (§4 no implicit conversions)", () => { + // Same rationale as the int→float assignment check: no implicit conversion in return + // statements either. `return 1;` from a `float`-returning function is an InvalidReturnType. + const source = `Shader "ret-implicit" { + SubShader "Default" { + Pass "test" { + mat4 renderer_MVPMat; + struct Attributes { vec3 POSITION; }; + float getF() { return 1; } + void vert(Attributes attr) { gl_Position = renderer_MVPMat * vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(getF(), 0.0, 0.0, 1.0); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const { diagnostics } = analyzer.analyze(source); + const ret = diagnostics.find((d: Diagnostic) => d.code === "InvalidReturnType"); + expect(ret, "int -> float return is not a valid implicit conversion — must flag").to.be.ok; + }); + + it("isolates analyze() calls — a prior parse failure must not corrupt the next", () => { + // The extra `)` is a GLSL syntax error, so parser.parse() bails early (returns null) — which + // used to leave the shared singleton parser's trace stack / macro level dirty. + const broken = `Shader "broken" { + SubShader "Default" { + Pass "test" { + void frag() { gl_FragColor = vec4(1.0)) ; } + FragmentShader = frag; + } + } +}`; + const brokenResult = analyzer.analyze(broken); + expect(brokenResult.diagnostics.length, "the broken shader should produce a diagnostic").to.be.greaterThan(0); + + // The same valid shader must be clean afterwards — proving the failed parse left no residue. + const valid = `Shader "valid" { + SubShader "Default" { + Pass "test" { + mat4 renderer_MVPMat; + float u_a; + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = renderer_MVPMat * vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(0.0); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const { diagnostics } = analyzer.analyze(valid); + expect(diagnostics, "a valid shader must stay clean even after a prior parse failure").to.be.empty; + }); + + it("flags a Pass that does not bind both vertex and fragment entries (MissingEntry)", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + mat4 renderer_MVPMat; + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = renderer_MVPMat * vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(0.0); } + VertexShader = vert; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "MissingEntry"); + expect(diag, "a Pass missing FragmentShader must report MissingEntry").to.be.ok; + expect(diag!.severity).to.equal("error"); + expect(diag!.range.start.line, "diagnostic points at the Pass").to.equal(3); + expect(diag!.range.start.column).to.equal(9); + }); + + it("does not flag a Pass that binds both entries", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + mat4 renderer_MVPMat; + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = renderer_MVPMat * vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(0.0); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "MissingEntry"); + expect(diag, "a Pass binding both entries must not report MissingEntry").to.be.undefined; + }); + + it("flags a non-bool 'if' condition (NonBoolCondition)", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { float a = 1.0; if (a) { gl_FragColor = vec4(0.0); } } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "NonBoolCondition"); + expect(diag, "if (float) must report NonBoolCondition").to.be.ok; + expect(diag!.severity).to.equal("error"); + expect(diag!.range.start.line, "diagnostic points at the condition").to.be.greaterThan(0); + }); + + it("does not flag a bool 'if' condition", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { float a = 1.0; if (a > 0.0) { gl_FragColor = vec4(0.0); } } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "NonBoolCondition"); + expect(diag, "if (bool) must not report NonBoolCondition").to.be.undefined; + }); + + it("flags a directly recursive function (RecursiveFunction)", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + float fib(float x) { return fib(x); } + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(0.0); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diags = analyzer.analyze(source).diagnostics; + const rec = diags.find((d: Diagnostic) => d.code === "RecursiveFunction"); + expect(rec, "a self-calling function must report RecursiveFunction").to.be.ok; + expect(rec!.severity).to.equal("error"); + expect( + diags.find((d: Diagnostic) => d.code === "UndefinedFunction"), + "recursion must not be mis-reported as UndefinedFunction" + ).to.be.undefined; + }); + + it("does not flag a non-recursive function", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + float dbl(float x) { return x + x; } + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(dbl(0.5)); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const rec = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "RecursiveFunction"); + expect(rec, "a non-recursive function must not report RecursiveFunction").to.be.undefined; + }); + + it("does not flag a call to a different overload of the same name", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + float pick(float x) { return x; } + float pick(vec2 v) { return pick(v.x); } + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(0.0); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const rec = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "RecursiveFunction"); + expect(rec, "calling a different overload of the same name is not recursion").to.be.undefined; + }); + + it("flags a sampler return type (NonConstructibleReturnType)", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + mediump sampler2D u_tex; + struct Attributes { vec3 POSITION; }; + sampler2D getTex() { return u_tex; } + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(0.0); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "NonConstructibleReturnType"); + expect(diag, "a function returning a sampler must report NonConstructibleReturnType").to.be.ok; + expect(diag!.severity).to.equal("error"); + }); + + it("does not flag a normal return type", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + float getX() { return 1.0; } + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(getX()); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "NonConstructibleReturnType"); + expect(diag, "a normal return type must not report NonConstructibleReturnType").to.be.undefined; + }); + + it("flags a struct-typed member in an IO struct (NestedIOStruct)", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + struct Inner { vec4 v; }; + struct Varyings { Inner nested; }; + Varyings vert(Attributes attr) { Varyings o; o.nested.v = vec4(attr.POSITION, 1.0); return o; } + void frag(Varyings i) { gl_FragColor = i.nested.v; } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "NestedIOStruct"); + expect(diag, "a struct member of an IO struct must report NestedIOStruct").to.be.ok; + expect(diag!.severity).to.equal("error"); + expect(diag!.message).to.include("nested"); + }); + + it("does not flag an IO struct with only primitive members", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + struct Varyings { vec4 v; }; + Varyings vert(Attributes attr) { Varyings o; o.v = vec4(attr.POSITION, 1.0); return o; } + void frag(Varyings i) { gl_FragColor = i.v; } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "NestedIOStruct"); + expect(diag, "a flat IO struct must not report NestedIOStruct").to.be.undefined; + }); + + it("flags integer division by a constant zero (ConstDivideByZero)", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { int x = 1 / 0; gl_FragColor = vec4(float(x)); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "ConstDivideByZero"); + expect(diag, "integer division by constant zero must report ConstDivideByZero").to.be.ok; + expect(diag!.severity).to.equal("error"); + }); + + it("does not flag float division by a constant zero (1.0/0.0 is Inf, not an error)", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { float x = 1.0 / 0.0; gl_FragColor = vec4(x); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "ConstDivideByZero"); + expect(diag, "float division by zero yields Inf, must not report ConstDivideByZero").to.be.undefined; + }); + + it("does not flag division by a non-zero constant", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { float x = 1.0 / 2.0; gl_FragColor = vec4(x); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "ConstDivideByZero"); + expect(diag, "division by a non-zero constant must not report ConstDivideByZero").to.be.undefined; + }); + + it("flags a shift amount out of range (ShiftOutOfRange)", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { int x = 1 << 40; gl_FragColor = vec4(float(x)); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "ShiftOutOfRange"); + expect(diag, "a shift amount >= 32 must report ShiftOutOfRange").to.be.ok; + expect(diag!.severity).to.equal("error"); + }); + + it("does not flag an in-range shift amount", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { int x = 1 << 4; gl_FragColor = vec4(float(x)); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "ShiftOutOfRange"); + expect(diag, "an in-range shift must not report ShiftOutOfRange").to.be.undefined; + }); + + it("flags a constant vector index out of bounds (IndexOutOfBounds)", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { vec3 v = vec3(0.0); float y = v[5]; gl_FragColor = vec4(y); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "IndexOutOfBounds"); + expect(diag, "indexing a vec3 at 5 must report IndexOutOfBounds").to.be.ok; + expect(diag!.severity).to.equal("error"); + }); + + it("does not flag an in-bounds vector index", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { vec3 v = vec3(0.0); float y = v[1]; gl_FragColor = vec4(y); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "IndexOutOfBounds"); + expect(diag, "an in-bounds index must not report IndexOutOfBounds").to.be.undefined; + }); + + it("flags a constant array index out of bounds (IndexOutOfBounds)", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { float a[3]; float y = a[5]; gl_FragColor = vec4(y); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "IndexOutOfBounds"); + expect(diag, "indexing a 3-element array at 5 must report IndexOutOfBounds").to.be.ok; + expect(diag!.severity).to.equal("error"); + }); + + it("does not flag an in-bounds array index", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { float a[3]; float y = a[2]; gl_FragColor = vec4(y); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "IndexOutOfBounds"); + expect(diag, "an in-bounds array index must not report IndexOutOfBounds").to.be.undefined; + }); + + it("flags '!' applied to a non-bool (InvalidUnaryOperand)", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { float u_f = 1.0; bool ok = !u_f; gl_FragColor = vec4(0.0); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "InvalidUnaryOperand"); + expect(diag, "'!' on a float must report InvalidUnaryOperand").to.be.ok; + expect(diag!.severity).to.equal("error"); + }); + + it("does not flag a valid unary operand", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { bool b = true; bool ok = !b; gl_FragColor = vec4(0.0); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "InvalidUnaryOperand"); + expect(diag, "'!' on a bool must not report InvalidUnaryOperand").to.be.undefined; + }); + + it("flags arithmetic on a bool operand (InvalidBinaryOperands)", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { bool b = true; float x = b + 1.0; gl_FragColor = vec4(x); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "InvalidBinaryOperands"); + expect(diag, "bool + float must report InvalidBinaryOperands").to.be.ok; + expect(diag!.severity).to.equal("error"); + }); + + it("does not flag arithmetic on numeric operands", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { float x = 1.0 + 2.0; vec3 v = vec3(1.0) * 2.0; gl_FragColor = vec4(v, x); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "InvalidBinaryOperands"); + expect(diag, "numeric arithmetic must not report InvalidBinaryOperands").to.be.undefined; + }); + + it("flags a non-integer index (NonIntegerIndex)", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { vec3 v = vec3(0.0); float y = v[1.5]; gl_FragColor = vec4(y); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "NonIntegerIndex"); + expect(diag, "a float index must report NonIntegerIndex").to.be.ok; + expect(diag!.severity).to.equal("error"); + }); + + it("does not flag an integer index", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { vec3 v = vec3(0.0); float y = v[1]; gl_FragColor = vec4(y); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "NonIntegerIndex"); + expect(diag, "an integer index must not report NonIntegerIndex").to.be.undefined; + }); + + it("flags a single-arg sampler cast (ConstructorArgType)", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + mediump sampler2D u_tex; + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { float x = float(u_tex); gl_FragColor = vec4(x); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "ConstructorArgType"); + expect(diag, "float(sampler) must report ConstructorArgType").to.be.ok; + expect(diag!.severity).to.equal("error"); + }); + + it("flags a sampler constructor argument (ConstructorArgType)", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + mediump sampler2D u_tex; + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { vec2 v = vec2(u_tex, 1.0); gl_FragColor = vec4(v, 0.0, 1.0); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "ConstructorArgType"); + expect(diag, "vec2(sampler, ...) must report ConstructorArgType").to.be.ok; + expect(diag!.severity).to.equal("error"); + }); + + it("does not flag a numeric constructor", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { vec3 v = vec3(1.0, 2.0, 3.0); gl_FragColor = vec4(v, 1.0); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diags = analyzer.analyze(source).diagnostics; + expect( + diags.find((d: Diagnostic) => d.code === "ConstructorArgType"), + "numeric ctor: no ConstructorArgType" + ).to.be.undefined; + }); + + it("flags too few constructor components (ConstructorArgCount)", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { vec3 v = vec3(1.0, 2.0); gl_FragColor = vec4(v, 1.0); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "ConstructorArgCount"); + expect(diag, "vec3(1.0, 2.0) must report ConstructorArgCount").to.be.ok; + expect(diag!.severity).to.equal("error"); + }); + + it("does not flag a valid constructor (splat or exact components)", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { vec3 a = vec3(1.0); vec4 b = vec4(a, 1.0); gl_FragColor = b; } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "ConstructorArgCount"); + expect(diag, "splat / exact-component constructors must not report ConstructorArgCount").to.be.undefined; + }); + + it("flags a vertex that never writes gl_Position (MissingVertexPosition)", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { } + void frag() { gl_FragColor = vec4(0.0); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "MissingVertexPosition"); + expect(diag, "a vertex without gl_Position must report MissingVertexPosition").to.be.ok; + expect(diag!.severity).to.equal("error"); + }); + + it("does not flag a vertex that writes gl_Position", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(0.0); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "MissingVertexPosition"); + expect(diag, "a vertex writing gl_Position must not report MissingVertexPosition").to.be.undefined; + }); + + it("deduces an arithmetic result type (vec3+vec3 -> vec3 enables AssignTypeMismatch)", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { vec3 a = vec3(0.0); vec3 b = vec3(1.0); float x = 0.0; x = a + b; gl_FragColor = vec4(x); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "AssignTypeMismatch"); + expect(diag, "assigning vec3 (a+b) to float must report AssignTypeMismatch").to.be.ok; + }); + + it("does not flag a matching arithmetic assignment", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { vec3 a = vec3(0.0); vec3 b = vec3(1.0); vec3 x = vec3(0.0); x = a + b; gl_FragColor = vec4(x, 1.0); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "AssignTypeMismatch"); + expect(diag, "vec3 = vec3 + vec3 must not report AssignTypeMismatch").to.be.undefined; + }); + + it("flags break outside a loop (MisplacedControlFlow)", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(0.0); break; } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "MisplacedControlFlow"); + expect(diag, "break outside a loop must report MisplacedControlFlow").to.be.ok; + expect(diag!.severity).to.equal("error"); + }); + + it("does not flag break inside a loop", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { for (int i = 0; i < 4; i++) { break; } gl_FragColor = vec4(0.0); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "MisplacedControlFlow"); + expect(diag, "break inside a loop must not report MisplacedControlFlow").to.be.undefined; + }); + + it("flags continue outside a loop (MisplacedControlFlow)", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(0.0); continue; } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "MisplacedControlFlow"); + expect(diag, "continue outside a loop must report MisplacedControlFlow").to.be.ok; + expect(diag!.severity).to.equal("error"); + }); + + it("does not flag continue inside a loop", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { for (int i = 0; i < 4; i++) { continue; } gl_FragColor = vec4(0.0); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "MisplacedControlFlow"); + expect(diag, "continue inside a loop must not report MisplacedControlFlow").to.be.undefined; + }); + + it("flags indexing a scalar (NonIndexableType)", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { float f = 1.0; float y = f[0]; gl_FragColor = vec4(y); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "NonIndexableType"); + expect(diag, "indexing a scalar must report NonIndexableType").to.be.ok; + expect(diag!.severity).to.equal("error"); + }); + + it("does not flag indexing an array or a vector", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { float a[3]; vec3 v = vec3(0.0); float y = a[0] + v[0]; gl_FragColor = vec4(y); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "NonIndexableType"); + expect(diag, "indexing an array or a vector must not report NonIndexableType").to.be.undefined; + }); + + it("flags a texture sample whose first arg is not a sampler (ExpectedSampler)", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { vec2 uv = vec2(0.0); vec4 c = texture(uv, uv); gl_FragColor = c; } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "ExpectedSampler"); + expect(diag, "texture() with a non-sampler first arg must report ExpectedSampler").to.be.ok; + expect(diag!.severity).to.equal("error"); + }); + + it("does not flag a texture sample with a real sampler", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + mediump sampler2D u_tex; + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { vec2 uv = vec2(0.0); vec4 c = texture(u_tex, uv); gl_FragColor = c; } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "ExpectedSampler"); + expect(diag, "texture(sampler2D, uv) must not report ExpectedSampler").to.be.undefined; + }); + + it("flags an integer varying without flat (NonFlatIntegerVarying)", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + struct Varyings { vec4 pos; int id; }; + Varyings vert(Attributes attr) { Varyings o; o.pos = vec4(attr.POSITION, 1.0); o.id = 0; gl_Position = o.pos; return o; } + void frag(Varyings i) { gl_FragColor = vec4(float(i.id)); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "NonFlatIntegerVarying"); + expect(diag, "an integer varying without flat must report NonFlatIntegerVarying").to.be.ok; + expect(diag!.severity).to.equal("error"); + }); + + it("does not flag a flat integer varying or a float varying", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + struct Varyings { vec4 pos; flat int id; float w; }; + Varyings vert(Attributes attr) { Varyings o; o.pos = vec4(attr.POSITION, 1.0); o.id = 0; o.w = 1.0; gl_Position = o.pos; return o; } + void frag(Varyings i) { gl_FragColor = vec4(float(i.id) + i.w); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "NonFlatIntegerVarying"); + expect(diag, "a flat integer varying or a float varying must not report NonFlatIntegerVarying").to.be.undefined; + }); + + it("flags a const initialized from a non-constant (NonConstInitializer)", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + float u_scale; + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { const float c = u_scale; gl_FragColor = vec4(c); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "NonConstInitializer"); + expect(diag, "const initialized from a uniform must report NonConstInitializer").to.be.ok; + expect(diag!.severity).to.equal("error"); + }); + + it("does not flag a const initialized from a literal or another const", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { const float a = 1.0; const float b = a; gl_FragColor = vec4(b); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "NonConstInitializer"); + expect(diag, "const = literal / const = const must not report NonConstInitializer").to.be.undefined; + }); + + it("flags an array sized by a non-const variable (NonConstArraySize)", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { int n = 3; float a[n]; gl_FragColor = vec4(a[0]); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "NonConstArraySize"); + expect(diag, "an array sized by a non-const variable must report NonConstArraySize").to.be.ok; + expect(diag!.severity).to.equal("error"); + }); + + it("does not flag an array sized by a literal or a const", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { const int N = 3; float a[N]; float b[4]; gl_FragColor = vec4(a[0] + b[0]); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "NonConstArraySize"); + expect(diag, "an array sized by a literal or a const must not report NonConstArraySize").to.be.undefined; + }); + + it("does not flag an array sized by a #define macro", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + #define ARR_LEN 3 + void frag() { float a[ARR_LEN]; gl_FragColor = vec4(a[0]); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "NonConstArraySize"); + expect(diag, "a macro-sized array must not report NonConstArraySize").to.be.undefined; + }); + + it("flags a bound entry that is not a function (EntryNotFound)", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(0.0); } + VertexShader = vrt; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "EntryNotFound"); + expect(diag, "binding an entry name that is not a function must report EntryNotFound").to.be.ok; + expect(diag!.severity).to.equal("error"); + }); + + it("does not flag valid bound entries", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(0.0); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "EntryNotFound"); + expect(diag, "valid bound entries must not report EntryNotFound").to.be.undefined; + }); + + it("flags dFdx used in a vertex shader (DerivativeInVertexShader)", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { float d = dFdx(1.0); gl_Position = vec4(attr.POSITION * d, 1.0); } + void frag() { gl_FragColor = vec4(0.0); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "DerivativeInVertexShader"); + expect(diag, "dFdx in a vertex entry must report DerivativeInVertexShader").to.be.ok; + expect(diag!.severity).to.equal("error"); + expect(diag!.message).to.include("dFdx"); + }); + + it("does not flag dFdx used in a fragment shader", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { float d = dFdx(1.0); gl_FragColor = vec4(d); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "DerivativeInVertexShader"); + expect(diag, "dFdx in the fragment stage must not report DerivativeInVertexShader").to.be.undefined; + }); + + it("flags a non-float argument to dFdx (NonFloatDerivativeArg)", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { int i = 1; float d = dFdx(i); gl_FragColor = vec4(d); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "NonFloatDerivativeArg"); + expect(diag, "dFdx(int) must report NonFloatDerivativeArg").to.be.ok; + expect(diag!.severity).to.equal("error"); + }); + + it("does not flag a float argument to dFdx", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { vec2 v = vec2(0.5); vec2 d = dFdx(v); gl_FragColor = vec4(d, 0.0, 1.0); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "NonFloatDerivativeArg"); + expect(diag, "dFdx(vec2) must not report NonFloatDerivativeArg").to.be.undefined; + }); + + it("flags too many constructor components (ConstructorArgCount)", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { vec3 v = vec3(1.0, 2.0, 3.0, 4.0); gl_FragColor = vec4(v, 1.0); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "ConstructorArgCount"); + expect(diag, "vec3(1.0, 2.0, 3.0, 4.0) must report ConstructorArgCount").to.be.ok; + expect(diag!.severity).to.equal("error"); + expect(diag!.message).to.include("3 components"); + expect(diag!.message).to.include("provide 4"); + }); + + it("does not flag a single-scalar splat vec4(1.0)", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { vec4 v = vec4(1.0); gl_FragColor = v; } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "ConstructorArgCount"); + expect(diag, "single-scalar splat must not report ConstructorArgCount").to.be.undefined; + }); + + it("warns on a function redefined in the same scope (Redefinition)", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + float dbl(float x) { return x + x; } + float dbl(float x) { return x * 2.0; } + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(dbl(0.5)); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "Redefinition"); + expect(diag, "a function redeclared with the same signature must report Redefinition").to.be.ok; + expect(diag!.message).to.include("dbl"); + }); + + it("does not flag a function overload with a different signature", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + float pick(float x) { return x; } + float pick(vec2 v) { return v.x; } + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(pick(0.5)); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "Redefinition"); + expect(diag, "a different-signature overload must not report Redefinition").to.be.undefined; + }); + + it("flags a non-bool 'while' condition (NonBoolCondition)", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { float a = 1.0; while (a) { break; } gl_FragColor = vec4(a); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "NonBoolCondition"); + expect(diag, "while (float) must report NonBoolCondition").to.be.ok; + }); + + it("flags a non-bool 'for' condition (NonBoolCondition)", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { for (int i = 0; i; i++) { break; } gl_FragColor = vec4(0.0); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "NonBoolCondition"); + expect(diag, "for (…; int; …) must report NonBoolCondition").to.be.ok; + }); + + it("flags a non-bool ternary condition (NonBoolCondition)", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { float a = 1.0; float b = a ? 1.0 : 0.0; gl_FragColor = vec4(b); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "NonBoolCondition"); + expect(diag, "float ? … : … must report NonBoolCondition").to.be.ok; + }); + + it("does not flag a bool 'while' / 'for' / ternary condition", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { + int j = 0; + while (j < 3) { j++; } + for (int i = 0; i < 3; i++) { j++; } + float b = (j > 0) ? 1.0 : 0.0; + gl_FragColor = vec4(b); + } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "NonBoolCondition"); + expect(diag, "bool conditions must not report NonBoolCondition").to.be.undefined; + }); + + it("flags MissingReturn when only one branch of an if returns", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + float pickIf(float x) { if (x > 0.0) return 1.0; } + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(pickIf(1.0)); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "MissingReturn"); + expect(diag, "an if without else must not guarantee return").to.be.ok; + }); + + it("flags MissingReturn when if/else's else branch is missing return", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + float pickIfElse(float x) { if (x > 0.0) return 1.0; else { float y = x; } } + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(pickIfElse(1.0)); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "MissingReturn"); + expect(diag, "an if/else missing a return in one arm must report MissingReturn").to.be.ok; + }); + + it("does not flag MissingReturn when both if/else arms return", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + float pickBoth(float x) { if (x > 0.0) return 1.0; else return 0.0; } + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(pickBoth(1.0)); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "MissingReturn"); + expect(diag, "both arms returning must not report MissingReturn").to.be.undefined; + }); + + it("flags a vertex shader that only reads gl_Position (MissingVertexPosition)", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { vec4 x = gl_Position; } + void frag() { gl_FragColor = vec4(0.0); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "MissingVertexPosition"); + expect(diag, "a vertex that only reads gl_Position must report MissingVertexPosition").to.be.ok; + }); + + it("does not flag a vertex that writes gl_Position.xyz component-wise", () => { + const source = `Shader "x" { + SubShader "Default" { + Pass "test" { + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position.xyz = attr.POSITION; gl_Position.w = 1.0; } + void frag() { gl_FragColor = vec4(0.0); } + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "MissingVertexPosition"); + expect(diag, "component-wise writes to gl_Position must count as a write").to.be.undefined; + }); + + // RenderState errors take an early return in ShaderSourceParser, so the property never reaches + // constantMap/variableMap. The message must state "will not be applied" so a user reading only + // the diagnostic can tell the engine did not receive their intended state. + it("InvalidRenderStateProperty message states the property will not be applied", () => { + const source = `Shader "rs-drop" { SubShader "s" { Pass "p" { + BlendState bs { NotARealProperty = true; } + } } }`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "InvalidRenderStateProperty"); + expect(diag, "invalid render state property must report").to.be.ok; + expect(diag!.message, "message must warn the user the property is dropped").to.include("not be applied"); + }); + + it("InvalidRenderStateVariable message states the property will not be applied", () => { + const source = `Shader "rs-drop-var" { SubShader "s" { Pass "p" { + DepthState = undefinedDepthVar; + } } }`; + const diag = analyzer.analyze(source).diagnostics.find((d: Diagnostic) => d.code === "InvalidRenderStateVariable"); + expect(diag, "invalid render state variable must report").to.be.ok; + expect(diag!.message, "message must warn the user the property is dropped").to.include("not be applied"); + }); + + it("stops render-state recovery at a closing brace when a semicolon is missing", () => { + const source = `Shader "rs-recovery" { SubShader "s" { Pass "p" { + BlendState broken { NotARealProperty = true } + } } }`; + const diagnostics = analyzer.analyze(source).diagnostics; + expect(diagnostics.some((diagnostic) => diagnostic.code === "InvalidRenderStateProperty")).to.equal(true); + }); +}); diff --git a/tests/src/shader-analyzer/ShaderIOAnalyzer.test.ts b/tests/src/shader-analyzer/ShaderIOAnalyzer.test.ts new file mode 100644 index 0000000000..cd32d21ab3 --- /dev/null +++ b/tests/src/shader-analyzer/ShaderIOAnalyzer.test.ts @@ -0,0 +1,257 @@ +import { + AnalyzerLexer, + analyzerSemanticDiagnostics, + branchAnalysis, + Preprocessor, + ShaderClueIR, + ShaderCompilerUtils, + ShaderCoreInfo, + ShaderSourceParser, + ShaderTargetParser +} from "@galacean/engine-shader-parser/internal/analyzer"; +import { ShaderAnalyzer } from "@galacean/engine-shader-analyzer"; +import { describe, expect, it } from "vitest"; + +/** + * Expectation-driven tests for the parser's IO semantic analysis. Each case asserts + * the diagnostics the analysis SHOULD produce per RFC — one code per real problem, + * correct code. Valid shaders (incl. the kind dev/2.0 compiles) must stay clean. + */ + +const parser = ShaderTargetParser.create(branchAnalysis, analyzerSemanticDiagnostics); +const analyzer = new ShaderAnalyzer(); +const ioDiagnosticCodes = new Set([ + "InvalidIOStruct", + "InvalidEntryReturnType", + "StructRoleConflict", + "GlFragColorWithMrt", + "NestedIOStruct", + "MissingVertexPosition", + "NonFlatIntegerVarying", + "EntryNotFound" +]); + +/** Run the standalone analyzer and return IO diagnostic codes with multiplicity. */ +function ioCodes(source: string): string[] { + return analyzer + .analyze(source) + .diagnostics.map((diagnostic) => diagnostic.code) + .filter((code) => ioDiagnosticCodes.has(code)) + .sort(); +} + +function wrap(pass: string): string { + return `Shader "io" { SubShader "Default" { Pass "test" {\n${pass}\n} } }`; +} + +const cases: { name: string; source: string; expected: string[] }[] = [ + { + name: "valid: plain frag, no IO struct → clean", + expected: [], + source: wrap(` + mat4 renderer_MVPMat; + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = renderer_MVPMat * vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(0.0); } + VertexShader = vert; + FragmentShader = frag;`) + }, + { + name: "valid: full varying IO → clean", + expected: [], + source: wrap(` + struct Attributes { vec3 POSITION; }; + struct Varyings { vec4 v_color; }; + Varyings vert(Attributes attr) { Varyings o; o.v_color = vec4(1.0); gl_Position = vec4(attr.POSITION, 1.0); return o; } + void frag(Varyings i) { gl_FragColor = i.v_color; } + VertexShader = vert; + FragmentShader = frag;`) + }, + { + name: "InvalidIOStruct: vertex returns undefined varying struct (once)", + expected: ["InvalidIOStruct"], + source: wrap(` + struct Attributes { vec3 POSITION; }; + Varyings vert(Attributes attr) { Varyings o; gl_Position = vec4(0.0); return o; } + void frag() { gl_FragColor = vec4(0.0); } + VertexShader = vert; + FragmentShader = frag;`) + }, + { + name: "InvalidEntryReturnType: vertex returns non-struct/void (once)", + expected: ["InvalidEntryReturnType"], + source: wrap(` + struct Attributes { vec3 POSITION; }; + float vert(Attributes attr) { gl_Position = vec4(0.0); return 1.0; } + void frag() { gl_FragColor = vec4(0.0); } + VertexShader = vert; + FragmentShader = frag;`) + }, + { + name: "InvalidIOStruct: vertex attribute param undefined struct (once)", + expected: ["InvalidIOStruct"], + source: wrap(` + void vert(Attributes attr) { gl_Position = vec4(0.0); } + void frag() { gl_FragColor = vec4(0.0); } + VertexShader = vert; + FragmentShader = frag;`) + }, + { + name: "InvalidEntryReturnType: fragment returns non-struct/vec4 (once)", + expected: ["InvalidEntryReturnType"], + source: wrap(` + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + float frag() { return 1.0; } + VertexShader = vert; + FragmentShader = frag;`) + }, + { + name: "StructRoleConflict: same struct as Varying and Attribute — reported ONCE", + expected: ["StructRoleConflict"], + source: wrap(` + struct IO { vec4 v; }; + IO vert(IO attr) { IO o; gl_Position = vec4(0.0); return o; } + void frag() { gl_FragColor = vec4(0.0); } + VertexShader = vert; + FragmentShader = frag;`) + }, + { + name: "GlFragColorWithMrt: fragment returns MRT yet writes gl_FragColor", + expected: ["GlFragColorWithMrt"], + source: wrap(` + struct MRT { vec4 c0; }; + void vert() { gl_Position = vec4(0.0); } + MRT frag() { MRT o; o.c0 = vec4(0.0); gl_FragColor = vec4(0.0); return o; } + VertexShader = vert; + FragmentShader = frag;`) + }, + { + // Array integer varying: `prop.typeInfo.type` remains `Keyword.INT` even when the member is + // `int arr[4]`, so `TypeSystem.isIntegerType` fires — verified end-to-end here. + name: "NonFlatIntegerVarying: integer array varying (int arr[4]) must be flat", + expected: ["NonFlatIntegerVarying"], + source: wrap(` + struct Varyings { vec4 pos; int arr[4]; }; + Varyings vert() { Varyings o; gl_Position = vec4(0.0); return o; } + void frag(Varyings i) { gl_FragColor = vec4(float(i.arr[0])); } + VertexShader = vert; + FragmentShader = frag;`) + }, + { + // Multi-level nesting: `Vary.b` is caught (`typeof prop.typeInfo.type === "string"`); the + // Reporting the parent is sufficient; nested members are not diagnosed again. + name: "NestedIOStruct: multi-level (Vary → B → A) → single report on B.b", + expected: ["NestedIOStruct"], + source: wrap(` + struct A { int x; }; + struct B { A a; }; + struct Vary { B b; }; + Vary vert() { Vary o; gl_Position = vec4(0.0); return o; } + void frag(Vary i) { gl_FragColor = vec4(float(i.b.a.x)); } + VertexShader = vert; + FragmentShader = frag;`) + }, + { + // Array-of-struct as a member: `prop.typeInfo.type` is the struct name (string) regardless of + // arrayness — same check catches it. + name: "NestedIOStruct: array of struct member (Inner arr[2]) is flagged", + expected: ["NestedIOStruct"], + source: wrap(` + struct Inner { vec4 v; }; + struct Vary { Inner arr[2]; }; + Vary vert() { Vary o; gl_Position = vec4(0.0); return o; } + void frag(Vary i) { gl_FragColor = i.arr[0].v; } + VertexShader = vert; + FragmentShader = frag;`) + }, + { + name: "MissingVertexPosition: a write in an unreachable helper does not satisfy the vertex entry", + expected: ["MissingVertexPosition"], + source: wrap(` + void unused() { gl_Position = vec4(0.0); } + void vert() {} + void frag() { gl_FragColor = vec4(0.0); } + VertexShader = vert; + FragmentShader = frag;`) + }, + { + name: "valid: a write in a helper reachable from the vertex entry satisfies gl_Position", + expected: [], + source: wrap(` + void writePosition() { gl_Position = vec4(0.0); } + void vert() { writePosition(); } + void frag() { gl_FragColor = vec4(0.0); } + VertexShader = vert; + FragmentShader = frag;`) + } +]; + +describe("ShaderIOAnalyzer (expectation-driven)", () => { + for (const c of cases) { + it(c.name, () => { + expect(ioCodes(c.source)).to.deep.equal([...c.expected].sort()); + }); + } +}); + +/** Analyze one pass and return its `io` result — used to inspect struct/prop arrays post-conflict. */ +function analyzeSinglePass(source: string): { io: any; codes: string[] } { + ShaderCompilerUtils.clearAllShaderCompilerObjectPool(); + const shaderSource = ShaderSourceParser.parse(source); + const pass = shaderSource.subShaders[0].passes.find((p) => !p.isUsePass)!; + const macroDefineList = {}; + const content = Preprocessor.parse(pass.contents, "", {}, new Map()); + const lexer = new AnalyzerLexer(content, macroDefineList); + const tokens = lexer.tokenize(); + ShaderCompilerUtils.processingPassText = content; + const program = parser.parse(tokens, macroDefineList)!; + const ir = new ShaderClueIR(program, content); + const { io } = ShaderCoreInfo.create(ir, pass.vertexEntry, pass.fragmentEntry); + ShaderCompilerUtils.processingPassText = undefined; + return { io, codes: ioCodes(source) }; +} + +describe("ShaderIOAnalyzer role-conflict recovery", () => { + it("StructRoleConflict (Varying+Attribute): the offending struct is dropped from every role array", () => { + // `IO` used as both vertex return (Varying) and vertex param (Attribute) — codegen would + // emit ambiguous `in IO` and `out IO` for the same struct name; the analyzer must clear + // the struct from both role arrays so codegen never sees it. + const { io, codes } = analyzeSinglePass( + `Shader "x" { SubShader "s" { Pass "p" { + struct IO { vec4 v; }; + IO vert(IO attr) { IO o; gl_Position = vec4(0.0); return o; } + void frag() { gl_FragColor = vec4(0.0); } + VertexShader = vert; + FragmentShader = frag; + } } }` + ); + expect(codes).to.include("StructRoleConflict"); + expect(io.attributeStructs, "attributeStructs must be empty after conflict").to.have.lengthOf(0); + expect(io.varyingStructs, "varyingStructs must be empty after conflict").to.have.lengthOf(0); + expect(io.attributeList, "attributeList props follow the struct removal").to.have.lengthOf(0); + expect(io.varyingList, "varyingList props follow the struct removal").to.have.lengthOf(0); + expect(Object.keys(io.vertexStructVarMap), "vertex variable roles follow the struct removal").to.be.empty; + expect(Object.keys(io.fragmentStructVarMap), "fragment variable roles follow the struct removal").to.be.empty; + }); + + it("StructRoleConflict (Varying+MRT): the offending struct is dropped from every role array", () => { + // `IO` used as vertex return (Varying) and fragment return (MRT). + const { io, codes } = analyzeSinglePass( + `Shader "x" { SubShader "s" { Pass "p" { + struct IO { vec4 v; }; + struct Attr { vec3 p; }; + IO vert(Attr a) { IO o; gl_Position = vec4(0.0); return o; } + IO frag(IO i) { return i; } + VertexShader = vert; + FragmentShader = frag; + } } }` + ); + // The frag(IO i)/return IO chain also creates a Varying/MRT conflict; either report is fine. + expect(codes).to.include("StructRoleConflict"); + expect(io.varyingStructs, "varyingStructs empty after conflict").to.have.lengthOf(0); + expect(io.mrtStructs, "mrtStructs empty after conflict").to.have.lengthOf(0); + expect(io.vertexStructVarMap.o, "conflicting vertex local has no role").to.be.undefined; + expect(io.fragmentStructVarMap.i, "conflicting fragment parameter has no role").to.be.undefined; + }); +}); diff --git a/tests/src/shader-analyzer/ShaderPlayground.test.ts b/tests/src/shader-analyzer/ShaderPlayground.test.ts new file mode 100644 index 0000000000..8bb98c67ea --- /dev/null +++ b/tests/src/shader-analyzer/ShaderPlayground.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it, vi } from "vitest"; + +const guiState = vi.hoisted(() => ({ + options: [] as string[], + onChange: undefined as ((label: string) => void) | undefined +})); + +vi.mock("dat.gui", () => ({ + GUI: class { + add(_config: unknown, _property: string, options: string[]) { + guiState.options = options; + return new (class { + name() { + return this; + } + + onChange(callback: (label: string) => void) { + guiState.onChange = callback; + return this; + } + })(); + } + } +})); + +interface MacroScenario { + label: string; + snippet: string; + diagnosticCount: number; + diagnostic?: string; + severity?: "error" | "warning"; +} + +const MACRO_SCENARIOS: readonly MacroScenario[] = [ + { label: "宏定义 / 对象式 #define", snippet: "#define BRANCH_SCALE", diagnosticCount: 0 }, + { label: "宏定义 / 函数式 #define", snippet: "#define APPLY_SCALE", diagnosticCount: 0 }, + { label: "宏分支 / #ifdef / #else 互斥", snippet: "#ifdef USE_BRANCH_VALUE", diagnosticCount: 0 }, + { + label: "宏分支 / #ifdef / #elif 完整互补", + snippet: "#elif !defined(USE_BRANCH_VALUE)", + diagnosticCount: 0 + }, + { + label: "宏分支 / #ifdef / #elif !宏值 互补", + snippet: "#elif !USE_BRANCH_VALUE", + diagnosticCount: 0 + }, + { + label: "宏分支 / #ifdef / #elif 同条件不可达", + snippet: "#elif defined(USE_BRANCH_VALUE)", + diagnosticCount: 1, + diagnostic: "UseBeforeDeclaration", + severity: "error" + }, + { + label: "宏分支 / 非法 #elif 表达式", + snippet: "#elif 123 defined(USE_BRANCH_VALUE)", + diagnosticCount: 1, + diagnostic: "PreprocessorError", + severity: "error" + }, + { label: "宏分支 / #ifndef / #else 互斥", snippet: "#ifndef DISABLE_BRANCH_VALUE", diagnosticCount: 0 }, + { + label: "宏分支 / #ifndef / #elif 存在遗漏", + snippet: "#elif A", + diagnosticCount: 1, + diagnostic: "UseBeforeDeclaration", + severity: "error" + }, + { label: "宏分支 / #ifndef / #elif 完整互补", snippet: "#elif defined(DISABLE_BRANCH_VALUE)", diagnosticCount: 0 }, + { label: "宏分支 / #if / #elif / #else 互斥", snippet: "#if MODE == 1", diagnosticCount: 0 }, + { label: "宏分支 / 复杂算术条件完整覆盖", snippet: "#if A + B > 1", diagnosticCount: 0 }, + { + label: "宏分支 / 复杂算术条件覆盖未知", + snippet: "#if A + B > 1", + diagnosticCount: 1, + diagnostic: "UseBeforeDeclaration", + severity: "warning" + }, + { + label: "宏分支 / 复杂算术条件互斥声明", + snippet: "#if A + B <= 1", + diagnosticCount: 0 + }, + { label: "宏分支 / 嵌套互斥分支", snippet: "#ifdef OUTER", diagnosticCount: 0 }, + { + label: "宏分支 / 独立宏的全局重定义", + snippet: "#ifdef FIRST_SOURCE", + diagnosticCount: 1, + diagnostic: "Redefinition", + severity: "error" + }, + { label: "宏分支 / canonical include guard 重复", snippet: "#ifndef BRANCH_SAMPLE_INCLUDED", diagnosticCount: 0 }, + { + label: "宏分支 / #undef 重新打开 guard", + snippet: "#undef RESETTABLE_INCLUDED", + diagnosticCount: 1, + diagnostic: "Redefinition", + severity: "error" + }, + { + label: "宏分支 / 独立局部宏可能并存", + snippet: "#ifdef CALLER_A", + diagnosticCount: 1, + diagnostic: "Redefinition", + severity: "error" + }, + { + label: "宏分支 / 同一 arm 重复", + snippet: "#ifdef BROKEN_ARM", + diagnosticCount: 1, + diagnostic: "Redefinition", + severity: "error" + }, + { + label: "宏分支 / struct 成员分歧", + snippet: "#ifdef HAS_VALUE", + diagnosticCount: 1, + diagnostic: "AmbiguousMacroBranchResolution", + severity: "error" + }, + { + label: "符号 / AmbiguousMacroBranchType", + snippet: "#ifdef USE_VEC3", + diagnosticCount: 1, + diagnostic: "AmbiguousMacroBranchType", + severity: "warning" + }, + { + label: "符号 / AmbiguousMacroBranchResolution", + snippet: "#ifdef USE_CONST_SIZE", + diagnosticCount: 1, + diagnostic: "AmbiguousMacroBranchResolution", + severity: "error" + }, + { + label: "宏分支 / 未定义宏按零参与比较", + snippet: "#if !defined(MODE)", + diagnosticCount: 1, + diagnostic: "Redefinition", + severity: "error" + }, + { label: "宏分支 / 条件 #undef 未执行", snippet: "#undef CONDITIONAL_GUARD", diagnosticCount: 0 }, + { label: "宏分支 / 定义后的嵌套检查", snippet: "#define G", diagnosticCount: 0 }, + { + label: "宏分支 / 声明未覆盖引用", + snippet: "#ifdef B", + diagnosticCount: 1, + diagnostic: "UseBeforeDeclaration", + severity: "error" + }, + { label: "宏分支 / #if 0 死分支", snippet: "#if 0", diagnosticCount: 0 }, + { label: "宏分支 / #elif 继承前置否定", snippet: "#elif B", diagnosticCount: 0 } +] as const; + +describe("shader playground", () => { + it("renders every macro branch preset after a dropdown change", async () => { + await import("../../../examples/src/shader-playground"); + + const editor = document.querySelector("#ed"); + const output = document.querySelector("#out"); + expect(editor).not.toBeNull(); + expect(output).not.toBeNull(); + + expect(guiState.onChange).toBeTypeOf("function"); + for (const scenario of MACRO_SCENARIOS) { + expect(guiState.options).to.include(scenario.label); + guiState.onChange!(scenario.label); + + expect(editor!.value).to.contain(scenario.snippet); + expect(output!.textContent, scenario.label).to.contain(`Diagnostics (${scenario.diagnosticCount})`); + + if (scenario.diagnostic) { + expect(output!.textContent).to.contain(scenario.diagnostic); + expect( + output!.querySelector(`.diag.${scenario.severity}`), + `${scenario.label} should render ${scenario.severity}: ${output!.textContent}` + ).not.toBeNull(); + } else { + expect(output!.textContent).to.contain("No diagnostics"); + } + expect(output!.textContent).not.to.contain("NonConstArraySize"); + } + + for (const label of guiState.options.filter((option) => !option.startsWith("宏") && option.includes(" / "))) { + const diagnosticType = label.slice(label.lastIndexOf(" / ") + 3); + guiState.onChange!(label); + expect(output!.textContent, label).to.contain(diagnosticType); + } + + guiState.onChange!("宏分支 / 非法 #elif 表达式"); + const renderedLines = output!.querySelector(".diag.error pre")!.textContent!.split("\n"); + const sourceLineIndex = renderedLines.findIndex((line) => line.includes("#elif 123 defined(USE_BRANCH_VALUE)")); + const sourceLine = renderedLines[sourceLineIndex].slice(renderedLines[sourceLineIndex].indexOf("| ") + 2); + const markerLine = renderedLines[sourceLineIndex + 1].slice(renderedLines[sourceLineIndex + 1].indexOf("| ") + 2); + expect(markerLine.indexOf("^")).to.equal(sourceLine.indexOf("defined")); + expect(markerLine.trim()).to.equal("^^^^^^^"); + }); +}); diff --git a/tests/src/shader-compiler/DiagnosticDriverConsistency.test.ts b/tests/src/shader-compiler/DiagnosticDriverConsistency.test.ts new file mode 100644 index 0000000000..4daa9a4294 --- /dev/null +++ b/tests/src/shader-compiler/DiagnosticDriverConsistency.test.ts @@ -0,0 +1,908 @@ +/** + * Analyzer/driver consistency for GLSL-body diagnostics. + * + * The compiler pipeline is intentionally independent from authoring diagnostics. A runtime macro + * or conditional `#include` may fill in what looks broken at precompile time, so the layers stay + * separate: + * analyzer → decides whether a diagnostic fires and at what severity + * codegen → produces GLSL or rejects a structural source/entry failure, without reading diagnostics + * driver → is the source of truth for what will actually run + * + * This suite ties the three together per case: + * - drive the DSL through the analyzer to collect diagnostics + * - drive the same pass content through the compiler to collect emitted GLSL + * - feed the emitted GLSL to a real WebGL2 context + * - assert the driver outcome matches the severity contract we set: + * severity=error → codegen rejects a structural failure, or the emitted source reaches the driver and is rejected + * severity=warning → the precompile GLSL alone still fails the driver; the warning + * severity encodes intent ("a runtime macro may rescue this at bind + * time"), NOT a claim that the driver would accept the GLSL as-is. + * If a case genuinely leaves driver behavior open (e.g. spec-undefined + * folding), it is marked `driverExpects: "either"` and documented. + * no diagnostic → driver must accept (clean shader stays clean) + */ + +import { Logger, ShaderLanguage } from "@galacean/engine-core"; +import { ShaderAnalyzer, type Diagnostic } from "@galacean/engine-shader-analyzer"; +import { ShaderCompiler } from "@galacean/engine-shader-compiler"; +import { describe, expect, it } from "vitest"; + +interface DriverOutcome { + vertexOk: boolean; + fragmentOk: boolean; + vertexLog: string; + fragmentLog: string; +} + +function driveWebGL(vs: string, fs: string): DriverOutcome | "no-webgl" { + const canvas = document.createElement("canvas"); + const gl = canvas.getContext("webgl"); + if (!gl) return "no-webgl"; + const compileOne = (src: string, type: number): { ok: boolean; log: string } => { + const sh = gl.createShader(type)!; + gl.shaderSource(sh, src); + gl.compileShader(sh); + const ok = gl.getShaderParameter(sh, gl.COMPILE_STATUS) as boolean; + const log = gl.getShaderInfoLog(sh) || ""; + return { ok, log }; + }; + const v = compileOne(vs, gl.VERTEX_SHADER); + const f = compileOne(fs, gl.FRAGMENT_SHADER); + return { vertexOk: v.ok, fragmentOk: f.ok, vertexLog: v.log, fragmentLog: f.log }; +} + +function wrapDSL(passBody: string, vertEntry: string, fragEntry: string): string { + // `_parseShaderPass` takes the raw GLSL body (entries are named parameters), so cases store the + // body alone. To feed the same case through `analyzer.analyze` we wrap it in the full DSL and + // append the entry-binding directives that the DSL parser expects. + return `Shader "consistency" { SubShader "s" { Pass "p" { +${passBody} +VertexShader = ${vertEntry}; +FragmentShader = ${fragEntry}; +} } }`; +} + +interface Case { + name: string; + code: string; + severity: "error" | "warning" | "none"; + passBody: string; + vertEntry: string; + fragEntry: string; + compilerExpects?: "emit" | "reject"; + driverExpects: "reject" | "accept" | "either"; + reason: string; +} + +const cases: Case[] = [ + { + name: "clean shader — no diagnostics, driver accepts", + code: "", + severity: "none", + passBody: ` + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(1.0); } + `, + vertEntry: "vert", + fragEntry: "frag", + driverExpects: "accept", + reason: "baseline — no diagnostic must correspond to a driver-clean shader" + }, + { + name: "AssignTypeMismatch (float → vec3) — analyzer errors, driver rejects", + code: "AssignTypeMismatch", + severity: "error", + passBody: ` + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { vec3 v; v = 1.0; gl_FragColor = vec4(v, 1.0); } + `, + vertEntry: "vert", + fragEntry: "frag", + driverExpects: "reject", + reason: "assigning a scalar to a vec3 is a real type error every driver rejects" + }, + { + name: "ConstructorArgCount (vec3 with 2 args) — analyzer errors, driver rejects", + code: "ConstructorArgCount", + severity: "error", + passBody: ` + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { vec3 v = vec3(1.0, 2.0); gl_FragColor = vec4(v, 1.0); } + `, + vertEntry: "vert", + fragEntry: "frag", + driverExpects: "reject", + reason: "vec3 constructor takes 1 or 3 components — driver rejects any other count" + }, + { + name: "InvalidReturnType (returning value from void) — analyzer errors, driver rejects", + code: "InvalidReturnType", + severity: "error", + passBody: ` + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { return vec4(0.0); } + `, + vertEntry: "vert", + fragEntry: "frag", + driverExpects: "reject", + reason: "returning an expression from a void function is a driver-level error" + }, + { + name: "IndexOutOfBounds (constant OOB index) — analyzer errors, driver rejects", + code: "IndexOutOfBounds", + severity: "error", + passBody: ` + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { vec3 v = vec3(0.0); float y = v[5]; gl_FragColor = vec4(y); } + `, + vertEntry: "vert", + fragEntry: "frag", + driverExpects: "reject", + reason: "constant OOB index on a vec3 is rejected by GLSL ES §5.5 spec-conforming drivers" + }, + { + name: "UnknownVariable — analyzer warns, driver rejects the precompile GLSL", + code: "UnknownVariable", + severity: "warning", + passBody: ` + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(undeclared_color, 1.0); } + `, + vertEntry: "vert", + fragEntry: "frag", + // The analyzer cannot know whether the material supplies this identifier as a runtime macro. + // The concrete precompile variant does not define it, so the driver must still reject it. + driverExpects: "reject", + reason: "unknown identifiers may be runtime macros, but this concrete precompile GLSL is not runnable" + }, + { + name: "UndefinedFunction — analyzer warns, driver rejects the precompile GLSL", + code: "UndefinedFunction", + severity: "warning", + passBody: ` + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(doesNotExist(1.0)); } + `, + vertEntry: "vert", + fragEntry: "frag", + driverExpects: "reject", + reason: "same rationale as UnknownVariable — warning is intent, driver still rejects" + }, + { + name: "NoMatchingOverload (known name, wrong args) — analyzer errors, driver rejects", + code: "NoMatchingOverload", + severity: "error", + passBody: ` + struct Attributes { vec3 POSITION; }; + float f(float a) { return a; } + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(f(vec3(0.0))); } + `, + vertEntry: "vert", + fragEntry: "frag", + driverExpects: "reject", + reason: "the name resolves, but there is no overload accepting the given arg types" + }, + { + name: "NonBoolCondition (float in `if`) — analyzer errors, driver rejects", + code: "NonBoolCondition", + severity: "error", + passBody: ` + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { float f = 1.0; if (f) { gl_FragColor = vec4(1.0); } else { gl_FragColor = vec4(0.0); } } + `, + vertEntry: "vert", + fragEntry: "frag", + driverExpects: "reject", + reason: "GLSL requires a bool in if-conditions; drivers reject implicit float coercion" + }, + { + name: "MisplacedControlFlow (break outside loop) — analyzer errors, driver rejects", + code: "MisplacedControlFlow", + severity: "error", + passBody: ` + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(0.0); break; } + `, + vertEntry: "vert", + fragEntry: "frag", + driverExpects: "reject", + reason: "`break` outside any loop or switch is a driver-level parse error" + }, + // ─────────────────────────── Type diagnostics ─────────────────────────── + { + name: "InvalidSwizzle (out-of-set letters)", + code: "InvalidSwizzle", + severity: "error", + passBody: ` + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { vec3 v = vec3(0.0); float x = v.abc; gl_FragColor = vec4(x); } + `, + vertEntry: "vert", + fragEntry: "frag", + driverExpects: "reject", + reason: "swizzle chars must be from one set only (xyzw / rgba / stpq)" + }, + { + name: "UndeclaredStructMember", + code: "UndeclaredStructMember", + severity: "error", + passBody: ` + struct Attributes { vec3 POSITION; }; + struct S { float f; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { S s; float y = s.notAField; gl_FragColor = vec4(y); } + `, + vertEntry: "vert", + fragEntry: "frag", + driverExpects: "reject", + reason: "accessing a non-existent struct field is rejected by any driver" + }, + { + name: "ConstDivideByZero (integer 1/0)", + code: "ConstDivideByZero", + severity: "error", + passBody: ` + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { int x = 1 / 0; gl_FragColor = vec4(float(x)); } + `, + vertEntry: "vert", + fragEntry: "frag", + // A driver may fold this constant and accept the shader (behavior is + // implementation-defined per GLSL ES §4.3.3). Analyzer is stricter. + driverExpects: "either", + reason: "constant integer division by zero — spec undefined; driver may fold" + }, + { + name: "ShiftOutOfRange (1 << 40)", + code: "ShiftOutOfRange", + severity: "error", + passBody: ` + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { int x = 1 << 40; gl_FragColor = vec4(float(x)); } + `, + vertEntry: "vert", + fragEntry: "frag", + // Overflow behavior is implementation-defined; drivers vary. + driverExpects: "either", + reason: "shift by 40 exceeds int width — GLSL ES leaves behavior implementation-defined" + }, + { + name: "NonIntegerIndex (v[1.5])", + code: "NonIntegerIndex", + severity: "error", + passBody: ` + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { vec3 v = vec3(0.0); float y = v[1.5]; gl_FragColor = vec4(y); } + `, + vertEntry: "vert", + fragEntry: "frag", + driverExpects: "reject", + reason: "vector index must be an integer expression" + }, + { + name: "NonIndexableType (float[0])", + code: "NonIndexableType", + severity: "error", + passBody: ` + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { float f = 1.0; float y = f[0]; gl_FragColor = vec4(y); } + `, + vertEntry: "vert", + fragEntry: "frag", + driverExpects: "reject", + reason: "scalar float is not indexable" + }, + { + name: "ExpectedSampler (first arg to texture() is not a sampler)", + code: "ExpectedSampler", + severity: "error", + passBody: ` + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { vec2 uv = vec2(0.0); vec4 c = texture(uv, uv); gl_FragColor = c; } + `, + vertEntry: "vert", + fragEntry: "frag", + driverExpects: "reject", + reason: "texture() requires a sampler as its first argument" + }, + { + name: "InvalidUnaryOperand (!float)", + code: "InvalidUnaryOperand", + severity: "error", + passBody: ` + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { float f = 1.0; bool ok = !f; gl_FragColor = vec4(0.0); } + `, + vertEntry: "vert", + fragEntry: "frag", + driverExpects: "reject", + reason: "logical NOT is defined only for bool" + }, + { + name: "InvalidBinaryOperands (bool + float)", + code: "InvalidBinaryOperands", + severity: "error", + passBody: ` + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { bool b = true; float x = b + 1.0; gl_FragColor = vec4(x); } + `, + vertEntry: "vert", + fragEntry: "frag", + driverExpects: "reject", + reason: "no arithmetic operator accepts a bool + float pair" + }, + { + name: "ConstructorArgType (sampler2D as vec2 component)", + code: "ConstructorArgType", + severity: "error", + passBody: ` + struct Attributes { vec3 POSITION; }; + mediump sampler2D u_tex; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { vec2 v = vec2(u_tex, 1.0); gl_FragColor = vec4(v, 0.0, 1.0); } + `, + vertEntry: "vert", + fragEntry: "frag", + driverExpects: "reject", + reason: "a sampler type can never appear as a constructor arg" + }, + { + name: "NonConstInitializer (const initialized from uniform)", + code: "NonConstInitializer", + severity: "error", + passBody: ` + struct Attributes { vec3 POSITION; }; + float u_scale; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { const float c = u_scale; gl_FragColor = vec4(c); } + `, + vertEntry: "vert", + fragEntry: "frag", + driverExpects: "reject", + reason: "a const initializer must be a compile-time constant" + }, + { + name: "NonConstArraySize (array sized by non-const var)", + code: "NonConstArraySize", + severity: "error", + passBody: ` + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { int n = 3; float a[n]; gl_FragColor = vec4(a[0]); } + `, + vertEntry: "vert", + fragEntry: "frag", + driverExpects: "reject", + reason: "GLSL ES §4.1.9 array size must be a constant expression" + }, + { + name: "InvalidArraySize (float a[0])", + code: "InvalidArraySize", + severity: "error", + passBody: ` + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { float a[0]; gl_FragColor = vec4(a[0]); } + `, + vertEntry: "vert", + fragEntry: "frag", + driverExpects: "reject", + reason: "GLSL ES §4.1.9 array size must be > 0" + }, + { + name: "NonFloatDerivativeArg (dFdx on int)", + code: "NonFloatDerivativeArg", + severity: "error", + passBody: ` + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { int i = 1; float d = dFdx(i); gl_FragColor = vec4(d); } + `, + vertEntry: "vert", + fragEntry: "frag", + driverExpects: "reject", + reason: "derivatives accept only float/vec* — integer arg has no overload" + }, + // ─────────────────── Function / control-flow diagnostics ─────────────────── + { + name: "MissingReturn (non-void function without return)", + code: "MissingReturn", + severity: "error", + passBody: ` + struct Attributes { vec3 POSITION; }; + float getX() { float a = 1.0; } + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(getX()); } + `, + vertEntry: "vert", + fragEntry: "frag", + driverExpects: "reject", + reason: "non-void function must return on every path" + }, + { + name: "Redefinition (variable redeclared in same scope)", + code: "Redefinition", + severity: "error", + passBody: ` + struct Attributes { vec3 POSITION; }; + float u_a; + float u_a; + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(u_a); } + `, + vertEntry: "vert", + fragEntry: "frag", + driverExpects: "reject", + reason: "redeclaring an identifier in the same scope is rejected" + }, + { + name: "RecursiveFunction (direct recursion)", + code: "RecursiveFunction", + severity: "error", + passBody: ` + struct Attributes { vec3 POSITION; }; + float f(float x) { return f(x); } + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(f(1.0)); } + `, + vertEntry: "vert", + fragEntry: "frag", + driverExpects: "reject", + reason: "GLSL ES §6.1 forbids recursion (direct or indirect)" + }, + { + name: "NonConstructibleReturnType (function returns sampler)", + code: "NonConstructibleReturnType", + severity: "error", + passBody: ` + struct Attributes { vec3 POSITION; }; + mediump sampler2D u_tex; + sampler2D getTex() { return u_tex; } + void vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(0.0); } + `, + vertEntry: "vert", + fragEntry: "frag", + // Codegen may drop an unreferenced function; when it does, the driver never sees it and + // accepts the shader. The analyzer's authoring-time check still stands — it fires as + // soon as the type appears in a `return`, not only when the function is reached. + driverExpects: "either", + reason: "sampler is opaque; analyzer catches at authoring; driver only sees emitted code" + }, + { + name: "DerivativeInVertexShader (dFdx in vert)", + code: "DerivativeInVertexShader", + severity: "error", + passBody: ` + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { float d = dFdx(1.0); gl_Position = vec4(d); } + void frag() { gl_FragColor = vec4(0.0); } + `, + vertEntry: "vert", + fragEntry: "frag", + driverExpects: "reject", + reason: "derivative built-ins are fragment-stage-only" + }, + // ─────────────────────── Pipeline / IO diagnostics ─────────────────────── + { + name: "InvalidEntryReturnType (int return from vert)", + code: "InvalidEntryReturnType", + severity: "error", + passBody: ` + struct Attributes { vec3 POSITION; }; + int vert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); return 1; } + void frag() { gl_FragColor = vec4(0.0); } + `, + vertEntry: "vert", + fragEntry: "frag", + driverExpects: "reject", + reason: "entry function must return void or an IO struct" + }, + { + name: "StructRoleConflict (same struct used as vert-out AND frag-out)", + code: "StructRoleConflict", + severity: "error", + passBody: ` + struct IO { vec4 v; }; + IO vert() { IO o; o.v = vec4(0.0); gl_Position = vec4(0.0); return o; } + IO frag(IO i) { IO o; o.v = i.v; return o; } + `, + vertEntry: "vert", + fragEntry: "frag", + // Codegen may reject entirely; if it produces GLSL, driver typically rejects too. + driverExpects: "either", + reason: "a struct can play at most one IO role — attributes / varyings / MRT" + }, + { + name: "GlFragColorWithMrt (MRT struct + gl_FragColor write)", + code: "GlFragColorWithMrt", + severity: "error", + passBody: ` + struct MRT { vec4 c0; }; + void vert() { gl_Position = vec4(0.0); } + MRT frag() { MRT o; o.c0 = vec4(0.0); gl_FragColor = vec4(0.0); return o; } + `, + vertEntry: "vert", + fragEntry: "frag", + driverExpects: "either", + reason: "an MRT-returning fragment must not also write gl_FragColor" + }, + { + name: "NestedIOStruct (varying struct contains a nested struct)", + code: "NestedIOStruct", + severity: "error", + passBody: ` + struct Inner { vec4 v; }; + struct Varyings { Inner nested; }; + Varyings vert() { Varyings o; gl_Position = vec4(0.0); return o; } + void frag(Varyings i) { gl_FragColor = i.nested.v; } + `, + vertEntry: "vert", + fragEntry: "frag", + driverExpects: "either", + reason: "GLSL ES varyings must be flat structs of scalars/vecs, not nested" + }, + { + name: "MissingVertexPosition (vert never writes gl_Position)", + code: "MissingVertexPosition", + severity: "error", + passBody: ` + void vert() { } + void frag() { gl_FragColor = vec4(0.0); } + `, + vertEntry: "vert", + fragEntry: "frag", + driverExpects: "either", + reason: "vertex stage must write gl_Position for the pipeline to run" + }, + { + name: "NonFlatIntegerVarying (int in varyings without flat)", + code: "NonFlatIntegerVarying", + severity: "error", + passBody: ` + struct Varyings { vec4 pos; int id; }; + Varyings vert() { Varyings o; gl_Position = vec4(0.0); return o; } + void frag(Varyings i) { gl_FragColor = vec4(float(i.id)); } + `, + vertEntry: "vert", + fragEntry: "frag", + driverExpects: "either", + reason: "GLSL ES 3.00 §4.3.4 integer varyings must be flat" + }, + { + name: "EntryNotFound (compiler passed an entry name that doesn't exist)", + code: "EntryNotFound", + severity: "error", + passBody: ` + void vert() { gl_Position = vec4(0.0); } + void frag() { gl_FragColor = vec4(0.0); } + `, + // Deliberately misspelled to trip EntryNotFound + vertEntry: "vrt", + fragEntry: "frag", + compilerExpects: "reject", + driverExpects: "either", + reason: "compile-time entry lookup miss — codegen has nothing to emit" + }, + // ─────────── InvalidAssignmentTarget — GLSL ES §5.8 l-value rule ─────────── + { + // Macro-as-LHS is deliberately NOT flagged by the analyzer. Whether a macro is an l-value + // depends on its expansion — `#define lumaN luma4B.z` in FXAA3_11.glsl expands to a legal + // swizzle l-value, and the driver accepts `lumaN = lumaW;`. Trying to reject every + // macro-LHS falsely rejects that shipping FXAA shader. If the expansion really is illegal + // (e.g. `#define K 3; K = 5;`), the driver still catches it after preprocess. + name: "Macro as LHS — analyzer stays silent, expansion may or may not be a legal l-value", + code: "", + severity: "none", + passBody: ` + #define A 1 + void vert() { gl_Position = vec4(0.0); } + void frag() { A = 2; gl_FragColor = vec4(1.0); } + `, + vertEntry: "vert", + fragEntry: "frag", + // Only the expanded source determines whether the assignment target is valid. + driverExpects: "reject", + reason: "expansion decides — analyzer refuses to pre-judge macros" + }, + { + name: "InvalidAssignmentTarget — assign to a numeric literal", + code: "InvalidAssignmentTarget", + severity: "error", + passBody: ` + void vert() { gl_Position = vec4(0.0); } + void frag() { 1 = 2; gl_FragColor = vec4(1.0); } + `, + vertEntry: "vert", + fragEntry: "frag", + driverExpects: "reject", + reason: "an integer literal cannot be an l-value" + }, + { + name: "InvalidAssignmentTarget — assign to a function-call result", + code: "InvalidAssignmentTarget", + severity: "error", + passBody: ` + float f() { return 1.0; } + void vert() { gl_Position = vec4(0.0); } + void frag() { f() = 2.0; gl_FragColor = vec4(1.0); } + `, + vertEntry: "vert", + fragEntry: "frag", + driverExpects: "reject", + reason: "a function-call result is a temporary — never an l-value" + }, + { + name: "InvalidAssignmentTarget — assign to a const-qualified variable", + code: "InvalidAssignmentTarget", + severity: "error", + passBody: ` + const float C = 1.0; + void vert() { gl_Position = vec4(0.0); } + void frag() { C = 2.0; gl_FragColor = vec4(C); } + `, + vertEntry: "vert", + fragEntry: "frag", + driverExpects: "reject", + reason: "a const-qualified variable is read-only per §4.3.2" + }, + { + name: "InvalidAssignmentTarget — assign to a compound arithmetic expression", + code: "InvalidAssignmentTarget", + severity: "error", + passBody: ` + void vert() { gl_Position = vec4(0.0); } + void frag() { float a = 1.0; float b = 2.0; (a + b) = 3.0; gl_FragColor = vec4(a); } + `, + vertEntry: "vert", + fragEntry: "frag", + driverExpects: "reject", + reason: "the result of an arithmetic operator is an r-value" + }, + // ─────── AssignTypeMismatch — GLSL ES §5.8 / §5.4.1 no implicit conversions ─────── + { + name: "AssignTypeMismatch — float initializer takes an int literal", + code: "AssignTypeMismatch", + severity: "error", + passBody: ` + void vert() { gl_Position = vec4(0.0); } + void frag() { float b = 1; gl_FragColor = vec4(b); } + `, + vertEntry: "vert", + fragEntry: "frag", + driverExpects: "reject", + reason: "GLSL ES §4 has no implicit int→float; driver rejects with 'cannot convert from const int to float'" + }, + { + name: "AssignTypeMismatch — int initializer takes a float literal", + code: "AssignTypeMismatch", + severity: "error", + passBody: ` + void vert() { gl_Position = vec4(0.0); } + void frag() { int a = 1.0; gl_FragColor = vec4(float(a)); } + `, + vertEntry: "vert", + fragEntry: "frag", + driverExpects: "reject", + reason: "GLSL ES §4 has no implicit float→int; driver rejects at initializer" + }, + { + name: "AssignTypeMismatch — assignment `float b; b = 1;` (later write)", + code: "AssignTypeMismatch", + severity: "error", + passBody: ` + void vert() { gl_Position = vec4(0.0); } + void frag() { float b = 0.0; b = 1; gl_FragColor = vec4(b); } + `, + vertEntry: "vert", + fragEntry: "frag", + driverExpects: "reject", + reason: "§5.8 assignment operands must have the same type — no implicit conversion" + }, + // ───────────────────────── Additional type diagnostics ───────────────────────── + { + name: "InvalidBinaryOperands — `%` on floats", + code: "InvalidBinaryOperands", + severity: "error", + passBody: ` + float u_f; + void vert() { gl_Position = vec4(0.0); } + void frag() { float x = u_f % u_f; gl_FragColor = vec4(x); } + `, + vertEntry: "vert", + fragEntry: "frag", + driverExpects: "reject", + reason: "GLSL ES §5.9: modulo requires integer operands" + }, + { + name: "InvalidAssignmentTarget — `u_i++` on a uniform (`u_i` global, no initializer)", + code: "InvalidAssignmentTarget", + severity: "error", + passBody: ` + int u_i; + void vert() { gl_Position = vec4(0.0); } + void frag() { u_i++; gl_FragColor = vec4(float(u_i)); } + `, + vertEntry: "vert", + fragEntry: "frag", + // Codegen still emits GLSL; the driver rejects with "l-value required (can't modify a uniform)". + driverExpects: "reject", + reason: "GLSL ES §5.9: a uniform is not an l-value" + }, + { + name: "InvalidSwizzle — `.rr` on a sampler receiver", + code: "InvalidSwizzle", + severity: "error", + passBody: ` + sampler2D s; + void vert() { gl_Position = vec4(0.0); } + void frag() { vec2 v = s.rr; gl_FragColor = vec4(v, 0.0, 1.0); } + `, + vertEntry: "vert", + fragEntry: "frag", + driverExpects: "reject", + reason: "GLSL ES §5.5: field selection requires structure / vector / scalar receiver" + }, + { + name: "InvalidSwizzle — `.xx` on a void function-call result", + code: "InvalidSwizzle", + severity: "error", + passBody: ` + void f() {} + void vert() { gl_Position = vec4(0.0); } + void frag() { f().xx; gl_FragColor = vec4(0.0); } + `, + vertEntry: "vert", + fragEntry: "frag", + driverExpects: "reject", + reason: "§5.5 — same rule as G3, void return has no fields" + }, + { + name: "InvalidBinaryOperands — `&&` between two int values (not bool)", + code: "InvalidBinaryOperands", + severity: "error", + passBody: ` + int u_i; + void vert() { gl_Position = vec4(0.0); } + void frag() { int x = int(u_i && u_i); gl_FragColor = vec4(float(x)); } + `, + vertEntry: "vert", + fragEntry: "frag", + driverExpects: "reject", + reason: "GLSL ES §5.9: `&&` requires scalar bool operands" + }, + { + name: "InvalidBinaryOperands — `int + float` mix", + code: "InvalidBinaryOperands", + severity: "error", + passBody: ` + int u_i; + float u_f; + void vert() { gl_Position = vec4(0.0); } + void frag() { float x = float(u_i + u_f > 0 ? 1 : 0); gl_FragColor = vec4(x); } + `, + vertEntry: "vert", + fragEntry: "frag", + // The `u_i + u_f` sub-expression is the target — driver rejects the whole shader. + driverExpects: "reject", + reason: "GLSL ES §4: no implicit conversion between int and float in arithmetic" + }, + { + name: "InvalidBinaryOperands — `ivec3 + uvec3` mix", + code: "InvalidBinaryOperands", + severity: "error", + passBody: ` + ivec3 u_iv3; + uvec3 u_uv3; + void vert() { gl_Position = vec4(0.0); } + void frag() { ivec3 x = u_iv3 + u_uv3; gl_FragColor = vec4(float(x.x)); } + `, + vertEntry: "vert", + fragEntry: "frag", + driverExpects: "reject", + reason: "GLSL ES §5.9: int and uint families are distinct — no auto-conversion" + }, + { + name: "LocalFunctionPrototype — `int g();` inside a function body", + code: "LocalFunctionPrototype", + severity: "error", + passBody: ` + void vert() { gl_Position = vec4(0.0); } + void frag() { int g(); gl_FragColor = vec4(0.0); } + `, + vertEntry: "vert", + fragEntry: "frag", + compilerExpects: "reject", + driverExpects: "reject", + reason: "GLSL ES §6: function prototypes only at global scope" + } +]; + +/** Best-effort collector for diagnostic codes surfaced through the engine Logger during compile. */ +function captureLoggerDiagnostics(fn: () => T): { result: T; errors: string[]; warns: string[] } { + const errors: string[] = []; + const warns: string[] = []; + const origError = Logger.error; + const origWarn = Logger.warn; + Logger.error = (...args: unknown[]) => errors.push(args.join(" ")); + Logger.warn = (...args: unknown[]) => warns.push(args.join(" ")); + try { + return { result: fn(), errors, warns }; + } finally { + Logger.error = origError; + Logger.warn = origWarn; + } +} + +describe("analyzer/codegen/driver consistency", () => { + for (const c of cases) { + it(c.name, () => { + // 1) Analyzer view — structured diagnostics off the DSL. + const analyzer = new ShaderAnalyzer(); + const dsl = wrapDSL(c.passBody, c.vertEntry, c.fragEntry); + const analyzed = analyzer.analyze(dsl); + const matching: Diagnostic | undefined = c.code ? analyzed.diagnostics.find((d) => d.code === c.code) : undefined; + + if (c.severity === "none") { + expect(analyzed.diagnostics, `${c.name}: expected no diagnostics`).to.be.empty; + } else { + expect(matching, `${c.name}: expected diagnostic ${c.code}`).to.be.ok; + expect(matching!.severity, `${c.name}: severity`).to.equal(c.severity); + } + + // 2) Codegen view — feed the same body through the compiler independently of diagnostics. + const compiler = new ShaderCompiler(); + const compiled = captureLoggerDiagnostics(() => + compiler._parseShaderPass(c.passBody, c.vertEntry, c.fragEntry, ShaderLanguage.GLSLES100, "") + ); + + if (c.compilerExpects === "reject") { + expect(compiled.result, `${c.name}: structural generation failure`).to.be.undefined; + return; + } + + expect(compiled.result, `${c.name}: analyzer-independent codegen must emit source for the driver`).not.to.be + .undefined; + + // 3) Driver view — try to compile the emitted GLSL on a real WebGL context. + const driver = driveWebGL(compiled.result!.vertex, compiled.result!.fragment); + if (driver === "no-webgl") { + console.warn(`[${c.name}] WebGL unavailable — driver check skipped`); + return; + } + + const bothCompiled = driver.vertexOk && driver.fragmentOk; + if (c.driverExpects === "accept") { + expect( + bothCompiled, + `${c.name}: expected driver to accept — vertexLog=${driver.vertexLog} fragmentLog=${driver.fragmentLog}` + ).to.be.true; + } else if (c.driverExpects === "reject") { + expect(bothCompiled, `${c.name}: expected driver to reject — vertex/fragment both compiled unexpectedly`).to.be + .false; + } + // "either" is reserved for cases where the driver's outcome is genuinely spec-undefined + // (e.g. constant integer division by zero, shift-overflow), NOT for warning-severity cases — + // those must still declare `"reject"` on the precompile GLSL and rely on the file header to + // explain that warning severity encodes intent, not driver acceptance. + }); + } +}); diff --git a/tests/src/shader-compiler/MacroBranchRuntime.test.ts b/tests/src/shader-compiler/MacroBranchRuntime.test.ts new file mode 100644 index 0000000000..bb5746f09a --- /dev/null +++ b/tests/src/shader-compiler/MacroBranchRuntime.test.ts @@ -0,0 +1,352 @@ +import { Logger, ShaderLanguage } from "@galacean/engine-core"; +import { ShaderMacroProcessor } from "@galacean/engine-core/src/shader/ShaderMacroProcessor"; +import { ShaderAnalyzer } from "@galacean/engine-shader-analyzer"; +import { ShaderCompiler } from "@galacean/engine-shader-compiler"; +import { Lexer, ShaderSourceParser, type MacroDefineList } from "@galacean/engine-shader-parser/internal"; +import { describe, expect, it, vi } from "vitest"; + +function shader(declarations: string, fragmentBody: string): string { + return `Shader "macro-branch-runtime" { SubShader "s" { Pass "p" { +${declarations} +void vert() { gl_Position = vec4(0.0); } +void frag() { +${fragmentBody} +} +VertexShader = vert; +FragmentShader = frag; +} } }`; +} + +function evaluate(source: string, macros: Array<[string, string]>) { + const result = new ShaderAnalyzer().analyze(source); + expect(result.diagnostics).to.be.empty; + const generated = compile(new ShaderCompiler(), source); + expect(generated).to.not.be.undefined; + expect(generated.vertexShaderInstructions).to.not.be.undefined; + expect(generated.fragmentShaderInstructions).to.not.be.undefined; + + return { + diagnostics: result.diagnostics.map((diagnostic) => diagnostic.code), + vertex: ShaderMacroProcessor.evaluate(generated.vertexShaderInstructions!, new Map(macros)), + fragment: ShaderMacroProcessor.evaluate(generated.fragmentShaderInstructions!, new Map(macros)) + }; +} + +function compile(compiler: ShaderCompiler, source: string) { + const pass = ShaderSourceParser.parse(source).subShaders[0].passes[0]; + return compiler._parseShaderPass(pass.contents, pass.vertexEntry, pass.fragmentEntry, ShaderLanguage.GLSLES100, ""); +} + +interface DriverResult { + ok: boolean; + vertexLog: string; + fragmentLog: string; +} + +function compileInWebGL(vertex: string, fragment: string): DriverResult | "no-webgl" { + const gl = document.createElement("canvas").getContext("webgl"); + if (!gl) return "no-webgl"; + + const compile = (source: string, type: number): { ok: boolean; log: string } => { + const shader = gl.createShader(type)!; + gl.shaderSource(shader, type === gl.FRAGMENT_SHADER ? `precision mediump float;\n${source}` : source); + gl.compileShader(shader); + return { + ok: gl.getShaderParameter(shader, gl.COMPILE_STATUS) as boolean, + log: gl.getShaderInfoLog(shader) || "" + }; + }; + + const vertexResult = compile(vertex, gl.VERTEX_SHADER); + const fragmentResult = compile(fragment, gl.FRAGMENT_SHADER); + return { ok: vertexResult.ok && fragmentResult.ok, vertexLog: vertexResult.log, fragmentLog: fragmentResult.log }; +} + +describe("macro branch runtime", () => { + it("does not register definitions after a statically matched conditional arm", () => { + const macroDefineList: MacroDefineList = {}; + const tokens = new Lexer( + `#if 1 +#define LIVE_VALUE 1 +#elif 1 +#define DEAD_ELIF_VALUE 2 +#else +#define DEAD_ELSE_VALUE 3 +#endif`, + macroDefineList + ).tokenize(); + for (const _token of tokens) { + // Exhausting the lexer performs directive registration. + } + expect(macroDefineList.LIVE_VALUE).to.be.ok; + expect(macroDefineList.DEAD_ELIF_VALUE).to.be.undefined; + expect(macroDefineList.DEAD_ELSE_VALUE).to.be.undefined; + }); + + it("selects exactly one declaration after a mutually exclusive conditional #undef", () => { + const source = shader( + `#ifdef FIRST_PATH + #ifndef CONDITIONAL_GUARD + #define CONDITIONAL_GUARD + float u_value; + #endif +#else + #undef CONDITIONAL_GUARD +#endif +#ifndef CONDITIONAL_GUARD + #define CONDITIONAL_GUARD + float u_value; +#endif`, + "gl_FragColor = vec4(u_value);" + ); + + for (const macros of [[], [["FIRST_PATH", ""]]]) { + const evaluated = evaluate(source, macros); + expect(evaluated.diagnostics).to.be.empty; + expect(evaluated.fragment.match(/uniform\s+float\s+u_value\s*;/g)).to.have.lengthOf(1); + const compiled = compileInWebGL(evaluated.vertex, evaluated.fragment); + if (compiled !== "no-webgl") { + expect(compiled.ok, `vertex=${compiled.vertexLog} fragment=${compiled.fragmentLog}`).to.be.true; + } + } + }); + + it("selects exactly one declaration for complementary numeric #if expressions", () => { + const source = shader( + `#if MODE == 1 +float u_value; +#endif +#if MODE != 1 +float u_value; +#endif`, + "gl_FragColor = vec4(u_value);" + ); + + for (const macros of [[["MODE", "1"]], [["MODE", "2"]]]) { + const evaluated = evaluate(source, macros); + expect(evaluated.diagnostics).to.be.empty; + expect(evaluated.fragment.match(/uniform\s+float\s+u_value\s*;/g)).to.have.lengthOf(1); + const compiled = compileInWebGL(evaluated.vertex, evaluated.fragment); + if (compiled !== "no-webgl") { + expect(compiled.ok, `vertex=${compiled.vertexLog} fragment=${compiled.fragmentLog}`).to.be.true; + } + } + }); + + it("selects exactly one declaration for complementary #ifndef/#elif defined arms", () => { + const source = shader( + `#ifndef DISABLE_VALUE +float u_value; +#elif defined(DISABLE_VALUE) +float u_value; +#endif`, + "gl_FragColor = vec4(u_value);" + ); + + for (const macros of [[], [["DISABLE_VALUE", "1"]]]) { + const evaluated = evaluate(source, macros); + expect(evaluated.fragment.match(/uniform\s+float\s+u_value\s*;/g)).to.have.lengthOf(1); + const compiled = compileInWebGL(evaluated.vertex, evaluated.fragment); + if (compiled !== "no-webgl") { + expect(compiled.ok, `vertex=${compiled.vertexLog} fragment=${compiled.fragmentLog}`).to.be.true; + } + } + }); + + it("selects exactly one declaration for complementary #ifdef/#elif !defined arms", () => { + const source = shader( + `#ifdef USE_VALUE +float u_value; +#elif !defined(USE_VALUE) +float u_value; +#endif`, + "gl_FragColor = vec4(u_value);" + ); + + for (const macros of [[], [["USE_VALUE", "1"]]]) { + const evaluated = evaluate(source, macros); + expect(evaluated.fragment.match(/uniform\s+float\s+u_value\s*;/g)).to.have.lengthOf(1); + const compiled = compileInWebGL(evaluated.vertex, evaluated.fragment); + if (compiled !== "no-webgl") { + expect(compiled.ok, `vertex=${compiled.vertexLog} fragment=${compiled.fragmentLog}`).to.be.true; + } + } + }); + + it("selects exactly one declaration for #ifdef/#elif !macro-value arms", () => { + const source = shader( + `#ifdef USE_VALUE +float u_value; +#elif !USE_VALUE +float u_value; +#endif`, + "gl_FragColor = vec4(u_value);" + ); + + for (const macros of [[], [["USE_VALUE", "0"]], [["USE_VALUE", "1"]]]) { + const evaluated = evaluate(source, macros); + expect(evaluated.fragment.match(/uniform\s+float\s+u_value\s*;/g)).to.have.lengthOf(1); + const compiled = compileInWebGL(evaluated.vertex, evaluated.fragment); + if (compiled !== "no-webgl") { + expect(compiled.ok, `vertex=${compiled.vertexLog} fragment=${compiled.fragmentLog}`).to.be.true; + } + } + }); + + it("selects the first true #elif arm", () => { + const source = shader( + `#if 0 +float u_value; +#elif 0 +float u_value; +#elif 1 +float u_value; +#elif 1 +float u_value; +#else +float u_value; +#endif`, + "gl_FragColor = vec4(u_value);" + ); + + const evaluated = evaluate(source, []); + expect(evaluated.fragment.match(/uniform\s+float\s+u_value\s*;/g)).to.have.lengthOf(1); + const compiled = compileInWebGL(evaluated.vertex, evaluated.fragment); + if (compiled !== "no-webgl") { + expect(compiled.ok, `vertex=${compiled.vertexLog} fragment=${compiled.fragmentLog}`).to.be.true; + } + }); + + it("ignores invalid macro replacement syntax after a statically true arm", () => { + const source = shader( + `#if 1 +float u_value; +#elif 1 +#define STRINGIFY(X) #X +#else +#define STRINGIFY_ELSE(X) #X +#endif`, + "gl_FragColor = vec4(u_value);" + ); + + const evaluated = evaluate(source, []); + expect(evaluated.fragment).to.include("uniform float u_value;"); + expect(evaluated.fragment).to.not.include("STRINGIFY"); + }); + + it("reports a non-complementary #ifndef/#elif declaration gap without blocking codegen", () => { + const source = shader( + `#ifndef DISABLE_VALUE +float u_value; +#elif A +float u_value; +#endif`, + "gl_FragColor = vec4(u_value);" + ); + + const analyzer = new ShaderAnalyzer(); + const result = analyzer.analyze(source); + expect(result.diagnostics.map((diagnostic) => diagnostic.code)).to.deep.equal(["UseBeforeDeclaration"]); + const compiler = new ShaderCompiler(); + expect(compile(compiler, source)).to.not.be.undefined; + }); + + it("reports a repeated #ifdef/#elif condition without blocking codegen", () => { + const source = shader( + `#ifdef USE_VALUE +float u_value; +#elif defined(USE_VALUE) +float u_value; +#endif`, + "gl_FragColor = vec4(u_value);" + ); + + const analyzer = new ShaderAnalyzer(); + const result = analyzer.analyze(source); + expect(result.diagnostics.map((diagnostic) => diagnostic.code)).to.deep.equal(["UseBeforeDeclaration"]); + const compiler = new ShaderCompiler(); + expect(compile(compiler, source)).to.not.be.undefined; + }); + + it("reports malformed #elif syntax while preserving compiler output", () => { + const source = shader( + `#ifdef USE_VALUE +float u_value; +#elif 123 defined(USE_VALUE) +float u_value; +#endif`, + "gl_FragColor = vec4(u_value);" + ); + + const analyzer = new ShaderAnalyzer(); + const result = analyzer.analyze(source); + expect(result.diagnostics.map((diagnostic) => diagnostic.code)).to.include("PreprocessorError"); + + const compiler = new ShaderCompiler(); + const errorSpy = vi.spyOn(Logger, "error").mockImplementation(() => {}); + try { + expect(compile(compiler, source)).to.not.be.undefined; + } finally { + errorSpy.mockRestore(); + } + }); + + it.each([ + ["const", "const float branchValue = 0.0;", "float branchValue = 0.0;", "branchValue = 1.0;"], + ["implicit uniform", "float branchValue;", "float branchValue = 0.0;", "branchValue = 1.0;"], + ["sampler", "sampler2D branchValue;", "vec4 branchValue = vec4(0.0);", "branchValue = branchValue;"] + ])( + "reports a branch-local %s assignment as non-modifiable", + (_name, restrictedDeclaration, fallbackDeclaration, assignment) => { + const source = shader( + `#ifdef WRITE_PROHIBITED +${restrictedDeclaration} +#else +${fallbackDeclaration} +#endif`, + `#ifdef WRITE_PROHIBITED +${assignment} +#endif +gl_FragColor = vec4(branchValue);` + ); + + const result = new ShaderAnalyzer().analyze(source); + expect(result.diagnostics.map((diagnostic) => diagnostic.code)).to.include("InvalidAssignmentTarget"); + } + ); + + it("does not gate compiler codegen when branch-local analysis fails", () => { + const source = shader( + `#ifdef WRITE_PROHIBITED +const float branchValue = 0.0; +#else +float branchValue = 0.0; +#endif`, + `#ifdef WRITE_PROHIBITED +branchValue = 1.0; +#endif +gl_FragColor = vec4(branchValue);` + ); + + const analyzer = new ShaderAnalyzer(); + const result = analyzer.analyze(source); + expect(result.diagnostics.map((diagnostic) => diagnostic.code)).to.include("InvalidAssignmentTarget"); + const compiler = new ShaderCompiler(); + expect(compile(compiler, source)).to.not.be.undefined; + }); + + it("does not gate compiler codegen when a macro declaration may not cover its reference", () => { + const source = shader( + `#ifdef DECLARED_ONLY_WITH_A +float branchValue; +#endif`, + "gl_FragColor = vec4(branchValue);" + ); + + const analyzer = new ShaderAnalyzer(); + const result = analyzer.analyze(source); + expect(result.diagnostics.map((diagnostic) => diagnostic.code)).to.include("UseBeforeDeclaration"); + const compiler = new ShaderCompiler(); + expect(compile(compiler, source)).to.not.be.undefined; + }); +}); diff --git a/tests/src/shader-compiler/Precompile.test.ts b/tests/src/shader-compiler/Precompile.test.ts index 5620e962f9..1775e7b766 100644 --- a/tests/src/shader-compiler/Precompile.test.ts +++ b/tests/src/shader-compiler/Precompile.test.ts @@ -505,6 +505,18 @@ describe("ShaderCompiler Precompile", async () => { expect(eval_(inst, [["FOO", ""]])).not.toContain("BODY"); }); + it("#if MACRO uses the numeric macro value", () => { + const inst = ShaderInstructionEncoder.parse("#if FOO\nBODY\n#endif\n"); + expect(eval_(inst, [])).not.toContain("BODY"); + expect(eval_(inst, [["FOO", "0"]])).not.toContain("BODY"); + expect(eval_(inst, [["FOO", "1"]])).toContain("BODY"); + }); + + it("rejects trailing tokens in a preprocessor condition", () => { + const instructions = ShaderInstructionEncoder.parse("#if 123 defined(FOO)\nBODY\n#endif\n"); + expect(() => eval_(instructions, [])).toThrow("Invalid preprocessor expression"); + }); + it("#if MACRO == value: correct branch selected", () => { const inst = ShaderInstructionEncoder.parse("#if FOO == 1\nONE\n#elif FOO == 2\nTWO\n#else\nOTHER\n#endif\n"); expect(eval_(inst, [["FOO", "1"]])).toContain("ONE"); @@ -1094,10 +1106,7 @@ describe("ShaderCompiler Precompile", async () => { } }); - // Regression for SkyMat `material_AtmosphereThickness undeclared`. The - // fixture exercises paren / operator / fn-call / unary / nested-fn macro - // values; each must emit a DefineVal and every user identifier in the - // value must become a real `uniform` declaration. + // Complex object-like macro values must retain their referenced uniforms. it("emits object-like Define instructions and uniforms for complex macro values", async () => { const source = await readFile("src/shader-compiler/shaders/macro-value-refs.shader"); const precompiled = shaderCompiler._precompile(source, ShaderLanguage.GLSLES100); @@ -1141,9 +1150,7 @@ describe("ShaderCompiler Precompile", async () => { } }); - // Regression: comment text inside `#define` values must not leak into the - // identifier scanner. Real `u_used_*` refs are kept; `u_in_comment_*` - // mentions are not promoted to uniforms. + // Comment text inside `#define` values is not an identifier reference. it("does not collect identifiers from comments inside #define values", async () => { const source = await readFile("src/shader-compiler/shaders/macro-value-refs-with-comments.shader"); const precompiled = shaderCompiler._precompile(source, ShaderLanguage.GLSLES100); diff --git a/tests/src/shader-compiler/PrecompileABTest.test.ts b/tests/src/shader-compiler/PrecompileABTest.test.ts index afac78179d..2bf705ed8b 100644 --- a/tests/src/shader-compiler/PrecompileABTest.test.ts +++ b/tests/src/shader-compiler/PrecompileABTest.test.ts @@ -113,6 +113,8 @@ describe("Precompile A/B Test: Live vs Precompiled", async () => { const canvas = document.createElement("canvas"); const engine = await WebGLEngine.create({ canvas }); const PBRSource = await readFile("../packages/shader/src/Shaders/PBR.shader"); + const ParticleSource = await readFile("../packages/shader/src/Shaders/Effect/Particle.shader"); + const SSAOSource = await readFile("../packages/shader/src/Shaders/Lighting/ScalableAmbientOcclusion.shader"); // @ts-ignore — bind runtime include map so the compiler can resolve `#include`. shaderCompiler._includeMap = ShaderFactory.includeMap; @@ -279,6 +281,17 @@ describe("Precompile A/B Test: Live vs Precompiled", async () => { validatePrecompiledWebGL(PBRSource, ShaderLanguage.GLSLES100, macros); }); + it("PBR with unsupported fog mode uses the no-fog fallback", () => { + const macros = baseMacros.map((m) => (m.name === "SCENE_FOG_MODE" ? { ...m, value: "99" } : m)); + validatePrecompiledWebGL(PBRSource, ShaderLanguage.GLSLES100, macros); + }); + + for (const quality of ["0", "1", "2", "99"]) { + it(`SSAO quality ${quality}`, () => { + validatePrecompiledWebGL(SSAOSource, ShaderLanguage.GLSLES100, [{ name: "SSAO_QUALITY", value: quality }]); + }); + } + it("PBR with UV1 + occlusion texture", () => { validatePrecompiledWebGL(PBRSource, ShaderLanguage.GLSLES100, [...baseMacros, ...uv1OcclusionMacros]); }); @@ -291,6 +304,60 @@ describe("Precompile A/B Test: Live vs Precompiled", async () => { validatePrecompiledWebGL(PBRSource, ShaderLanguage.GLSLES100, [...baseMacros, ...tangentNormalMacros]); }); + for (const mode of [ + "RENDERER_MODE_SPHERE_BILLBOARD", + "RENDERER_MODE_STRETCHED_BILLBOARD", + "RENDERER_MODE_HORIZONTAL_BILLBOARD", + "RENDERER_MODE_VERTICAL_BILLBOARD", + "RENDERER_MODE_MESH" + ]) { + it(`Particle ${mode} mode`, () => { + validatePrecompiledWebGL(ParticleSource, ShaderLanguage.GLSLES100, [{ name: mode }]); + }); + } + + it("Particle uses deterministic priority when render-mode macros overlap", () => { + const macros = [ + { name: "RENDERER_MODE_SPHERE_BILLBOARD" }, + { name: "RENDERER_MODE_STRETCHED_BILLBOARD" }, + { name: "RENDERER_MODE_HORIZONTAL_BILLBOARD" }, + { name: "RENDERER_MODE_VERTICAL_BILLBOARD" }, + { name: "RENDERER_MODE_MESH" }, + { name: "RENDERER_ENABLE_VERTEXCOLOR" } + ]; + validatePrecompiledWebGL(ParticleSource, ShaderLanguage.GLSLES100, macros); + + const precompiled = shaderCompiler._precompile(ParticleSource, ShaderLanguage.GLSLES100, ""); + const pass = precompiled.subShaders[0].passes.find((candidate) => candidate.name === "Forward Pass"); + const vertexSource = ShaderMacroProcessor.evaluate(pass!.vertexShaderInstructions!, makeMacroMap(macros)); + expect(vertexSource).to.match(/normalize\s*\(\s*cross\s*\(\s*camera_Forward\s*,\s*camera_Up\s*\)\s*\)/); + expect(vertexSource).to.not.include("rotationZHalfPI"); + expect(vertexSource).to.not.match(/cameraUpVector\s*=\s*vec3\s*\(\s*0\.0\s*,\s*1\.0\s*,\s*0\.0\s*\)/); + }); + + it("Particle mesh with separate random size-over-lifetime curves", () => { + const macros = [ + { name: "RENDERER_MODE_MESH" }, + { name: "RENDERER_SOL_CURVE_MODE" }, + { name: "RENDERER_SOL_IS_SEPARATE" }, + { name: "RENDERER_SOL_IS_RANDOM_TWO" } + ]; + validatePrecompiledWebGL(ParticleSource, ShaderLanguage.GLSLES100, macros); + + const precompiled = shaderCompiler._precompile(ParticleSource, ShaderLanguage.GLSLES100); + const pass = precompiled.subShaders[0].passes.find((candidate) => candidate.name === "Forward Pass"); + expect(pass?.vertexShaderInstructions).toBeDefined(); + const vertexSource = ShaderMacroProcessor.evaluate(pass!.vertexShaderInstructions!, makeMacroMap(macros)); + for (const axis of ["X", "Y", "Z"]) { + expect(vertexSource).toMatch( + new RegExp( + `lifeSize${axis}\\s*=\\s*mix\\s*\\(\\s*evaluateParticleCurve\\s*\\(\\s*renderer_SOLMinCurve${axis}[^;]+lifeSize${axis}` + ) + ); + } + expect(vertexSource).toMatch(/size\s*\*=\s*vec3\s*\(\s*lifeSizeX\s*,\s*lifeSizeY\s*,\s*lifeSizeZ\s*\)/); + }); + const simpleShaders = ["noFragArgs.shader", "waterfull.shader", "mrt-struct.shader", "multi-pass.shader"]; for (const file of simpleShaders) { it(`${file}: precompiled GLSL → WebGL`, async () => { diff --git a/tests/src/shader-compiler/PreprocessorConditionConformance.test.ts b/tests/src/shader-compiler/PreprocessorConditionConformance.test.ts new file mode 100644 index 0000000000..7017d27718 --- /dev/null +++ b/tests/src/shader-compiler/PreprocessorConditionConformance.test.ts @@ -0,0 +1,378 @@ +import { ShaderLanguage } from "@galacean/engine-core"; +import { ShaderMacroProcessor } from "@galacean/engine-core/src/shader/ShaderMacroProcessor"; +import { ShaderAnalyzer } from "@galacean/engine-shader-analyzer"; +import { ShaderCompiler } from "@galacean/engine-shader-compiler"; +import { ShaderInstructionEncoder } from "@galacean/engine-shader-compiler/src/ShaderInstructionEncoder"; +import { + parsePreprocessorCondition, + ShaderSourceParser, + type PreprocessorCondition +} from "@galacean/engine-shader-parser/internal"; +import { describe, expect, it } from "vitest"; + +interface MacroConfiguration { + macros: Array<[string, string]>; + firstArm: boolean; +} + +interface ConditionCase { + name: string; + expression: string; + root: PreprocessorCondition["t"]; + configurations: MacroConfiguration[]; +} + +const conditionCases: readonly ConditionCase[] = [ + { + name: "defined macro", + expression: "defined(USE)", + root: "def", + configurations: [ + { macros: [], firstArm: false }, + { macros: [["USE", "0"]], firstArm: true }, + { macros: [["USE", "1"]], firstArm: true } + ] + }, + { + name: "bare macro numeric value", + expression: "USE", + root: "cmp", + configurations: [ + { macros: [], firstArm: false }, + { macros: [["USE", "0"]], firstArm: false }, + { macros: [["USE", "1"]], firstArm: true }, + { macros: [["USE", "-2"]], firstArm: true } + ] + }, + { + name: "numeric equality", + expression: "MODE == 1", + root: "cmp", + configurations: [ + { macros: [], firstArm: false }, + { macros: [["MODE", "0"]], firstArm: false }, + { macros: [["MODE", "1"]], firstArm: true }, + { macros: [["MODE", "2"]], firstArm: false } + ] + }, + { + name: "numeric inequality", + expression: "MODE != 0", + root: "cmp", + configurations: [ + { macros: [], firstArm: false }, + { macros: [["MODE", "0"]], firstArm: false }, + { macros: [["MODE", "1"]], firstArm: true }, + { macros: [["MODE", "-1"]], firstArm: true } + ] + }, + { + name: "defined and numeric conjunction", + expression: "defined(A) && B", + root: "and", + configurations: [ + { macros: [], firstArm: false }, + { + macros: [ + ["A", "1"], + ["B", "0"] + ], + firstArm: false + }, + { + macros: [ + ["A", "1"], + ["B", "2"] + ], + firstArm: true + } + ] + }, + { + name: "mixed precedence", + expression: "defined(A) || defined(B) && MODE > 1", + root: "or", + configurations: [ + { macros: [], firstArm: false }, + { + macros: [ + ["B", "1"], + ["MODE", "1"] + ], + firstArm: false + }, + { + macros: [ + ["B", "1"], + ["MODE", "2"] + ], + firstArm: true + }, + { macros: [["A", "0"]], firstArm: true } + ] + }, + { + name: "nested negation", + expression: "!(defined(A) && MODE == 0)", + root: "not", + configurations: [ + { macros: [], firstArm: true }, + { + macros: [ + ["A", "1"], + ["MODE", "0"] + ], + firstArm: false + }, + { + macros: [ + ["A", "1"], + ["MODE", "1"] + ], + firstArm: true + } + ] + }, + { + name: "hexadecimal literal", + expression: "MODE == 0x10", + root: "cmp", + configurations: [ + { macros: [], firstArm: false }, + { macros: [["MODE", "15"]], firstArm: false }, + { macros: [["MODE", "16"]], firstArm: true } + ] + } +]; + +const malformedExpressions = ["123 defined(USE)", "defined()", "USE &&", "!", "USE || || OTHER"] as const; + +function shader(condition: string): string { + return `Shader "preprocessor-condition-conformance" { SubShader "s" { Pass "p" { +#if ${condition} +float u_value; +const float u_selectedArm = 1.0; +#elif !(${condition}) +float u_value; +const float u_selectedArm = 2.0; +#endif +void vert() { gl_Position = vec4(0.0); } +void frag() { gl_FragColor = vec4(u_value * u_selectedArm); } +VertexShader = vert; +FragmentShader = frag; +} } }`; +} + +function compileInWebGL(vertex: string, fragment: string): { ok: boolean; log: string } | "no-webgl" { + const gl = document.createElement("canvas").getContext("webgl"); + if (!gl) return "no-webgl"; + + const compile = (source: string, type: number): { ok: boolean; log: string } => { + const shader = gl.createShader(type)!; + gl.shaderSource(shader, type === gl.FRAGMENT_SHADER ? `precision mediump float;\n${source}` : source); + gl.compileShader(shader); + return { ok: gl.getShaderParameter(shader, gl.COMPILE_STATUS) as boolean, log: gl.getShaderInfoLog(shader) || "" }; + }; + + const vertexResult = compile(vertex, gl.VERTEX_SHADER); + const fragmentResult = compile(fragment, gl.FRAGMENT_SHADER); + return { + ok: vertexResult.ok && fragmentResult.ok, + log: `vertex=${vertexResult.log} fragment=${fragmentResult.log}` + }; +} + +function evaluateNativeCondition( + expression: string, + macros: readonly (readonly [string, string])[] +): { supported: true; firstArm: boolean } | { supported: false; log: string } | "no-webgl" { + const macroNames = new Set(macros.map(([name]) => name)); + const normalizedExpression = expression + .replace( + /\bdefined\s*(?:\(\s*([A-Za-z_]\w*)\s*\)|([A-Za-z_]\w*))/g, + (_match, parenthesized: string | undefined, bare: string | undefined) => + macroNames.has(parenthesized ?? bare!) ? "1" : "0" + ) + .replace(/\b[A-Za-z_]\w*\b/g, (name) => (macroNames.has(name) ? name : "0")); + const definitions = macros.map(([name, value]) => `#define ${name} ${value}`).join("\n"); + const invalidDeclaration = "float native_condition_selected_the_wrong_arm = ;"; + const compileProbe = (firstArm: boolean) => + compileInWebGL( + "void main() { gl_Position = vec4(0.0); }", + `${definitions} +#if ${normalizedExpression} +${firstArm ? "const float native_condition_value = 1.0;" : invalidDeclaration} +#else +${firstArm ? invalidDeclaration : "const float native_condition_value = 0.0;"} +#endif +void main() { gl_FragColor = vec4(native_condition_value); }` + ); + const firstProbe = compileProbe(true); + if (firstProbe === "no-webgl") return firstProbe; + if (firstProbe.ok) return { supported: true, firstArm: true }; + const secondProbe = compileProbe(false); + if (secondProbe === "no-webgl") return secondProbe; + if (secondProbe.ok) return { supported: true, firstArm: false }; + return { supported: false, log: `first=${firstProbe.log} second=${secondProbe.log}` }; +} + +describe("preprocessor condition conformance", () => { + for (const conditionCase of conditionCases) { + it(`${conditionCase.name}: fast parser, analyzer, runtime, and WebGL agree`, () => { + expect(parsePreprocessorCondition(conditionCase.expression)).to.have.property("t", conditionCase.root); + + const source = shader(conditionCase.expression); + const result = new ShaderAnalyzer().analyze(source); + expect(result.diagnostics).to.be.empty; + const pass = ShaderSourceParser.parse(source).subShaders[0].passes[0]; + const generated = new ShaderCompiler()._parseShaderPass( + pass.contents, + pass.vertexEntry, + pass.fragmentEntry, + ShaderLanguage.GLSLES100, + "" + ); + expect(generated).to.not.be.undefined; + expect(generated!.vertexShaderInstructions).to.not.be.undefined; + expect(generated!.fragmentShaderInstructions).to.not.be.undefined; + + for (const configuration of conditionCase.configurations) { + const native = evaluateNativeCondition(conditionCase.expression, configuration.macros); + if (native !== "no-webgl") { + expect(native.supported, native.supported ? "" : native.log).to.be.true; + if (native.supported) expect(native.firstArm).to.equal(configuration.firstArm); + } + + const macros = new Map(configuration.macros); + const vertex = ShaderMacroProcessor.evaluate(generated!.vertexShaderInstructions!, macros); + const fragment = ShaderMacroProcessor.evaluate( + generated!.fragmentShaderInstructions!, + new Map(configuration.macros) + ); + const selectedArm = configuration.firstArm ? "1.0" : "2.0"; + const otherArm = configuration.firstArm ? "2.0" : "1.0"; + expect(fragment.match(/uniform\s+float\s+u_value\s*;/g)).to.have.lengthOf(1); + expect(fragment).to.contain(`const float u_selectedArm = ${selectedArm};`); + expect(fragment).not.to.contain(`const float u_selectedArm = ${otherArm};`); + + const webgl = compileInWebGL(vertex, fragment); + if (webgl !== "no-webgl") expect(webgl.ok, webgl.log).to.be.true; + } + }); + } + + for (const [expression, macros, firstArm] of [ + [ + "A + B > 1", + [ + ["A", "1"], + ["B", "1"] + ], + true + ], + [ + "A + B > 1", + [ + ["A", "0"], + ["B", "1"] + ], + false + ], + ["(MASK & 3) == 2", [["MASK", "6"]], true], + [ + "A ? B : C", + [ + ["A", "0"], + ["B", "0"], + ["C", "1"] + ], + true + ], + [ + "((A == B || A == C))", + [ + ["A", "2"], + ["B", "1"], + ["C", "2"] + ], + true + ], + ["(MASK >> 1) == 3", [["MASK", "6"]], true], + ["(~MASK & 0xffu) != 0", [["MASK", "255"]], false], + ["0xffffffffu + 1u == 0u", [], true], + ["-1 < 1u", [], true], + ["0xffffffffu > 0u", [], false], + ["2147483648", [], true], + ["MODE == 2147483648", [["MODE", "2147483648"]], true], + [ + "A && (10 / B)", + [ + ["A", "0"], + ["B", "0"] + ], + false + ], + [ + "A ? (10 / B) : C", + [ + ["A", "0"], + ["B", "0"], + ["C", "1"] + ], + true + ], + [ + "FIRST SECOND == 22", + [ + ["FIRST", "17"], + ["SECOND", "+ 5"] + ], + true + ] + ] as const) { + it(`evaluates full preprocessor expression '${expression}' through codegen and WebGL`, () => { + expect(() => parsePreprocessorCondition(expression)).to.throw(); + const native = evaluateNativeCondition(expression, macros); + if (native !== "no-webgl" && !expression.includes("?")) { + expect(native.supported, native.supported ? "" : native.log).to.be.true; + if (native.supported) expect(native.firstArm).to.equal(firstArm); + } + + const source = shader(expression); + const diagnostics = new ShaderAnalyzer().analyze(source).diagnostics; + expect(diagnostics.filter((diagnostic) => diagnostic.severity === "error")).to.be.empty; + + const pass = ShaderSourceParser.parse(source).subShaders[0].passes[0]; + const generated = new ShaderCompiler()._parseShaderPass( + pass.contents, + pass.vertexEntry, + pass.fragmentEntry, + ShaderLanguage.GLSLES100, + "" + ); + expect(generated).to.not.be.undefined; + + const vertex = ShaderMacroProcessor.evaluate(generated!.vertexShaderInstructions!, new Map(macros)); + const fragment = ShaderMacroProcessor.evaluate(generated!.fragmentShaderInstructions!, new Map(macros)); + const selectedArm = firstArm ? "1.0" : "2.0"; + const otherArm = firstArm ? "2.0" : "1.0"; + expect(fragment.match(/uniform\s+float\s+u_value\s*;/g)).to.have.lengthOf(1); + expect(fragment).to.contain(`const float u_selectedArm = ${selectedArm};`); + expect(fragment).not.to.contain(`const float u_selectedArm = ${otherArm};`); + + const webgl = compileInWebGL(vertex, fragment); + if (webgl !== "no-webgl") expect(webgl.ok, webgl.log).to.be.true; + }); + } + + for (const expression of [...malformedExpressions, "1.5"] as const) { + it(`diagnoses malformed expression '${expression}' without making encoding a diagnostic gate`, () => { + expect(() => parsePreprocessorCondition(expression)).to.throw("Unsupported or malformed preprocessor condition"); + const instructions = ShaderInstructionEncoder.parse(`#if ${expression}\nBODY\n#endif\n`); + + const result = new ShaderAnalyzer().analyze(shader(expression)); + expect(result.diagnostics.map((diagnostic) => diagnostic.code)).to.include("PreprocessorError"); + expect(() => ShaderMacroProcessor.evaluate(instructions, new Map())).to.throw("Invalid preprocessor expression"); + }); + } +}); diff --git a/tests/src/shader-compiler/ReturnStatementInvariant.test.ts b/tests/src/shader-compiler/ReturnStatementInvariant.test.ts new file mode 100644 index 0000000000..a8b316e01f --- /dev/null +++ b/tests/src/shader-compiler/ReturnStatementInvariant.test.ts @@ -0,0 +1,64 @@ +import { ShaderCompiler } from "@galacean/engine-shader-compiler"; +import { describe, expect, it } from "vitest"; + +/** + * Codegen invariant lock: `FunctionDefinition.returnStatement` may only be recorded when the + * enclosing function is non-void AND has a value return. The fragment-entry rewrite path + * (`GLESVisitor._fragmentMain` → `visitJumpStatement`) reads it as an Expression at + * `children[1]`; a bare `return;` inside a `void` fragment has a `;` token there, not an + * expression; recording a bare return would emit malformed GLSL (`gl_FragColor = ;`). + */ +const shaderCompiler = new ShaderCompiler(); + +const SHADER_SOURCE = `Shader "return-invariant" { + SubShader "Default" { + Pass "test" { + mat4 renderer_MVPMat; + float u_flag; + struct Attributes { vec3 POSITION; }; + + void vert(Attributes attr) { gl_Position = renderer_MVPMat * vec4(attr.POSITION, 1.0); } + void frag() { + if (u_flag > 0.5) return; + gl_FragColor = vec4(1.0); + } + + VertexShader = vert; + FragmentShader = frag; + } + } +}`; + +describe("ReturnStatementInvariant", () => { + it("void frag with a bare `return;` emits a well-formed `gl_FragColor` block", () => { + const shaderSource = shaderCompiler._parseShaderSource(SHADER_SOURCE); + const passSource = shaderSource.subShaders[0].passes[0]; + + let programSource: ReturnType; + expect(() => { + programSource = shaderCompiler._parseShaderPass( + passSource.contents, + passSource.vertexEntry, + passSource.fragmentEntry, + 0 /* GLSLES100 */ + ); + }).to.not.throw(); + + expect(programSource, "codegen must produce a program for a legal shader").to.be.ok; + const { vertex, fragment } = programSource!; + + // The bare `return;` in `void frag()` must fall through to the default JumpStatement path + // (a plain `return;`). It must NOT be rewritten as a `gl_FragColor = ;` assignment — + // there's no expression to assign, so the rewrite would emit a malformed statement. + expect(fragment).to.match(/\breturn\s*;/); + expect(fragment).to.not.match(/gl_FragColor\s*=\s*;/); + expect(fragment).to.not.match(/gl_FragColor\s*=\s*[)\]}]/); // no orphan closer / garbage RHS + expect(fragment).to.not.include("undefined"); + + // Sanity: the legitimate `gl_FragColor = vec4(...);` assignment still made it through. + expect(fragment).to.match(/gl_FragColor\s*=\s*vec4/); + + // Sanity: vertex was also emitted (compilation didn't half-fail). + expect(vertex).to.be.a("string").and.not.empty; + }); +}); diff --git a/tests/src/shader-compiler/ShaderCompiler.test.ts b/tests/src/shader-compiler/ShaderCompiler.test.ts index b6b8029a01..5502425fab 100644 --- a/tests/src/shader-compiler/ShaderCompiler.test.ts +++ b/tests/src/shader-compiler/ShaderCompiler.test.ts @@ -9,18 +9,16 @@ import { StencilOperation } from "@galacean/engine-core"; import { ShaderCompiler as ShaderCompilerRelease } from "@galacean/engine-shader-compiler"; -import { ShaderCompiler as ShaderCompilerVerbose } from "@galacean/engine-shader-compiler/verbose"; +import { ShaderSourceParser } from "@galacean/engine-shader-parser/internal"; import { glslValidate } from "./ShaderValidate"; import { Logger, WebGLEngine } from "@galacean/engine"; import { describe, expect, it, vi } from "vitest"; import { server } from "@vitest/browser/context"; - const { readFile } = server.commands; Logger.enable(); -const shaderCompilerVerbose = new ShaderCompilerVerbose(); const shaderCompilerRelease = new ShaderCompilerRelease(); describe("ShaderCompiler", async () => { @@ -29,12 +27,11 @@ describe("ShaderCompiler", async () => { const PBRSource = await readFile("../packages/shader/src/Shaders/PBR.shader"); it("create shaderCompiler", async () => { - expect(shaderCompilerVerbose).not.be.null; expect(shaderCompilerRelease).not.be.null; }); it("PBR", async () => { - const shader = shaderCompilerVerbose._parseShaderSource(PBRSource); + const shader = shaderCompilerRelease._parseShaderSource(PBRSource); const subShader = shader.subShaders[0]; const passList = subShader.passes; const pass1 = passList[2]; @@ -74,7 +71,6 @@ describe("ShaderCompiler", async () => { }); // Compile test - glslValidate(engine, PBRSource, shaderCompilerVerbose); glslValidate(engine, PBRSource, shaderCompilerRelease); // some material variants @@ -92,7 +88,7 @@ describe("ShaderCompiler", async () => { it("render state", async () => { const demoShader = await readFile("src/shader-compiler/shaders/render-state.shader"); - const shader = shaderCompilerRelease._parseShaderSource(demoShader); + const { shaderSource: shader } = ShaderSourceParser.parseWithErrors(demoShader); const subShader = shader.subShaders[0]; const passList = subShader.passes; @@ -192,8 +188,10 @@ describe("ShaderCompiler", async () => { } } }`; - const result = shaderCompilerVerbose._parseShaderSource(shaderSource); - const pass = result.subShaders[0].passes[0]; + const result = ShaderSourceParser.parseWithErrors(shaderSource); + expect(result.errors.some((error) => error.message.includes("Bitwise OR '|' is not supported"))).to.equal(true); + const shader = result.shaderSource; + const pass = shader.subShaders[0].passes[0]; // CompareFunction should not appear in constantMap because bitwise OR is not allowed on non-bitmask enums expect(pass.renderStates.constantMap[RenderStateElementKey.DepthStateCompareFunction]).to.be.undefined; }); @@ -208,8 +206,9 @@ describe("ShaderCompiler", async () => { } } }`; - const result = shaderCompilerVerbose._parseShaderSource(shaderSource); - const pass = result.subShaders[0].passes[0]; + const result = ShaderSourceParser.parseWithErrors(shaderSource); + expect(result.errors.some((error) => error.message.includes("Cannot mix enum types"))).to.equal(true); + const pass = result.shaderSource.subShaders[0].passes[0]; // Mixed enum types should be rejected expect(pass.renderStates.constantMap[RenderStateElementKey.BlendStateColorWriteMask0]).to.be.undefined; }); @@ -224,8 +223,9 @@ describe("ShaderCompiler", async () => { } } }`; - const result = shaderCompilerVerbose._parseShaderSource(shaderSource); - const pass = result.subShaders[0].passes[0]; + const result = ShaderSourceParser.parseWithErrors(shaderSource); + expect(result.errors.some((error) => error.message.includes("Invalid syntax after '|'"))).to.equal(true); + const pass = result.shaderSource.subShaders[0].passes[0]; // ColorWriteMask should not appear in constantMap due to invalid syntax after '|' expect(pass.renderStates.constantMap[RenderStateElementKey.BlendStateColorWriteMask0]).to.be.undefined; }); @@ -250,15 +250,14 @@ describe("ShaderCompiler", async () => { glslValidate(engine, shaderSource, shaderCompilerRelease); }); - // Regression: function-like macro form params and macro-name-as-var cross-arm - // probes used to emit spurious "declared before used" warnings. + // Function-like parameters and cross-arm macro names are not undeclared references. it("function-like macro form params and macro names don't warn as undeclared", async () => { const src = await readFile("src/shader-compiler/shaders/macro-form-params-no-warn.shader"); const warns: string[] = []; const origWarn = console.warn; console.warn = (...args: any[]) => warns.push(args.join(" ")); try { - glslValidate(engine, src, shaderCompilerVerbose); + glslValidate(engine, src, shaderCompilerRelease); } finally { console.warn = origWarn; } @@ -268,7 +267,6 @@ describe("ShaderCompiler", async () => { it("macro-negate-number (!0, !1 in #if expressions)", async () => { const shaderSource = await readFile("src/shader-compiler/shaders/macro-negate-number.shader"); - glslValidate(engine, shaderSource, shaderCompilerVerbose); glslValidate(engine, shaderSource, shaderCompilerRelease); }); @@ -277,13 +275,31 @@ describe("ShaderCompiler", async () => { glslValidate(engine, shaderSource, shaderCompilerRelease); }); + // Same-named entry parameters resolve their struct role independently per stage. + it("struct-based-attribute (same-named params across stages routes per stage)", async () => { + const shaderSource = await readFile("src/shader-compiler/shaders/struct-based-attribute.shader"); + glslValidate(engine, shaderSource, shaderCompilerRelease); + + const shader = shaderCompilerRelease._parseShaderSource(shaderSource); + const passSource = shader.subShaders[0].passes[0]; + const { vertex } = shaderCompilerRelease._parseShaderPass( + passSource.contents, + passSource.vertexEntry, + passSource.fragmentEntry, + 0 + )!; + + expect(vertex).to.match(/attribute\s+vec4\s+POSITION\s*;/); + expect(vertex).to.match(/attribute\s+vec3\s+NORMAL\s*;/); + }); + it("define-struct-access-global (global #define with struct member access)", async () => { const shaderSource = await readFile("src/shader-compiler/shaders/define-struct-access-global.shader"); glslValidate(engine, shaderSource, shaderCompilerRelease); - const shader = shaderCompilerVerbose._parseShaderSource(shaderSource); + const shader = shaderCompilerRelease._parseShaderSource(shaderSource); const passSource = shader.subShaders[0].passes[0]; - const { vertex, fragment } = shaderCompilerVerbose._parseShaderPass( + const { vertex, fragment } = shaderCompilerRelease._parseShaderPass( passSource.contents, passSource.vertexEntry, passSource.fragmentEntry, @@ -292,17 +308,17 @@ describe("ShaderCompiler", async () => { const expectedVert = await readFile("src/shader-compiler/expected/define-struct-access-global.vert.glsl"); const expectedFrag = await readFile("src/shader-compiler/expected/define-struct-access-global.frag.glsl"); - expect(vertex).to.equal(expectedVert); - expect(fragment).to.equal(expectedFrag); + expect(vertex).to.equal(expectedVert.trimEnd()); + expect(fragment).to.equal(expectedFrag.trimEnd()); }); it("define-struct-access (function-body #define with struct member access)", async () => { const shaderSource = await readFile("src/shader-compiler/shaders/define-struct-access.shader"); glslValidate(engine, shaderSource, shaderCompilerRelease); - const shader = shaderCompilerVerbose._parseShaderSource(shaderSource); + const shader = shaderCompilerRelease._parseShaderSource(shaderSource); const passSource = shader.subShaders[0].passes[0]; - const { vertex, fragment } = shaderCompilerVerbose._parseShaderPass( + const { vertex, fragment } = shaderCompilerRelease._parseShaderPass( passSource.contents, passSource.vertexEntry, passSource.fragmentEntry, @@ -315,54 +331,37 @@ describe("ShaderCompiler", async () => { expect(fragment).to.equal(expectedFrag); }); - it("macro-member-access-builtin-arg (Cocos FSInput pattern: member access macro as builtin fn arg)", async () => { + it("macro-member-access-builtin-arg (member access macro as builtin fn arg)", async () => { const shaderSource = await readFile("src/shader-compiler/shaders/macro-member-access-builtin-arg.shader"); - // Regression guard: before the preprocessor/AST deduplication fix, each - // AST-form member-access macro (e.g. `#define FSInput_worldNormal v.v_normal.xyz`) - // fired a spurious "has an unrecognized value" warning on every access. - const warnSpy = vi.spyOn(Logger, "warn"); - try { - glslValidate(engine, shaderSource, shaderCompilerRelease); - - // Also verify verbose mode (semantic analysis) succeeds — this was the original bug: - // member access macros resolved to struct type "Varyings" instead of TypeAny, - // causing builtin overload matching to fail. - const shader = shaderCompilerVerbose._parseShaderSource(shaderSource); - const passSource = shader.subShaders[0].passes[0]; - const { vertex, fragment } = shaderCompilerVerbose._parseShaderPass( - passSource.contents, - passSource.vertexEntry, - passSource.fragmentEntry, - 0 - )!; - - expect(vertex).to.be.a("string").and.not.empty; - expect(fragment).to.be.a("string").and.not.empty; - - // Verify key builtins are present in output (macros expanded correctly) - expect(fragment).to.contain("normalize"); - expect(fragment).to.contain("dot"); - expect(fragment).to.contain("texture2D"); - - const unrecognizedCalls = warnSpy.mock.calls.filter((args) => - args.some((a) => typeof a === "string" && a.includes("unrecognized value")) - ); - expect(unrecognizedCalls).to.have.lengthOf(0); - } finally { - warnSpy.mockRestore(); - } + glslValidate(engine, shaderSource, shaderCompilerRelease); + + // Member-access macros must resolve to TypeAny, not the concrete struct type, so builtin + // overload matching succeeds and the macros expand into the output. + const shader = shaderCompilerRelease._parseShaderSource(shaderSource); + const passSource = shader.subShaders[0].passes[0]; + const { vertex, fragment } = shaderCompilerRelease._parseShaderPass( + passSource.contents, + passSource.vertexEntry, + passSource.fragmentEntry, + 0 + )!; + + expect(vertex).to.be.a("string").and.not.empty; + expect(fragment).to.be.a("string").and.not.empty; + expect(fragment).to.contain("normalize"); + expect(fragment).to.contain("dot"); + expect(fragment).to.contain("texture2D"); }); - it("global-varying-var (Cocos VSOutput pattern: global Varyings var with #define macros)", async () => { + it("global-varying-var (global Varyings var with #define macros)", async () => { const shaderSource = await readFile("src/shader-compiler/shaders/global-varying-var.shader"); glslValidate(engine, shaderSource, shaderCompilerRelease); - // Verify verbose mode: global "Varyings o;" should not produce "uniform Varyings o;" - // and should not duplicate varying declarations. - const shader = shaderCompilerVerbose._parseShaderSource(shaderSource); + // A global "Varyings o;" must neither become a uniform nor duplicate varying declarations + const shader = shaderCompilerRelease._parseShaderSource(shaderSource); const passSource = shader.subShaders[0].passes[0]; - const { vertex, fragment } = shaderCompilerVerbose._parseShaderPass( + const { vertex, fragment } = shaderCompilerRelease._parseShaderPass( passSource.contents, passSource.vertexEntry, passSource.fragmentEntry, @@ -388,166 +387,143 @@ describe("ShaderCompiler", async () => { it("define-ctor-with-member (constructor-style macro with struct member access)", async () => { const shaderSource = await readFile("src/shader-compiler/shaders/define-ctor-with-member.shader"); glslValidate(engine, shaderSource, shaderCompilerRelease); - glslValidate(engine, shaderSource, shaderCompilerVerbose); }); it("paren-define (object-like with space-before-paren vs function-like without space)", async () => { const shaderSource = await readFile("src/shader-compiler/shaders/paren-define-repro.shader"); glslValidate(engine, shaderSource, shaderCompilerRelease); - glslValidate(engine, shaderSource, shaderCompilerVerbose); }); it("macro-value-refs (uniforms referenced inside paren / operator / fn-call / unary / nested macro values)", async () => { const shaderSource = await readFile("src/shader-compiler/shaders/macro-value-refs.shader"); glslValidate(engine, shaderSource, shaderCompilerRelease); - glslValidate(engine, shaderSource, shaderCompilerVerbose); }); it("macro-call-struct-arg (struct-member access as function-like macro arg)", async () => { const shaderSource = await readFile("src/shader-compiler/shaders/macro-call-struct-arg-repro.shader"); glslValidate(engine, shaderSource, shaderCompilerRelease); - glslValidate(engine, shaderSource, shaderCompilerVerbose); }); it("macro-top-level-comma (replacement list with top-level `,` — GLSL ES 3.00 §3.4)", async () => { const shaderSource = await readFile("src/shader-compiler/shaders/macro-top-level-comma-repro.shader"); glslValidate(engine, shaderSource, shaderCompilerRelease); - glslValidate(engine, shaderSource, shaderCompilerVerbose); }); it("macro-leading-dot-float (`.5` is a legal GLSL ES §4.1.4 float literal)", async () => { const shaderSource = await readFile("src/shader-compiler/shaders/macro-leading-dot-float.shader"); glslValidate(engine, shaderSource, shaderCompilerRelease); - glslValidate(engine, shaderSource, shaderCompilerVerbose); }); - // Authoring-error `#define` shapes (trailing comma, unbalanced bracket, - // leading punctuation, trailing operator, …) get one uniform diagnostic: - // "#define : invalid replacement list — not a valid GLSL expression - // (\"\")". The user reads the rule + value and fixes their GLSL — - // the engine doesn't categorize further. - const assertMacroAuthorError = async (fixturePath: string, expectedValueFragment: string) => { + const assertOpaqueMacro = async (fixturePath: string, expectedDirective: string) => { const source = await readFile(fixturePath); + glslValidate(engine, source, shaderCompilerRelease); const parsed = shaderCompilerRelease._parseShaderSource(source); const pass = parsed.subShaders[0].passes.find((p) => !p.isUsePass); if (!pass) throw new Error("test fixture missing a non-usepass"); - let captured: unknown = null; - try { - shaderCompilerRelease._parseShaderPass( - pass.contents, - pass.vertexEntry, - pass.fragmentEntry, - ShaderLanguage.GLSLES100 - ); - } catch (e) { - captured = e; - } - expect(captured, "expected a lexer error").to.be.instanceOf(Error); - const msg = (captured as Error).message; - expect(msg).to.match(/#define BAD: invalid replacement list/); - expect(msg).to.include(expectedValueFragment); + const result = shaderCompilerRelease._parseShaderPass( + pass.contents, + pass.vertexEntry, + pass.fragmentEntry, + ShaderLanguage.GLSLES100 + ); + expect(result).to.not.be.undefined; + expect(result!.fragment).to.include(expectedDirective); }; - it("macro-author-error: trailing comma surfaces a uniform diagnostic", async () => { - await assertMacroAuthorError("src/shader-compiler/shaders/macro-author-error-trailing-comma.shader", "u_a, u_b,"); + it("preserves a trailing-comma macro replacement list", async () => { + await assertOpaqueMacro( + "src/shader-compiler/shaders/macro-token-fragment-trailing-comma.shader", + "#define BAD u_a, u_b," + ); }); - it("macro-author-error: unbalanced bracket surfaces a uniform diagnostic", async () => { - await assertMacroAuthorError("src/shader-compiler/shaders/macro-author-error-unbalanced-bracket.shader", "u_a[u_b"); + it("preserves an unbalanced-bracket macro replacement list", async () => { + await assertOpaqueMacro( + "src/shader-compiler/shaders/macro-token-fragment-unbalanced-bracket.shader", + "#define BAD u_a[u_b" + ); }); - it("macro-author-error: unbalanced paren surfaces a uniform diagnostic", async () => { - await assertMacroAuthorError("src/shader-compiler/shaders/macro-author-error-unbalanced-paren.shader", "u_a("); + it("preserves an unbalanced-parenthesis macro replacement list", async () => { + await assertOpaqueMacro( + "src/shader-compiler/shaders/macro-token-fragment-unbalanced-paren.shader", + "#define BAD u_a(" + ); }); it("type-alias-repro (FXAA-style portability macros aliasing GLSL types)", async () => { const shaderSource = await readFile("src/shader-compiler/shaders/type-alias-repro.shader"); glslValidate(engine, shaderSource, shaderCompilerRelease); - glslValidate(engine, shaderSource, shaderCompilerVerbose); }); it("type-alias-sampler-only (sampler2D alias alone — should pass via legacy path)", async () => { const shaderSource = await readFile("src/shader-compiler/shaders/type-alias-sampler-only.shader"); glslValidate(engine, shaderSource, shaderCompilerRelease); - glslValidate(engine, shaderSource, shaderCompilerVerbose); }); it("digit-ending-id-repro (struct field ending in digit: v0.xyz, uv1.xy)", async () => { const shaderSource = await readFile("src/shader-compiler/shaders/digit-ending-id-repro.shader"); glslValidate(engine, shaderSource, shaderCompilerRelease); - glslValidate(engine, shaderSource, shaderCompilerVerbose); }); it("paren-member-access-repro (inline (v).v_uv release-mode flatten)", async () => { const shaderSource = await readFile("src/shader-compiler/shaders/paren-member-access-repro.shader"); glslValidate(engine, shaderSource, shaderCompilerRelease); - glslValidate(engine, shaderSource, shaderCompilerVerbose); }); - it("define-in-comment-repro (Issue 2980 ex.1: regex must not false-positive on /* #define */)", async () => { + it("define-in-comment-repro (regex must not false-positive on /* #define */)", async () => { const shaderSource = await readFile("src/shader-compiler/shaders/define-in-comment-repro.shader"); glslValidate(engine, shaderSource, shaderCompilerRelease); - glslValidate(engine, shaderSource, shaderCompilerVerbose); }); - it("define-line-continuation-repro (Issue 2980 ex.2: \\-continuation in #define value)", async () => { + it("define-line-continuation-repro (\\-continuation in #define value)", async () => { const shaderSource = await readFile("src/shader-compiler/shaders/define-line-continuation-repro.shader"); glslValidate(engine, shaderSource, shaderCompilerRelease); - glslValidate(engine, shaderSource, shaderCompilerVerbose); }); it("define-comment-in-peek (block comment between macro name and value)", async () => { const shaderSource = await readFile("src/shader-compiler/shaders/define-comment-in-peek.shader"); glslValidate(engine, shaderSource, shaderCompilerRelease); - glslValidate(engine, shaderSource, shaderCompilerVerbose); }); - it("define-comment-with-dot (reviewer P1-1: `.` inside block comment must not route to AST)", async () => { + it("define-comment-with-dot (`.` inside block comment must not route to AST)", async () => { const shaderSource = await readFile("src/shader-compiler/shaders/define-comment-with-dot.shader"); glslValidate(engine, shaderSource, shaderCompilerRelease); - glslValidate(engine, shaderSource, shaderCompilerVerbose); }); - it("define-line-continuation-member-access (reviewer P1-2: `\\\\\\n` followed by .field must route to AST)", async () => { + it("define-line-continuation-member-access (`\\\\\\n` followed by .field must route to AST)", async () => { const shaderSource = await readFile("src/shader-compiler/shaders/define-line-continuation-member-access.shader"); glslValidate(engine, shaderSource, shaderCompilerRelease); - glslValidate(engine, shaderSource, shaderCompilerVerbose); }); it("define-line-continuation-no-dot (`\\\\\\n` in directive without member access — `_registerMacroDefine` must fold before regex)", async () => { const shaderSource = await readFile("src/shader-compiler/shaders/define-line-continuation-no-dot.shader"); glslValidate(engine, shaderSource, shaderCompilerRelease); - glslValidate(engine, shaderSource, shaderCompilerVerbose); }); it("define-multiline-params (`\\\\\\n` inside function-like macro header — `_scanUtilBreakLine`/`_scanMacroDefineParams` must honor line continuation)", async () => { const shaderSource = await readFile("src/shader-compiler/shaders/define-multiline-params.shader"); glslValidate(engine, shaderSource, shaderCompilerRelease); - glslValidate(engine, shaderSource, shaderCompilerVerbose); }); it("define-if-stack-balance (#if/#elif must keep branch-stack depth so #endif pops correct level)", async () => { const shaderSource = await readFile("src/shader-compiler/shaders/define-if-stack-balance.shader"); glslValidate(engine, shaderSource, shaderCompilerRelease); - glslValidate(engine, shaderSource, shaderCompilerVerbose); }); it("define-elif-polarity (#elif arm must not inherit previous arm's branch tag)", async () => { const shaderSource = await readFile("src/shader-compiler/shaders/define-elif-polarity.shader"); glslValidate(engine, shaderSource, shaderCompilerRelease); - glslValidate(engine, shaderSource, shaderCompilerVerbose); }); - it("frag-return-vec4 (Cocos pattern: fragment entry returns vec4 instead of void)", async () => { + it("frag-return-vec4 (fragment entry returns vec4 instead of void)", async () => { const shaderSource = await readFile("src/shader-compiler/shaders/frag-return-vec4.shader"); glslValidate(engine, shaderSource, shaderCompilerRelease); - glslValidate(engine, shaderSource, shaderCompilerVerbose); }); it("macro-type-alias (macro-defined type aliases in declarations, params, struct members, return types)", async () => { const shaderSource = await readFile("src/shader-compiler/shaders/macro-type-alias.shader"); glslValidate(engine, shaderSource, shaderCompilerRelease); - glslValidate(engine, shaderSource, shaderCompilerVerbose); }); it("cross-if-declarator-collision (declarator name shadowed by #define in sibling #if arm)", async () => { @@ -566,9 +542,9 @@ describe("ShaderCompiler", async () => { // sibling-arm declaration so the variant where `#if` is false // still has `lumaS` defined. const shaderSource = await readFile("src/shader-compiler/shaders/cross-if-declarator-collision.shader"); - const sourceMeta = shaderCompilerVerbose._parseShaderSource(shaderSource); + const sourceMeta = shaderCompilerRelease._parseShaderSource(shaderSource); const passSource = sourceMeta.subShaders[0].passes[0]; - const passProgram = shaderCompilerVerbose._parseShaderPass( + const passProgram = shaderCompilerRelease._parseShaderPass( passSource.contents, passSource.vertexEntry, passSource.fragmentEntry, @@ -582,37 +558,32 @@ describe("ShaderCompiler", async () => { expect(passProgram!.vertex, "vertex must keep #else-arm `lumaS` declaration").to.match(/float\s+lumaS\s*=/); // The full shader pass should still validate end-to-end. glslValidate(engine, shaderSource, shaderCompilerRelease); - glslValidate(engine, shaderSource, shaderCompilerVerbose); }); it("texture-generic (GVec4 → vec4 resolve)", async () => { const shaderSource = await readFile("src/shader-compiler/shaders/texture-generic.shader"); - glslValidate(engine, shaderSource, shaderCompilerVerbose); glslValidate(engine, shaderSource, shaderCompilerRelease); }); it("generic-return-type (builtin generic return as arg to user function)", async () => { const shaderSource = await readFile("src/shader-compiler/shaders/generic-return-type.shader"); - glslValidate(engine, shaderSource, shaderCompilerVerbose); glslValidate(engine, shaderSource, shaderCompilerRelease); }); it("define-nested-ifdef (branch stack: nested #ifdef registers entries under combined signatures)", async () => { const shaderSource = await readFile("src/shader-compiler/shaders/define-nested-ifdef.shader"); glslValidate(engine, shaderSource, shaderCompilerRelease); - glslValidate(engine, shaderSource, shaderCompilerVerbose); }); it("define-branch-scoped-ast (per-branch filtering: same flag, both AST forms, different members)", async () => { const shaderSource = await readFile("src/shader-compiler/shaders/define-branch-scoped-ast.shader"); glslValidate(engine, shaderSource, shaderCompilerRelease); - glslValidate(engine, shaderSource, shaderCompilerVerbose); // Default macro state activates the `#else` branch — codegen must reference // `v_tangent`, not `v_normal`, in the macro substitution path. - const shader = shaderCompilerVerbose._parseShaderSource(shaderSource); + const shader = shaderCompilerRelease._parseShaderSource(shaderSource); const passSource = shader.subShaders[0].passes[0]; - const { fragment } = shaderCompilerVerbose._parseShaderPass( + const { fragment } = shaderCompilerRelease._parseShaderPass( passSource.contents, passSource.vertexEntry, passSource.fragmentEntry, @@ -624,7 +595,7 @@ describe("ShaderCompiler", async () => { // for the call site uses the correct branch's value — `v_tangent`. }); - it("define-mixed-form-repro (Issue 2980 nit: AST/legacy mixed across #ifdef branches must not pollute call-site type)", async () => { + it("define-mixed-form-repro (AST/legacy mixed across #ifdef branches must not pollute call-site type)", async () => { const shaderSource = await readFile("src/shader-compiler/shaders/define-mixed-form-repro.shader"); // Both branches of the mixed `#define LIGHT_INPUT` are legal GLSL on their @@ -634,13 +605,12 @@ describe("ShaderCompiler", async () => { // legacy branch was active. Fix: switch to `.every(...)` so mixed forms // fall back to legacy `referenceSymbolNames`-based inference. glslValidate(engine, shaderSource, shaderCompilerRelease); - glslValidate(engine, shaderSource, shaderCompilerVerbose); // Default macro state activates the legacy branch — generated GLSL must // reference `u_globalLightDir`, not the AST-form `v.v_normal` substitution. - const shader = shaderCompilerVerbose._parseShaderSource(shaderSource); + const shader = shaderCompilerRelease._parseShaderSource(shaderSource); const passSource = shader.subShaders[0].passes[0]; - const { fragment } = shaderCompilerVerbose._parseShaderPass( + const { fragment } = shaderCompilerRelease._parseShaderPass( passSource.contents, passSource.vertexEntry, passSource.fragmentEntry, @@ -649,4 +619,68 @@ describe("ShaderCompiler", async () => { expect(fragment).to.contain("u_globalLightDir"); expect(fragment).to.contain("normalize"); }); + + it("preserves unused token-fragment macro replacement lists", () => { + const shaderSource = `Shader "macro-token-fragments" { SubShader "Default" { Pass "p" { + #define ADD + + #define OPEN ( + #define TRAILING value + + void vert() { gl_Position = vec4(0.0); } + void frag() { gl_FragColor = vec4(1.0); } + VertexShader = vert; + FragmentShader = frag; + } } }`; + const parsed = shaderCompilerRelease._parseShaderSource(shaderSource); + const pass = parsed.subShaders[0].passes[0]; + const output = shaderCompilerRelease._parseShaderPass(pass.contents, pass.vertexEntry, pass.fragmentEntry, 0); + expect(output).to.not.be.undefined; + expect(output!.fragment).to.include("#define ADD +"); + expect(output!.fragment).to.include("#define OPEN ("); + expect(output!.fragment).to.include("#define TRAILING value +"); + }); + + // A struct with conflicting pipeline roles must not produce duplicate stage declarations. + it("struct-role-conflict codegen: no duplicate in/out declarations for the same struct name", () => { + const conflict = `Shader "conf" { SubShader "s" { Pass "p" { + struct IO { vec4 v; }; + IO vert(IO attr) { IO o; gl_Position = vec4(0.0); return o; } + void frag() { gl_FragColor = vec4(0.0); } + VertexShader = vert; + FragmentShader = frag; + } } }`; + const parsed = shaderCompilerRelease._parseShaderSource(conflict); + const pass = parsed.subShaders[0].passes[0]; + const out = shaderCompilerRelease._parseShaderPass(pass.contents, pass.vertexEntry, pass.fragmentEntry, 0); + expect(out, "codegen still returns a result").not.to.be.undefined; + // An ambiguous role emits neither direction. + const combined = out!.vertex + "\n" + out!.fragment; + expect(combined).not.to.match(/^\s*in\s+IO\b/m); + expect(combined).not.to.match(/^\s*out\s+IO\b/m); + // `attribute IO` / `varying IO` cover the GLSL ES 1.00 codegen path (same rationale). + expect(combined).not.to.match(/^\s*attribute\s+IO\b/m); + expect(combined).not.to.match(/^\s*varying\s+IO\b/m); + }); + + it("missing entry codegen: rejects before backend generation without corrupting visitor state", () => { + const missingEntry = `Shader "miss" { SubShader "s" { Pass "p" { + struct Attributes { vec3 POSITION; }; + void realVert(Attributes attr) { gl_Position = vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(0.0); } + VertexShader = notReal; + FragmentShader = frag; + } } }`; + const parsed = shaderCompilerRelease._parseShaderSource(missingEntry); + const pass = parsed.subShaders[0].passes[0]; + const out = shaderCompilerRelease._parseShaderPass(pass.contents, pass.vertexEntry, pass.fragmentEntry, 0); + expect(out, "a missing entry is a structural generation failure").to.be.undefined; + + const valid = shaderCompilerRelease._parseShaderPass( + `void vert() { gl_Position = vec4(0.0); } + void frag() { gl_FragColor = vec4(1.0); }`, + "vert", + "frag", + 0 + ); + expect(valid, "a failed entry lookup must not corrupt the next compile").not.to.be.undefined; + }); }); diff --git a/tests/src/shader-compiler/ShaderNeutralIR.test.ts b/tests/src/shader-compiler/ShaderNeutralIR.test.ts new file mode 100644 index 0000000000..ffe388120b --- /dev/null +++ b/tests/src/shader-compiler/ShaderNeutralIR.test.ts @@ -0,0 +1,77 @@ +import { + ShaderCoreInfo, + TreeNode, + parseShaderPass, + type ShaderClueIR +} from "@galacean/engine-shader-parser/internal/analyzer"; +import { describe, expect, it } from "vitest"; + +interface NeutralBackendSnapshot { + entries: { vertex: string; fragment: string }; + io: { attributes: string[]; varyings: string[]; mrt: string[] }; + sourceFiles: string[]; + conditionalMacros: string[]; +} + +function inspectNeutralIR(ir: ShaderClueIR, coreInfo: ShaderCoreInfo): NeutralBackendSnapshot { + const conditionalMacros = new Set(); + const visit = (node: TreeNode): void => { + for (const constraint of node._branch) { + const condition = constraint.condition; + if (condition?.kind === "defined" || condition?.kind === "comparison") { + conditionalMacros.add(condition.name); + } else if (condition?.kind === "expression") { + for (const name of condition.names) conditionalMacros.add(name); + } else { + conditionalMacros.add(constraint.name); + } + } + for (const child of node.children) { + if (child instanceof TreeNode) visit(child); + } + }; + visit(ir.program); + + return { + entries: { vertex: coreInfo.vertexEntry.name, fragment: coreInfo.fragmentEntry.name }, + io: { + attributes: coreInfo.io.attributeStructs.map((node) => node.ident!.lexeme), + varyings: coreInfo.io.varyingStructs.map((node) => node.ident!.lexeme), + mrt: coreInfo.io.mrtStructs.map((node) => node.ident!.lexeme) + }, + sourceFiles: [...new Set(ir.sourceMap.map((segment) => segment.file).filter((file): file is string => !!file))], + conditionalMacros: [...conditionalMacros].sort() + }; +} + +describe("neutral shader IR", () => { + it("exposes include, conditional, entry, and IO facts without a GLES or analyzer dependency", () => { + const source = ` +#include "common.glsl" +struct Varyings { vec4 color; }; +#if defined(USE_TINT) +vec4 tintColor; +#endif +Varyings vert(Attributes input) { + Varyings output; + gl_Position = vec4(input.position, 1.0); + output.color = vec4(1.0); + return output; +} +void frag(Varyings input) { gl_FragColor = input.color; } +`; + const parsed = parseShaderPass(source, { "common.glsl": "struct Attributes { vec3 position; };" }, new Map()); + expect(parsed.errors).to.have.lengthOf(0); + expect(parsed.ir).to.not.equal(null); + + const ir = parsed.ir!; + const coreInfo = ShaderCoreInfo.create(ir, "vert", "frag"); + expect(inspectNeutralIR(ir, coreInfo)).to.deep.equal({ + entries: { vertex: "vert", fragment: "frag" }, + io: { attributes: ["Attributes"], varyings: ["Varyings"], mrt: [] }, + sourceFiles: ["common.glsl"], + conditionalMacros: ["USE_TINT"] + }); + expect(ir.program.shaderData).to.equal(ir.shaderData); + }); +}); diff --git a/tests/src/shader-compiler/StandaloneAnalyzer.test.ts b/tests/src/shader-compiler/StandaloneAnalyzer.test.ts new file mode 100644 index 0000000000..79bda44afd --- /dev/null +++ b/tests/src/shader-compiler/StandaloneAnalyzer.test.ts @@ -0,0 +1,77 @@ +import { Logger, ShaderLanguage } from "@galacean/engine-core"; +import { ShaderAnalyzer } from "@galacean/engine-shader-analyzer"; +import { ShaderCompiler } from "@galacean/engine-shader-compiler"; +import { describe, expect, it, vi } from "vitest"; + +const passWithIssue = ` +struct Attributes { vec3 POSITION; }; +struct Varyings { vec4 color; }; +Varyings vert(Attributes attr) { Varyings o; o.color = vec4(attr.POSITION, 1.0); return o; } +void frag(Varyings i) { gl_FragColor = i.notAField; }`; + +describe("standalone analyzer and runtime compiler", () => { + it("reports an analyzer error without blocking compiler code generation", () => { + const source = `Shader "separate" { SubShader "Default" { Pass "p" { +${passWithIssue} +VertexShader = vert; +FragmentShader = frag; +} } }`; + const diagnostics = new ShaderAnalyzer().analyze(source).diagnostics; + expect(diagnostics.some((diagnostic) => diagnostic.code === "UndeclaredStructMember")).to.be.true; + + const output = new ShaderCompiler()._parseShaderPass(passWithIssue, "vert", "frag", ShaderLanguage.GLSLES300, ""); + expect(output, "diagnostics are not a compiler gate").to.not.be.undefined; + }); + + it("preserves a valid condition that is opaque to branch reasoning", () => { + const pass = ` +#if A + B > 1 +float branchValue; +#else +vec3 branchValue; +#endif +void vert() { gl_Position = vec4(0.0); } +void frag() { gl_FragColor = vec4(1.0); }`; + const output = new ShaderCompiler()._parseShaderPass(pass, "vert", "frag", ShaderLanguage.GLSLES300, ""); + expect(output).to.not.be.undefined; + expect(output!.vertex).to.include("#if A + B > 1"); + expect(output!.fragment).to.include("#if A + B > 1"); + }); + + it("parses and emits legal non-square matrix types", () => { + const pass = ` +mat2x3 makeMatrix() { return mat2x3(1.0); } +void vert() { + mat3x2 transposed = transpose(makeMatrix()); + gl_Position = vec4(transposed[0][0]); +} +void frag() { gl_FragColor = vec4(1.0); }`; + const output = new ShaderCompiler()._parseShaderPass(pass, "vert", "frag", ShaderLanguage.GLSLES300, ""); + expect(output).to.not.be.undefined; + expect(output!.vertex).to.include("mat2x3 makeMatrix"); + expect(output!.vertex).to.include("mat3x2 transposed"); + + const source = `Shader "matrix" { SubShader "Default" { Pass "p" { +${pass} +VertexShader = vert; +FragmentShader = frag; +} } }`; + expect(new ShaderAnalyzer().analyze(source).diagnostics).to.have.lengthOf(0); + }); + + it("still rejects a missing include as a preprocessing failure", () => { + const errorSpy = vi.spyOn(Logger, "error").mockImplementation(() => {}); + try { + const output = new ShaderCompiler()._parseShaderPass( + '#include "missing.glsl"\nvoid vert() { gl_Position = vec4(0.0); }\nvoid frag() { gl_FragColor = vec4(1.0); }', + "vert", + "frag", + ShaderLanguage.GLSLES300, + "" + ); + expect(output).to.be.undefined; + } finally { + errorSpy.mockRestore(); + } + }); +}); diff --git a/tests/src/shader-compiler/StateIsolation.test.ts b/tests/src/shader-compiler/StateIsolation.test.ts new file mode 100644 index 0000000000..a0b5f19790 --- /dev/null +++ b/tests/src/shader-compiler/StateIsolation.test.ts @@ -0,0 +1,55 @@ +/** + * Guards the shared-mutable-state risk: the compiler reuses a singleton parser + VisitorContext + + * static scratch + the `processingPassText` global, all reset per compile. A missed reset (or a + * reset skipped by an early-return / throw) leaks state across shaders. These tests compile + * distinct shaders interleaved and after a throwing compile, asserting each result is unaffected + * by what was compiled before. + */ +import { Logger, ShaderLanguage } from "@galacean/engine-core"; +import { ShaderCompiler } from "@galacean/engine-shader-compiler"; +import { describe, expect, it, vi } from "vitest"; + +const shaderA = ` +struct Attributes { vec3 POSITION; }; +struct Varyings { vec4 color; }; +Varyings vert(Attributes attr) { Varyings o; o.color = vec4(attr.POSITION, 1.0); return o; } +void frag(Varyings i) { gl_FragColor = i.color; }`; + +const shaderB = ` +struct Attr2 { vec3 POSITION; vec2 UV; }; +struct V2 { vec4 a; vec4 b; }; +V2 vert(Attr2 attr) { V2 o; o.a = vec4(attr.POSITION, 1.0); o.b = vec4(attr.UV, 0.0, 1.0); return o; } +void frag(V2 i) { gl_FragColor = i.a + i.b; }`; + +// Missing entries throw during generation, then the compiler logs, restores pass text, and returns undefined. +const broken = `struct Attributes { vec3 POSITION; }; void notAnEntry() {}`; + +function compile(c: ShaderCompiler, src: string) { + return c._parseShaderPass(src, "vert", "frag", ShaderLanguage.GLSLES300, ""); +} + +describe("compiler state isolation (no cross-shader leak)", () => { + it("interleaved compiles are deterministic (A, B, A → both A identical)", () => { + const c = new ShaderCompiler(); + const a1 = compile(c, shaderA); + compile(c, shaderB); + const a2 = compile(c, shaderA); + expect(a2!.vertex).to.equal(a1!.vertex); + expect(a2!.fragment).to.equal(a1!.fragment); + }); + + it("a degraded compile (missing entries) does not corrupt the next valid compile", () => { + const c = new ShaderCompiler(); + const clean = compile(c, shaderA); + const errorSpy = vi.spyOn(Logger, "error").mockImplementation(() => {}); + try { + expect(compile(c, broken)).to.be.undefined; + expect(errorSpy).toHaveBeenCalledTimes(1); + } finally { + errorSpy.mockRestore(); + } + const after = compile(c, shaderA); + expect(after!.vertex).to.equal(clean!.vertex); + expect(after!.fragment).to.equal(clean!.fragment); + }); +}); diff --git a/tests/src/shader-compiler/expected/define-struct-access-global.frag.glsl b/tests/src/shader-compiler/expected/define-struct-access-global.frag.glsl index 010ccd7beb..1b0d8830c3 100644 --- a/tests/src/shader-compiler/expected/define-struct-access-global.frag.glsl +++ b/tests/src/shader-compiler/expected/define-struct-access-global.frag.glsl @@ -2,12 +2,12 @@ varying vec2 v_uv; uniform sampler2D u_texture; -#define ATTR_POS POSITION +#define ATTR_POS attr.POSITION -#define VARYING_UV v_uv +#define VARYING_UV o.v_uv #define FRAG_UV v_uv -void main() { gl_FragColor = texture2D ( u_texture , FRAG_UV ) ; } \ No newline at end of file +void main() { gl_FragColor = texture2D ( u_texture , FRAG_UV ) ; } diff --git a/tests/src/shader-compiler/expected/define-struct-access-global.vert.glsl b/tests/src/shader-compiler/expected/define-struct-access-global.vert.glsl index ada86cda16..5c14f47460 100644 --- a/tests/src/shader-compiler/expected/define-struct-access-global.vert.glsl +++ b/tests/src/shader-compiler/expected/define-struct-access-global.vert.glsl @@ -11,9 +11,9 @@ varying vec2 v_uv; #define VARYING_UV v_uv -#define FRAG_UV v_uv +#define FRAG_UV v.v_uv void main() { gl_Position = renderer_MVPMat * ATTR_POS ; VARYING_UV = TEXCOORD_0 ; - } \ No newline at end of file + } diff --git a/tests/src/shader-compiler/shaders/define-comment-with-dot.shader b/tests/src/shader-compiler/shaders/define-comment-with-dot.shader index 48e35a6f59..e78e1b4629 100644 --- a/tests/src/shader-compiler/shaders/define-comment-with-dot.shader +++ b/tests/src/shader-compiler/shaders/define-comment-with-dot.shader @@ -7,10 +7,7 @@ Shader "define-comment-with-dot" { VertexShader = vert; FragmentShader = frag; - // Reviewer P1-1: `_defineHasValue` previously scanned for `.` in raw - // source without honoring comments. A `.` inside a block comment - // wrongly routed the directive to the AST path, where `highp` is not - // a valid expression starter — directive parse failed. + // Dots inside comments do not make a declaration-style replacement list an expression. #define HP /* a.b */ highp Varyings vert(Attributes a) { diff --git a/tests/src/shader-compiler/shaders/define-elif-polarity.shader b/tests/src/shader-compiler/shaders/define-elif-polarity.shader index 05aaaf4417..4c5b280d9d 100644 --- a/tests/src/shader-compiler/shaders/define-elif-polarity.shader +++ b/tests/src/shader-compiler/shaders/define-elif-polarity.shader @@ -7,19 +7,7 @@ Shader "define-elif-polarity" { struct Attributes { vec4 POSITION; }; struct Varyings { vec2 v_uv; }; - // `#elif` is semantically `#else + #if`: the new arm is active when - // no prior arm held *and* the elif condition holds. Without a stack - // case, the `#elif` arm inherits the previous arm's branch tag — - // exactly the *opposite* of where it's active. Engine shader pattern - // `#ifdef RENDERER_HAS_NORMAL / ... / #elif defined(HAS_DERIVATIVES)` - // (FragmentPBR.glsl:188) would silently mistag any `#define` inside - // the `#elif` arm with `RENDERER_HAS_NORMAL=true` semantics. - // - // Flipping polarity (like `#else` does) only works for the first - // `#elif` of a chain; longer chains (`#ifdef A / #elif B / #elif C`) - // would ping-pong polarity. Degrading the top constraint to a - // sentinel (`name === ""`) is uniformly conservative — drops - // polarity precision but never wrongly inherits. + // Each `#elif` arm carries its own condition plus the negated prior arms. #ifdef USE_BR_A #define ARM_A 1 #elif defined(USE_BR_B) diff --git a/tests/src/shader-compiler/shaders/define-in-comment-repro.shader b/tests/src/shader-compiler/shaders/define-in-comment-repro.shader index 4106fffbce..9f8c5aa0ca 100644 --- a/tests/src/shader-compiler/shaders/define-in-comment-repro.shader +++ b/tests/src/shader-compiler/shaders/define-in-comment-repro.shader @@ -1,10 +1,7 @@ Shader "define-in-comment-repro" { SubShader "Default" { Pass "0" { - // Issue #2980 example 1: - // The Preprocessor regex used to false-positive register MAX_LIGHTS - // from inside the block comment, so a use site below got tokenized - // as MACRO_CALL but no expansion was emitted. + // Directives inside block comments must not register macros. // Fix: regex runs on a comment-stripped source. /* * Documentation: diff --git a/tests/src/shader-compiler/shaders/define-line-continuation-member-access.shader b/tests/src/shader-compiler/shaders/define-line-continuation-member-access.shader index eb2a5338d5..b1c5d2eda4 100644 --- a/tests/src/shader-compiler/shaders/define-line-continuation-member-access.shader +++ b/tests/src/shader-compiler/shaders/define-line-continuation-member-access.shader @@ -7,10 +7,7 @@ Shader "define-line-continuation-member-access" { VertexShader = vert; FragmentShader = frag; - // Reviewer P1-2: line continuation `\` + `\n` was not honored by - // `_defineHasValue`. The `.v_uv` lives on the next physical line; the - // peek would stop at the first `\n` and misroute to legacy, leaving - // `.v_uv` as a stray top-level token. + // A continued replacement list is one logical expression even when member access starts on the next line. #define UV foo \ .v_uv diff --git a/tests/src/shader-compiler/shaders/define-line-continuation-repro.shader b/tests/src/shader-compiler/shaders/define-line-continuation-repro.shader index 00c5f55cf8..8237297def 100644 --- a/tests/src/shader-compiler/shaders/define-line-continuation-repro.shader +++ b/tests/src/shader-compiler/shaders/define-line-continuation-repro.shader @@ -1,10 +1,7 @@ Shader "define-line-continuation-repro" { SubShader "Default" { Pass "0" { - // Issue #2980 example 2: - // `\`-line-continuation in `#define`. Pre-fix the regex `.*?` didn't - // span lines, so the value was truncated and `referenceName` extraction - // failed. Fix: continuations are squashed before regex runs. + // A continued replacement list retains references from every physical line. #define LONG_VAL v.v_uv \ + v.v_uv diff --git a/tests/src/shader-compiler/shaders/define-mixed-form-repro.shader b/tests/src/shader-compiler/shaders/define-mixed-form-repro.shader index d4d445a0d5..74a29daa2e 100644 --- a/tests/src/shader-compiler/shaders/define-mixed-form-repro.shader +++ b/tests/src/shader-compiler/shaders/define-mixed-form-repro.shader @@ -1,10 +1,8 @@ Shader "define-mixed-form-repro" { SubShader "Default" { Pass "0" { - // Issue #2980 nit: same `#define` name with different forms across - // `#ifdef` branches is legal GLSL (preprocessor is text replacement; - // each active branch produces a self-consistent program). The shader - // compiler must not pollute the call site with TypeAny — it should fall back + // Different replacement forms in exclusive branches are legal because + // each active branch produces a self-consistent program. Type inference falls back // to legacy `referenceSymbolNames`-based inference whichever branch // is active. #ifdef USE_AST_FORM diff --git a/tests/src/shader-compiler/shaders/define-multiline-params.shader b/tests/src/shader-compiler/shaders/define-multiline-params.shader index d6b1b53341..5cc2acad5d 100644 --- a/tests/src/shader-compiler/shaders/define-multiline-params.shader +++ b/tests/src/shader-compiler/shaders/define-multiline-params.shader @@ -7,12 +7,7 @@ Shader "define-multiline-params" { VertexShader = vert; FragmentShader = frag; - // `\` + `\n` inside a function-like macro header must collapse - // logically. Previously `_scanUtilBreakLine` cut the directive at the - // first `\n`, leaving `a, b, c \\\n ) max(...)` as stray top-level - // tokens, and `_scanMacroDefineParams` (when reached) would push raw - // `\` `\n` into the params lexeme. Fix: both routines now honor C/GLSL - // line continuation. + // A continued function-like macro header is one logical directive. #define MAX3( \ a, b, c \ ) max(max(a, b), c) diff --git a/tests/src/shader-compiler/shaders/digit-ending-id-repro.shader b/tests/src/shader-compiler/shaders/digit-ending-id-repro.shader index 7417c5a70f..9e6dcf15e2 100644 --- a/tests/src/shader-compiler/shaders/digit-ending-id-repro.shader +++ b/tests/src/shader-compiler/shaders/digit-ending-id-repro.shader @@ -1,14 +1,7 @@ Shader "digit-ending-id-repro" { SubShader "Default" { Pass "0" { - // Regression: `_defineHasValue` previously checked only the single char - // before `.` to skip decimal points (`3.14`). But GLSL identifiers can - // end in digits (`v0`, `uv1`, `pos2D`), so `v.uv1.xy` and similar - // member-access via digit-ending fields were mis-routed to legacy and - // never got varying-flatten rewriting. - // - // Fix: walk back the entire alnum/_ run; member-access only when the - // run starts with alpha or `_`. + // Macro member access must distinguish digit-ending identifiers from decimal points. #define UV0 v.uv0.xy #define UV1 v.uv1.xy #define POS v.pos2D diff --git a/tests/src/shader-compiler/shaders/macro-author-error-trailing-comma.shader b/tests/src/shader-compiler/shaders/macro-author-error-trailing-comma.shader deleted file mode 100644 index 1b03644a1e..0000000000 --- a/tests/src/shader-compiler/shaders/macro-author-error-trailing-comma.shader +++ /dev/null @@ -1,25 +0,0 @@ -Shader "macro-author-error-trailing-comma" { - SubShader "Default" { - Pass "test" { - mat4 renderer_MVPMat; - - // Authoring error: the value isn't a valid GLSL expression. Rule: - // `#define` values must be either (a) a legal GLSL expression - // (including comma-separated expression lists), or (b) one of the - // three engine-supported legacy shapes — empty value, single - // type/qualifier keyword (e.g. `#define FxaaFloat float`), or - // type-qualifier list (e.g. `mediump sampler2D s`). Everything else - // throws a single uniform diagnostic with the macro name + value text. - #define BAD u_a, u_b, - - float u_a; - float u_b; - - struct Attributes { vec3 POSITION; }; - void vert(Attributes attr) { gl_Position = renderer_MVPMat * vec4(attr.POSITION, 1.0); } - void frag() { gl_FragColor = vec4(0.0); } - VertexShader = vert; - FragmentShader = frag; - } - } -} diff --git a/tests/src/shader-compiler/shaders/macro-author-error-unbalanced-paren.shader b/tests/src/shader-compiler/shaders/macro-author-error-unbalanced-paren.shader deleted file mode 100644 index 4c955edba8..0000000000 --- a/tests/src/shader-compiler/shaders/macro-author-error-unbalanced-paren.shader +++ /dev/null @@ -1,22 +0,0 @@ -Shader "macro-author-error-unbalanced-paren" { - SubShader "Default" { - Pass "test" { - mat4 renderer_MVPMat; - - // Authoring error: unbalanced `(` in a `#define` value. GLSL ES §3.4 - // allows arbitrary token sequences in the replacement list, but the - // engine doesn't route this politely — there's no real-world use case - // (X-macro pattern uses function-like macros, not paren fragments). - // Same uniform diagnostic as other authoring-error shapes. - #define BAD u_a( - - float u_a; - - struct Attributes { vec3 POSITION; }; - void vert(Attributes attr) { gl_Position = renderer_MVPMat * vec4(attr.POSITION, 1.0); } - void frag() { gl_FragColor = vec4(0.0); } - VertexShader = vert; - FragmentShader = frag; - } - } -} diff --git a/tests/src/shader-compiler/shaders/macro-member-access-builtin-arg.shader b/tests/src/shader-compiler/shaders/macro-member-access-builtin-arg.shader index 70ae36f3bc..c23f2d76b0 100644 --- a/tests/src/shader-compiler/shaders/macro-member-access-builtin-arg.shader +++ b/tests/src/shader-compiler/shaders/macro-member-access-builtin-arg.shader @@ -13,7 +13,7 @@ Shader "macro-member-access-builtin-arg-test" { vec3 u_lightDir; vec3 u_cameraPos; - // Cocos-style FSInput macros: member access used as builtin function args + // Member-access macros used as builtin function arguments. #define FSInput_worldNormal v.v_normal.xyz #define FSInput_faceSideSign v.v_normal.w #define FSInput_worldPos v.v_worldPos diff --git a/tests/src/shader-compiler/shaders/macro-token-fragment-trailing-comma.shader b/tests/src/shader-compiler/shaders/macro-token-fragment-trailing-comma.shader new file mode 100644 index 0000000000..1b7800e713 --- /dev/null +++ b/tests/src/shader-compiler/shaders/macro-token-fragment-trailing-comma.shader @@ -0,0 +1,19 @@ +Shader "macro-token-fragment-trailing-comma" { + SubShader "Default" { + Pass "test" { + mat4 renderer_MVPMat; + + // Replacement lists are preprocessing tokens and need not form standalone expressions. + #define BAD u_a, u_b, + + float u_a; + float u_b; + + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = renderer_MVPMat * vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(0.0); } + VertexShader = vert; + FragmentShader = frag; + } + } +} diff --git a/tests/src/shader-compiler/shaders/macro-author-error-unbalanced-bracket.shader b/tests/src/shader-compiler/shaders/macro-token-fragment-unbalanced-bracket.shader similarity index 54% rename from tests/src/shader-compiler/shaders/macro-author-error-unbalanced-bracket.shader rename to tests/src/shader-compiler/shaders/macro-token-fragment-unbalanced-bracket.shader index 37ce666f3a..85e4d2280a 100644 --- a/tests/src/shader-compiler/shaders/macro-author-error-unbalanced-bracket.shader +++ b/tests/src/shader-compiler/shaders/macro-token-fragment-unbalanced-bracket.shader @@ -1,12 +1,9 @@ -Shader "macro-author-error-unbalanced-bracket" { +Shader "macro-token-fragment-unbalanced-bracket" { SubShader "Default" { Pass "test" { mat4 renderer_MVPMat; - // Authoring error: the value isn't a valid GLSL expression. Same - // uniform diagnostic as other authoring-error shapes (trailing - // comma, trailing operator, leading punctuation). The user sees the - // macro name + value text and fixes their GLSL. + // Delimiter fragments are legal in an unused preprocessing replacement list. #define BAD u_a[u_b float u_a; diff --git a/tests/src/shader-compiler/shaders/macro-token-fragment-unbalanced-paren.shader b/tests/src/shader-compiler/shaders/macro-token-fragment-unbalanced-paren.shader new file mode 100644 index 0000000000..deadda0c59 --- /dev/null +++ b/tests/src/shader-compiler/shaders/macro-token-fragment-unbalanced-paren.shader @@ -0,0 +1,18 @@ +Shader "macro-token-fragment-unbalanced-paren" { + SubShader "Default" { + Pass "test" { + mat4 renderer_MVPMat; + + // Delimiter fragments are legal in an unused preprocessing replacement list. + #define BAD u_a( + + float u_a; + + struct Attributes { vec3 POSITION; }; + void vert(Attributes attr) { gl_Position = renderer_MVPMat * vec4(attr.POSITION, 1.0); } + void frag() { gl_FragColor = vec4(0.0); } + VertexShader = vert; + FragmentShader = frag; + } + } +} diff --git a/tests/src/shader-compiler/shaders/macro-value-refs-with-comments.shader b/tests/src/shader-compiler/shaders/macro-value-refs-with-comments.shader index 852d57252a..61c8c0008b 100644 --- a/tests/src/shader-compiler/shaders/macro-value-refs-with-comments.shader +++ b/tests/src/shader-compiler/shaders/macro-value-refs-with-comments.shader @@ -3,18 +3,7 @@ Shader "macro-value-refs-with-comments" { Pass "test" { mat4 renderer_MVPMat; - // Regression for the "comments-in-#define-value" bug: the raw directive - // slice fed to `_registerMacroDefine` retains comment text, and the - // identifier scanner runs on that raw slice. Without stripping comments - // before the scan, words inside `/* */` and `//` get harvested as fake - // references, and if a same-named global exists in this pass it gets - // wrongly marked as live, defeating dead-code elimination. - // - // Each macro below names *only* `u_used_*` in its actual value. The - // comments deliberately mention `u_in_comment_*` words to bait the - // scanner. We then declare both `u_used_*` (real refs) and - // `u_in_comment_*` (fake refs) globally, and the test asserts the fake - // refs do NOT leak into the emitted uniform set. + // Only identifiers in replacement tokens are references; comments are ignored. #define V_BLOCK /* u_in_comment_block */ u_used_block #define V_LINE u_used_line // u_in_comment_line #define V_MIXED /* u_in_comment_mid */ u_used_mid // u_in_comment_tail diff --git a/tests/src/shader-compiler/shaders/macro-value-refs.shader b/tests/src/shader-compiler/shaders/macro-value-refs.shader index 0975e2c4e5..6f31c3a706 100644 --- a/tests/src/shader-compiler/shaders/macro-value-refs.shader +++ b/tests/src/shader-compiler/shaders/macro-value-refs.shader @@ -3,16 +3,15 @@ Shader "macro-value-refs" { Pass "test" { mat4 renderer_MVPMat; - // 1) Parenthesized — pre-fix `_defineDirectiveReg` mis-classified this - // as function-like `V_PAREN(u_paren)` with empty value. + // Parenthesized object-like replacement. #define V_PAREN (u_paren) - // 2) Binary operator — old regex anchored `^id(...)?$`, no trailing operand. + // Replacement containing a binary expression. #define V_OP u_op_a + u_op_b - // 3) Fn call — old regex captured `mix` only, missed user args. + // Replacement containing a function call. #define V_FN mix(u_fn_a, u_fn_b, 0.5) - // 4) Unary — old regex failed on leading `-`. + // Replacement containing a unary expression. #define V_UNARY -u_unary - // 5) `SkyProcedural`'s `#define RAYLEIGH …` shape — real-world repro. + // Nested replacement matching a built-in shader pattern. #define V_SKY (mix(0.0, 0.0025, pow(material_AtmosphereThickness, 2.5))) // Declarations after the #defines — exercises lazy lookup at call site. diff --git a/tests/src/shader-compiler/shaders/paren-member-access-repro.shader b/tests/src/shader-compiler/shaders/paren-member-access-repro.shader index 361ea04005..f24a144bfa 100644 --- a/tests/src/shader-compiler/shaders/paren-member-access-repro.shader +++ b/tests/src/shader-compiler/shaders/paren-member-access-repro.shader @@ -6,7 +6,7 @@ Shader "paren-member-access-repro" { // left-side run starts with alpha — wrongly excludes `(v).v_uv`, // `v . v_uv`, `((v)).v_uv` etc. // 2. `AssignmentExpression.semanticAnalyze` was wrapped in - // `// #if _VERBOSE`, so type propagation was stripped in release + // a legacy compile-time guard, so type propagation was stripped in release // builds and outer `(expr).field` codegen saw `TypeAny`, // skipping varying flatten. // Fix routes by "anything except `digit.digit`" + restores diff --git a/tests/src/shader-compiler/shaders/struct-based-attribute.shader b/tests/src/shader-compiler/shaders/struct-based-attribute.shader new file mode 100644 index 0000000000..fc8f496f2c --- /dev/null +++ b/tests/src/shader-compiler/shaders/struct-based-attribute.shader @@ -0,0 +1,38 @@ +// Vertex entry (`mainVert`) takes an attribute-struct `input`; fragment entry (`mainFrag`) +// takes a varying-struct also named `input`. The two `input`s must be disambiguated per +// stage so `input.POSITION` in vertex resolves to an attribute reference (emit `attribute +// vec4 POSITION;`) rather than being routed to varying by the fragment binding. +Shader "Tutorial/04-Outline" { + SubShader "Default" { + Pass "Main" { + mat4 renderer_MVPMat; + vec4 material_BaseColor; + + struct a2v { + vec4 POSITION; + vec3 NORMAL; + }; + + struct v2f { + vec3 worldNormal; + vec3 worldPos; + }; + + VertexShader = mainVert; + FragmentShader = mainFrag; + + v2f mainVert(a2v input) { + v2f output; + gl_Position = renderer_MVPMat * input.POSITION; + output.worldNormal = input.NORMAL; + output.worldPos = input.POSITION.xyz; + return output; + } + + void mainFrag(v2f input) { + vec3 normal = normalize(input.worldNormal); + gl_FragColor = vec4(normal, 1.0); + } + } + } +} diff --git a/tests/vitest.config.ts b/tests/vitest.config.ts index d6c396b06e..2b9d9de0a3 100644 --- a/tests/vitest.config.ts +++ b/tests/vitest.config.ts @@ -11,22 +11,29 @@ export default defineProject({ "@galacean/engine-loader", "@galacean/engine-rhi-webgl", "@galacean/engine-math", - "@galacean/engine-core" + "@galacean/engine-core", + "@galacean/engine-design", + "@galacean/engine-shader", + "@galacean/engine-shader-analyzer", + "@galacean/engine-shader-compiler", + "@galacean/engine-shader-parser/internal", + "@galacean/engine-shader-parser/internal/analyzer", + "playwright", + "playwright-core", + "fsevents" ] }, test: { browser: { provider: "playwright", enabled: true, + headless: process.env.HEADLESS === "true", screenshotFailures: false, instances: [ { browser: "chromium", launch: { - args: - process.env.HEADLESS === "true" - ? ["--use-gl=egl", "--ignore-gpu-blocklist", "--use-gl=angle", "--headless"] - : ["--use-gl=egl", "--ignore-gpu-blocklist", "--use-gl=angle"] + args: ["--ignore-gpu-blocklist", "--use-gl=angle"] } } ]