Files
godot-shader-experiments/addons/gecs/ecs/world.gd
2026-01-15 15:27:48 +01:00

1342 lines
52 KiB
GDScript

## World
##
## Represents the game world in the [_ECS] framework, managing all [Entity]s and [System]s.
##
## The World class handles the addition and removal of [Entity]s and [System]s, and orchestrates the processing of [Entity]s through [System]s each frame.
## The World class also maintains an index mapping of components to entities for efficient querying.
@icon("res://addons/gecs/assets/world.svg")
class_name World
extends Node
#region Signals
## Emitted when an entity is added
signal entity_added(entity: Entity)
signal entity_enabled(entity: Entity)
## Emitted when an entity is removed
signal entity_removed(entity: Entity)
signal entity_disabled(entity: Entity)
## Emitted when a system is added
signal system_added(system: System)
## Emitted when a system is removed
signal system_removed(system: System)
## Emitted when a component is added to an entity
signal component_added(entity: Entity, component: Variant)
## Emitted when a component is removed from an entity
signal component_removed(entity: Entity, component: Variant)
## Emitted when a component property changes on an entity
signal component_changed(
entity: Entity, component: Variant, property: String, new_value: Variant, old_value: Variant
)
## Emitted when a relationship is added to an entity
signal relationship_added(entity: Entity, relationship: Relationship)
## Emitted when a relationship is removed from an entity
signal relationship_removed(entity: Entity, relationship: Relationship)
## Emitted when the queries are invalidated because of a component change
signal cache_invalidated
#endregion Signals
#region Exported Variables
## Where are all the [Entity] nodes placed in the scene tree?
@export var entity_nodes_root: NodePath
## Where are all the [System] nodes placed in the scene tree?
@export var system_nodes_root: NodePath
## Default serialization config for all entities in this world
@export var default_serialize_config: GECSSerializeConfig
#endregion Exported Variables
#region Public Variables
## All the [Entity]s in the world.
var entities: Array[Entity] = []
## All the [Observer]s in the world.
var observers: Array[Observer] = []
## All the [System]s by group Dictionary[String, Array[System]]
var systems_by_group: Dictionary[String, Array] = {}
## All the [System]s in the world flattened into a single array
var systems: Array[System]:
get:
var all_systems: Array[System] = []
for group in systems_by_group.keys():
all_systems.append_array(systems_by_group[group])
return all_systems
## ID to [Entity] registry - Prevents duplicate IDs and enables fast ID lookups and singleton behavior
var entity_id_registry: Dictionary = {} # String (id) -> Entity
## ARCHETYPE STORAGE - Entity storage by component signature for O(1) queries
## Maps archetype signature (FNV-1a hash) -> Archetype instance
var archetypes: Dictionary = {} # int -> Archetype
## Fast lookup: Entity -> its current Archetype
var entity_to_archetype: Dictionary = {} # Entity -> Archetype
## The [QueryBuilder] instance for this world used to build and execute queries.
## Anytime we request a query we want to connect the cache invalidated signal to the query
## so that all queries are invalidated anytime we emit cache_invalidated.
var query: QueryBuilder:
get:
var q: QueryBuilder = QueryBuilder.new(self)
if not cache_invalidated.is_connected(q.invalidate_cache):
cache_invalidated.connect(q.invalidate_cache)
return q
## Index for relationships to entities (Optional for optimization)
var relationship_entity_index: Dictionary = {}
## Index for reverse relationships (target to source entities)
var reverse_relationship_index: Dictionary = {}
## Logger for the world to only log to a specific domain
var _worldLogger = GECSLogger.new().domain("World")
## Cache for commonly used query results - stores matching archetypes, not entities
## This dramatically reduces cache invalidation since archetypes are stable
var _query_archetype_cache: Dictionary = {} # query_sig -> Array[Archetype]
## Track cache hits for performance monitoring
var _cache_hits: int = 0
var _cache_misses: int = 0
## Track cache invalidations for debugging
var _cache_invalidation_count: int = 0
var _cache_invalidation_reasons: Dictionary = {} # reason -> count
## Global cache: resource_path -> Script (loaded once, reused forever)
var _component_script_cache: Dictionary = {} # String -> Script
## OPTIMIZATION: Flag to control cache invalidation during batch operations
var _should_invalidate_cache: bool = true
## Frame + accumulated performance metrics (debug-only)
var _perf_metrics := {
"frame": {}, # Per-frame aggregated timings
"accum": {} # Long-lived totals (cleared manually)
}
## Internal perf helper (debug only)
func perf_mark(key: String, duration_usec: int, extra: Dictionary = {}) -> void:
if not ECS.debug:
return
# Aggregate per frame
var entry = _perf_metrics.frame.get(key, {"count": 0, "time_usec": 0})
entry.count += 1
entry.time_usec += duration_usec
for k in extra.keys():
# Attach/overwrite ancillary data (last value wins)
entry[k] = extra[k]
_perf_metrics.frame[key] = entry
# Accumulate lifetime totals
var accum_entry = _perf_metrics.accum.get(key, {"count": 0, "time_usec": 0})
accum_entry.count += 1
accum_entry.time_usec += duration_usec
_perf_metrics.accum[key] = accum_entry
## Reset per-frame metrics (called at world.process start)
func perf_reset_frame() -> void:
if ECS.debug:
_perf_metrics.frame.clear()
## Get a copy of current frame metrics
func perf_get_frame_metrics() -> Dictionary:
return _perf_metrics.frame.duplicate(true)
## Get a copy of accumulated metrics
func perf_get_accum_metrics() -> Dictionary:
return _perf_metrics.accum.duplicate(true)
## Reset accumulated metrics
func perf_reset_accum() -> void:
if ECS.debug:
_perf_metrics.accum.clear()
#endregion Public Variables
#region Built-in Virtual Methods
## Called when the World node is ready.
func _ready() -> void:
#_worldLogger.disabled = true
initialize()
func _make_nodes_root(name: String) -> Node:
var node = Node.new()
node.name = name
add_child(node)
return node
## Adds [Entity]s and [System]s from the scene tree to the [World].
## Called when the World node is ready or when we should re-initialize the world from the tree.
func initialize():
# Initialize default serialize config if not set
if default_serialize_config == null:
default_serialize_config = GECSSerializeConfig.new()
# if no entities/systems root node is set create them and use them. This keeps things tidy for debugging
entity_nodes_root = (
_make_nodes_root("Entities").get_path() if not entity_nodes_root else entity_nodes_root
)
system_nodes_root = (
_make_nodes_root("Systems").get_path() if not system_nodes_root else system_nodes_root
)
# Add systems from scene tree
var _systems = get_node(system_nodes_root).find_children("*", "System") as Array[System]
add_systems(_systems, true) # and sort them after they're added
_worldLogger.debug("_initialize Added Systems from Scene Tree and dep sorted: ", _systems)
# Add observers from scene tree
var _observers = get_node(system_nodes_root).find_children("*", "Observer") as Array[Observer]
add_observers(_observers)
_worldLogger.debug("_initialize Added Observers from Scene Tree: ", _observers)
# Add entities from the scene tree
var _entities = get_node(entity_nodes_root).find_children("*", "Entity") as Array[Entity]
add_entities(_entities)
_worldLogger.debug("_initialize Added Entities from Scene Tree: ", _entities)
if ECS.debug:
assert(GECSEditorDebuggerMessages.world_init(self ), '')
# Register debugger message handler for entity polling
if not Engine.is_editor_hint() and OS.has_feature("editor"):
EngineDebugger.register_message_capture("gecs", _handle_debugger_message)
#endregion Built-in Virtual Methods
#region Public Methods
## Called every frame by the [method _ECS.process] to process [System]s.
## [param delta] The time elapsed since the last frame.
## [param group] The string for the group we should run. If empty runs all systems in default "" group.
func process(delta: float, group: String = "") -> void:
# PERF: Reset frame metrics at start of processing step
perf_reset_frame()
if systems_by_group.has(group):
var system_index = 0
for system in systems_by_group[group]:
if system.active:
system._handle(delta)
if ECS.debug:
# Add execution order to last run data
system.lastRunData["execution_order"] = system_index
assert(GECSEditorDebuggerMessages.system_last_run_data(system, system.lastRunData), '')
system_index += 1
if ECS.debug:
assert(GECSEditorDebuggerMessages.process_world(delta, group), '')
## Updates the pause behavior for all systems based on the provided paused state.
## If paused, only systems with PROCESS_MODE_ALWAYS remain active; all others become inactive.
## If unpaused, systems with PROCESS_MODE_DISABLED stay inactive; all others become active.
func update_pause_state(paused: bool) -> void:
for group_key in systems_by_group.keys():
for system in systems_by_group[group_key]:
# Check to see if the system is can process based on the process mode and paused state
system.paused = not system.can_process()
## Adds a single [Entity] to the world.[br]
## [param entity] The [Entity] to add.[br]
## [param components] The optional list of [Component] to add to the entity.[br]
## [b]Example:[/b]
## [codeblock]
## # add just an entity
## world.add_entity(player_entity)
## # add an entity with some components
## world.add_entity(other_entity, [component_a, component_b])
## [/codeblock]
func add_entity(entity: Entity, components = null, add_to_tree = true) -> void:
# Check for ID collision - if entity with same ID exists, replace it
var entity_id = GECSIO.uuid() if not entity.id else entity.id
entity.id = entity_id # update entity with it's new id
if entity_id in entity_id_registry:
var existing_entity = entity_id_registry[entity_id]
_worldLogger.debug("ID collision detected, replacing entity: ", existing_entity.name, " with: ", entity.name)
remove_entity(existing_entity)
# Register this entity's ID
entity_id_registry[entity_id] = entity
# ID will auto-generate in _enter_tree if empty, or via property getter on first access
# Update index
_worldLogger.debug("add_entity Adding Entity to World: ", entity)
# Connect to entity signals for components so we can track global component state
if not entity.component_added.is_connected(_on_entity_component_added):
entity.component_added.connect(_on_entity_component_added)
if not entity.component_removed.is_connected(_on_entity_component_removed):
entity.component_removed.connect(_on_entity_component_removed)
if not entity.relationship_added.is_connected(_on_entity_relationship_added):
entity.relationship_added.connect(_on_entity_relationship_added)
if not entity.relationship_removed.is_connected(_on_entity_relationship_removed):
entity.relationship_removed.connect(_on_entity_relationship_removed)
# Add the entity to the tree if it's not already there after hooking up the signals
# This ensures that any _ready methods on the entity or its components are called after setup
if add_to_tree and not entity.is_inside_tree():
get_node(entity_nodes_root).add_child(entity)
# add entity to our list
entities.append(entity)
# ARCHETYPE: Add entity to archetype system BEFORE initialization
# Start with empty archetype, then move as components are added
_add_entity_to_archetype(entity)
# initialize the entity and its components in game only
# This will trigger component_added signals which move the entity to the right archetype
if not Engine.is_editor_hint():
entity._initialize(components if components else [])
entity_added.emit(entity)
# All the entities are ready so we should run the pre-processors now
for processor in ECS.entity_preprocessors:
processor.call(entity)
if ECS.debug:
assert(GECSEditorDebuggerMessages.entity_added(entity), '')
## Adds multiple entities to the world.[br]
## [param entities] An array of entities to add.
## [param components] The optional list of [Component] to add to the entity.[br]
## [b]Example:[/b]
## [codeblock]world.add_entities([player_entity, enemy_entity], [component_a])[/codeblock]
func add_entities(_entities: Array, components = null):
# OPTIMIZATION: Batch processing to reduce cache invalidations
# Temporarily disable cache invalidation during batch, then invalidate once at the end
var original_invalidate = _should_invalidate_cache
_should_invalidate_cache = false
var new_archetypes_created = false
var initial_archetype_count = archetypes.size()
# Process all entities
for _entity in _entities:
add_entity(_entity, components)
# Check if any new archetypes were created
if archetypes.size() > initial_archetype_count:
new_archetypes_created = true
# Re-enable cache invalidation and invalidate once if needed
_should_invalidate_cache = original_invalidate
if new_archetypes_created:
_invalidate_cache("batch_add_entities")
## Removes an [Entity] from the world.[br]
## [param entity] The [Entity] to remove.[br]
## [b]Example:[/b]
## [codeblock]world.remove_entity(player_entity)[/codeblock]
func remove_entity(entity) -> void:
entity = entity as Entity
for processor in ECS.entity_postprocessors:
processor.call(entity)
entity_removed.emit(entity)
_worldLogger.debug("remove_entity Removing Entity: ", entity)
entities.erase(entity) # FIXME: This doesn't always work for some reason?
# Only disconnect signals if they're actually connected
if entity.component_added.is_connected(_on_entity_component_added):
entity.component_added.disconnect(_on_entity_component_added)
if entity.component_removed.is_connected(_on_entity_component_removed):
entity.component_removed.disconnect(_on_entity_component_removed)
if entity.relationship_added.is_connected(_on_entity_relationship_added):
entity.relationship_added.disconnect(_on_entity_relationship_added)
if entity.relationship_removed.is_connected(_on_entity_relationship_removed):
entity.relationship_removed.disconnect(_on_entity_relationship_removed)
# Remove from ID registry
var entity_id = entity.id
if entity_id != "" and entity_id in entity_id_registry and entity_id_registry[entity_id] == entity:
entity_id_registry.erase(entity_id)
# ARCHETYPE: Remove entity from archetype system (parallel)
_remove_entity_from_archetype(entity)
# Destroy entity normally
entity.on_destroy()
entity.queue_free()
if ECS.debug:
assert(GECSEditorDebuggerMessages.entity_removed(entity), '')
## Removes an Array of [Entity] from the world.[br]
## [param entity] The Array of [Entity] to remove.[br]
## [b]Example:[/b]
## [codeblock]world.remove_entities([player_entity, other_entity])[/codeblock]
func remove_entities(_entities: Array) -> void:
# OPTIMIZATION: Batch processing to reduce cache invalidations
# Temporarily disable cache invalidation during batch, then invalidate once at the end
var original_invalidate = _should_invalidate_cache
_should_invalidate_cache = false
# Process all entities
for _entity in _entities:
remove_entity(_entity)
# Re-enable cache invalidation and always invalidate when entities are removed
# QueryBuilder caches execute() results, so any entity removal requires cache invalidation
_should_invalidate_cache = original_invalidate
_invalidate_cache("batch_remove_entities")
## Disable an [Entity] from the world. Disabled entities don't run process or physics,[br]
## are hidden and removed the entities list and the[br]
## [param entity] The [Entity] to disable.[br]
## [b]Example:[/b]
## [codeblock]world.disable_entity(player_entity)[/codeblock]
func disable_entity(entity) -> Entity:
entity = entity as Entity
entity.enabled = false # This will trigger _on_entity_enabled_changed via setter
entity_disabled.emit(entity)
_worldLogger.debug("disable_entity Disabling Entity: ", entity)
entity.component_added.disconnect(_on_entity_component_added)
entity.component_removed.disconnect(_on_entity_component_removed)
entity.relationship_added.disconnect(_on_entity_relationship_added)
entity.relationship_removed.disconnect(_on_entity_relationship_removed)
entity.on_disable()
entity.set_process(false)
entity.set_physics_process(false)
if ECS.debug:
assert(GECSEditorDebuggerMessages.entity_disabled(entity), '')
return entity
## Disable an Array of [Entity] from the world. Disabled entities don't run process or physics,[br]
## are hidden and removed the entities list[br]
## [param entity] The [Entity] to disable.[br]
## [b]Example:[/b]
## [codeblock]world.disable_entities([player_entity, other_entity])[/codeblock]
func disable_entities(_entities: Array) -> void:
for _entity in _entities:
disable_entity(_entity)
## Enables a single [Entity] to the world.[br]
## [param entity] The [Entity] to enable.[br]
## [param components] The optional list of [Component] to add to the entity.[br]
## [b]Example:[/b]
## [codeblock]
## # enable just an entity
## world.enable_entity(player_entity)
## # enable an entity with some components
## world.enable_entity(other_entity, [component_a, component_b])
## [/codeblock]
func enable_entity(entity: Entity, components = null) -> void:
# Update index
_worldLogger.debug("enable_entity Enabling Entity to World: ", entity)
entity.enabled = true # This will trigger _on_entity_enabled_changed via setter
entity_enabled.emit(entity)
# Connect to entity signals for components so we can track global component state
if not entity.component_added.is_connected(_on_entity_component_added):
entity.component_added.connect(_on_entity_component_added)
if not entity.component_removed.is_connected(_on_entity_component_removed):
entity.component_removed.connect(_on_entity_component_removed)
if not entity.relationship_added.is_connected(_on_entity_relationship_added):
entity.relationship_added.connect(_on_entity_relationship_added)
if not entity.relationship_removed.is_connected(_on_entity_relationship_removed):
entity.relationship_removed.connect(_on_entity_relationship_removed)
if components:
entity.add_components(components)
entity.set_process(true)
entity.set_physics_process(true)
entity.on_enable()
if ECS.debug:
assert(GECSEditorDebuggerMessages.entity_enabled(entity), '')
## Find an entity by its persistent ID
## [param id] The id to search for
## [return] The Entity with matching ID, or null if not found
func get_entity_by_id(id: String) -> Entity:
return entity_id_registry.get(id, null)
## Check if an entity with the given ID exists in the world
## [param id] The id to check
## [return] true if an entity with this ID exists, false otherwise
func has_entity_with_id(id: String) -> bool:
return id in entity_id_registry
#region Systems
## Adds a single system to the world.
##
## [param system] The system to add.
##
## [b]Example:[/b]
## [codeblock]world.add_system(movement_system)[/codeblock]
func add_system(system: System, topo_sort: bool = false) -> void:
if not system.is_inside_tree():
get_node(system_nodes_root).add_child(system)
_worldLogger.trace("add_system Adding System: ", system)
# Give the system a reference to this world
system._world = self
if not systems_by_group.has(system.group):
systems_by_group[system.group] = []
systems_by_group[system.group].push_back(system)
system_added.emit(system)
system._internal_setup() # Determines execution method and calls user setup()
if topo_sort:
ArrayExtensions.topological_sort(systems_by_group)
if ECS.debug:
assert(GECSEditorDebuggerMessages.system_added(system), '')
## Adds multiple systems to the world.
##
## [param systems] An array of systems to add.
##
## [b]Example:[/b]
## [codeblock]world.add_systems([movement_system, render_system])[/codeblock]
func add_systems(_systems: Array, topo_sort: bool = false):
for _system in _systems:
add_system(_system)
# After we add them all sort them
if topo_sort:
ArrayExtensions.topological_sort(systems_by_group)
## Removes a [System] from the world.[br]
## [param system] The [System] to remove.[br]
## [b]Example:[/b]
## [codeblock]world.remove_system(movement_system)[/codeblock]
func remove_system(system, topo_sort: bool = false) -> void:
_worldLogger.debug("remove_system Removing System: ", system)
systems_by_group[system.group].erase(system)
if systems_by_group[system.group].size() == 0:
systems_by_group.erase(system.group)
system_removed.emit(system)
# Update index
system.queue_free()
if topo_sort:
ArrayExtensions.topological_sort(systems_by_group)
if ECS.debug:
assert(GECSEditorDebuggerMessages.system_removed(system), '')
## Removes an Array of [System] from the world.[br]
## [param system] The Array of [System] to remove.[br]
## [b]Example:[/b]
## [codeblock]world.remove_systems([movement_system, other_system])[/codeblock]
func remove_systems(_systems: Array, topo_sort: bool = false) -> void:
for _system in _systems:
remove_system(_system)
if topo_sort:
ArrayExtensions.topological_sort(systems_by_group)
## Removes all systems in a group from the world.[br]
## [param group] The group name of the systems to remove.[br]
## [b]Example:[/b]
## [codeblock]world.remove_system_group("Gameplay")[/codeblock]
func remove_system_group(group: String, topo_sort: bool = false) -> void:
if systems_by_group.has(group):
for system in systems_by_group[group]:
remove_system(system)
if topo_sort:
ArrayExtensions.topological_sort(systems_by_group)
## Removes all [Entity]s and [System]s from the world.[br]
## [param should_free] Optionally frees the world node by default
## [param keep] A list of entities that should be kept in the world
func purge(should_free = true, keep := []) -> void:
# Get rid of all entities
_worldLogger.debug("Purging Entities", entities)
for entity in entities.duplicate().filter(func(x): return not keep.has(x)):
remove_entity(entity)
# Clear relationship indexes after purging entities
relationship_entity_index.clear()
reverse_relationship_index.clear()
_worldLogger.debug("Cleared relationship indexes after purge")
# ARCHETYPE: Clear archetype system
# First, break circular references by clearing edges
for archetype in archetypes.values():
archetype.add_edges.clear()
archetype.remove_edges.clear()
archetypes.clear()
entity_to_archetype.clear()
_worldLogger.debug("Cleared archetype storage after purge")
# Purge all systems
_worldLogger.debug("Purging All Systems")
for group_key in systems_by_group.keys():
for system in systems_by_group[group_key].duplicate():
remove_system(system)
# Purge all observers
_worldLogger.debug("Purging Observers", observers)
for observer in observers.duplicate():
remove_observer(observer)
_invalidate_cache("purge")
# remove itself
if should_free:
queue_free()
## Executes a query to retrieve entities based on component criteria.[br]
## [param all_components] [Component]s that [Entity]s must have all of.[br]
## [param any_components] [Component]s that [Entity]s must have at least one of.[br]
## [param exclude_components] [Component]s that [Entity]s must not have.[br]
## [param returns] An [Array] of [Entity]s that match the query.[br]
## [br]
## Performance Optimization:[br]
## When checking for all_components, the system first identifies the component with the smallest[br]
## set of entities and starts with that set. This significantly reduces the number of comparisons needed,[br]
## as we only need to check the smallest possible set of entities against other components.
#endregion Systems
#region Signal Callbacks
## [signal Entity.component_added] Callback when a component is added to an entity.[br]
## [param entity] The entity that had a component added.[br]
## [param component] The resource path of the added component.
func _on_entity_component_added(entity: Entity, component: Resource) -> void:
# ARCHETYPE: Move entity to new archetype
if entity_to_archetype.has(entity):
var old_archetype = entity_to_archetype[entity]
var comp_path = component.get_script().resource_path
var new_archetype = _move_entity_to_new_archetype_fast(entity, old_archetype, comp_path, true)
# Must invalidate: QueryBuilder caches execute() results, not just archetype matches
_invalidate_cache("entity_component_added")
# Emit Signal
component_added.emit(entity, component)
_handle_observer_component_added(entity, component)
if not entity.component_property_changed.is_connected(_on_entity_component_property_change):
entity.component_property_changed.connect(_on_entity_component_property_change)
if ECS.debug:
assert(GECSEditorDebuggerMessages.entity_component_added(entity, component), '')
## Called when a component property changes through signals called on the components and connected to.[br]
## in the _ready method.[br]
## [param entity] The [Entity] with the component change.[br]
## [param component] The [Component] that changed.[br]
## [param property_name] The name of the property that changed.[br]
## [param old_value] The old value of the property.[br]
## [param new_value] The new value of the property.[br]
func _on_entity_component_property_change(
entity: Entity,
component: Resource,
property_name: String,
old_value: Variant,
new_value: Variant
) -> void:
# Notify the World to trigger observers
_handle_observer_component_changed(entity, component, property_name, new_value, old_value)
# ARCHETYPE: No cache invalidation - property changes don't affect archetype membership
# Send the message to the debugger if we're in debug
if ECS.debug:
assert(GECSEditorDebuggerMessages.entity_component_property_changed(
entity, component, property_name, old_value, new_value
), '')
## [signal Entity.component_removed] Callback when a component is removed from an entity.[br]
## [param entity] The entity that had a component removed.[br]
## [param component] The resource path of the removed component.
func _on_entity_component_removed(entity, component: Resource) -> void:
if entity_to_archetype.has(entity):
var old_archetype = entity_to_archetype[entity]
var comp_path = component.resource_path
var new_archetype = _move_entity_to_new_archetype_fast(entity, old_archetype, comp_path, false)
# Must invalidate: QueryBuilder caches execute() results, not just archetype matches
_invalidate_cache("entity_component_removed")
component_removed.emit(entity, component)
_handle_observer_component_removed(entity, component)
if ECS.debug:
assert(GECSEditorDebuggerMessages.entity_component_removed(entity, component), '')
## (Optional) Update index when a relationship is added.
func _on_entity_relationship_added(entity: Entity, relationship: Relationship) -> void:
var key = relationship.relation.resource_path
if not relationship_entity_index.has(key):
relationship_entity_index[key] = []
relationship_entity_index[key].append(entity)
# Index the reverse relationship
if is_instance_valid(relationship.target) and relationship.target is Entity:
var rev_key = "reverse_" + key
if not reverse_relationship_index.has(rev_key):
reverse_relationship_index[rev_key] = []
reverse_relationship_index[rev_key].append(relationship.target)
# PERFORMANCE: Do NOT invalidate archetype cache on relationship changes
# Relationships do not alter archetype membership (structural component sets)
# QueryBuilder.execute() performs relationship filtering on entity results.
# Systems use archetypes() + per-entity filtering, so invalidation here only
# increases cache churn without improving correctness.
# Emit Signal
relationship_added.emit(entity, relationship)
if ECS.debug:
assert(GECSEditorDebuggerMessages.entity_relationship_added(entity, relationship), '')
## (Optional) Update index when a relationship is removed.
func _on_entity_relationship_removed(entity: Entity, relationship: Relationship) -> void:
var key = relationship.relation.resource_path
if relationship_entity_index.has(key):
relationship_entity_index[key].erase(entity)
if is_instance_valid(relationship.target) and relationship.target is Entity:
var rev_key = "reverse_" + key
if reverse_relationship_index.has(rev_key):
reverse_relationship_index[rev_key].erase(relationship.target)
# PERFORMANCE: No cache invalidation (see comment in _on_entity_relationship_added)
# Emit Signal
relationship_removed.emit(entity, relationship)
if ECS.debug:
assert(GECSEditorDebuggerMessages.entity_relationship_removed(entity, relationship), '')
## Adds a single [Observer] to the [World].
## [param observer] The [Observer] to add.
## [b]Example:[/b]
## [codeblock]world.add_observer(health_change_system)[/codeblock]
func add_observer(_observer: Observer) -> void:
# Verify the system has a valid watch component
_observer.watch() # Just call to validate it returns a component
if not _observer.is_inside_tree():
get_node(system_nodes_root).add_child(_observer)
_worldLogger.trace("add_observer Adding Observer: ", _observer)
observers.append(_observer)
# Initialize the query builder for the observer
_observer.q = QueryBuilder.new(self )
# Verify the system has a valid watch component
_observer.watch() # Just call to validate it returns a component
## Adds multiple [Observer]s to the [World].
## [param observers] An array of [Observer]s to add.
## [b]Example:[/b]
## [codeblock]world.add_observers([health_system, damage_system])[/codeblock]
func add_observers(_observers: Array):
for _observer in _observers:
add_observer(_observer)
## Removes an [Observer] from the [World].
## [param observer] The [Observer] to remove.
## [b]Example:[/b]
## [codeblock]world.remove_observer(health_system)[/codeblock]
func remove_observer(observer: Observer) -> void:
_worldLogger.debug("remove_observer Removing Observer: ", observer)
observers.erase(observer)
# if ECS.debug:
# # Don't use system_removed as it expects a System not ReactiveSystem
# GECSEditorDebuggerMessages.exit_world() # Just send a general update
observer.queue_free()
## Handle component property changes and notify observers
## [param entity] The entity with the component change
## [param component] The component that changed
## [param property] The property name that changed
## [param new_value] The new value of the property
## [param old_value] The previous value of the property
func handle_component_changed(
entity: Entity, component: Resource, property: String, new_value: Variant, old_value: Variant
) -> void:
# Emit the general signal
component_changed.emit(entity, component, property, new_value, old_value)
# Find observers watching for this component and notify them
_handle_observer_component_changed(entity, component, property, new_value, old_value)
## Notify observers when a component is added
func _handle_observer_component_added(entity: Entity, component: Resource) -> void:
for reactive_system in observers:
# Get the component that this system is watching
var watch_component = reactive_system.watch()
if (
watch_component
and component and component.get_script()
and watch_component.resource_path == component.get_script().resource_path
):
# Check if the entity matches the system's query
var query_builder = reactive_system.match()
var matches = true
if query_builder:
# Use the _query method instead of trying to use query as a function
var entities_matching = _query(
query_builder._all_components,
query_builder._any_components,
query_builder._exclude_components
)
# Check if our entity is in the result set
matches = entities_matching.has(entity)
if matches:
reactive_system.on_component_added(entity, component)
## Notify observers when a component is removed
func _handle_observer_component_removed(entity: Entity, component: Resource) -> void:
for reactive_system in observers:
# Get the component that this system is watching
var watch_component = reactive_system.watch()
if (
watch_component
and component and component.get_script()
and watch_component.resource_path == component.get_script().resource_path
):
# For removal, we don't check the query since the component is already removed
# Just notify the system
reactive_system.on_component_removed(entity, component)
## Notify observers when a component property changes
func _handle_observer_component_changed(
entity: Entity, component: Resource, property: String, new_value: Variant, old_value: Variant
) -> void:
for reactive_system in observers:
# Get the component that this system is watching
var watch_component = reactive_system.watch()
if (
watch_component
and component and component.get_script()
and watch_component.resource_path == component.get_script().resource_path
):
# Check if the entity matches the system's query
var query_builder = reactive_system.match()
var matches = true
if query_builder:
# Use the _query method instead of trying to use query as a function
var entities_matching = _query(
query_builder._all_components,
query_builder._any_components,
query_builder._exclude_components
)
# Check if our entity is in the result set
matches = entities_matching.has(entity)
if matches:
reactive_system.on_component_changed(
entity, component, property, new_value, old_value
)
#endregion Signal Callbacks
#endregion Public Methods
#region Utility Methods
func _query(all_components = [], any_components = [], exclude_components = [], enabled_filter = null, precalculated_cache_key: int = -1) -> Array:
var _perf_start_total := 0
if ECS.debug:
_perf_start_total = Time.get_ticks_usec()
# Early return if no components specified - return all entities
if all_components.is_empty() and any_components.is_empty() and exclude_components.is_empty():
if enabled_filter == null:
if ECS.debug:
perf_mark("query_all_entities", Time.get_ticks_usec() - _perf_start_total, {"returned": entities.size()})
return entities
else:
# OPTIMIZATION: Use bitset filtering from all archetypes instead of entity.enabled check
var filtered: Array[Entity] = []
for archetype in archetypes.values():
filtered.append_array(archetype.get_entities_by_enabled_state(enabled_filter))
if ECS.debug:
perf_mark("query_all_entities_filtered", Time.get_ticks_usec() - _perf_start_total, {"returned": filtered.size(), "enabled_filter": enabled_filter})
return filtered
# OPTIMIZATION: Use pre-calculated cache key if provided (avoids hash recalculation)
var _perf_start_cache_key := 0
if ECS.debug:
_perf_start_cache_key = Time.get_ticks_usec()
var cache_key = precalculated_cache_key if precalculated_cache_key != -1 else QueryCacheKey.build(all_components, any_components, exclude_components)
if ECS.debug:
perf_mark("query_cache_key", Time.get_ticks_usec() - _perf_start_cache_key)
# Check if we have cached matching archetypes for this query
var matching_archetypes: Array[Archetype] = []
if _query_archetype_cache.has(cache_key):
_cache_hits += 1
matching_archetypes = _query_archetype_cache[cache_key]
if ECS.debug:
perf_mark("query_cache_hit", 0, {"archetypes": matching_archetypes.size()})
else:
_cache_misses += 1
var _perf_start_scan := 0
if ECS.debug:
_perf_start_scan = Time.get_ticks_usec()
# Find all archetypes that match this query
var map_resource_path = func(x): return x.resource_path
var _all := all_components.map(map_resource_path)
var _any := any_components.map(map_resource_path)
var _exclude := exclude_components.map(map_resource_path)
for archetype in archetypes.values():
if archetype.matches_query(_all, _any, _exclude):
matching_archetypes.append(archetype)
# Cache the matching archetypes (not the entity arrays!)
_query_archetype_cache[cache_key] = matching_archetypes
if ECS.debug:
perf_mark("query_archetype_scan", Time.get_ticks_usec() - _perf_start_scan, {"archetypes": matching_archetypes.size()})
# OPTIMIZATION: If there's only ONE matching archetype with no filtering, return it directly
# This avoids array allocation and copying for the common case
if matching_archetypes.size() == 1 and enabled_filter == null:
if ECS.debug:
perf_mark("query_single_archetype", Time.get_ticks_usec() - _perf_start_total, {"entities": matching_archetypes[0].entities.size()})
return matching_archetypes[0].entities
# Collect entities from all matching archetypes with enabled filtering if needed
var _perf_start_flatten := 0
if ECS.debug:
_perf_start_flatten = Time.get_ticks_usec()
var result: Array[Entity] = []
for archetype in matching_archetypes:
if enabled_filter == null:
# No filtering - add all entities
result.append_array(archetype.entities)
else:
# OPTIMIZATION: Use bitset filtering instead of per-entity enabled check
result.append_array(archetype.get_entities_by_enabled_state(enabled_filter))
if ECS.debug:
perf_mark("query_flatten", Time.get_ticks_usec() - _perf_start_flatten, {"returned": result.size(), "archetypes": matching_archetypes.size()})
perf_mark("query_total", Time.get_ticks_usec() - _perf_start_total, {"returned": result.size()})
return result
## OPTIMIZATION: Group entities by their archetype for column-based iteration
## Enables systems to use get_column() for cache-friendly array access
## [param entities] Array of entities to group
## [returns] Dictionary mapping Archetype -> Array[Entity]
##
## Example usage in a System:
## [codeblock]
## func process_all(entities: Array, delta: float):
## var grouped = ECS.world.group_entities_by_archetype(entities)
## for archetype in grouped.keys():
## process_columns(archetype, delta)
## [/codeblock]
func group_entities_by_archetype(entities: Array) -> Dictionary:
var grouped = {}
for entity in entities:
if entity_to_archetype.has(entity):
var archetype = entity_to_archetype[entity]
if not grouped.has(archetype):
grouped[archetype] = []
grouped[archetype].append(entity)
return grouped
## OPTIMIZATION: Get matching archetypes directly from query (no entity array flattening)
## This is MUCH faster than query().execute() + group_entities_by_archetype()
## [param query_builder] The query to execute
## [returns] Array of matching archetypes
##
## Example usage in a System:
## [codeblock]
## func process_all(entities: Array, delta: float):
## # OLD WAY (slow):
## # var grouped = ECS.world.group_entities_by_archetype(entities)
##
## # NEW WAY (fast):
## var archetypes = ECS.world.get_matching_archetypes(q.with_all([C_Velocity]))
## for archetype in archetypes:
## var velocities = archetype.get_column(C_Velocity.resource_path)
## for i in range(velocities.size()):
## # Process with cache-friendly column access
## [/codeblock]
func get_matching_archetypes(query_builder: QueryBuilder) -> Array[Archetype]:
var _perf_start := 0
if ECS.debug:
_perf_start = Time.get_ticks_usec()
# PERFORMANCE: Archetype matching is based ONLY on structural components.
# Relationship/group filters are evaluated per-entity in System execution.
# This avoids double-scanning entities (World + System) and reduces cache churn.
var all_components = query_builder._all_components
var any_components = query_builder._any_components
var exclude_components = query_builder._exclude_components
# Use a COMPONENT-ONLY cache key (ignore relationships/groups)
var cache_key = QueryCacheKey.build(all_components, any_components, exclude_components)
if _query_archetype_cache.has(cache_key):
if ECS.debug:
perf_mark("archetypes_cache_hit", Time.get_ticks_usec() - _perf_start)
return _query_archetype_cache[cache_key]
var map_resource_path = func(x): return x.resource_path
var _all := all_components.map(map_resource_path)
var _any := any_components.map(map_resource_path)
var _exclude := exclude_components.map(map_resource_path)
var matching: Array[Archetype] = []
var _perf_scan_start := 0
if ECS.debug:
_perf_scan_start = Time.get_ticks_usec()
for archetype in archetypes.values():
if archetype.matches_query(_all, _any, _exclude):
matching.append(archetype)
if ECS.debug:
perf_mark("archetypes_scan", Time.get_ticks_usec() - _perf_scan_start, {"archetypes": matching.size()})
_query_archetype_cache[cache_key] = matching
if ECS.debug:
perf_mark("archetypes_total", Time.get_ticks_usec() - _perf_start, {"archetypes": matching.size()})
return matching
## Get performance statistics for cache usage
func get_cache_stats() -> Dictionary:
var total_requests = _cache_hits + _cache_misses
var hit_rate = 0.0 if total_requests == 0 else float(_cache_hits) / float(total_requests)
return {
"cache_hits": _cache_hits,
"cache_misses": _cache_misses,
"hit_rate": hit_rate,
"cached_queries": _query_archetype_cache.size(),
"total_archetypes": archetypes.size(),
"invalidation_count": _cache_invalidation_count,
"invalidation_reasons": _cache_invalidation_reasons.duplicate()
}
## Reset cache statistics
func reset_cache_stats() -> void:
_cache_hits = 0
_cache_misses = 0
_cache_invalidation_count = 0
_cache_invalidation_reasons.clear()
## Internal helper to track cache invalidations (debug mode only)
func _invalidate_cache(reason: String) -> void:
# OPTIMIZATION: Skip invalidation during batch operations
if not _should_invalidate_cache:
return
_query_archetype_cache.clear()
cache_invalidated.emit()
# Track invalidation stats (debug mode only)
if ECS.debug:
_cache_invalidation_count += 1
_cache_invalidation_reasons[reason] = _cache_invalidation_reasons.get(reason, 0) + 1
## Calculate archetype signature for an entity based on its components
## Uses the same hash function as queries for consistency
## An entity signature is just a query with all its components (no any/exclude)
func _calculate_entity_signature(entity: Entity) -> int:
# Get component resource paths
var comp_paths = entity.components.keys()
comp_paths.sort() # Sort paths for consistent ordering
# Convert paths to Script objects using cached scripts (load once, reuse forever)
var comp_scripts = []
for comp_path in comp_paths:
# Check cache first
if not _component_script_cache.has(comp_path):
# Load once and cache
var component = entity.components[comp_path]
_component_script_cache[comp_path] = component.get_script()
comp_scripts.append(_component_script_cache[comp_path])
# Use the SAME hash function as queries - entity is just "all components, no any/exclude"
# OPTIMIZATION: Removed enabled_marker from signature - now handled by bitset in archetype
var signature = QueryCacheKey.build(comp_scripts, [], [])
return signature
## Get or create an archetype for the given signature and component types
func _get_or_create_archetype(signature: int, component_types: Array) -> Archetype:
var is_new = not archetypes.has(signature)
if is_new:
var archetype = Archetype.new(signature, component_types)
archetypes[signature] = archetype
_worldLogger.trace("Created new archetype: ", archetype)
# ARCHETYPE OPTIMIZATION: Only invalidate cache when NEW archetype is created
# This is rare compared to entities moving between existing archetypes
_invalidate_cache("new_archetype_created")
return archetypes[signature]
## Add entity to appropriate archetype (parallel system)
func _add_entity_to_archetype(entity: Entity) -> void:
# Calculate signature based on entity's components (enabled state now handled by bitset)
var signature = _calculate_entity_signature(entity)
# Get component type paths for this entity
var comp_types = entity.components.keys()
# Get or create archetype (no longer needs enabled filter value)
var archetype = _get_or_create_archetype(signature, comp_types)
# Add entity to archetype
archetype.add_entity(entity)
entity_to_archetype[entity] = archetype
_worldLogger.trace("Added entity ", entity.name, " to archetype: ", archetype)
## Remove entity from its current archetype
func _remove_entity_from_archetype(entity: Entity) -> bool:
if not entity_to_archetype.has(entity):
return false
var archetype = entity_to_archetype[entity]
var removed = archetype.remove_entity(entity)
entity_to_archetype.erase(entity)
# Clean up empty archetypes (optional - can keep them for reuse)
if archetype.is_empty():
# Break circular references before removing
archetype.add_edges.clear()
archetype.remove_edges.clear()
archetypes.erase(archetype.signature)
_worldLogger.trace("Removed empty archetype: ", archetype)
# OPTIMIZATION: Only invalidate when archetype is actually removed from world
_invalidate_cache("empty_archetype_removed")
return removed
## Fast path: Move entity when we already know which component was added/removed
## This avoids expensive set comparisons to find the difference
## Returns the new archetype the entity was moved to
func _move_entity_to_new_archetype_fast(entity: Entity, old_archetype: Archetype, comp_path: String, is_add: bool) -> Archetype:
# Try to use archetype edge for O(1) transition
var new_archetype: Archetype = null
if is_add:
# Check if we have a cached edge for this component addition
new_archetype = old_archetype.get_add_edge(comp_path)
else:
# Check if we have a cached edge for this component removal
new_archetype = old_archetype.get_remove_edge(comp_path)
# BUG FIX: If archetype retrieved from edge cache was removed from world.archetypes
# when it became empty, re-add it so queries can find it
if new_archetype != null and not archetypes.has(new_archetype.signature):
archetypes[new_archetype.signature] = new_archetype
_worldLogger.trace("Re-added archetype from edge cache: ", new_archetype)
# If no cached edge, calculate signature and find/create archetype
if new_archetype == null:
var new_signature = _calculate_entity_signature(entity)
var comp_types = entity.components.keys()
new_archetype = _get_or_create_archetype(new_signature, comp_types)
# Cache the edge for next time (archetype graph optimization)
if is_add:
old_archetype.set_add_edge(comp_path, new_archetype)
new_archetype.set_remove_edge(comp_path, old_archetype)
else:
old_archetype.set_remove_edge(comp_path, new_archetype)
new_archetype.set_add_edge(comp_path, old_archetype)
# Remove from old archetype
old_archetype.remove_entity(entity)
# Add to new archetype
new_archetype.add_entity(entity)
entity_to_archetype[entity] = new_archetype
_worldLogger.trace("Moved entity ", entity.name, " from ", old_archetype, " to ", new_archetype)
# Clean up empty old archetype
if old_archetype.is_empty():
# Break circular references before removing
old_archetype.add_edges.clear()
old_archetype.remove_edges.clear()
archetypes.erase(old_archetype.signature)
return new_archetype
## Move entity from one archetype to another (when components change)
## Uses archetype edges for O(1) transitions when possible
## NOTE: This slow path compares sets - only used when we don't know which component changed
func _move_entity_to_new_archetype(entity: Entity, old_archetype: Archetype) -> void:
# Determine which component was added/removed by comparing old archetype with current entity
var old_comp_set = {}
for comp_path in old_archetype.component_types:
old_comp_set[comp_path] = true
var new_comp_set = {}
for comp_path in entity.components.keys():
new_comp_set[comp_path] = true
# Find the difference (added or removed component)
var added_comp: String = ""
var removed_comp: String = ""
for comp_path in new_comp_set.keys():
if not old_comp_set.has(comp_path):
added_comp = comp_path
break
for comp_path in old_comp_set.keys():
if not new_comp_set.has(comp_path):
removed_comp = comp_path
break
# Try to use archetype edge for O(1) transition
var new_archetype: Archetype = null
if added_comp != "":
# Check if we have a cached edge for this component addition
new_archetype = old_archetype.get_add_edge(added_comp)
elif removed_comp != "":
# Check if we have a cached edge for this component removal
new_archetype = old_archetype.get_remove_edge(removed_comp)
# If no cached edge, calculate signature and find/create archetype
if new_archetype == null:
var new_signature = _calculate_entity_signature(entity)
var comp_types = entity.components.keys()
new_archetype = _get_or_create_archetype(new_signature, comp_types)
# Cache the edge for next time (archetype graph optimization)
if added_comp != "":
old_archetype.set_add_edge(added_comp, new_archetype)
new_archetype.set_remove_edge(added_comp, old_archetype)
elif removed_comp != "":
old_archetype.set_remove_edge(removed_comp, new_archetype)
new_archetype.set_add_edge(removed_comp, old_archetype)
# Remove from old archetype
old_archetype.remove_entity(entity)
# Add to new archetype
new_archetype.add_entity(entity)
entity_to_archetype[entity] = new_archetype
_worldLogger.trace("Moved entity ", entity.name, " from ", old_archetype, " to ", new_archetype)
# Clean up empty old archetype
if old_archetype.is_empty():
# Break circular references before removing
old_archetype.add_edges.clear()
old_archetype.remove_edges.clear()
archetypes.erase(old_archetype.signature)
#endregion Utility Methods
#region Debugger Support
## Handle messages from the editor debugger
func _handle_debugger_message(message: String, data: Array) -> bool:
if message == "set_system_active":
# Editor requested to toggle a system's active state
var system_id = data[0]
var new_active = data[1]
# Find the system by instance ID
for sys in systems:
if sys.get_instance_id() == system_id:
sys.active = new_active
# Send confirmation back to editor
GECSEditorDebuggerMessages.system_added(sys)
return true
return false
elif message == "poll_entity":
# Editor requested a component poll for a specific entity
var entity_id = data[0]
_poll_entity_for_debugger(entity_id)
return true
elif message == "select_entity":
# Editor requested to select an entity in the scene tree
var entity_path = data[0]
print("GECS World: Received select_entity request for path: ", entity_path)
# Get the actual node to get its ObjectID
var node = get_node_or_null(entity_path)
if node:
var obj_id = node.get_instance_id()
var _class_name = node.get_class()
# The path needs to be an array of node names from root to target
var path_array = str(entity_path).split("/", false)
print(" Found node, sending inspect message")
print(" ObjectID: ", obj_id)
print(" Class: ", _class_name)
if GECSEditorDebuggerMessages.can_send_message():
# The scene:inspect_object format per Godot source code:
# [object_id (uint64), class_name (STRING), properties_array (ARRAY)]
# NO path_array! Just 3 elements total
# properties_array contains arrays of 6 elements each:
# [name (STRING), type (INT), hint (INT), hint_string (STRING), usage (INT), value (VARIANT)]
# Get actual properties from the node
var properties: Array = []
var prop_list = node.get_property_list()
# Add properties (limit to avoid huge payload)
for i in range(min(20, prop_list.size())):
var prop = prop_list[i]
var prop_name: String = prop.name
var prop_type: int = prop.type
var prop_hint: int = prop.get("hint", 0)
var prop_hint_string: String = prop.get("hint_string", "")
var prop_usage: int = prop.usage
var prop_value = node.get(prop_name)
var prop_info: Array = [prop_name, prop_type, prop_hint, prop_hint_string, prop_usage, prop_value]
properties.append(prop_info)
# Message format: [object_id, class_name, properties] - only 3 elements!
var msg_data: Array = [obj_id, _class_name, properties]
print(" Sending scene:inspect_object: [", obj_id, ", ", _class_name, ", ", properties.size(), " props]")
EngineDebugger.send_message("scene:inspect_object", msg_data)
else:
print(" ERROR: Could not find node at path: ", entity_path)
return true
return false
## Poll a specific entity's components and send updates to the debugger
func _poll_entity_for_debugger(entity_id: int) -> void:
# Find the entity by instance ID
var entity: Entity = null
for ent in entities:
if ent.get_instance_id() == entity_id:
entity = ent
break
if entity == null:
return
# Re-send all component data with fresh serialize() calls
for comp_path in entity.components.keys():
var comp = entity.components[comp_path]
if comp and comp is Resource:
# Send updated component data
GECSEditorDebuggerMessages.entity_component_added(entity, comp)
#endregion Debugger Support