Skip to content

Eccentricities

This section explains where AXSL deviates from standard GLSL or C-style compilers. These design choices prioritize simplicity and per-stage performance over conventional language features.

Flat AST with Integer IDs

Most AST implementations use heap-allocated nodes connected by pointers. AXSLC uses a single std::vector<ASTNode> and uint32_t indices.

Benefits

  • Trivial include merging - append vectors and rebase indices
  • No pointer invalidation - vector may reallocate, but indices remain stable
  • High memory locality - sequential traversal benefits from cache coherency
  • Simple serialization - no complex graph serialization needed

Trade-offs

  • No compile-time type safety - accessing m_nodes[id] and std::get-ing the wrong variant type is a runtime crash
  • No parent pointers - traversal is always top-down from a known root ID

Why It Matters

This design makes the visit_offset() pattern possible, which is central to how includes work. See AST Design for details.

do-whileWhileStmt

do { body } while (cond) is parsed and represented internally as a WhileStmt{cond, body} - the same node type as a plain while loop.

The AST carries no information distinguishing the two.

Current Behavior

The emitter emits both as while (cond) { body }, losing the do-while semantics (the body executing at least once).

Impact

If you need a true do-while in emitted GLSL, this is a known limitation. Workaround:

axsl
// Instead of:
do {
    body();
} while (cond);

// Write:
body();  // first iteration
while (cond) {
    body();
}

Per-Stage Uniform Dead-Code Elimination

Global uniforms are declared once at the top of the .axsl file but are only emitted into a stage's GLSL output if the linker determines that stage's code actually references the uniform name.

Why

  • Prevents spurious unused-uniform warnings from the GLSL compiler
  • Reduces shader binary size
  • Makes it clear which stages use which resources

Example

axsl
uniform float u_vertex_time;
uniform float u_fragment_time;

@stage vertex {
    void main() {
        float t = u_vertex_time;  // uses u_vertex_time
    }
}

@stage fragment {
    void main() {
        float t = u_fragment_time;  // uses u_fragment_time
    }
}

Vertex GLSL:

glsl
uniform float u_vertex_time;  // present
// u_fragment_time is absent

Fragment GLSL:

glsl
uniform float u_fragment_time;  // present
// u_vertex_time is absent

Uniform Shadowing

A stage-local uniform declaration with the same name as a global uniform completely replaces the global in that stage.

Why

Allows stages to declare more specific or differently-annotated versions of a shared concept without renaming.

Example

axsl
@binding(0) uniform float u_time;  // global default

@stage compute {
    @binding(5) uniform float u_time;  // override binding for compute stage

    void main() {
        // uses binding 5 here
    }
}

Compute GLSL:

glsl
layout(binding = 5) uniform float u_time;  // stage-local version

Other stages get the global version at binding 0.

Interface System

The interface keyword defines a named group of fields at global scope. An interface declaration is not itself emitted as GLSL. It serves purely as a template.

Why

Avoids repeating the field list in every stage that passes data between vertex and fragment shaders. DRY principle for interface blocks.

How It Works

When a stage declares in IfaceName inst, the emitter expands the template inline.

See Interface System for full details.

Unbounded Array Syntax

An array field with no size (mat4 models[]) is stored in the FieldDecl as array_size = std::optional<uint32_t>(0).

That is:

  • The optional is set (meaning "is an array")
  • Its value is zero (a sentinel meaning "unbounded")

Emitter Behavior

The emitter checks for this and emits [] rather than [0].

Why

Unbounded arrays are a GLSL feature for SSBOs (shader storage buffer objects). The size is determined at runtime, not compile time.

axsl
@std430 @binding(0) buffer Data {
    float values[];  // unbounded
} data;

Emits:

glsl
layout(std430, binding = 0) buffer Data {
    float values[];
} data;

Trailing Dot Float Literals

AXSL accepts float literals with a trailing dot and no fractional digits: 0., 1., 2..

Tokenizer Logic

After consuming digits, checks if:

  1. current() == '.'
  2. The character after the dot is neither an alpha character nor _

If both are true, the dot is consumed as part of the float.

Why

Matches common GLSL usage patterns where authors abbreviate 0.0 to 0. for brevity.

Examples

axsl
float a = 0.;   // valid, same as 0.0
float b = 1.;   // valid, same as 1.0

But:

axsl
int x = 0.something;  // parse error: 0. is a float, .something is invalid

No Type Checking

AXSLC performs no type inference, type checking, or type annotation beyond storing the declared type of variable and function declarations.

The ASTNode::type field is populated as void for all nodes by push_node. No phase attempts to infer or validate expression types.

Why

The downstream GLSL compiler (the OpenGL driver) is the authoritative type checker. AXSLC doesn't try to replicate that work.

What AXSLC Does Check

  • Identifier resolution (is x declared?)
  • Function existence (is foo a known function or built-in?)
  • Include file loading (does common/defs.axsl exist?)

What AXSLC Doesn't Check

  • Type correctness (is x + y valid for types of x and y?)
  • Argument count/types (does foo(a, b) match foo's signature?)
  • Return type matching (does return x; match the function's return type?)

All of these are caught by the GLSL compiler.

70+ GLSL Built-in Pre-declarations

The parser pre-populates its m_declared set with the names of all commonly used GLSL built-in functions.

Why

Prevents these names from being flagged as UnresolvedRef when used as callees.

List

Covers:

  • Trig: sin, cos, tan, asin, acos, atan, ...
  • Math: pow, exp, log, sqrt, abs, sign, ...
  • Geometric: length, distance, dot, cross, normalize, ...
  • Texture: texture, texture2D, textureLod, ...
  • Atomic: atomicAdd, atomicMin, ...
  • Compute: barrier, memoryBarrier, ...

See Pratt Parser - Declared Set for the complete list.

expect() Error Location

When a required token is missing, expect() reports the error at the end of the last successfully consumed token rather than at the start of the unexpected lookahead token.

The column is computed as:

cpp
last_token.col + token_src_length(last_token)

Why

This produces error messages that point to the exact position where the missing token was expected, not to the token that happens to be next in the stream.

Example

axsl
void main() {
    x = 1.0  // missing semicolon
    y = 2.0;
}

Error:

shader.axsl:2:12: expected ';' after expression
    1 | void main() {
    2 |     x = 1.0
      |            ^
    3 |     y = 2.0;

The error is reported at the end of line 2 (after 1.0), not at the start of line 3 (before y).

This makes it immediately clear that the semicolon is missing after 1.0.

Summary

These design choices prioritize:

  • Simplicity - flat AST, no type checking
  • Correctness - delegate to GLSL compiler where appropriate
  • Efficiency - dead-code elimination, cache locality
  • Convenience - interface system, uniform shadowing, trailing dots

Trade-offs include:

  • Do-while semantics lost
  • No compile-time type safety on AST node access
  • Some features (like @define) not yet implemented