Skip to content

Particle System

Create fire, explosions, smoke, and other visual effects with Brine2D's high-performance particle system featuring textures, rotation, trails, blend modes, and automatic object pooling.

Overview

Brine2D's particle system uses object pooling to render thousands of particles without allocating memory. Particles are reused from a pool, ensuring smooth performance even with complex effects.

New in v0.8.0-beta: - Particle textures (custom sprite textures) - Rotation support (start, end, rotation speed) - Trail effects (motion trails behind particles) - Blend modes (additive, alpha, none) - 7 emitter shapes (point, circle, ring, box, cone, line, burst)

graph LR
    A[ParticleEmitterComponent] --> B[ObjectPool]
    B --> C[Particle Instance]
    C --> D[Update Position]
    C --> E[Update Color]
    C --> F[Update Size]
    C --> G[Update Rotation]
    D --> H{Expired?}
    E --> H
    F --> H
    G --> H
    H -->|Yes| I[Return to Pool]
    H -->|No| J[Render]
    I --> B

Quick Start

Basic Particle Emitter

Create a simple particle effect:

using Brine2D.ECS;
using Brine2D.ECS.Components;
using Brine2D.Rendering.ECS;
using System.Numerics;

public class GameScene : Scene
{
    private readonly IEntityWorld _world;

    protected override void OnInitialize()
    {
        // Create entity with particle emitter
        var fireEffect = _world.CreateEntity("Fire");

        var transform = fireEffect.AddComponent<TransformComponent>();
        transform.Position = new Vector2(400, 300);

        var emitter = fireEffect.AddComponent<ParticleEmitterComponent>();
        emitter.IsEmitting = true;
        emitter.EmissionRate = 50f; // 50 particles per second
        emitter.MaxParticles = 200;
        emitter.ParticleLifetime = 2f; // Seconds

        // Appearance
        emitter.StartColor = new Color(255, 200, 0, 255); // Bright yellow
        emitter.EndColor = new Color(255, 50, 0, 0); // Dark red, transparent
        emitter.StartSize = 8f;
        emitter.EndSize = 2f;

        // Physics
        emitter.InitialVelocity = new Vector2(0, -50); // Upward
        emitter.VelocitySpread = 30f; // Random angle variance
        emitter.Gravity = new Vector2(0, 100); // Pull down
    }
}

That's it! The ParticleSystem will automatically update and render particles.


New Features (v0.8.0)

Particle Textures

Use custom textures instead of solid circles:

var emitter = entity.AddComponent<ParticleEmitterComponent>();

// Use a texture for particles
emitter.TexturePath = "assets/particles/fire.png";
emitter.TextureScaleMode = TextureScaleMode.Nearest; // For pixel art

// Texture combines with color tint
emitter.StartColor = new Color(255, 255, 255, 255); // White = no tint
emitter.EndColor = new Color(255, 100, 0, 0); // Orange fade

Best Practices: - Keep textures small (16x16 to 64x64 pixels) - Use semi-transparent textures for blending - White textures work best for color tinting - Use texture atlasing for multiple particle types

Rotation

Rotate particles over their lifetime:

var emitter = entity.AddComponent<ParticleEmitterComponent>();

// Rotation angles (in radians)
emitter.StartRotation = 0f; // Starting angle
emitter.EndRotation = MathF.PI * 2; // End angle (full rotation)

// OR use rotation speed (overrides lerp)
emitter.RotationSpeed = 2f; // Radians per second (constant spin)

// Random rotation variance
emitter.RotationSpread = MathF.PI / 4; // ±45 degrees initial rotation

Rotation Modes:

// Lerp rotation (smooth start → end)
emitter.StartRotation = 0f;
emitter.EndRotation = MathF.PI; // 180 degree rotation over lifetime
emitter.RotationSpeed = 0f; // Disabled

// Constant spin
emitter.StartRotation = 0f;
emitter.RotationSpeed = 3f; // Spin at 3 rad/s regardless of lifetime

// Random initial rotation
emitter.StartRotation = 0f;
emitter.RotationSpread = MathF.PI; // Random ±180 degrees
emitter.RotationSpeed = 1f; // All spin at same speed

Trail Effects

Add motion trails behind particles:

var emitter = entity.AddComponent<ParticleEmitterComponent>();

