13 KiB
GECS Performance Optimization Guide
Make your ECS games run fast and smooth
This guide shows you how to optimize your GECS-based games for maximum performance. Learn to identify bottlenecks, optimize queries, and design systems that scale.
📋 Prerequisites
- Understanding of Core Concepts
- Familiarity with Best Practices
- A working GECS project to optimize
🎯 Performance Fundamentals
The ECS Performance Model
GECS performance depends on three key factors:
- Query Efficiency - How fast you find entities
- Component Access - How quickly you read/write data
- System Design - How well your logic is organized
Most performance gains come from optimizing these in order of impact.
🔍 Profiling Your Game
Monitor Query Cache Performance
Always profile before optimizing. GECS provides query cache statistics for performance monitoring:
# Main.gd
func _process(delta):
ECS.process(delta)
# Print cache performance stats every second
if Engine.get_process_frames() % 60 == 0:
var cache_stats = ECS.world.get_cache_stats()
print("ECS Performance:")
print(" Query cache hits: ", cache_stats.get("hits", 0))
print(" Query cache misses: ", cache_stats.get("misses", 0))
print(" Total entities: ", ECS.world.entities.size())
# Reset stats for next measurement period
ECS.world.reset_cache_stats()
Use Godot's Built-in Profiler
Monitor your game's performance in the Godot editor:
- Run your project in debug mode
- Open the Profiler (Debug → Profiler)
- Look for ECS-related spikes in the frame time
- Identify the slowest systems in your processing groups
⚡ Query Optimization
1. Choose the Right Query Method ⭐ NEW!
As of v5.0.0-rc4, query performance ranking (10,000 entities):
.enabled(true/false)queries: ~0.05ms 🏆 (Fastest - Use when possible!).with_all([Components])queries: ~0.6ms 🥈 (Excellent for most use cases).with_any([Components])queries: ~5.6ms 🥉 (Good for OR-style queries).with_group("name")queries: ~16ms 🐌 (Avoid for performance-critical code)
Performance Recommendations:
# 🏆 FASTEST - Use enabled/disabled queries when you only need active entities
class_name ActiveSystemsOnly extends System
func query():
return q.enabled(true) # Constant-time O(1) performance!
# 🥈 EXCELLENT - Component-based queries (heavily optimized cache)
class_name MovementSystem extends System
func query():
return q.with_all([C_Position, C_Velocity]) # ~0.6ms for 10K entities
# 🥉 GOOD - Use with_any sparingly, split into multiple systems when possible
class_name DamageableSystem extends System
func query():
return q.with_any([C_Player, C_Enemy]).with_all([C_Health])
# 🐌 AVOID - Group queries are the slowest
class_name PlayerSystem extends System
func query():
return q.with_group("player") # Consider using components instead
# Better: q.with_all([C_Player])
2. Use Proper System Query Pattern
GECS automatically handles query optimization when you follow the standard pattern:
2. Use Proper System Query Pattern
GECS automatically handles query optimization when you follow the standard pattern:
# ✅ Good - Standard GECS pattern (automatically optimized)
class_name MovementSystem extends System
func query():
return q.with_all([C_Position, C_Velocity]).with_none([C_Frozen])
func process(entities: Array[Entity], components: Array, delta: float):
# Process each entity
for entity in entities:
var pos = entity.get_component(C_Position)
var vel = entity.get_component(C_Velocity)
pos.value += vel.value * delta
# ❌ Avoid - Manual query building in process methods
func process(entities: Array[Entity], components: Array, delta: float):
# Don't do this - bypasses automatic query optimization
var custom_entities = ECS.world.query.with_all([C_Position]).execute()
# Process custom_entities...
3. Optimize Query Specificity
More specific queries run faster:
# ✅ Fast - Use enabled filter for active entities only
class_name PlayerInputSystem extends System
func query():
return q.with_all([C_Input, C_Movement]).enabled(true)
# Super fast enabled filtering + component matching
# ✅ Fast - Specific component query
class_name ProjectileSystem extends System
func query():
return q.with_all([C_Projectile, C_Velocity])
# Only matches projectiles - very specific
# ❌ Slow - Overly broad query
class_name UniversalSystem extends System
func query():
return q.with_all([C_Position])
# Matches almost everything in the game!
func process(entities: Array[Entity], components: Array, delta: float):
# Now we need expensive type checking in a loop
for entity in entities:
if entity.has_component(C_Player):
# Handle player...
elif entity.has_component(C_Enemy):
# Handle enemy...
# This defeats the purpose of ECS!
4. Smart Use of with_any Queries
with_any queries are much faster than before but still slower than with_all. Use strategically:
# ✅ Good - with_any for legitimate OR scenarios
class_name DamageSystem extends System
func query():
return q.with_any([C_Player, C_Enemy, C_NPC]).with_all([C_Health])
# When you truly need "any of these types with health"
# ✅ Better - Split when entities have different behavior
class_name PlayerMovementSystem extends System
func query(): return q.with_all([C_Player, C_Movement])
class_name EnemyMovementSystem extends System
func query(): return q.with_all([C_Enemy, C_Movement])
# Split systems = simpler logic + better performance
5. Avoid Group Queries for Performance-Critical Code
Group queries are now the slowest option. Use component-based queries instead:
# ❌ Slow - Group-based query (~16ms for 10K entities)
class_name PlayerSystem extends System
func query():
return q.with_group("player")
# ✅ Fast - Component-based query (~0.6ms for 10K entities)
class_name PlayerSystem extends System
func query():
return q.with_all([C_Player])
🧱 Component Design for Performance
Keep Components Lightweight
Smaller components = faster memory access:
# ✅ Good - Lightweight components
class_name C_Position extends Component
@export var position: Vector2
class_name C_Velocity extends Component
@export var velocity: Vector2
class_name C_Health extends Component
@export var current: float
@export var maximum: float
# ❌ Heavy - Bloated component
class_name MegaComponent extends Component
@export var position: Vector2
@export var velocity: Vector2
@export var health: float
@export var mana: float
@export var inventory: Array[Item] = []
@export var abilities: Array[Ability] = []
@export var dialogue_history: Array[String] = []
# Too much data in one place!
Minimize Component Additions/Removals
Adding and removing components requires index updates. Batch component operations when possible:
# ✅ Good - Batch component operations
func setup_new_enemy(entity: Entity):
# Add multiple components in one batch
entity.add_components([
C_Health.new(),
C_Position.new(),
C_Velocity.new(),
C_Enemy.new()
])
# ✅ Good - Single component change when needed
func apply_damage(entity: Entity, damage: float):
var health = entity.get_component(C_Health)
health.current = clamp(health.current - damage, 0, health.maximum)
if health.current <= 0:
entity.add_component(C_Dead.new()) # Single component addition
Choose Between Boolean Properties vs Components Based on Usage
The choice between boolean properties and separate components depends on how frequently states change and how many entities need them.
Use Boolean Properties for Frequently-Changing States
When states change often, boolean properties avoid expensive index updates:
# ✅ Good for frequently-changing states (buffs, status effects, etc.)
class_name C_EntityState extends Component
@export var is_stunned: bool = false
@export var is_invisible: bool = false
@export var is_invulnerable: bool = false
class_name MovementSystem extends System
func query():
return q.with_all([C_Position, C_Velocity, C_EntityState])
# All entities that might need states must have this component
func process(entity: Entity, delta: float):
var state = entity.get_component(C_EntityState)
if state.is_stunned:
return # Just a property check - no index updates
# Process movement...
Tradeoffs:
- ✅ Fast state changes (no index rebuilds)
- ✅ Simple property checks in systems
- ❌ All entities need the state component (memory overhead)
- ❌ Less precise queries (can't easily find "only stunned entities")
Use Separate Components for Rare or Permanent States
When states are long-lasting or infrequent, separate components provide precise queries:
# ✅ Good for rare/permanent states (player vs enemy, permanent abilities)
class_name MovementSystem extends System
func query():
return q.with_all([C_Position, C_Velocity]).with_none([C_Paralyzed])
# Precise query - only entities that can move
# Separate systems can target specific states precisely
class_name ParalyzedSystem extends System
func query():
return q.with_all([C_Paralyzed]) # Only paralyzed entities
Tradeoffs:
- ✅ Memory efficient (only entities with states have components)
- ✅ Precise queries for specific states
- ❌ State changes trigger expensive index updates
- ❌ Complex queries with multiple exclusions
Guidelines:
- High-frequency changes (every few frames): Use boolean properties
- Low-frequency changes (minutes apart): Use separate components
- Related states (buffs/debuffs): Group into property components
- Distinct entity types (player/enemy): Use separate components
⚙️ System Performance Patterns
Early Exit Strategies
Return early when no processing is needed:
class_name HealthRegenerationSystem extends System
func process(entities: Array[Entity], components: Array, delta: float):
for entity in entities:
var health = entity.get_component(C_Health)
# Early exits for common cases
if health.current >= health.maximum:
continue # Already at full health
if health.regeneration_rate <= 0:
continue # No regeneration configured
# Only do expensive work when needed
health.current = min(health.current + health.regeneration_rate * delta, health.maximum)
Batch Entity Operations
Group entity operations together:
# ✅ Good - Batch creation
func spawn_enemy_wave():
var enemies: Array[Entity] = []
# Create all entities using entity pooling
for i in range(50):
var enemy = ECS.world.create_entity() # Uses entity pool for performance
setup_enemy_components(enemy)
enemies.append(enemy)
# Add all to world at once
ECS.world.add_entities(enemies)
# ✅ Good - Individual removal (batch removal not available)
func cleanup_dead_entities():
var dead_entities = ECS.world.query.with_all([C_Dead]).execute()
for entity in dead_entities:
ECS.world.remove_entity(entity) # Remove individually
📊 Performance Targets
Frame Rate Targets
Aim for these processing times per frame:
- 60 FPS target: ECS processing < 16ms per frame
- 30 FPS target: ECS processing < 33ms per frame
- Mobile target: ECS processing < 8ms per frame
Entity Scale Guidelines
GECS handles these entity counts well with proper optimization:
- Small games: 100-500 entities
- Medium games: 500-2000 entities
- Large games: 2000-10000 entities
- Massive games: 10000+ entities (requires advanced optimization)
🎯 Next Steps
- Profile your current game to establish baseline performance
- Apply query optimizations from this guide
- Redesign heavy components into lighter, focused ones
- Implement system improvements like early exits and batching
- Consider advanced techniques like pooling and spatial partitioning for demanding scenarios
🔍 Additional Performance Features
Entity Pooling
GECS includes built-in entity pooling for optimal performance:
# Use the entity pool for frequent entity creation/destruction
var new_entity = ECS.world.create_entity() # Gets from pool when available
Query Cache Statistics
Monitor query performance with built-in cache tracking:
# Get detailed cache performance data
var stats = ECS.world.get_cache_stats()
print("Cache hit rate: ", stats.get("hits", 0) / (stats.get("hits", 0) + stats.get("misses", 1)))
Need more help? Check the Troubleshooting Guide for specific performance issues.
"Fast ECS code isn't about clever tricks - it's about designing systems that naturally align with how the framework works best."