Object-Oriented Entity-Component-System Design

The Challenge

About two months ago, I decided to start developing a fresh codebase for my voxel project – something that many indie developers are probably all-too familiar with, including myself. This time around, there were three major features that I wanted to implement:

  • System modularity
  • C# and C/C++ Interop where either can be used or combined for nearly everything
  • Effortless scripting and modding extensibility

The overall goal was to address scalability issues that I’ve encountered with my previous engines. It’s pretty easy to get a Vulkan application up and running and throw some voxel stuff at it, but without a solid foundation to build systems on top of, everything sort of becomes a mess of spaghetti code and hacky interop.

Actually, this problem extends beyond just engine development; it’s pretty much true of any software design. Developers need to try and anticipate the needs of future development while also making sure not to design an architecture that’s too abstract. There are lots of software design patterns and philosophies out there, but ultimately they are broad tools and you still have to figure out the best way to apply them.

I won’t pretend to be an expert in this field by any stretch of the imagination. I’m sure people that have degrees in computer science and years of professional experience can maybe offer better insight here. In fact, this is one of those problems that it feels the more you know, the less you know. More experience leads to more obstacles you try and prepare for, which ultimately leads to over-abstraction and less and less time writing actual functional code. But with the right design, there’s got to be a crossover where the solid foundation produces more functional code at some point, right?

Introducing the Onion Engine!

No, that’s not its actual name. Someone compared my new engine to an onion because of the numerous layers it has, and it stuck because it also makes me want to cry at times.

Let’s start by defining the things every PC game engine should support:

  • Game window
  • Input capturing
  • Networking
  • Game loop
  • Render loop
  • ImGui (of course)
  • Logging

More or less every system that you build is an extension of one or more of these components. Players incorporate input for moving, terrain incorporates rendering, enemies incorporate the game loop to engage in combat, and so on.

So the obvious step one is to incorporate these elements. An object-oriented system might then look something like this:

public class Player : IGameObject
{
    public override void OnUpdate(double dt)
    {
    }

    public override void OnRender(double dt)
    {
    }

    // etc...
}

For most games and simple engines, this works perfectly fine. If I was trying to get straight to developing a game, this is the method that I would use.

But this is in C#. In C++, it would look pretty similar, but goal #2 was to have C# and C/C++ interop. This means we have to write C and use function pointers, which might look like:

typedef void(*game_object_update_fn_t)(struct game_object_t* object, double dt);
typedef void(*game_object_render_fn_t)(struct game_object_t* object, double dt);
struct game_object_t
{
    game_object_update_fn_t fn_update;
    game_object_render_fn_t fn_render;
};

A little more verbose, but that’s C for you. Nothing really unreasonable here for a simple game.

But what happens when you want to add a new system to the engine? Or maybe you want to attach some data to just these components like the window that we’re drawing to. You can pass parameters into the functions, but then what if someone else comes in and wants to add to those parameters? Do you then pass in a heap-allocated list of structured parameters? What about code reusability like for networking? And perhaps another question we should be asking is who’s driving? The engine is going to have to iterate over every game object and check to see if it has a render function during the render loop even if it only updates.

I can already hear the collective groan about an impending entity-component-system or ECS lecture and how CPUs weren’t built for object-oriented programming, and why you right now need to start focusing on data-oriented designs because “your CPU cache will thank you.”

Breaking Entity-Component-System’s Rules

But actually, I want to talk about how ECS and violating many of its “rules” & core principles helped address the above questions. I put “rules” in quotes because there aren’t actually rules. ECS is merely a design guideline and is usually described as follows:

  • Entity: A simple identifier that groups together components
  • Component: Just some raw data that systems can operate on
  • System: One or more functions that performs some operation on a specific type (or types) of component

This is great for some aspects of game development, like the usual example that gets thrown around being a Position or Transformation component. An update system could apply velocities to the position, and then the render system could fetch both sprite components and transform components simultaneously to know what to draw and where. It’s quite elegant in that respect.