// Enable trails
emitter.TrailEnabled = true;
emitter.TrailLength = 10; // Number of trail segments
emitter.TrailColor = new Color(255, 100, 0, 128); // Semi-transparent orange

// Trail particles inherit main particle properties
// but fade based on trail position

Trail Configuration:

// Short, faint trails (subtle)
emitter.TrailEnabled = true;
emitter.TrailLength = 5;
emitter.TrailColor = new Color(255, 255, 255, 50); // Very transparent

// Long, visible trails (dramatic)
emitter.TrailEnabled = true;
emitter.TrailLength = 15;
emitter.TrailColor = new Color(255, 100, 0, 200); // More opaque

// Colored trails (different from particle)
emitter.StartColor = Color.White; // White particles
emitter.TrailColor = new Color(100, 150, 255, 150); // Blue trails

Blend Modes

Control how particles blend with the background:

var emitter = entity.AddComponent<ParticleEmitterComponent>();

// Additive blending (fire, explosions, energy)
emitter.BlendMode = BlendMode.Additive;

// Alpha blending (smoke, fog, default)
emitter.BlendMode = BlendMode.AlphaBlend;

// No blending (solid particles)
emitter.BlendMode = BlendMode.None;

Blend Mode Effects:

Blend Mode Visual Effect Best For
Additive Bright, glowing overlaps Fire, explosions, energy, magic
AlphaBlend Standard transparency Smoke, fog, water, dust
None Solid, opaque Solid objects, debris, sparks

Emitter Shapes

Choose from 7 emitter shapes for different spawn patterns:

var emitter = entity.AddComponent<ParticleEmitterComponent>();

// Point emitter (single point)
emitter.EmitterShape = EmitterShape.Point;

// Circle emitter (spawn anywhere in circle)
emitter.EmitterShape = EmitterShape.Circle;
emitter.SpawnRadius = 20f;

// Ring emitter (spawn on circle edge)
emitter.EmitterShape = EmitterShape.Ring;
emitter.SpawnRadius = 30f;

// Box emitter (rectangular spawn area)
emitter.EmitterShape = EmitterShape.Box;
emitter.SpawnRadius = 50f; // Half-width/height

// Cone emitter (directional spray)
emitter.EmitterShape = EmitterShape.Cone;
emitter.ConeAngle = MathF.PI / 4; // 45 degree cone

// Line emitter (spawn along line)
emitter.EmitterShape = EmitterShape.Line;
emitter.LineLength = 100f;

// Burst emitter (radial explosion)
emitter.EmitterShape = EmitterShape.Burst;
emitter.BurstCount = 50; // Particles per burst

Particle Properties

Emission Properties

Control how particles are spawned:

var emitter = entity.AddComponent<ParticleEmitterComponent>();

// Basic emission
emitter.IsEmitting = true; // Toggle emission on/off
emitter.EmissionRate = 50f; // Particles per second
emitter.MaxParticles = 200; // Pool size (max concurrent particles)
emitter.ParticleLifetime = 2f; // How long each particle lives (seconds)

// Emitter shape
emitter.EmitterShape = EmitterShape.Circle;
emitter.SpawnRadius = 10f; // Shape-specific size

Visual Properties

Control particle appearance:

// Color interpolation (start → end over lifetime)
emitter.StartColor = new Color(255, 200, 0, 255); // Bright yellow, opaque
emitter.EndColor = new Color(255, 50, 0, 0); // Dark red, transparent

// Size interpolation (start → end over lifetime)
emitter.StartSize = 8f; // pixels
emitter.EndSize = 2f; // Shrink over time

// Texture (new in v0.8.0)
emitter.TexturePath = "assets/particles/spark.png";
emitter.TextureScaleMode = TextureScaleMode.Linear;

// Rotation (new in v0.8.0)
emitter.StartRotation = 0f;
emitter.EndRotation = MathF.PI * 2;
emitter.RotationSpeed = 2f;
emitter.RotationSpread = MathF.PI / 4;

// Trails (new in v0.8.0)
emitter.TrailEnabled = true;
emitter.TrailLength = 10;
emitter.TrailColor = new Color(255, 100, 0, 128);

// Blend mode (new in v0.8.0)
emitter.BlendMode = BlendMode.Additive;

