Table of Contents

Custom ECS-Aware Shader Language - Research Report

Date: December 2024 Purpose: Evaluate approaches for creating a custom shader language that integrates with KeenEyes ECS for GPU compute and rendering

Executive Summary

This report explores the design and implementation of KESL (KeenEyes Shader Language), a custom shader language that provides first-class integration with ECS component data. The goal is to reduce boilerplate when moving entity data to/from the GPU while maintaining performance and cross-platform compatibility.

Key Findings

  1. Existing shader languages (GLSL, HLSL, WGSL) are general-purpose and require manual data marshaling
  2. Game engines (Unity, Unreal, Godot) use custom layers but none are ECS-aware
  3. ECS frameworks with GPU support (Bevy, Flecs) use manual bindings or procedural macros
  4. An ECS-aware language can eliminate 50-80% of GPU compute boilerplate
  5. Transpilation to GLSL/HLSL is more practical than custom GPU backends

Recommendation

Build KESL as a transpiler that:

  • Compiles to GLSL (primary) and HLSL (secondary)
  • Generates C# binding code for KeenEyes integration
  • Integrates with MSBuild for compile-time processing
  • Supports hot-reload during development

Existing Shader Languages Analysis

GLSL (OpenGL Shading Language)

Strengths:

  • Industry standard, well-documented
  • Wide platform support (OpenGL, Vulkan via SPIR-V)
  • Familiar C-like syntax
  • Direct hardware mapping

Weaknesses:

  • No native module system (#include is vendor extension)
  • No type inference
  • Manual buffer binding management
  • Verbose for simple operations

Example:

#version 450

layout(std430, binding = 0) buffer PositionBuffer {
    vec3 positions[];
};

layout(std430, binding = 1) readonly buffer VelocityBuffer {
    vec3 velocities[];
};

uniform float deltaTime;
uniform uint entityCount;

layout(local_size_x = 64) in;

void main() {
    uint idx = gl_GlobalInvocationID.x;
    if (idx >= entityCount) return;

    positions[idx] += velocities[idx] * deltaTime;
}

HLSL (High-Level Shading Language)

Strengths:

  • First-class DirectX support
  • Better tooling (Visual Studio integration)
  • More expressive than GLSL
  • Structured buffer syntax

Weaknesses:

  • Windows-centric (cross-platform via DXC/SPIR-V)
  • DirectX-specific features don't translate
  • More verbose register management

Example:

StructuredBuffer<float3> velocities : register(t0);
RWStructuredBuffer<float3> positions : register(u0);

cbuffer Constants : register(b0) {
    float deltaTime;
    uint entityCount;
};

[numthreads(64, 1, 1)]
void CSMain(uint3 id : SV_DispatchThreadID) {
    if (id.x >= entityCount) return;
    positions[id.x] += velocities[id.x] * deltaTime;
}

WGSL (WebGPU Shading Language)

Strengths:

  • Modern design (2020+)
  • Memory-safe by design
  • Clean syntax without legacy baggage
  • Designed for web and native

Weaknesses:

  • Young ecosystem
  • Limited tooling
  • No compute shader hot-reload story
  • Browser-focused initially

Example:

struct Position {
    x: f32,
    y: f32,
    z: f32,
}

@group(0) @binding(0) var<storage, read_write> positions: array<Position>;
@group(0) @binding(1) var<storage, read> velocities: array<Position>;

struct Params {
    deltaTime: f32,
    entityCount: u32,
}
@group(0) @binding(2) var<uniform> params: Params;

@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) id: vec3<u32>) {
    if (id.x >= params.entityCount) { return; }
    positions[id.x].x += velocities[id.x].x * params.deltaTime;
    positions[id.x].y += velocities[id.x].y * params.deltaTime;
    positions[id.x].z += velocities[id.x].z * params.deltaTime;
}

Slang (Shader Language)

Strengths:

  • Modern shader language from NVIDIA
  • Compiles to GLSL, HLSL, SPIR-V, CUDA, C++
  • Differentiable programming support
  • Generics and interfaces

Weaknesses:

  • Complex implementation
  • Large runtime dependency
  • Learning curve

Relevance: Slang demonstrates the viability of a shader meta-language that compiles to multiple backends. KESL could follow a similar architecture.


Unity ShaderLab

Strengths:

  • Declarative shader definition
  • Material property integration
  • Render state management
  • Multi-pass support

Weaknesses:

  • Unity-specific, not portable
  • Mixes syntax concerns
  • Complex for simple shaders

Example:

Shader "Custom/Simple" {
    Properties {
        _Color ("Color", Color) = (1,1,1,1)
    }
    SubShader {
        Pass {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            float4 _Color;

            float4 vert(float4 pos : POSITION) : SV_POSITION {
                return UnityObjectToClipPos(pos);
            }

            float4 frag() : SV_Target {
                return _Color;
            }
            ENDCG
        }
    }
}

Bevy (Rust ECS + WGSL)