Here’s where somebody starts chopping up onions. What if we used the idea of ECS as a way to define our engine’s systems? I talked about the core components (component) that an engine has, and how some objects (entity) combine one or more of these components to build a system. The thing is, “pure” ECS designs make inter-system notoriously annoying by having to deal with message systems. Keeping functionality out of components and writing systems can also be tedious.

As it turns out, using ECS where it shines but mixing in some object-oriented principles makes for a powerful software design. It can feel a bit recursive at times, which I’ll touch on in a moment, but it addresses the key problems both with an objected-oriented design and plain ECS.

Changing Terminology

This part gets confusing because unlike ECS, the entity-component-system is not clear and changes based on the current perspective. An entity can become a system, and vice-versa. Let’s start by defining some terms though:

  • Engine: The host of an ECS world that creates and stores domains and contexts
  • Context: A core system that creates & attaches and operates on engine components
  • Component: A class consisting of some optional data and callbacks that a context calls
  • Domain: A collection of components, but can also become a context and create components or be a single component and act as a singleton

The part that really stands out is the Domain, which can actually be all three: an entity, component, and a system. Some might even argue that at that point it just turns into an object-oriented design, and they’d be right! That’s part of what makes it powerful and easy to use in practice. And due to the way the engine operates, it still gives us the benefits of ECS by treating it as a context or component.

A domain is meant to be thought of as a collection of core systems that can be processed independently by default, or reach into other domains for extended functionality.

For example, the Chat domain allows the users to chat with other players when in multiplayer and draws the messages to the screen. The rigid body physics domain crunches physics calculations and updates rigid body components. The player domain receives input and renders the players, and uses per-voxel collisions that it can fetch from the terrain domain.

Admittedly, this is the part that makes me tear up, because it was actually really tough to understand at times. “What fits into where?” Is a question I kept asking myself along the way, but every time I took a step back and tried finding a better design, this kept making more and more sense with the 3 feature goals that were outlined in the beginning.

How it Looks in Code

Let’s jump straight into what a domain in C# looks like. For this example I’m using a camera, which has its updating processed in C++ but registry, ImGui rendering, and input handling in C#.

public unsafe partial class FPSCamera
{
	private CameraDataT* native_data = null;

	public FPSCamera(CameraDataT* _native_data)
	{
		native_data = _native_data;
	}

	// Data functions
	public ref vec3 Position => ref native_data->v_position;
	public ref vec3 Velocity => ref native_data->v_velocity;
	public ref vec3 Rotation => ref native_data->v_rot;
}

public unsafe partial class FPSCameraSystem : DomainSystem
{
	public FPSCamera Singleton { get; private set; }
	public IntPtr NativePointer { get; protected set; }

	protected InputComponent InputComponent { get; private set; }

	public FPSCameraSystem(Engine engine) : base(engine, "FPSCamera")
	{
		NativePointer = vcore_fps_camera_domain_create(NativeID, engine.NativePointer);
		Singleton = new FPSCamera(vcore_fps_camera_get_singleton(NativePointer));
	}

	~FPSCameraSystem()
	{
		vcore_fps_camera_domain_destroy(NativePointer);
	}

	public override void AddRenderComponents(RenderContext render_context)
	{
		vcore_fps_camera_attach_render_components(NativePointer, render_context.NativePointer);

		// Imgui component
		{
			ImGuiComponent component = (ImGuiComponent)Engine.ImGuiContext.CreateSystemComponent(this);
			component.Render += ImGui_Render;
			component.Register();
			Components.Add("imgui_component", component);
		}

		base.AddRenderComponents(render_context);
	}

	public override void AddStandardComponents()
	{
		vcore_fps_camera_attach_standard_components(NativePointer);

		// Input component
		{
			InputComponent = (InputComponent)Engine.InputContext.CreateSystemComponent(this);
			InputComponent.FixedEvents.MouseDown += FixedRenderEvents_MouseDown;
			InputComponent.RenderEvents.MouseMove += RenderInputEvents_MouseMove;
			InputComponent.Register();
			Components.Add("input_component", InputComponent);
		}

		base.AddStandardComponents();
	}