Physics Properties

Control particle movement:

// Initial velocity (pixels per second)
emitter.InitialVelocity = new Vector2(0, -100); // Upward

// Velocity spread (random angle variance in degrees)
emitter.VelocitySpread = 45f; // ±45° cone

// Gravity (acceleration, pixels per second²)
emitter.Gravity = new Vector2(0, 200); // Pull down

// No gravity (floating particles)
emitter.Gravity = Vector2.Zero;

Preset Effects

Fire Effect (Enhanced)

var fireEmitter = entity.AddComponent<ParticleEmitterComponent>();

// Emission
fireEmitter.IsEmitting = true;
fireEmitter.EmissionRate = 100f;
fireEmitter.MaxParticles = 200;
fireEmitter.ParticleLifetime = 1.5f;
fireEmitter.EmitterShape = EmitterShape.Circle;
fireEmitter.SpawnRadius = 15f;

// Appearance
fireEmitter.TexturePath = "assets/particles/fire.png"; // ← NEW
fireEmitter.BlendMode = BlendMode.Additive; // ← NEW
fireEmitter.StartColor = new Color(255, 200, 0, 255);
fireEmitter.EndColor = new Color(255, 50, 0, 0);
fireEmitter.StartSize = 8f;
fireEmitter.EndSize = 2f;

// Rotation (NEW)
fireEmitter.StartRotation = 0f;
fireEmitter.RotationSpeed = 2f;
fireEmitter.RotationSpread = MathF.PI / 2;

// Trails (NEW)
fireEmitter.TrailEnabled = true;
fireEmitter.TrailLength = 5;
fireEmitter.TrailColor = new Color(255, 100, 0, 100);

// Physics
fireEmitter.InitialVelocity = new Vector2(0, -100);
fireEmitter.VelocitySpread = 30f;
fireEmitter.Gravity = new Vector2(0, -20);

Explosion Effect (Enhanced)

var explosionEmitter = entity.AddComponent<ParticleEmitterComponent>();

// Emission (burst)
explosionEmitter.EmitterShape = EmitterShape.Burst; // ← NEW
explosionEmitter.BurstCount = 100; // ← NEW
explosionEmitter.IsEmitting = false;
explosionEmitter.MaxParticles = 100;
explosionEmitter.ParticleLifetime = 1f;

// Appearance
explosionEmitter.TexturePath = "assets/particles/explosion.png"; // ← NEW
explosionEmitter.BlendMode = BlendMode.Additive; // ← NEW
explosionEmitter.StartColor = new Color(255, 255, 255, 255);
explosionEmitter.EndColor = new Color(255, 100, 0, 0);
explosionEmitter.StartSize = 12f;
explosionEmitter.EndSize = 2f;

// Rotation (NEW)
explosionEmitter.StartRotation = 0f;
explosionEmitter.RotationSpeed = 5f;

// Physics
explosionEmitter.InitialVelocity = new Vector2(0, -200);
explosionEmitter.VelocitySpread = 180f;
explosionEmitter.Gravity = new Vector2(0, 500);

// Trigger explosion
explosionEmitter.EmitBurst(100);

Smoke Effect (Enhanced)

var smokeEmitter = entity.AddComponent<ParticleEmitterComponent>();

// Emission
smokeEmitter.IsEmitting = true;
smokeEmitter.EmissionRate = 20f;
smokeEmitter.MaxParticles = 100;
smokeEmitter.ParticleLifetime = 3f;
smokeEmitter.EmitterShape = EmitterShape.Circle; // ← NEW
smokeEmitter.SpawnRadius = 5f;

// Appearance
smokeEmitter.TexturePath = "assets/particles/smoke.png"; // ← NEW
smokeEmitter.BlendMode = BlendMode.AlphaBlend; // ← NEW
smokeEmitter.StartColor = new Color(60, 60, 60, 200);
smokeEmitter.EndColor = new Color(150, 150, 150, 0);
smokeEmitter.StartSize = 4f;
smokeEmitter.EndSize = 12f;

// Rotation (NEW)
smokeEmitter.StartRotation = 0f;
smokeEmitter.RotationSpeed = 0.5f;
smokeEmitter.RotationSpread = MathF.PI;

// Physics
smokeEmitter.InitialVelocity = new Vector2(0, -30);
smokeEmitter.VelocitySpread = 20f;
smokeEmitter.Gravity = new Vector2(0, -10);

Magic Spell Effect (NEW)

var spellEmitter = entity.AddComponent<ParticleEmitterComponent>();

// Emission
spellEmitter.EmitterShape = EmitterShape.Ring; // ← Ring shape
spellEmitter.SpawnRadius = 40f;
spellEmitter.IsEmitting = true;
spellEmitter.EmissionRate = 60f;
spellEmitter.MaxParticles = 200;
spellEmitter.ParticleLifetime = 2f;

// Appearance
spellEmitter.TexturePath = "assets/particles/magic.png";
spellEmitter.BlendMode = BlendMode.Additive; // Glowing effect
spellEmitter.StartColor = new Color(150, 100, 255, 255); // Purple
spellEmitter.EndColor = new Color(150, 100, 255, 0);
spellEmitter.StartSize = 6f;
spellEmitter.EndSize = 1f;

// Rotation
spellEmitter.StartRotation = 0f;
spellEmitter.RotationSpeed = 3f;

// Trails
spellEmitter.TrailEnabled = true;
spellEmitter.TrailLength = 8;
spellEmitter.TrailColor = new Color(150, 100, 255, 100);

// Physics (spiral inward)
spellEmitter.InitialVelocity = new Vector2(-50, 0);
spellEmitter.VelocitySpread = 10f;
spellEmitter.Gravity = Vector2.Zero;

Projectile Trail (NEW)

// Attach to moving projectile
var trailEmitter = projectile.AddComponent<ParticleEmitterComponent>();

// Emission
trailEmitter.EmitterShape = EmitterShape.Point;
trailEmitter.IsEmitting = true;
trailEmitter.EmissionRate = 100f;
trailEmitter.MaxParticles = 200;
trailEmitter.ParticleLifetime = 0.5f;

// Appearance
trailEmitter.TexturePath = "assets/particles/spark.png";
trailEmitter.BlendMode = BlendMode.Additive;
trailEmitter.StartColor = new Color(100, 200, 255, 255);
trailEmitter.EndColor = new Color(100, 200, 255, 0);
trailEmitter.StartSize = 6f;
trailEmitter.EndSize = 1f;

// Rotation
trailEmitter.RotationSpeed = 10f;

// Trails (trail of a trail!)
trailEmitter.TrailEnabled = true;
trailEmitter.TrailLength = 5;
trailEmitter.TrailColor = new Color(100, 200, 255, 80);

// Physics (no velocity, stay where spawned)
trailEmitter.InitialVelocity = Vector2.Zero;
trailEmitter.Gravity = Vector2.Zero;

Fountain Effect (NEW)

var fountainEmitter = entity.AddComponent<ParticleEmitterComponent>();

// Emission
fountainEmitter.EmitterShape = EmitterShape.Cone; // ← Cone shape
fountainEmitter.ConeAngle = MathF.PI / 6; // 30 degree cone
fountainEmitter.IsEmitting = true;
fountainEmitter.EmissionRate = 80f;
fountainEmitter.MaxParticles = 300;
fountainEmitter.ParticleLifetime = 2.5f;

// Appearance
fountainEmitter.TexturePath = "assets/particles/water.png";
fountainEmitter.BlendMode = BlendMode.AlphaBlend;
fountainEmitter.StartColor = new Color(100, 150, 255, 200);
fountainEmitter.EndColor = new Color(100, 150, 255, 0);
fountainEmitter.StartSize = 4f;
fountainEmitter.EndSize = 2f;

// Physics (upward spray)
fountainEmitter.InitialVelocity = new Vector2(0, -300);
fountainEmitter.VelocitySpread = 15f;
fountainEmitter.Gravity = new Vector2(0, 500); // Strong gravity

Advanced Techniques

Layered Particles

Combine multiple emitters for complex effects:

public void CreateExplosion(Vector2 position)
{
    var explosionEntity = _world.CreateEntity("Explosion");
    var transform = explosionEntity.AddComponent<TransformComponent>();
    transform.Position = position;

    // Layer 1: Bright flash
    var flashEmitter = explosionEntity.AddComponent<ParticleEmitterComponent>();
    flashEmitter.EmitterShape = EmitterShape.Burst;
    flashEmitter.BurstCount = 20;
    flashEmitter.TexturePath = "assets/particles/flash.png";
    flashEmitter.BlendMode = BlendMode.Additive;
    flashEmitter.StartColor = Color.White;
    flashEmitter.EndColor = new Color(255, 255, 255, 0);
    flashEmitter.ParticleLifetime = 0.2f;
    flashEmitter.StartSize = 20f;
    flashEmitter.EndSize = 40f;

    // Layer 2: Fire burst
    var fireEmitter = CreateChildEmitter(explosionEntity);
    fireEmitter.EmitterShape = EmitterShape.Burst;
    fireEmitter.BurstCount = 50;
    fireEmitter.BlendMode = BlendMode.Additive;
    fireEmitter.StartColor = new Color(255, 200, 0, 255);
    fireEmitter.EndColor = new Color(255, 50, 0, 0);
    fireEmitter.ParticleLifetime = 1f;
    fireEmitter.InitialVelocity = new Vector2(0, -200);
    fireEmitter.VelocitySpread = 180f;

    // Layer 3: Smoke aftermath
    var smokeEmitter = CreateChildEmitter(explosionEntity);
    smokeEmitter.EmissionRate = 30f;
    smokeEmitter.BlendMode = BlendMode.AlphaBlend;
    smokeEmitter.StartColor = new Color(60, 60, 60, 200);
    smokeEmitter.EndColor = new Color(120, 120, 120, 0);
    smokeEmitter.ParticleLifetime = 3f;
    smokeEmitter.StartSize = 4f;
    smokeEmitter.EndSize = 20f;

    // Trigger effects
    flashEmitter.EmitBurst(20);
    fireEmitter.EmitBurst(50);
}

Dynamic Emitter Movement

Create moving particle sources:

public class MovingEmitterSystem : IUpdateSystem
{
    public void Update(GameTime gameTime)
    {
        var emitters = _world.GetEntitiesWithComponent<ParticleEmitterComponent>();

        foreach (var entity in emitters)
        {
            var transform = entity.GetComponent<TransformComponent>();
            var emitter = entity.GetComponent<ParticleEmitterComponent>();

            // Move in a circle
            var time = (float)gameTime.TotalTime * 2f;
            transform.Position = new Vector2(
                640 + MathF.Cos(time) * 200,
                360 + MathF.Sin(time) * 200
            );
        }
    }
}

Conditional Emission

Control emission based on game state:

public class ConditionalParticleSystem : IUpdateSystem
{
    public void Update(GameTime gameTime)
    {
        var player = GetPlayer();
        var emitter = player.GetComponent<ParticleEmitterComponent>();
        var velocity = player.GetComponent<VelocityComponent>();

        // Only emit when moving fast
        var speed = velocity.Velocity.Length();
        emitter.IsEmitting = speed > 100f;

        // Adjust emission rate based on speed
        emitter.EmissionRate = speed * 0.5f; // More particles = faster movement
    }
}

Performance

Object Pooling

Particles use object pooling automatically - no GC allocations!

// Under the hood (automatic):
public class ParticleEmitterComponent
{
    private readonly ObjectPool<Particle> _pool;

    private void EmitParticle()
    {
        var particle = _pool.Get(); // ✅ Reuse from pool
        // Configure particle...
    }

    private void KillParticle(Particle particle)
    {
        _pool.Return(particle); // ✅ Return to pool
    }
}

Performance Tips

// ✅ GOOD: Reasonable particle counts
emitter.MaxParticles = 200; // ~200 particles = negligible cost

// ⚠️ ACCEPTABLE: Many particles
emitter.MaxParticles = 1000; // ~1000 particles = minor cost

// ❌ BAD: Too many particles
emitter.MaxParticles = 10000; // > 10k particles = significant cost

// ✅ SOLUTION: Use multiple smaller emitters
for (int i = 0; i < 10; i++)
{
    var smallEmitter = CreateEmitter();
    smallEmitter.MaxParticles = 100; // 10 x 100 = 1000 total
}

Performance Guidelines: - < 500 particles per emitter - Excellent performance - 500-1000 particles - Good performance - > 1000 particles - Consider splitting into multiple emitters - Textures - Use texture atlasing for multiple particle types - Trails - Trails multiply particle count (10 trail length = 10x particles) - Blend modes - Additive blending has similar cost to alpha blending

