Why Source Generators?
KeenEyes uses Roslyn source generators to create efficient, type-safe code at compile time. This page explains why we chose this approach over alternatives.
The Problem: Boilerplate vs Performance
ECS requires repetitive patterns:
// Without any automation - lots of boilerplate
public struct Position : IComponent
{
public float X;
public float Y;
}
// Registration (every component, every world)
world.Components.Register<Position>();
world.Components.Register<Velocity>();
world.Components.Register<Health>();
// ... hundreds more
// Query iteration (manual pattern)
foreach (var entity in world.Query<Position, Velocity>())
{
ref var pos = ref world.Get<Position>(entity);
ref readonly var vel = ref world.Get<Velocity>(entity);
// ...
}
Common solutions:
- Manual code - Error-prone, tedious
- Runtime reflection - Convenient but slow, no AOT support
- Source generators - Best of both worlds
How Source Generators Work
Source generators run at compile time:
Your Code → Roslyn Compiler → Generator → Additional Code → Compilation
They see your source code and generate additional C# code that compiles alongside yours.
Example: Component Generation
You write:
[Component]
public partial struct Position
{
public float X;
public float Y;
}
Generator produces:
// Auto-generated - do not edit
public partial struct Position : IComponent
{
// Fluent builder method
public static EntityBuilder With(EntityBuilder builder, Position component)
=> builder.With(component);
}
public static class PositionBuilderExtensions
{
public static EntityBuilder WithPosition(this EntityBuilder builder, float x, float y)
=> builder.With(new Position { X = x, Y = y });
}
You can now write:
var entity = world.Spawn()
.WithPosition(10, 20) // Generated method
.WithVelocity(1, 0) // Generated method
.Build();
Why Not Runtime Reflection?
Reflection reads type metadata at runtime:
// Reflection-based approach
public void RegisterAllComponents(Assembly assembly)
{
foreach (var type in assembly.GetTypes())
{
if (typeof(IComponent).IsAssignableFrom(type))
{
// Use reflection to get constructor, fields, etc.
var method = typeof(ComponentRegistry)
.GetMethod("Register")
.MakeGenericMethod(type);
method.Invoke(registry, null);
}
}
}
Problem 1: Performance
Reflection is 10-100x slower than direct calls:
| Operation | Direct Call | Reflection |
|---|---|---|
| Method invoke | ~1ns | ~100ns |
| Field access | ~0.5ns | ~50ns |
| Type lookup | ~0.1ns | ~10ns |
Not dramatic for one-time setup, but problematic if used in hot paths.
Problem 2: No Native AOT Support
Native AOT compiles directly to machine code - no JIT, no runtime type generation:
// This FAILS with Native AOT
var method = genericMethod.MakeGenericMethod(runtimeType); // Requires JIT
method.Invoke(target, args); // Dynamic dispatch
Native AOT is increasingly important for:
- Mobile games (iOS requires AOT)
- WebAssembly (WASM has no JIT)
- Cloud functions (faster cold starts)
- Console games (fixed hardware)
Problem 3: Trimming Issues
The IL Linker removes unused code for smaller binaries. It can't trace reflection calls:
// Linker can't see this uses MyComponent
var type = Type.GetType("MyGame.MyComponent");
Activator.CreateInstance(type); // May be trimmed away!
You need [DynamicDependency] attributes everywhere, which is fragile.
Problem 4: No Compile-Time Validation
Reflection errors surface at runtime:
// Typo - won't error until runtime
var type = Type.GetType("MyGame.Positoin"); // Misspelled
registry.Register(type); // null type - runtime exception
With source generators, errors appear in your IDE as you type.
Source Generator Benefits
Benefit 1: Zero Runtime Cost
Generated code is identical to hand-written code:
// Generated (compiles to same IL as hand-written)
public static EntityBuilder WithPosition(this EntityBuilder builder, float x, float y)
=> builder.With(new Position { X = x, Y = y });
No reflection, no boxing, no dynamic dispatch.
Benefit 2: Native AOT Compatible
All code exists at compile time:
dotnet publish -c Release -r linux-x64 --self-contained /p:PublishAot=true
Works perfectly because there's no runtime code generation.
Benefit 3: Trimming Safe
The linker sees all code paths:
// Generated code - linker sees the direct reference
builder.With(new Position { X = x, Y = y });
// Position type won't be trimmed
Benefit 4: Compile-Time Errors
IDE shows problems immediately:
[Component]
public partial struct Position
{
// Error: 'X' is not a valid field name (if you had a typo)
}
// If you forget [Component]:
world.Spawn().WithPosition(10, 20); // Error: WithPosition doesn't exist
Benefit 5: IDE Support
Generated methods appear in IntelliSense:
world.Spawn().With → [Autocomplete dropdown]
WithPosition(float x, float y)
WithVelocity(float x, float y)
WithHealth(int current, int max)
What KeenEyes Generates
Component Extensions
[Component]
public partial struct Health
{
public int Current;
public int Max;
}
// Generates:
// - WithHealth(int current, int max) builder extension
// - Implements IComponent interface
Tag Components
[TagComponent]
public partial struct Enemy { }
// Generates:
// - WithTag<Enemy>() builder extension
// - Implements ITagComponent interface
// - Zero-size component optimization
System Metadata
[System(Phase = SystemPhase.Update, Order = 100)]
public partial class MovementSystem : SystemBase
{
// Generates:
// - Phase property override
// - Order property override
// - Registration helpers
}
Query Iterators
// With generators, queries are optimized
foreach (var entity in world.Query<Position, Velocity>())
{
// Generated iterator avoids allocations
// Direct archetype chunk access
}
Serialization
[Component]
public partial struct Position
{
public float X;
public float Y;
}
// Generates:
// - IComponentSerializer implementation
// - Binary serialization methods
// - JSON serialization support
Tradeoffs
Longer Compile Times
Generators add compilation overhead:
- Initial compile: +10-30%
- Incremental compile: Minimal impact
Mitigated by incremental generation - only regenerates when source changes.
Generated Code Complexity
Sometimes generated code is hard to debug:
// Error in generated code?
// Check: obj/Debug/net10.0/generated/KeenEyes.Generators/...
Mitigated by clear generation patterns and good error messages.
Attribute Requirements
You must mark types with attributes:
[Component] // Required for generation
public partial struct Position { }
A small cost for the benefits gained.
Comparison Table
| Feature | Reflection | Source Generators |
|---|---|---|
| Runtime performance | Slow | Fast |
| Native AOT | No | Yes |
| Trimming | Fragile | Safe |
| Compile-time errors | No | Yes |
| IDE support | Limited | Full |
| Compile time | Fast | Slightly slower |
| Complexity | Simple | More setup |
See Also
- ADR-004: Reflection Elimination - Full technical analysis
- Why Native AOT? - AOT compilation benefits
- Components Guide - Using generated component code