	private void RenderInputEvents_MouseMove(object sender, MouseMoveEventArgs e)
	{
		// Move the camera
	}

	private void FixedRenderEvents_MouseDown(object sender, MouseEventArgs e)
	{
		Console.WriteLine("Pressed " + e.Button.ToString() + " from camera");
	}

	private void ImGui_Render(object sender, EventArgs e)
	{
		ImGui.Begin("Camera");
		ImGui.Text("Position:\t" + Singleton.Position.ToString());
		ImGui.Text("Velocity:\t" + Singleton.Velocity.ToString());
		ImGui.Text("Rotation:\t" + Singleton.Rotation.ToString());
		ImGui.Text("-");
		if (InputComponent != null)
		{
			ImGui.Text("Mouse Pos:\t" + InputComponent.RenderInput.GetMouse().X + ", " + InputComponent.RenderInput.GetMouse().Y);
		}
		ImGui.End();
	}
}

Of course it’s not fully complete, but you can see how easy it is to use and attach new functionality. The DomainSystem that it inherits from looks like the following:

public partial class DomainSystem
{
	public Engine Engine { get; private set; }
	public uint NativeID { get; private set; }
	public int ManagedID { get; set; }

	public string Name { get; private set; }

	public Dictionary<string, SystemComponent> Components { get; private set; }

	public DomainSystem(Engine engine, string name)
	{
		Engine = engine;
		Name = name;
		NativeID = vc_domain_create(engine.NativePointer, name);
		ManagedID = engine.GetNewDomainID();

		Components = new Dictionary<string, SystemComponent>();
	}

	~DomainSystem()
	{
		vc_domain_destroy(Engine.NativePointer, NativeID);
		Engine.UnregisterDomain(ManagedID);
	}

	/// <summary>
	/// Add standard (non-render) components here.
	/// </summary>
	public virtual void AddStandardComponents()
	{
	}

	/// <summary>
	/// Add render components here.
	/// </summary>
	public virtual void AddRenderComponents(RenderContext render_context)
	{
	}

	public static uint GetIDFromName(Engine engine, string name)
	{
		return vc_domain_get_from_name(engine.NativePointer, name);
	}
}

The actual ECS backbone is stored on the native side. I use https://github.com/SanderMertens/flecs here because I know I couldn’t write an ECS library that’s better. Because people might wonder, I did try EnTT, but it doesn’t work across dynamic libraries.

You may notice that there are separate managed and native IDs, and this is so that people can write and define systems entirely in C#. And thus, it’s important to sort of mirror the representation on the managed end as well. Also, the components need to be stored in the managed side when they wrap a native component to prevent the garbage collector from destroying them.

Let’s take a look at what the camera domain looks like on the native side:

fps_camera_domain_t::fps_camera_domain_t(vc_entity_t domain_id, voxely_engine_t* engine) : domain_t(engine, "fps_camera", domain_id)
{
	engine->get_domain_registry().component<fps_camera_component_wrapper_t>();

	m_singleton = new fps_camera_t();
	auto entity = flecs::entity(engine->get_domain_registry(), domain_id);
	entity.emplace<fps_camera_component_wrapper_t>(m_singleton);
}

fps_camera_domain_t::~fps_camera_domain_t()
{
	delete m_singleton;
	m_singleton = 0;
}

void fps_camera_domain_t::attach_standard_components()
{
}

void fps_camera_domain_t::attach_render_components(class render_context_t* render_context)
{
	using namespace std::placeholders;
	render_component_t* render_component = render_context->attach_component(m_domain_id);
	render_component->fn_pre_render[VC_NATIVE] = std::bind(&fps_camera_domain_t::update, this, _1, _2, _3, _4);
	render_component->fn_window_resize[VC_NATIVE] = std::bind(&fps_camera_domain_t::on_resize, this, _1, _2, _3, _4, _5, _6);
}