Texture Optimization

// ✅ GOOD: Pack particle textures into atlas
var atlas = await AtlasBuilder.BuildAtlasAsync(
    _renderer,
    _textureLoader,
    new[] {
        "assets/particles/fire.png",
        "assets/particles/smoke.png",
        "assets/particles/spark.png"
    },
    padding: 2,
    maxSize: 1024
);

// Use atlas regions
emitter.TexturePath = "assets/particles/fire.png"; // Automatically uses atlas

Monitoring

Check Particle Count

var emitter = entity.GetComponent<ParticleEmitterComponent>();

Logger.LogDebug($"Active particles: {emitter.ParticleCount}/{emitter.MaxParticles}");

if (emitter.ParticleCount >= emitter.MaxParticles)
{
    Logger.LogWarning("Particle pool exhausted! Increase MaxParticles.");
}

Performance Stats

// Enable performance monitoring
builder.Services.AddPerformanceMonitoring(options =>
{
    options.EnableOverlay = true;
});

// Press F3 in-game to toggle detailed stats
// Look for:
// - "Sprites" count (includes particles)
// - "Draw Calls" (should batch with atlasing)
// - Frame time (< 16.67ms for 60 FPS)

Best Practices

Do

  • Use texture atlasing - Pack all particle textures together
  • Set reasonable MaxParticles - Start with 200, adjust as needed
  • Use additive blending for bright effects - Fire, explosions, energy
  • Use alpha blending for soft effects - Smoke, fog, water
  • Enable trails sparingly - They multiply particle count
  • Test on target hardware - Performance varies by device

Don't

  • Don't use massive textures - Keep particles 16x16 to 64x64
  • Don't create too many emitters - Prefer fewer, larger emitters
  • Don't enable trails on all particles - Use selectively
  • Don't forget to set BlendMode - Defaults to AlphaBlend
  • Don't use None blend mode with transparency - Use Alpha or Additive

Troubleshooting

Particles Not Visible

Problem: Emitter is active but no particles appear.

Solutions:

// 1. Check emission is enabled
emitter.IsEmitting = true;

// 2. Verify EmissionRate is reasonable
emitter.EmissionRate = 50f; // Not 0!

// 3. Check ParticleLifetime
emitter.ParticleLifetime = 2f; // Not too short

// 4. Verify colors have opacity
emitter.StartColor = new Color(255, 255, 255, 255); // Alpha = 255

// 5. Check blend mode
emitter.BlendMode = BlendMode.AlphaBlend; // Or Additive

Textures Not Loading

Problem: Particles show as solid circles instead of texture.

Solution:

// Verify texture path is correct
if (!File.Exists("assets/particles/fire.png"))
{
    Logger.LogError("Particle texture not found!");
}

// Check texture loaded successfully
emitter.TexturePath = "assets/particles/fire.png";

// Fallback: If no texture, particles render as circles (expected)

Poor Performance

Problem: Frame rate drops with particles active.

Solutions:

// 1. Reduce MaxParticles
emitter.MaxParticles = 200; // Instead of 2000

// 2. Reduce trail length
emitter.TrailLength = 5; // Instead of 20

// 3. Use texture atlasing
// Pack all particle textures into one atlas

// 4. Disable distant emitters
if (distance > 1000f)
{
    emitter.IsEmitting = false;
}

// 5. Check particle count
Logger.LogInfo($"Total particles: {GetTotalParticleCount()}");

Trails Look Wrong

Problem: Trails appear disconnected or too faint.

Solutions:

// 1. Increase trail opacity
emitter.TrailColor = new Color(255, 100, 0, 200); // More opaque

// 2. Increase trail length
emitter.TrailLength = 10; // More segments

// 3. Match trail color to particle
emitter.StartColor = new Color(255, 100, 0, 255);
emitter.TrailColor = new Color(255, 100, 0, 150);

// 4. Increase emission rate for continuous trails
emitter.EmissionRate = 100f; // More particles = smoother trails

See Also


Next Steps: - Experiment with different emitter shapes - Try combining blend modes - Create layered particle effects - Add particle textures with atlasing - Use trails for motion effects