Approach:

  • Uses WGSL directly
  • Procedural macros generate GPU bindings
  • AsBindGroup derive macro creates buffer layouts
  • Manual but ergonomic

Example:

#[derive(Component, AsBindGroup, Clone)]
struct ParticleMaterial {
    #[uniform(0)]
    color: Color,
    #[texture(1)]
    texture: Handle<Image>,
}

Insight: Bevy's approach uses Rust's macro system rather than a custom language. For C#, source generators serve a similar purpose but can't modify shader code.


ECS-GPU Integration Patterns

Pattern 1: Manual Marshaling (Current Standard)

// Extract from ECS
var positions = new Vector3[count];
var velocities = new Vector3[count];
int i = 0;
foreach (var entity in world.Query<Position, Velocity>())
{
    positions[i] = world.Get<Position>(entity).ToVector3();
    velocities[i] = world.Get<Velocity>(entity).ToVector3();
    i++;
}

// Upload
positionBuffer.SetData(positions);
velocityBuffer.SetData(velocities);

// Dispatch
shader.Dispatch(count / 64 + 1, 1, 1);

// Download
positionBuffer.GetData(positions);

// Write back
i = 0;
foreach (var entity in world.Query<Position, Velocity>())
{
    ref var pos = ref world.Get<Position>(entity);
    pos = new Position(positions[i].X, positions[i].Y, positions[i].Z);
    i++;
}

Problems:

  • Verbose (30+ lines for simple operation)
  • Error-prone (index management)
  • Allocates temporary arrays
  • Query executed twice

Pattern 2: Generated Bindings (Bevy-style)

// Source generator creates GpuPositionBuffer, GpuVelocityBuffer
// Based on [GpuComponent] attribute

[GpuComponent]
public partial struct Position { ... }

// Generated code handles upload/download
var gpuQuery = world.GpuQuery<Position, Velocity>();
gpuQuery.Upload();
shader.Dispatch(gpuQuery.Count / 64 + 1, 1, 1);
gpuQuery.Download<Position>();  // Only download modified components

Improvement: Less boilerplate but shader still written separately with manual binding management.


Pattern 3: Unified Language (KESL Proposal)

// physics.kesl

compute UpdatePhysics {
    query {
        write Position
        read  Velocity
    }

    params {
        deltaTime: float
    }

    execute() {
        Position.x += Velocity.x * deltaTime;
        Position.y += Velocity.y * deltaTime;
        Position.z += Velocity.z * deltaTime;
    }
}

Compiles to:

  1. GLSL compute shader with correct buffer bindings
  2. C# class with Execute(World, float deltaTime) method
  3. Automatic upload/download of declared components

Language Design Considerations

Type System

KESL Type C# Type GLSL Type Size
float float float 4
float2 Vector2 vec2 8
float3 Vector3 vec3 12
float4 Vector4 vec4 16
int int int 4
uint uint uint 4
bool bool bool 4
mat4 Matrix4x4 mat4 64

Component Mapping

Components must have compatible layouts:

// C# component
[Component]
public partial struct Position
{
    public float X;
    public float Y;
    public float Z;
}

// Equivalent KESL component reference
// Position.x, Position.y, Position.z available in shader

Considerations:

  • Padding for GPU alignment (vec3 → 16 bytes)
  • Struct-of-Arrays vs Array-of-Structs
  • Handle non-blittable types (strings, references)

Query Semantics

query {
    read  ComponentA      // Read-only access
    write ComponentB      // Read-write access
    optional ComponentC   // May or may not exist
    without ComponentD    // Exclude entities with this
}

Maps to:

  • C#: world.Query<A, B>().With<C>().Without<D>()
  • GPU: Separate dispatches per archetype

Access Modes

Mode GPU Access Upload Download
read readonly buffer Yes No
write buffer Yes Yes
optional Conditional access If present If present

Compilation Pipeline

┌─────────────────────────────────────────────────────────┐
│                    Source (.kesl)                        │
└─────────────────────────────────────────────────────────┘
                          │
                          ▼
┌─────────────────────────────────────────────────────────┐
│                      Lexer                               │
│  "compute UpdatePhysics { ... }"                        │
│         ↓                                                │
│  [Compute, Identifier, LeftBrace, ...]                  │
└─────────────────────────────────────────────────────────┘
                          │
                          ▼
┌─────────────────────────────────────────────────────────┐
│                      Parser                              │
│  Tokens → Abstract Syntax Tree (AST)                    │
│                                                          │
│  ComputeShader {                                        │
│      Name: "UpdatePhysics",                             │
│      Query: [...],                                      │
│      Execute: [...]                                     │
│  }                                                       │
└─────────────────────────────────────────────────────────┘
                          │
                          ▼
┌─────────────────────────────────────────────────────────┐
│                 Semantic Analysis                        │
│  - Resolve component types                              │
│  - Type check expressions                               │
│  - Validate access modes                                │
│  - Check GPU compatibility                              │
└─────────────────────────────────────────────────────────┘
                          │
              ┌───────────┴───────────┐
              ▼                       ▼