// We could update the singleton on its own or process the query in the event there are multiple cameras
void fps_camera_domain_t::on_resize(voxely_engine_t* engine, render_context_t* render_context, vc_entity_t domain_id, GLFWwindow* window, unsigned int window_width, unsigned int window_height)
{
	m_component_query.each([&](flecs::entity entity, fps_camera_component_wrapper_t& wrapper)
		{
			fps_camera_t* component = wrapper.data;
			component->on_resize(window_width, window_height);
		});
}

void fps_camera_domain_t::update(voxely_engine_t* engine, render_context_t* render_context, vc_entity_t domain_id, double dt)
{
	m_component_query.each([&](flecs::entity entity, fps_camera_component_wrapper_t& wrapper)
		{
			fps_camera_t* component = wrapper.data;
			component->update(dt);
		});
}

fps_camera_t* fps_camera_domain_t::get_fps_camera(flecs::world& domain_registry, vc_entity_t domain_id)
{
	auto entity = flecs::entity(domain_registry, domain_id);
	return entity.get<fps_camera_component_wrapper_t>()->data;
}

fps_camera_domain_t* vcore_fps_camera_domain_create(vc_entity_t domain_id, voxely_engine_t* engine)
{
	return new fps_camera_domain_t(domain_id, engine);
}

void vcore_fps_camera_domain_destroy(fps_camera_domain_t* domain)
{
	delete domain;
}

void vcore_fps_camera_attach_standard_components(fps_camera_domain_t* domain)
{
	domain->attach_standard_components();
}

void vcore_fps_camera_attach_render_components(fps_camera_domain_t* domain, class render_context_t* render_context)
{
	domain->attach_render_components(render_context);
}

fps_camera_t* vcore_fps_camera_get(uint32_t entity_id, voxely_engine_t* engine)
{
	return fps_camera_domain_t::get_fps_camera(engine->get_domain_registry(), (vc_entity_t)entity_id);
}

fps_camera_t* vcore_fps_camera_get_singleton(fps_camera_domain_t* domain)
{
	return domain->get_singleton();
}

This block includes the C bindings, if you were curious what those looked like. But overall, everything again is pretty clean. The way that the domain becomes more like an object is pretty evident here. Also, I do include functionality inside fps_camera_t, which holds all of the actual camera data like its matrices. The domain could function as a static system and operate strictly on fps_camera_t components, but it includes the Singleton since in most cases there’s only ever going to be one camera and so it’s not a static system. But it could be.

The main takeaway here though is this camera system was defined both in C# and C++ as an extension of core systems that were defined, but not hardcoded. In C#, the core contexts are cached for easy lookup, and the game & render loops are setup here as well, but contexts and systems in general can be added and interfaced dynamically with no additional complexity.

For low level systems like rendering and input, it’s a bit overkill, but with just a bit of extra effort, the camera domain can be treated the same way as a context, and thus we’ve come full circle! The engine foundation has no idea what a camera is, and yet it can be used as if it were a core component, which means other systems can access it and our modularity, managed/native interop, and extensibility problems are solved.

Render Components

I want to briefly show what the render component looks like, and how treating component data with callbacks as a system outside of a Singleton looks.

typedef void (render_component_render_fn_t)(class voxely_engine_t* engine, class render_context_t* render_context, vc_entity_t domain_id, double dt);
typedef void (render_component_window_resize_fn_t)(class voxely_engine_t* engine, class render_context_t* render_context, vc_entity_t domain_id, GLFWwindow* window, unsigned int window_width, unsigned int window_height);

class render_component_t : public render_derivative_t, public context_component_t
{
public:
	VCCPPAPI render_component_t(class render_context_t* render_context);
	VCCPPAPI render_component_t(render_component_t& rhs) = default;
	VCCPPAPI render_component_t(render_component_t&& rhs) noexcept = default;

	VCCPPAPI inline render_component_t& operator=(const render_component_t&) { return *this; }

