update articles
This commit is contained in:
@@ -167,7 +167,7 @@ export default function Callout({ children }: { children: React.ReactNode }) {
|
|||||||
)}
|
)}
|
||||||
<span
|
<span
|
||||||
style={{ color: config.titleColor }}
|
style={{ color: config.titleColor }}
|
||||||
className="font-semibold text-base font-sans"
|
className="font-semibold text-base font-sans uppercase"
|
||||||
>
|
>
|
||||||
{keyword}
|
{keyword}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
155
src/content/articles/ecs-in-csharp.md
Normal file
155
src/content/articles/ecs-in-csharp.md
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
---
|
||||||
|
title: "High-performance ECS in C#"
|
||||||
|
desc: "Achieving high-performance code in pure c# with ecs and data oriented design."
|
||||||
|
date: "April 13, 2026"
|
||||||
|
tag: "Game Engine"
|
||||||
|
time: "20 min read"
|
||||||
|
heroImageUrl: "https://www.pvsm.ru/images/2017/12/05/shablon-proektirovaniya-Entity-Component-System-realizaciya-i-primer-igry-2.png"
|
||||||
|
heroImageCaption: "ECS DESIGN"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Why we need ecs?
|
||||||
|
|
||||||
|
If you have ever tried to build a complex simulation or a game in C# using traditional Object-Oriented Programming, you have likely hit a wall. As your inheritance trees grow deeper, your code becomes harder to maintain. Worse, from a performance standpoint, arrays of class references scatter your data across the managed heap, leading to cache misses that quietly throttle your CPU.
|
||||||
|
|
||||||
|
To push modern hardware to its limits, we need to stop thinking about "objects" and start thinking about "data." This is where the Entity Component System (ECS) comes in.
|
||||||
|
|
||||||
|
At its core, an ECS is an architectural pattern built on Data-Oriented Design. It strips away behavior from data, breaking everything down into three simple concepts: **Entities** (IDs), **Components** (pure data), and **Systems** (logic).
|
||||||
|
|
||||||
|
While C# is an object-oriented language by nature, its modern feature set including value types (`struct`), `ref` semantics, and contiguous memory managemen makes it uniquely capable of running high-performance, zero-allocation ECS frameworks.
|
||||||
|
|
||||||
|
In this post, we are going to build an Archetype-based ECS from scratch in C#. We will look at how to organize memory for maximum cache locality, how to process data efficiently, and look at the benchmarks to see just how much this architecture can outperform a traditional OOP approach.
|
||||||
|
|
||||||
|
## The Problem of OOP
|
||||||
|
|
||||||
|
In traditional OOP, we often have a class called `GameObject`, and each `GameObject` contains `Transform`, a list of `Component`, and a list of `GameObject` for children.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public class GameObject
|
||||||
|
{
|
||||||
|
private Transform _transform;
|
||||||
|
private readonly List<Component> _components = new();
|
||||||
|
private readonly List<GameObject> _children = new();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
We already use this structure for around 20 years and still working very well today, until your try to build a data heavy game like rts or open world.
|
||||||
|
|
||||||
|
When we talk about the flaws of OOP in C#, it is easy to point at deep inheritance trees or virtual method overhead. But the real performance killer is much more fundamental: **OOP is fundamentally hostile to the CPU cache.**
|
||||||
|
|
||||||
|
Modern CPUs are incredibly fast, but main memory (RAM) is comparatively slow. To bridge this gap, CPUs use small, ultra-fast caches (L1, L2, L3). When the CPU needs data, it doesn't just grab a single variable; it pulls a contiguous 64-byte chunk of memory called a "cache line." If the next piece of data your program needs is within that same 64-byte chunk, you get a "cache hit." It processes instantly. If it isn't, you get a "cache miss," and the CPU halts, wasting hundreds of cycles waiting for RAM to deliver the next chunk.
|
||||||
|
|
||||||
|
Let's look at a standard OOP approach in C#. You might have a `List<GameObject>`, and every frame, you loop through it to update positions:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
foreach (var obj in gameObjects)
|
||||||
|
{
|
||||||
|
obj.UpdatePosition();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In a managed language like C#, `GameObject` is a reference type (a `class`). That `List` isn't a neat, contiguous block of game objects; it is just a contiguous block of memory addresses. The actual object data is scattered completely randomly across the managed heap, wherever the Garbage Collector decided to put it.
|
||||||
|
|
||||||
|
When you iterate through that loop, your CPU is forced into a game of pointer-chasing. It reads an address, fetches a cache line from a random spot in RAM, updates the position, and then immediately throws the rest of that cache line away because the next object is stored somewhere entirely different. This results in constant cache misses. Your CPU spends more time waiting for memory than it does actually computing.
|
||||||
|
|
||||||
|
Furthermore, OOP encourages grouping data by concept rather than by usage. A `GameObject` might contain a Transform, a Renderer, a Collider, and Health data. If our physics system only needs the Transform and Collider, it still pulls the entire "fat object" into the cache, evicting other useful data.
|
||||||
|
|
||||||
|
To achieve maximum performance, we need to guarantee linear memory access. We need contiguous arrays of pure data, perfectly packed so the CPU can chew through them without a single stall. That is exactly the problem an Archetype ECS solves.
|
||||||
|
|
||||||
|
## How ECS Solve the problem that OOP can't?
|
||||||
|
|
||||||
|
ECS flips the traditional programming model on its head by completely separating state from behavior. Instead of thinking about "an Orc that can run and attack," you think about "a collection of data being transformed by logic."
|
||||||
|
|
||||||
|
To understand how this fixes our CPU cache problem, we need to look at the three pillars of the architecture:
|
||||||
|
|
||||||
|
1. Entities are just IDs. An entity is not a class. It is not an object. It is literally just an integer (e.g., int entityId = 42;). It serves merely as a key to look up data.
|
||||||
|
2. Components are pure data. In C#, these are strictly defined as struct (value types). They contain zero logic, no methods, and no hidden overhead.
|
||||||
|
3. Systems are pure logic. A system has no state of its own. It simply asks the database for all entities matching a specific set of components, and iterates over them.
|
||||||
|
|
||||||
|
### The Archetype Advantage
|
||||||
|
|
||||||
|
The real magic of a high-performance ECS lies in how it stores those struct components in memory. This is where Archetypes come in.
|
||||||
|
|
||||||
|
An Archetype is a unique combination of components. If you create an Entity and give it a `Position` and a `Velocity` component, it belongs to the `[Position, Velocity]` archetype. If you add a Health component to that same entity, it moves to the `[Position, Velocity, Health]` archetype.
|
||||||
|
|
||||||
|
Under the hood, the ECS groups all entities of the exact same Archetype into tightly packed, contiguous arrays (often referred to as "Chunks" or "Tables").
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Components are strictly structs
|
||||||
|
public struct Position { public float X, Y, Z; }
|
||||||
|
public struct Velocity { public float X, Y, Z; }
|
||||||
|
|
||||||
|
// In memory, an Archetype stores these as contiguous arrays:
|
||||||
|
// Position[]: [P0, P1, P2, P3, P4, P5...]
|
||||||
|
// Velocity[]: [V0, V1, V2, V3, V4, V5...]
|
||||||
|
```
|
||||||
|
|
||||||
|
### The Cache-Friendly Loop
|
||||||
|
|
||||||
|
e C# arrays of `structs` are guaranteed to be laid out sequentially in memory, we have entirely eliminated the pointer-chasing problem. When your `MovementSystem` asks for all entities with a `Position` and `Velocity`, the ECS doesn't hand you a list of scattered objects. It hands you direct references (or modern C# `Span<T>`) to those perfectly packed arrays.
|
||||||
|
|
||||||
|
Here is what the update loop looks like in a Data-Oriented C# system:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public void UpdatePositions(Span<Position> positions, ReadOnlySpan<Velocity> velocities, float deltaTime)
|
||||||
|
{
|
||||||
|
// The CPU prefetcher LOVES this loop
|
||||||
|
for (int i = 0; i < positions.Length; i++)
|
||||||
|
{
|
||||||
|
positions[i].X += velocities[i].X * deltaTime;
|
||||||
|
positions[i].Y += velocities[i].Y * deltaTime;
|
||||||
|
positions[i].Z += velocities[i].Z * deltaTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notice what is missing here:
|
||||||
|
|
||||||
|
- No Virtual Method Calls: We are doing simple math directly on the data.
|
||||||
|
- No Pointer Dereferencing: `positions[i]` is exactly where the CPU expects it to be right next to `positions[i-1]`.
|
||||||
|
- No Wasted Cache Lines: We only fetch `Position` and `Velocity`. Even if the entity has a massive `Renderer` or `AIState` component, it doesn't matter. Those are stored in different arrays. The CPU only loads exactly what it needs for this specific operation.
|
||||||
|
|
||||||
|
When this loop runs, the CPU's hardware prefetcher recognizes the linear, predictable access pattern. It starts pulling cache lines into the L1 cache before the loop even asks for them. The result is a massive reduction in cache misses, drastically lower execution times, and zero Garbage Collection allocations.
|
||||||
|
|
||||||
|
This is the power of Data-Oriented Design. By respecting the hardware and organizing our data into Archetypes, we unlock the true speed of modern processors paving the way for massive simulations that OOP could never handle.
|
||||||
|
|
||||||
|
# The Anatomy of an Archetype ECS
|
||||||
|
|
||||||
|
Before we start writing C# code, we need to map out the architecture. Building an ECS is essentially building an in-memory, high-performance relational database tailored for real-time processing.
|
||||||
|
|
||||||
|
To make this work seamlessly, our ECS needs four major structural concepts: The **World**, **Archetypes**, **Chunks**, and **Queries**.
|
||||||
|
|
||||||
|
## World
|
||||||
|
|
||||||
|
The World is the top-level container for your ECS. It acts as the central registry and memory manager. It is responsible for spawning and destroying entities, maintaining the list of available Archetypes, and routing component additions/removals. Think of it as the `DbContext` of your game.
|
||||||
|
|
||||||
|
## Archetype
|
||||||
|
|
||||||
|
As mentioned earlier, an `Archetype` represents a unique signature of components (e.g., `Entity + Position + Velocity`). In our database analogy, an Archetype is the schema of a table. It dictates exactly what columns exist.
|
||||||
|
|
||||||
|
It is vital to understand that when you add or remove a component from an existing entity, its schema changes. The ECS must move the entity's data from its current Archetype to a new Archetype. This is called a Structural Change. Because structural changes require copying memory from one array to another, they are the most expensive operations in an ECS. We design our systems to minimize them during the hot loop (gameplay).
|
||||||
|
|
||||||
|
## Chunk
|
||||||
|
|
||||||
|
If Archetypes are the table schemas, Chunks (sometimes called Blocks or Pages) are the actual data rows.
|
||||||
|
|
||||||
|
Why do we need Chunks? Imagine an Archetype has an array of Position structs. As we create thousands of entities, this array will fill up. In standard C#, resizing an array requires allocating a brand new, larger array and copying all the old data over a massive performance hit that triggers the Garbage Collector.
|
||||||
|
|
||||||
|
Instead of infinitely growing arrays, an Archetype allocates memory in fixed-size blocks—let's say 16KB. This block is a Chunk.
|
||||||
|
A single Chunk holds data strictly for one Archetype. Inside the Chunk, data is stored in a Structure of Arrays (SoA) format.
|
||||||
|
|
||||||
|
For an `[Entity, Position, Velocity]` Archetype, a 16KB Chunk might hold exactly 341 entities. The Chunk memory layout looks like this:
|
||||||
|
|
||||||
|
- `[Entity ID 0 ... Entity ID 340]`
|
||||||
|
- `[Position 0 ... Position 340]`
|
||||||
|
- `[Velocity 0 ... Velocity 340]`
|
||||||
|
|
||||||
|
When a Chunk gets full, the Archetype simply allocates a brand new 16KB Chunk and links it. No resizing, no copying old data, no massive GC spikes. Just clean, contiguous memory allocation.
|
||||||
|
|
||||||
|
## Query
|
||||||
|
|
||||||
|
Finally, we have the Query. Systems don't care about `Archetypes` or `Chunks`; they only care about data.
|
||||||
|
|
||||||
|
A Physics System might say: "Give me every entity that has a `Position` and a `Velocity`, but exclude anything that has a `Static` component."
|
||||||
|
|
||||||
|
The Query evaluates this request by checking the bitmasks of all registered Archetypes. It builds a cached list of matching Archetypes. When the system runs, it simply iterates over the Chunks belonging to those matched Archetypes, extracts the underlying arrays as `Span<T>`, and blasts through the math.
|
||||||
@@ -10,10 +10,10 @@ header:
|
|||||||
href: "/"
|
href: "/"
|
||||||
- label: "ARTICLES"
|
- label: "ARTICLES"
|
||||||
href: "/articles"
|
href: "/articles"
|
||||||
- label: "PROJECTS"
|
# - label: "PROJECTS"
|
||||||
href: "/projects"
|
# href: "/projects"
|
||||||
- label: "ABOUT"
|
# - label: "ABOUT"
|
||||||
href: "/about"
|
# href: "/about"
|
||||||
right_button:
|
right_button:
|
||||||
label: "" # Optional text
|
label: "" # Optional text
|
||||||
icon: "search" # Options: "search", "mail", "plus", etc.
|
icon: "search" # Options: "search", "mail", "plus", etc.
|
||||||
@@ -30,8 +30,8 @@ footer:
|
|||||||
href: "https://git.personalnas.com/Misaki"
|
href: "https://git.personalnas.com/Misaki"
|
||||||
|
|
||||||
homepage:
|
homepage:
|
||||||
title: "A CURATED COLLECTION OF THOUGHTS ON THE INTERSECTION OF CODE AND CRAFT"
|
title: "THE INTERSECTION OF CODE AND CRAFT"
|
||||||
subtitle: "Explore deep inquiries into the architecture of systems, the ethics of automation, and the pursuit of digital mastery."
|
subtitle: "Explore deep inquiries into the architecture of systems, the boundary of real time graphics, and the pursuit of digital mastery"
|
||||||
featured_label: "FEATURED THOUGHT"
|
featured_label: "FEATURED THOUGHT"
|
||||||
recent_label: "RECENT ARTICLES"
|
recent_label: "RECENT ARTICLES"
|
||||||
archive_link_text: "EXPLORE FULL ARCHIVE"
|
archive_link_text: "EXPLORE FULL ARCHIVE"
|
||||||
|
|||||||
Reference in New Issue
Block a user