Skip to content

RaZeR-RBI/ignis

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

69 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Stability: alpha Line coverage Branch coverage

Ignis - a simple and lightweight Entity Component System (ECS)

Entity Component System is an architectural pattern that's mostly used in game development.

In short, you describe your game entities as composition of components like 'Position', 'Velocity', 'Model' and so on, and systems operate on them, decoupling the game logic from the entities and allowing a wide range of behaviors to be combined easily. Entities are represented as simple types (like int) and carry zero overhead.

This library provides a simple, fast and NativeAOT-friendly implementation for such a pattern.

Interested? Read more about ECS

Documentation

Goals

  • Easy to use API
  • Minimal or zero heap allocations in hot paths
  • Simple architecture
  • Minimal boilerplate code - Microsoft.Extensions.DependencyInjection is used as dependency injection container, which allows easy and fast injection of needed services and objects, keeping your code clean

Non-goals

To reduce both code and usage complexity the following trade-offs were made:

  • Single instance of component per entity
  • Order of execution is defined by the user
  • No parent/child relationships (can be augmented by an additional component)

Native AOT support

Library can be used in Native AOT mode. To make everything work, add ILLink.Descriptors.xml file to your project folder. Here's an example from supplied ConsoleSample:

<?xml version="1.0" encoding="utf-8"?>
<linker>
	<assembly fullname="Ignis" preserve="all" />
	<assembly fullname="ConsoleSample" preserve="all" />
</linker>

Then link it as an embedded resource in your .csproj:

<ItemGroup>
	<EmbeddedResource Include="ILLink.Descriptors.xml">
		<LogicalName>ILLink.Descriptors.xml</LogicalName>
	</EmbeddedResource>
</ItemGroup>

This configuration retains all declared members in both library and the code which uses it.

Definitions

  • Entity - represented as int, components are attached to them.
  • Component - a plain data struct with fields and properties - basically a data container. Although classes can be used instead of structs, it's recommended to use structs to avoid heap allocations.
  • System - a class that implements some part of logic related to specific components.
  • Container - the root of the whole system. Encapsulates systems, component storage and an entity manager.
  • Entity manager - manages the entity IDs and the component associations, allowing for creation and deletion of entities and their components, querying and so on. There is one entity manager per container.
  • Component collection (storage) - an object that stores the specific component values for each entity that has that component. There is one component collection per one registered component type.
  • Entity view - an automatically updated collection of entity IDs that have all components listed in it's filter (sometimes it's called an archetype).

Tutorial

Full code can be found here.

Let's try to do a simple thing - make objects fly around on our screen and bounce off the screen edges. For simplicity, we will be rendering everything to terminal using the ANSITerm library.

There are various approaches to designing your game logic in an ECS system - perhaps the most easiest one is to start with components. As a starting point, just ask yourself - what are the most basic things that every entity needs to have?

We need a way to position it and something to draw. Since we're working with positions and velocities at once, let's make a PhysicsObject component:

public struct PhysicsObject
{
	public Vector2 Position, Velocity;
	/* constructor omitted */
}

TIP: You can go even more granular and separate Position and Velocity into separate components - but we wouldn't go that way here to make things simpler. If you're familiar with Unity, you should have worked with components already - perhaps the most simplest one is the Unity Transform component, which you can also easily implement yourself.


Since we need to draw those entities, let's make a Drawable component (Color16 and ColorValue come from ANSITerm library):

public struct Drawable
{
	public char Symbol;
	public ColorValue Color;
	/* constructor omitted */
}

The second thing that we need is a State parameter - use it to encapsulate the information that's needed for every system run. Almost always it will have at least a way to measure frame time to advance computations - in our case, we will use seconds since the last frame (DeltaSeconds).

It's strongly recommended to define it as a class, not as struct to allow systems and other objects to alter it.

We will add additional properties to it to assist us with rendering:

public class GameState
{
	public float DeltaSeconds { get; set; }
	public int ScreenWidth { get; set; }
	public int ScreenHeight { get; set; }
	public IConsoleBackend Backend { get; set; }
	/* constructor omitted */
}