	VCCPPAPI pipeline_cache_t& get_pipeline_cache() noexcept;

protected:
	pipeline_cache_t m_pipeline_cache;

public:
	std::function<render_component_render_fn_t> fn_pre_render[VC_NUM_FN_TYPES] = {};
	std::function<render_component_render_fn_t> fn_render[VC_NUM_FN_TYPES] = {};
	std::function<render_component_render_fn_t> fn_display[VC_NUM_FN_TYPES] = {};
	std::function<render_component_window_resize_fn_t> fn_window_resize[VC_NUM_FN_TYPES] = {};

	
};

typedef context_component_wrapper_t<render_component_t> render_component_wrapper_t;

VCAPI void vc_render_component_register_pre_render_fn(render_component_t* component, VC_FN_TYPES type, render_component_render_fn_t* fn);
VCAPI void vc_render_component_register_render_fn(render_component_t* component, VC_FN_TYPES type, render_component_render_fn_t* fn);
VCAPI void vc_render_component_register_display_fn(render_component_t* component, VC_FN_TYPES type, render_component_render_fn_t* fn);

VCAPI void vc_render_component_register_window_resize_fn(render_component_t* component, VC_FN_TYPES type, render_component_window_resize_fn_t* fn);

VCAPI pipeline_cache_t* vc_render_component_get_pipeline_cache(render_component_t* component);

And then the implementation:

render_component_t::render_component_t(render_context_t* render_context) : context_component_t(render_context), render_derivative_t(render_context), m_pipeline_cache(render_context)
{
}

pipeline_cache_t& render_component_t::get_pipeline_cache() noexcept
{
	return m_pipeline_cache;
}

void vc_render_component_register_pre_render_fn(render_component_t* component, VC_FN_TYPES type, render_component_render_fn_t* fn)
{
	component->fn_pre_render[(int)type] = fn;
}

void  vc_render_component_register_render_fn(render_component_t* component, VC_FN_TYPES type, render_component_render_fn_t* fn)
{
	component->fn_render[(int)type] = fn;
}

void vc_render_component_register_display_fn(render_component_t* component, VC_FN_TYPES type, render_component_render_fn_t* fn)
{
	component->fn_display[(int)type] = fn;
}

void vc_render_component_register_window_resize_fn(render_component_t* component, VC_FN_TYPES type, render_component_window_resize_fn_t* fn)
{
	component->fn_window_resize[(int)type] = fn;
}

pipeline_cache_t* vc_render_component_get_pipeline_cache(render_component_t* component)
{
	return &component->get_pipeline_cache();
}

So this part is pretty standard. And then the render context simply operates like this:

void render_context_t::render(double dt)
{
	auto& command_buffer = m_vk_context->get_active_command_buffer();

	{
	m_component_query.each([&](flecs::entity entity, render_component_wrapper_t& component_wrapper)
		{
			render_component_t& component = *component_wrapper.data;
				if (component.fn_pre_render[VC_NATIVE])
					component.fn_pre_render[VC_NATIVE](m_engine, this, entity, dt);
				if (component.fn_pre_render[VC_MANAGED])
					component.fn_pre_render[VC_MANAGED](m_engine, this, entity, dt);
			});
	}

	{
		m_component_query.each([&](flecs::entity entity, render_component_wrapper_t& component_wrapper)
			{
				render_component_t& component = *component_wrapper.data;
				if (component.fn_render[VC_NATIVE])
					component.fn_render[VC_NATIVE](m_engine, this, entity, dt);
				if (component.fn_render[VC_MANAGED])
					component.fn_render[VC_MANAGED](m_engine, this, entity, dt);

				component.get_pipeline_cache().launch_pipelines(command_buffer);
			});
	}
}

render_component_t* render_context_t::attach_component(vc_entity_t entity_id)
{
	std::scoped_lock<std::mutex> lock(m_component_mutex);

	auto entity = flecs::entity(m_domain_registry, entity_id);
	render_component_t* component = new render_component_t(this);
	entity.emplace<render_component_wrapper_t>(component);

	component->get_entity_id() = entity_id;

	return component;
}