┌─────────────────────────┐ ┌─────────────────────────────┐
│     GLSL Generator      │ │     C# Binding Generator    │
│  - Buffer declarations  │ │  - IGpuSystem class         │
│  - Uniform bindings     │ │  - Upload/download logic    │
│  - main() function      │ │  - Query matching           │
└─────────────────────────┘ └─────────────────────────────┘
              │                       │
              ▼                       ▼
        UpdatePhysics.glsl    UpdatePhysicsShader.g.cs

Implementation Approaches

Option A: Standalone Compiler (keslc)

Architecture:

  • Command-line tool: keslc physics.kesl -o output/
  • MSBuild target invokes during build
  • Outputs .glsl and .g.cs files

Pros:

  • Language-agnostic implementation
  • Easy to debug and test
  • Clear separation of concerns

Cons:

  • Additional build step
  • Not incremental (must track dependencies)
  • Separate tooling from source generators

Option B: Roslyn Source Generator

Architecture:

  • Additional files (.kesl) trigger generator
  • Generator parses and emits C# + embedded shader strings

Pros:

  • Integrated with existing build
  • IDE support (errors in editor)
  • Incremental compilation

Cons:

  • Limited to C# output (shader strings embedded)
  • Harder to debug generator
  • Can't easily emit separate shader files

Architecture:

  • Core compiler library (KeenEyes.Shaders.Compiler)
  • CLI tool for standalone use (keslc)
  • Source generator for build integration
  • MSBuild targets for asset processing

Pros:

  • Flexibility for different workflows
  • Testable compiler core
  • IDE integration via source generator

Existing Tools and Libraries

Parsing Libraries for .NET

Library Approach Performance Learning Curve
ANTLR Grammar-based Medium High
Pidgin Parser combinators Good Medium
Superpower Parser combinators Good Medium
Hand-written Recursive descent Best Low-Medium

Recommendation: Hand-written recursive descent parser. For a domain-specific language with clear grammar, this provides the best error messages and performance.

Shader Compilation

Tool Input Output .NET Support
Veldrid.SPIRV GLSL/HLSL SPIR-V, cross-compile Native bindings
Glslang.NET GLSL SPIR-V .NET 8+
shaderc GLSL SPIR-V Via P/Invoke

Recommendation: Generate GLSL, use Veldrid.SPIRV for SPIR-V compilation if needed.


Scope and Limitations

In Scope (MVP)

  1. Compute shaders operating on component data
  2. Query-based entity selection
  3. Read/write component access
  4. Basic types (float, float2, float3, float4, int, mat4)
  5. Standard math operations
  6. Control flow (if/else, for loops)
  7. GLSL backend

Out of Scope (Future)

  1. Vertex/fragment shaders (rendering pipeline)
  2. Texture sampling
  3. Entity relationships in shaders
  4. Spatial queries on GPU
  5. Multi-backend (HLSL, SPIR-V, Metal)
  6. Debugging/profiling integration
  7. Shader variants/permutations

Known Limitations

  1. Archetype iteration: Each archetype requires separate dispatch
  2. Component layout: Must match GPU alignment requirements
  3. Data transfer: CPU-GPU bandwidth is a bottleneck
  4. Entity identity: GPU doesn't have entity handles, only indices

Performance Considerations

When GPU Compute Makes Sense

Scenario CPU Time GPU Time Winner
100 entities 0.01ms 1.0ms (upload) CPU
1,000 entities 0.1ms 1.1ms CPU
10,000 entities 1.0ms 1.2ms Comparable
100,000 entities 10.0ms 1.5ms GPU
1,000,000 entities 100.0ms 3.0ms GPU

Rule of thumb: GPU compute beneficial for 10,000+ entities with parallel operations.

Optimization Strategies

  1. Double buffering: Upload frame N while GPU processes frame N-1
  2. Persistent mapping: Use GL_MAP_PERSISTENT_BIT for zero-copy
  3. Batched dispatches: Combine small dispatches
  4. SoA layout: Better GPU cache utilization

Research Summary

Comparison Matrix

Approach Boilerplate Type Safety Performance Portability Effort
Manual marshaling High Low Baseline High None
Generated bindings Medium Medium Baseline High Low
Custom language (KESL) Low High Baseline+ Medium High
Embedded DSL Low High Baseline High Medium

Recommendation

Proceed with KESL implementation using the hybrid approach:

  1. Phase 1: Core compiler with GLSL backend (MVP)
  2. Phase 2: Source generator integration
  3. Phase 3: MSBuild/SDK integration
  4. Phase 4: Additional backends (HLSL, SPIR-V)
  5. Phase 5: Rendering shader support

The investment is justified if KeenEyes targets GPU-accelerated gameplay systems (particles, physics, AI, large-scale simulations).


Sources

Shader Languages

ECS + GPU

Compiler Implementation

.NET Tools