Okay, now we need to get things moving. Let's implement our first system. Since the movement logic doesn't depend on drawing stuff, the only component we need in it is the PhysicsObject. Let's make a PhysicsSystem:

public class PhysicsSystem : SystemBase<GameState>
{
	private readonly IComponentCollection<PhysicsObject> _objects;

	public PhysicsSystem(ContainerProvider<GameState> ownerProvider,
	                     IComponentCollection<PhysicsObject> objects) : base(ownerProvider)
	{
		_objects = objects;
	}

	public override void Execute(GameState state)
	{
		/* we need to do something here */
	}
}

We will get our IComponentCollection injected into our system automatically later on. All systems should derive from SystemBase.

The heart of all systems is their Execute(state) method - it gets executed on each ExecuteSystems(state) call (we'll see it later).


TIP: If you need to do some initialization logic after the system has been constructed, feel free to override Initialize(state) method. If you need to dispose something when the whole ECS gets disposed, override the Dispose() method.


TIP: The whole ECS container is based on MicroResolver library, so you are free to inject the needed storages, systems and other objects in any way that's supported by that library - personally I like the attribute method.


Now we need to process the PhysicsObject components. IComponentCollection provides several methods to help us with it, and the most straightforward one is the Process(Func<int, T, T>).

It runs a callback on each 'entity ID'-'component value' pair, you pass a new component value, and it saves it.

The second one is the ForEach(Action<int, T, U>, U param) - it doesn't save the new component value automatically and also allows you to pass an additional parameter of any type.


TIP: Lambda functions that use (capture) variables from 'outside' (here is a good writeup on implicit allocations) allocate additional memory on the heap, consider using ForEach instead of Process when you need to access anything other than the entity ID and the component value to reduce memory allocations and GC pressure (which in turn increases performance).


TIP: ClrHeapAllocationAnalyzer is a very useful tool that lets you keep an eye on heap allocations in your code.


Since we need to access the outer state to do our calculations, let's use the ForEach method.

public override void Execute(GameState state)
{
	// pass current state and instance as parameter to avoid heap allocations
	var param = (Self: this, State: state);
	// process each component
	_objects.ForEach((id, obj, p) => p.Self.Move(id, obj, p.State), param);
}

Let's implement our Move method:

private void Move(int id, PhysicsObject obj, GameState state)
{
	// move object
	obj.Position += obj.Velocity * state.DeltaSeconds;

	// reflect off screen edges
	if (obj.Position.X < 0 || obj.Position.X >= state.ScreenWidth)
	{
		obj.Velocity.X *= -1;
		obj.Position.X = obj.Velocity.X > 0 ? 0 : state.ScreenWidth - 1;
	}

	if (obj.Position.Y < 0 || obj.Position.Y >= state.ScreenHeight)
	{
		obj.Velocity.Y *= -1;
		obj.Position.Y = obj.Velocity.Y > 0 ? 0 : state.ScreenHeight - 1;
	}

	// update component value
	_objects.UpdateCurrent(obj);
}

If you want to update a component value for a specific entity ID, use the Update(int, T) method.

Now let's get to the rendering part. To draw stuff we need both position and a description of what to draw - in other words, we need both the PhysicsObject and Drawable components.

A set of components of an entity is often called an archetype - we can work with them through the entity views (IEntityView).

There is two ways of retrieving an IEntityView - IEntityManager (accessible from all systems and from the whole container) has a GetView(Type[]) method, and each IComponentCollection<T> has GetView() method that returns IEntityView with the single component that it stores.

We need two components, so we need to request it from the EntityManager:

public class RenderingSystem : SystemBase<GameState>
{
	private readonly IComponentCollection<PhysicsObject> _physObjects;

	private readonly IComponentCollection<Drawable> _drawables;

	private readonly IEntityView _drawableIds;

	public RenderingSystem(ContainerProvider<GameState> ownerProvider,
	                       IComponentCollection<PhysicsObject> physObjects,
	                       IComponentCollection<Drawable> drawables) : base(ownerProvider)
	{
		_physObjects = physObjects;
		_drawables = drawables;
		// filter out entities that have both PhysicsObject and Drawable
		_drawableIds = EntityManager.GetView<PhysicsObject, Drawable>();
	}
	public override void Execute(GameState state)
	{
		/* implement logic here */
	}
}

IEntityView implements IEnumerable<int>, so working with it boils down to using foreach:

foreach (var id in _drawableIds)
{
	/* work with entity ID here */
}

To retrieve a component from IComponentCollection for a specific entity ID use the Get(int) method.

Let's implement the Execute method:

public override void Execute(GameState state)
{
	var term = state.Backend;
	term.BackgroundColor = new ColorValue(Color16.Black);
	term.Clear();
	// enumerate through entity view
	foreach (var id in _drawableIds)
	{
		var position = _physObjects.Get(id).Position;
		position.X = MathF.Round(position.X);
		position.Y = MathF.Round(position.Y);
		var drawable = _drawables.Get(id);

		// draw it
		term.SetCursorPosition((int)position.X, (int)position.Y);
		term.ForegroundColor = drawable.Color;
		term.Write(drawable.Symbol.ToString());
	}
}

Now we have all of our components and systems in place, let's configure the ECS container.

To do that you need to use the ContainerFactory:

private static IContainer<GameState> ConfigureECS()
{
	return ContainerFactory.CreateContainer<GameState>()
	                       .AddComponent<PhysicsObject>()
	                       .AddComponent<Drawable>()
	                       .AddSystem<PhysicsSystem>()
	                       .AddSystem<RenderingSystem>()
	                       .Build();
}

Don't forget to call the Build method after configuring all your components, systems and objects (those are configured using Register method). There is also overloads that accept interface type along with the implementing type.

The order of registration of the systems should be the same as the order you want them to be run in - in other words, PhysicsSystem will be run first, and the RenderingSystem will be run second.


NOTE: Some ECS users advocate against coupling systems, but I personally find it hard to express some things with a pure non-coupled ECS way, so when I find out a that some part of system logic can be used in another other system, I abstract it through interfaces. That way the systems depend on abstractions and not concrete types, and they can be freely swapped and replaced.


After initializing our container we can spawn some entities:

private static int SpawnRandomEntity(IContainer<GameState> ecs, IConsoleBackend term)
{
	var em = ecs.EntityManager;
	var id = em.Create(); // create an entity ID
	var screenSize = new Vector2(term.WindowWidth, term.WindowHeight);
	var maxSpeed = new Vector2(10f);

	// add physics component
	var position = RandomVector(Vector2.Zero, screenSize);
	var velocity = RandomVector(-maxSpeed, maxSpeed);
	ecs.AddComponent(id, new PhysicsObject(position, velocity));

	// add drawable component
	var symbol = RandomElement(s_symbols);
	var color = RandomEnumValue<Color16>();
	ecs.AddComponent(id, new Drawable(symbol, color));

	return id;
}

Now let's implement our game loop:

private static void RunLoop(IConsoleBackend term, IContainer<GameState> ecs)
{
	const int msPerFrame = 20;
	var width = term.WindowWidth;
	var height = term.WindowHeight;
	var state = new GameState(msPerFrame / 1000f, width, height, term);
	// initialize
	ecs.InitializeSystems(state);

	// run
	while (true)
	{
		// Note - in a real game update and rendering logic
		// is often separated - there is no requirement
		// for putting the rendering logic inside ECS -
		// since it's a DI container, you can freely
		// resolve needed components and systems from
		// external code and use them in any other way
		ecs.ExecuteSystems(state);
		Thread.Sleep(20);
		if (term.KeyAvailable)
			break; // exit on any key press
	}
}

TL;DR


  1. Define your components as structs
  2. Define your game/app state as a class (it will be passed to each system)
  3. Define your systems by inheriting them from SystemBase<T> where T is the state type
  4. Configure their types in an ECS container, then Build() it
  5. Initialize the systems through the container and then run them as you wish

Full code can be found here.

About

Simple and fast ECS (Entity Component System) for .NET Standard

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published