Skip to content

About

Standalone ShaderLab lexer, parser and syntax tree for Unity .shader files.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Tesearis.ShaderLabParser

Standalone ShaderLab lexer, parser and syntax tree for .shader files.

Tesearis.ShaderLabParser turns ShaderLab source into an immutable, strongly-typed AST so you can inspect, tool, or lint .shader files.

Features

  • Zero dependencies: no runtime dependencies at all.
  • Full ShaderLab grammar: Properties, nesting, passes, tags, command blocks, program blocks...
  • Forward-compatible: commands the parser doesn't specifically recognize still parse as generic so newer syntax won't break parsing.
  • Program directive scanning: preprocessor directives are recognized without parsing the shader code itself.
  • Conditional resolution: #if/#ifdef/#ifndef/#elif/#else resolve against a caller-supplied macro environment, kept up to date automatically as #define/#undef are encountered across program/include blocks.
  • Best-effort recovery: never throws on malformed input; genuine syntax errors become Error diagnostics while parsing continues.
  • Targeted: netstandard2.0 (for use in the Unity Editor) and net8.0.

What this library does not do

  • It never reads files from disk.
  • It never resolves #include, Fallback, UsePass, or Dependency targets.
  • It doesn't tokenize or parse the CG/HLSL/GLSL code inside program blocks. Content is captured verbatim.

Usage

Parse a shader

using Tesearis.ShaderLabParser.Parsing;

var result = ShaderLab.Parse(shaderSource, fileName: "Toon.shader");

// Parsing never throws, even on malformed input — check diagnostics instead.
if (result.HasErrors)
{
    foreach (var diagnostic in result.Diagnostics)
        Console.WriteLine(diagnostic); // "Toon.shader(7,13): error SL0104: ..."
}

ShaderNode shader = result.Shader; // null only if the source has no `Shader { ... }` at all

Walk the tree

using Tesearis.ShaderLabParser.Syntax;

shader.Name; // "Custom/Toon"

foreach (var property in shader.Properties.Items)
    Console.WriteLine($"{property.Name} : {property.TypeName} (\"{property.DisplayName}\")");

// AllSubShaders also flattens SubShaders nested inside legacy Category blocks.
foreach (var subShader in shader.AllSubShaders)
{
    var renderType = subShader.Tags?.GetValue("RenderType"); // case-insensitive lookup
    foreach (var pass in subShader.Passes.OfType<PassNode>())
        Console.WriteLine($"Pass {pass.Name ?? "<unnamed>"}: {pass.ProgramBlocks.Count} program block(s)");
}

CommandNode covers render-state and other commands (Blend, ZWrite, Stencil, ...), recognized or not: command.NameIs("ZWrite"), command.GetArgumentText(0), command.IsRecognized. Every node also exposes Span, Children, DescendantsAndSelf(), and FindNodeAt(position) for offset-based lookups — handy for editor tooling.

Dump the tree

string tree = ShaderLabTreeDumper.Dump(shader, result.Source);
Console.WriteLine(tree);
Shader "Custom/Toon"   @1:1
  Properties (2)   @3:5
    Property _MainTex : 2D = "white" {}  ("Texture")   @5:9
    Property _Color : Color = (1, 1, 1, 1)  ("Tint")   @6:9
  SubShader (LOD -1, 1 passes)   @8:5
    Tags (1)   @10:9
      Tag "RenderType" = "Opaque"   @10:16
    Pass <unnamed>   @11:9
      ProgramBlock Hlsl/Program (116 chars)   @13:13
        #pragma vertex vert   @14
        #pragma fragment frag   @15
        #include "UnityCG.cginc"   @16

Custom traversal

Subclass ShaderLabVisitor and override only what you need — call DefaultVisit to keep descending into a node's children:

sealed class PropertyNameCollector : ShaderLabVisitor
{
    public List<string> Names { get; } = new();

    public override void VisitProperty(PropertyNode node)
    {
        Names.Add(node.Name);
        DefaultVisit(node);
    }
}

var collector = new PropertyNameCollector();
collector.Visit(shader); // Names: ["_MainTex", "_Color"]

Program directives and conditional resolution

#pragma/#include directives inside every ProgramBlockNode are scanned automatically during Parse — no separate call needed:

foreach (var block in pass.ProgramBlocks)
{
    foreach (var pragma in block.Directives.Pragmas)
        Console.WriteLine($"#pragma {pragma.Name} {string.Join(" ", pragma.Arguments)}");

    foreach (var include in block.Directives.Includes)
        Console.WriteLine(include.Path); // raw and unresolved, like Fallback/Dependency/UsePass
}

Pass an isMacroDefined lookup to Parse to resolve #if/#ifdef/#ifndef/#elif/#else branches against your build's macros. It's tri-state (true/false/null = unknown), so partial knowledge is fine — a branch that can't be resolved is conservatively kept "live" rather than dropped:

var definedMacros = new HashSet<string> { "SHADER_API_MOBILE" };
var result = ShaderLab.Parse(shaderSource, "Toon.shader", name => definedMacros.Contains(name));

var block = result.Shader.AllSubShaders.First().Passes.OfType<PassNode>().First().ProgramBlocks[0];
foreach (var directive in block.Directives.All)
{
    if (!directive.IsBranchLive) continue; // provably dead for this macro set
    // ...
}

ProgramDirectiveScanner.Scan(...) is also public directly, for re-scanning a block with different options (e.g. reportUnknownPragmas: true) or scanning raw text with no ProgramBlockNode of its own — such as an #include file's contents once you've resolved it yourself.

#define/#undef tracking

#define NAME / #define NAME(args) ... and #undef NAME are recognized as DefineDirectiveNode/UndefDirectiveNode — structurally only: the macro name (and, for #define, whether it's function-like) is captured, but the replacement text is kept verbatim and never expanded anywhere, matching this library's "no macro-value substitution" scope.

What is handled automatically: a #define/#undef seen in one program or include block resolves #if/#ifdef/#ifndef in a later block within the same file scope — e.g. a CGINCLUDE's #define gates a subsequent CGPROGRAM's #if in the same Pass. This is scoped correctly per SubShader/Pass/Category (a scope's own #defines are visible to its children but never leak to sibling scopes) and per shader language (Cg/HLSL/GLSL are tracked independently, since Unity never concatenates one into another). No extra wiring is needed beyond calling Parse.

For callers who want to seed platform/build macros up front instead of (or alongside) the bare isMacroDefined delegate, MacroTable is available as a structured, tri-state, pre-seedable macro environment.

Building & testing

dotnet build Tesearis.ShaderLabParser.slnx
dotnet test Tesearis.ShaderLabParser.slnx

License

MIT — see LICENSE.

About

Standalone ShaderLab lexer, parser and syntax tree for Unity .shader files.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages