Skip to content

Performance Optimization

Master zero-allocation patterns, object pooling, and other techniques to build high-performance games in Brine2D.

Overview

Brine2D is designed for performance from the ground up, using modern .NET techniques to minimize garbage collection pressure and maximize frame rates. This guide covers best practices for building games that run at 60+ FPS with minimal memory allocations.

graph TD
    A[Performance Optimization] --> B[Zero Allocations]
    A --> C[Object Pooling]
    A --> D[Sprite Batching]
    A --> E[Query Optimization]

    B --> F[ArrayPool]
    B --> G[Span & stackalloc]
    B --> H[Cached Queries]

    C --> I[Particle Pools]
    C --> J[Entity Pools]
    C --> K[Custom Pools]

    D --> L[Texture Grouping]
    D --> M[Layer Sorting]
    D --> N[Frustum Culling]

    E --> O[Cached Queries]
    E --> P[Spatial Queries]
    E --> Q[Predicate Optimization]

Zero-Allocation Patterns

Understanding Allocations

Every new allocation creates garbage that must eventually be collected:

// ❌ BAD: Allocates every frame
protected override void OnUpdate(GameTime gameTime)
{
    var enemies = new List<Entity>(); // Allocation!

    foreach (var entity in World.Entities)
    {
        if (entity.HasComponent<EnemyComponent>())
        {
            enemies.Add(entity);
        }
    }

    // Process enemies...
}

Problems: - Creates garbage every frame - Triggers Gen 0 collections (minor, but still pauses) - Eventually triggers Gen 2 collections (major pauses!)


ArrayPool

Use ArrayPool<T> for temporary buffers:

using System.Buffers;

// ✅ GOOD: Zero allocation
protected override void OnUpdate(GameTime gameTime)
{
    var enemyCount = World.GetEntitiesWithComponent<EnemyComponent>().Count();
    var array = ArrayPool<Entity>.Shared.Rent(enemyCount);

    try
    {
        int index = 0;
        foreach (var entity in World.GetEntitiesWithComponent<EnemyComponent>())
        {
            array[index++] = entity;
        }

        // Process array[0..index]
        for (int i = 0; i < index; i++)
        {
            ProcessEnemy(array[i]);
        }
    }
    finally
    {
        ArrayPool<Entity>.Shared.Return(array, clearArray: true);
    }
}

Benefits: - Zero allocation (array is reused) - No GC pressure - Minimal overhead

Best Practices: - Always use try/finally to ensure return - Set clearArray: true to avoid memory leaks - Rent exact size if known, or slightly larger


Cached Queries

Brine2D's cached queries eliminate allocations in hot paths:

public class MovementSystem : ECSSystem
{
    private readonly IEntityWorld _world;
    private readonly CachedQuery<TransformComponent, VelocityComponent> _movingEntities;

    public MovementSystem(IEntityWorld world)
    {
        _world = world;

        // Create cached query once (setup cost)
        _movingEntities = world.CreateCachedQuery<TransformComponent, VelocityComponent>();
    }

    public override void Update(GameTime gameTime)
    {
        var deltaTime = (float)gameTime.DeltaTime;

        // ✅ Zero allocation per frame!
        foreach (var entity in _movingEntities.Execute())
        {
            var transform = entity.GetComponent<TransformComponent>();
            var velocity = entity.GetComponent<VelocityComponent>();

            transform.Position += velocity.Velocity * deltaTime;
        }
    }
}

When to Use: - Hot paths (every frame) - Systems processing many entities - Frequently executed queries

Limitations: - Up to 3 component types - No complex predicates - Automatically invalidated on entity/component changes


Avoid LINQ in Hot Paths

LINQ is convenient but allocates:

// ❌ BAD: LINQ allocates enumerators
var weakEnemies = World.Query()
    .With<EnemyComponent>()
    .With<HealthComponent>()
    .Execute()
    .Where(e => e.GetComponent<HealthComponent>().CurrentHealth < 50) // Allocation!
    .ToList(); // More allocation!

// ✅ GOOD: Manual iteration
var weakEnemies = new List<Entity>(capacity: 10); // Pre-allocate once

foreach (var entity in World.Query()
    .With<EnemyComponent>()
    .With<HealthComponent>()
    .Execute())
{
    var health = entity.GetComponent<HealthComponent>();
    if (health.CurrentHealth < 50)
    {
        weakEnemies.Add(entity);
    }
}

Even Better:

// ✅ BEST: Use query predicates
var weakEnemies = World.Query()
    .With<EnemyComponent>()
    .With<HealthComponent>(h => h.CurrentHealth < 50) // Filtered at query level!
    .Execute();

Object Pooling

Built-in Particle Pooling

Brine2D's particle system uses object pooling automatically:

// ParticleEmitterComponent uses ObjectPool<Particle> internally
var emitter = entity.AddComponent<ParticleEmitterComponent>();
emitter.MaxParticles = 200;
emitter.EmissionRate = 50f;

// Particles are Get() from pool on spawn, Return() on death
// Zero allocation per particle!

Under the Hood:

public class ParticleEmitterComponent : Component
{
    private readonly ObjectPool<Particle> _particlePool;

    public ParticleEmitterComponent()
    {
        // Pool created once
        _particlePool = new ObjectPool<Particle>(
            createFunc: () => new Particle(),
            resetAction: p => p.Reset(),
            maxSize: MaxParticles
        );
    }

    private void EmitParticle()
    {
        var particle = _particlePool.Get(); // Reuse existing particle
        // Configure particle...
        _particles.Add(particle);
    }

    private void UpdateParticle(Particle particle)
    {
        if (particle.IsExpired)
        {
            _particlePool.Return(particle); // Return to pool
        }
    }
}

Custom Object Pools

Create your own pools for frequently spawned objects:

using Brine2D.Core.Pooling;

public class BulletPool
{
    private readonly ObjectPool<Entity> _pool;
    private readonly IEntityWorld _world;

    public BulletPool(IEntityWorld world, int maxSize = 100)
    {
        _world = world;

        _pool = new ObjectPool<Entity>(
            createFunc: CreateBullet,
            resetAction: ResetBullet,
            maxSize: maxSize
        );
    }

    private Entity CreateBullet()
    {
        var bullet = _world.CreateEntity("Bullet");
        bullet.AddComponent<TransformComponent>();
        bullet.AddComponent<VelocityComponent>();
        bullet.AddComponent<SpriteComponent>();
        bullet.AddComponent<BulletComponent>();
        bullet.IsActive = false; // Start disabled

        return bullet;
    }

    private void ResetBullet(Entity bullet)
    {
        bullet.IsActive = false;
        bullet.GetComponent<TransformComponent>().Position = Vector2.Zero;
        bullet.GetComponent<VelocityComponent>().Velocity = Vector2.Zero;
    }

    public Entity SpawnBullet(Vector2 position, Vector2 velocity)
    {
        var bullet = _pool.Get();

        bullet.IsActive = true;
        bullet.GetComponent<TransformComponent>().Position = position;
        bullet.GetComponent<VelocityComponent>().Velocity = velocity;

        return bullet;
    }

    public void DespawnBullet(Entity bullet)
    {
        _pool.Return(bullet);
    }
}

Usage:

public class WeaponSystem : ECSSystem
{
    private readonly BulletPool _bulletPool;

    public WeaponSystem(IEntityWorld world)
    {
        _bulletPool = new BulletPool(world, maxSize: 100);
    }

    public void FireWeapon(Vector2 position, Vector2 direction)
    {
        // ✅ Zero allocation - bullet is pooled!
        var bullet = _bulletPool.SpawnBullet(position, direction * 500f);
    }

    public void OnBulletHit(Entity bullet)
    {
        _bulletPool.DespawnBullet(bullet); // Return to pool
    }
}

Pool Guidelines

When to Pool: - ✅ Frequently spawned/destroyed objects (bullets, particles, effects) - ✅ Large objects (expensive to allocate) - ✅ Objects with complex initialization

When NOT to Pool: - ❌ Rarely spawned objects (bosses, level geometry) - ❌ Objects with unique state - ❌ Small, simple structs (use stack allocation instead)

Pool Sizing: - Set maxSize to expected maximum concurrent instances - Too small: Pool doesn't help much - Too large: Wastes memory


Sprite Batching

Automatic Batching

SpriteRenderingSystem automatically batches sprites:

// Sprites are automatically batched by:
// 1. Texture (same texture = same batch)
// 2. Layer (sort for correct rendering order)

var sprite1 = entity1.AddComponent<SpriteComponent>();
sprite1.TexturePath = "assets/enemy.png";
sprite1.Layer = 10;

var sprite2 = entity2.AddComponent<SpriteComponent>();
sprite2.TexturePath = "assets/enemy.png"; // Same texture!
sprite2.Layer = 10; // Same layer!

// Both sprites rendered in 1 draw call! ✅

Check Batching Efficiency:

var spriteSystem = world.GetSystem<SpriteRenderingSystem>();
var (spriteCount, drawCalls) = spriteSystem.GetBatchStats();
var efficiency = (float)spriteCount / drawCalls;

Logger.LogDebug($"Batch efficiency: {efficiency:F1}x ({spriteCount} sprites in {drawCalls} calls)");

if (efficiency < 5f)
{
    Logger.LogWarning("Low batch efficiency! Consider using texture atlases.");
}

Texture Atlases

Combine multiple textures into one atlas:

// ❌ BAD: Many textures = many batches
sprite1.TexturePath = "assets/enemy1.png";  // Batch 1
sprite2.TexturePath = "assets/enemy2.png";  // Batch 2
sprite3.TexturePath = "assets/player.png";  // Batch 3
// 3 draw calls for 3 sprites!

// ✅ GOOD: One atlas = one batch
sprite1.TexturePath = "assets/atlas.png";
sprite1.SourceRect = new Rectangle(0, 0, 32, 32);    // Enemy 1

sprite2.TexturePath = "assets/atlas.png";
sprite2.SourceRect = new Rectangle(32, 0, 32, 32);   // Enemy 2

sprite3.TexturePath = "assets/atlas.png";
sprite3.SourceRect = new Rectangle(64, 0, 32, 32);   // Player

// 1 draw call for 3 sprites! ✅

Tools for Creating Atlases: - TexturePacker - ShoeBox - LibGDX Texture Packer


Layer Optimization

Group sprites by layer to minimize state changes:

// ✅ GOOD: Group by layer
background.Layer = 0;   // All backgrounds
terrain.Layer = 1;      // All terrain
enemies.Layer = 10;     // All enemies
player.Layer = 15;      // Player
effects.Layer = 20;     // All effects
ui.Layer = 100;         // All UI

// Rendered in order: 0 → 1 → 10 → 15 → 20 → 100
// Minimal layer switches = better batching!

Query Optimization

Spatial Queries

Use spatial queries to reduce iteration:

// ❌ BAD: Check all entities
foreach (var entity in World.Query().With<EnemyComponent>().Execute())
{
    var distance = Vector2.Distance(entity.Position, playerPosition);
    if (distance < 200f)
    {
        // Process nearby enemy
    }
}

// ✅ GOOD: Only iterate nearby entities
var nearbyEnemies = World.Query()
    .WithinRadius(playerPosition, 200f)
    .With<EnemyComponent>()
    .Execute();

foreach (var enemy in nearbyEnemies)
{
    // Already filtered by distance!
}

Component Predicates

Filter at query level, not in loops:

// ❌ BAD: Filter in loop
foreach (var entity in World.Query().With<HealthComponent>().Execute())
{
    var health = entity.GetComponent<HealthComponent>();
    if (health.CurrentHealth < 50)
    {
        // Process low health...
    }
}

// ✅ GOOD: Filter in query
var lowHealthEntities = World.Query()
    .With<HealthComponent>(h => h.CurrentHealth < 50)
    .Execute();

foreach (var entity in lowHealthEntities)
{
    // Already filtered!
}

Query Complexity

Keep queries simple for best performance:

// ⚠️ ACCEPTABLE: Simple predicate
var result = World.Query()
    .With<HealthComponent>(h => h.CurrentHealth < 50)
    .Execute();

// ❌ BAD: Complex predicate (executes per entity!)
var result = World.Query()
    .With<TransformComponent>()
    .Where(e => 
    {
        var transform = e.GetComponent<TransformComponent>();
        var distance = Vector2.Distance(transform.Position, playerPos);
        var health = e.GetComponent<HealthComponent>();
        return distance < 200f && health.CurrentHealth < 50;
    })
    .Execute();

// ✅ BETTER: Split into multiple simpler queries
var nearbyLowHealth = World.Query()
    .WithinRadius(playerPos, 200f)
    .With<HealthComponent>(h => h.CurrentHealth < 50)
    .Execute();

Memory Management

Avoid String Allocations

// ❌ BAD: Concatenation allocates
var message = "Player: " + player.Name + " HP: " + player.Health;

// ✅ GOOD: Interpolation is optimized by compiler
var message = $"Player: {player.Name} HP: {player.Health}";

// ✅ BEST: StringBuilder for complex cases
var sb = new StringBuilder(capacity: 100); // Pre-allocate
sb.Append("Player: ");
sb.Append(player.Name);
sb.Append(" HP: ");
sb.Append(player.Health);
var message = sb.ToString();
sb.Clear(); // Reuse!

Struct vs Class

Use structs for small, immutable data:

// ✅ GOOD: Struct for small data (no allocation)
public struct Velocity
{
    public float X;
    public float Y;

    public Velocity(float x, float y)
    {
        X = x;
        Y = y;
    }
}

// Use it
var velocity = new Velocity(10, 20); // Stack allocated!

Guidelines: - ✅ Use structs for < 16 bytes - ✅ Use structs for immutable data - ❌ Avoid large structs (copying is expensive) - ❌ Avoid mutable structs (confusing semantics)


Collection Capacity

Pre-allocate collections with known sizes:

// ❌ BAD: Grows dynamically (allocates multiple times)
var list = new List<Entity>();
for (int i = 0; i < 1000; i++)
{
    list.Add(CreateEntity()); // Reallocates at 4, 8, 16, 32...
}

// ✅ GOOD: Pre-allocate
var list = new List<Entity>(capacity: 1000);
for (int i = 0; i < 1000; i++)
{
    list.Add(CreateEntity()); // No reallocation!
}

Profiling Tips

Use BenchmarkDotNet

Micro-benchmark critical code:

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

[MemoryDiagnoser]
public class QueryBenchmarks
{
    private IEntityWorld _world;

    [GlobalSetup]
    public void Setup()
    {
        _world = new EntityWorld();
        // Create test entities...
    }

    [Benchmark]
    public void QueryWithLinq()
    {
        var result = _world.Query()
            .With<EnemyComponent>()
            .Execute()
            .Where(e => e.GetComponent<HealthComponent>().CurrentHealth < 50)
            .ToList();
    }

    [Benchmark]
    public void QueryWithPredicate()
    {
        var result = _world.Query()
            .With<EnemyComponent>()
            .With<HealthComponent>(h => h.CurrentHealth < 50)
            .Execute();
    }
}

// Run: dotnet run -c Release

Measure GC Pressure

Track Gen 2 collections:

var gen2Before = GC.CollectionCount(2);

// Run your code...
RunGameLoop();

var gen2After = GC.CollectionCount(2);
var gen2Collections = gen2After - gen2Before;

if (gen2Collections > 0)
{
    Logger.LogWarning($"Triggered {gen2Collections} Gen 2 collections!");
}

Performance Checklist

Hot Path Checklist

Before shipping, verify:

  • [ ] No LINQ in hot paths (Update, Render)
  • [ ] Cached queries for frequent lookups
  • [ ] ArrayPool used for temporary buffers
  • [ ] Object pools for frequently spawned objects
  • [ ] Sprite batching enabled (check efficiency)
  • [ ] Frustum culling enabled
  • [ ] No string concatenation in loops
  • [ ] Collections pre-allocated with capacity
  • [ ] Gen 2 collections < 1 per minute
  • [ ] Frame time < 16.67ms (60 FPS)

Best Practices

DO

Profile before optimizing

Use the Performance Monitor to identify real bottlenecks.

Use cached queries in systems

private readonly CachedQuery<T1, T2> _query = world.CreateCachedQuery<T1, T2>();

Pool frequently spawned objects

Bullets, particles, effects, projectiles.

Pre-allocate collections

var list = new List<Entity>(capacity: expectedSize);

Use structs for small data

Position, velocity, color (< 16 bytes).

DON'T

Don't use LINQ in hot paths

LINQ allocates enumerators.

Don't create new objects every frame

Use pooling or reuse.

Don't use ToList() on queries

Iterate directly with foreach.

Don't ignore GC warnings

Gen 2 collections = serious problem!

Don't optimize prematurely

Measure first!


Next Steps


Remember: Profile first, optimize second, measure results!