One thing to address quickly is why the components have a wrapper. It’s because flecs can shift components around which invalidates any references to the components, including on the managed side. One could make the argument that you should be fetching the component from the entity every single time you want it, but not only does a managed to native function call cost about 1ns, but components could be added on a separate thread while the managed side is operating on its reference of the component and without a stable pointer, it will crash. Having the component data scattered around the heap is perfectly fine here since they involve immediate external function calls anyway.

Layering

The core Foundation layer defines those initial game engine components and a large amount of C# bindings. It really is meant to be the foundation layer to build a solid voxel engine on top of that is ripe for game development and modding.

That layer is the Framework layer. There’s not much there yet, but it’s where the real meat of the engine will go and where most function-first engines get the ball rolling. However, I think that we’re closing in on that crossing point where it’s going to become more time-efficient having this solid foundation in place.

The third layer is the Game layer. This is where game mechanics are defined and built on top of the framework – things like specialized procedural generation, combat, inventories, etc. The Modding layer is just above this.

Lastly that just leaves the Launcher, which is the small program that initializes the .NET Core, creates a foundation engine context, finds and loads a game layer, and connects the two and launches the game. Mods will also be loaded here.

Also I’m sure I’ll get these questions – I don’t use Unity/Unreal/<xyz engine here> because you’re always sacrificing something when you use someone else’s code. Unity is stuck with Mono. Unreal has very limited hardware ray tracing support. Both engines were built with the intent on developers making games with rasterized triangles. It makes more sense to me to build an engine around my end goals than to try and hack my way around an existing giant codebase that is both missing what I need and has way, way more than I need.

Closing Remarks

Overall, I’m very excited to begin integrating the old voxel systems on top of this foundation. I really do hate love C/C++, but only for low-level stuff where I feel like I need the full control and an understanding of what happens. C# is such a pretty language and is easy to just pick up, and not to mention omega powerful and fast in .NET Core. Things like chat and the player behavior just don’t belong in C++. They aren’t performance critical and having to fake automatic garbage collection in C++ or deal with the STL is an easy way to turn content developers off.

Perhaps in the next post I’ll talk about how the rendering and networking components are setup, as those are the two components that inspired this whole design and most the work went into them! Here’s a preview of how the pipeline creation works, where specialized constants, descriptor sets, resources, and shaders are all handled and mapped automagically:

// Render component
{
	RenderComponent component = (RenderComponent)render_context.CreateSystemComponent(this);
	render_component = component;

	RayTracedPipeline test_raytrace = new RayTracedPipeline(render_context, "test");
	test_raytrace.AddRayGen("test.rgen");
	test_raytrace.AddHitGroup("test.rchit", "test.rahit", "test.rint");
	test_raytrace.AddMiss("test.rmiss");
	test_raytrace.Create();
	test_raytrace.PreLaunch += (pipeline, args) =>
	{
		((Pipeline)pipeline).LaunchSize = new ivec3(args.WindowSize.x, args.WindowSize.y, 1);
	};
	component.PipelineCache.AddPipeline(test_raytrace);

	component.Register();
	Components.Add("render_component", component);
}

Thanks for reading.

~Lin

16 comments

  1. Pretty nice, keep it coming, hoping at one point you will speak also about rendering, voxel, proc gen, ai etc..

  2. This is fantastic. You put such great work in the smallest things. You even included a dark mode for the page.

  3. The page doesn’t render properly in Firefox. The code sections are cut off at the same width as the main body text, cropping half of it.

    If it’s supposed to be that way then I suggest setting the column limit in the text editor you are copying the code from to 50 columns since that is as many characters are visible per line.

  4. please keep making your voxel game it it literally my dream game and I’d buy a steam key I’d support whatever. you have no idea the capabilities of this game you are creating. everything can be simulated by the physics to recreate real life. its insane, i have been dreaming of a game like this ever since I played minecraft and terraria, I knew i wanted this game before I knew it existed.

    1. I agree, and i hope to be able to use this engine to develope games or inspire me to develop my own engine, the possibilities are endless.

Leave a comment

Your email address will not be published. Required fields are marked *