ECSY

WebXR, ECSY, three.js

ECSY (pronounced "eck-see") Entity Component System framework for javascript

ECSY

Introduction

After working on many interactive graphics projects for the web in the last few years, we had a brainstorm at the Mixed Reality team at Mozilla and we were trying to identify the common issues when developing something bigger than a simple example. Based on our findings we discussed what an ideal framework would need:

  • Component-based: Help to structure and reuse code across multiple projects.
  • Predictable: Avoids random events or callbacks interrupting the main flow, which would make it hard to debug or trace what is going on in the application.
  • Good performance: Most web graphics applications are CPU bound, so we should focus on performance much more.
  • Simple API: The core API should be simple, making the framework easier to understand, optimize and contribute to; but also allow building more complex layers on top of it if needed.
  • Graphics engine agnostic: It should not be tied to any specific graphics engine or framework.

These requirements are high-level features that are not usually provided by graphics engines like three.js or babylon.js. On the other hand, A-Frame provides a nice component-based architecture, which is really handy when developing bigger projects, but it lacks the rest of the previously mentioned features. For example:

  • Performance: Dealing with the DOM implies overhead. Although we have been building A-Frame applications with good performance, this could be done by breaking the API contract, for example by accessing the values of the components directly instead of using setAttribute/getAttribute. This can lead to some unwanted side effects, such as incompatibility between components and a lack of reactive behavior.
  • Predictable: Dealing with asynchrony because of the DOM life cycle or the events’ callbacks when modifying attributes makes the code really hard to debug and to trace.
  • Graphics engine agnostic: Currently A-Frame and its components are so strongly tied to Three.js that it makes no sense to change it to any other engine.

After analyzing these points, gathering our experience with three.js and A-Frame, and looking at the state of the art on game engines like Unity, we decided to work on building this new framework using a pure Entity Component System architecture. The difference between a pure ECS like Unity DOTS, entt, or Entitas, and a more object oriented approach, such as Unity’s MonoBehaviour or A-Frame's Components, is that in the latter the components and systems both have logic and data, while with a pure ECS approach components just have data (without logic) and the logic resides in the systems.

So based on that research I decided to start working on a prototype which later become ECSY.

Focusing on building a simple core for this new framework helps iterate faster when developing new applications and lets us implement new features on top of it as needed. It also allows us to use it with existing libraries as three.js, Babylon.js, Phaser, PixiJS, interacting directly with the DOM, Canvas or WebGL APIs, or prototype around new APIs as WebGPU, WebAssembly or WebWorkers.

Stacks Technology stack examples

Architecture

We decided to use a data-oriented architecture as we noticed that having data and logic separated helps us better think about the structure of our applications. This also allows us to work internally on optimizations, for example how to store and access this data or how to get the advantage of parallelism for the logic.

The terms you must know in order to work with our framework are mostly the same as any other ECS:

  • Entities: Empty objects with unique IDs that can have multiple components attached to it.
  • Components: Different facets of an entity. ex: geometry, physics, hit points. Components only store data.
  • Systems: Where the logic is. They do the actual work by processing entities and modifying their components. They are data processors, basically.
  • Queries: Used by systems to determine which entities they are interested in, based on the components the entities own.
  • World: A container for entities, components, systems, and queries.

ECSY-Architecture ECSY Architecture

Example

So far all the information has been quite abstract, so let’s dig into a simple example to see how the API feels.

The example will consist of boxes and circles moving around the screen, nothing fancy but enough to understand how the API works.

We will start by defining components that will be attached to the entities in our application:

  • Position: The position of the entity on the screen.
  • Velocity: The speed and direction in which the entity moves.
  • Shape: The type of shape the entity has: circle or box. Now we will define the systems that will hold the logic in our application:
  • MovableSystem: It will look for entities that have speed and position and it will update their position component.
  • RendererSystem: It will paint the shapes at their current position.

Circles and balls example Circles and balls example design

Below is the code for the example described, some parts have been omitted to abbreviate (Check the full source code on Github or Glitch)


We start by defining the components we will be using:

// Velocity component
class Velocity {
constructor() {
this.x = this.y = 0;
}
}

// Position component
class Position {
constructor() {
this.x = this.y = 0;
}
}

// Shape component
class Shape {
constructor() {
this.primitive = 'box';
}
}

// Renderable component
class Renderable extends TagComponent {}

Now we implement the two systems our example will use:

// MovableSystem
class MovableSystem extends System {
// This method will get called on every frame by default
execute(delta, time) {
// Iterate through all the entities on the query
this.queries.moving.results.forEach(entity => {
var velocity = entity.getComponent(Velocity);
var position = entity.getMutableComponent(Position);
position.x += velocity.x * delta;
position.y += velocity.y * delta;

if (position.x > canvasWidth + SHAPE_HALF_SIZE) position.x = - SHAPE_HALF_SIZE;
if (position.x < - SHAPE_HALF_SIZE) position.x = canvasWidth + SHAPE_HALF_SIZE;
if (position.y > canvasHeight + SHAPE_HALF_SIZE) position.y = - SHAPE_HALF_SIZE;
if (position.y < - SHAPE_HALF_SIZE) position.y = canvasHeight + SHAPE_HALF_SIZE;
});
}
}

// Define a query of entities that have "Velocity" and "Position" components
MovableSystem.queries = {
moving: {
components: [Velocity, Position]
}
}

// RendererSystem
class RendererSystem extends System {
// This method will get called on every frame by default
execute(delta, time) {

ctx.globalAlpha = 1;
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvasWidth, canvasHeight);

// Iterate through all the entities on the query
this.queries.renderables.results.forEach(entity => {
var shape = entity.getComponent(Shape);
var position = entity.getComponent(Position);
if (shape.primitive === 'box') {
this.drawBox(position);
} else {
this.drawCircle(position);
}
});
}
// drawCircle and drawCircle hidden for simplification
}

// Define a query of entities that have "Renderable" and "Shape" components
RendererSystem.queries = {
renderables: { components: [Renderable, Shape] }
}

We create a world and register the systems that it will use:

var world = new World();
world
.registerSystem(MovableSystem)
.registerSystem(RendererSystem);

We create some entities with random position, speed, and shape.

for (let i = 0; i < NUM_ELEMENTS; i++) {
world
.createEntity()
.addComponent(Velocity, getRandomVelocity())
.addComponent(Shape, getRandomShape())
.addComponent(Position, getRandomPosition())
.addComponent(Renderable)
}

Finally, we just have to update it on each frame:

function run() {
// Compute delta and elapsed time
var time = performance.now();
var delta = time - lastTime;

// Run all the systems
world.execute(delta, time);

lastTime = time;
requestAnimationFrame(run);
}

var lastTime = performance.now();
run();

Features

The main features that the framework currently has are:

  • Engine/framework agnostic: You can use ECSY directly with whichever 2D or 3D engine you are already used to. We have provided some simple examples for Babylon.js, three.js, and 2D canvas. To make things even easier, we plan to release a set of bindings and helper components for the most commonly used engines, starting with three.js.
  • Focused on providing a simple, yet efficient API: We want to make sure to keep the API surface as small as possible, so that the core remains simple and is easy to maintain and optimize. More advanced features can be layered on top, rather than being baked into the core.
  • Designed to avoid garbage collection as much as possible: It will try to use pools for entities and components whenever possible, so objects won’t be allocated when adding new entities or components to the world.
  • Systems, entities, and components are scoped in a “world” instance: It means that we don’t register the components or systems on the global scope, allowing you to have multiple worlds or apps running on the same page without interferences between them.
  • Multiple queries per system: You can define as many queries per system as you want.
  • Reactive support:
    • Systems can react to changes in the entities and components
    • Systems can get mutable or immutable references to components on an entity.
  • Predictable:
    • Systems will always run in the order they were registered or based on a priority attribute.
    • Reactive events won’t generate “random” callbacks when emitted. Instead they will be queued and processed in order, when the listener systems are executed.
  • Modern Javascript: ES6, classes, modules

Developer tools

While working on ECSY I needed a way to debug how the framework was performing so I started building the ECSY developer tools extension) which has proven to be very useful for learning and debugging the framework.

A-Painter

Benchmarks

When building a framework like ECSY that is expected to be doing a lot of operations per frame, it’s hard to infer how a change in the implementation will impact the overall performance. So I was working on a benchmark command to run a set of tests and measure how long they take. It's based on a lite benchmark framework I created called benchmarker.js

It is specially interesting to use the generated data to compare across different branches and get an estimation on how much specific changes are affecting the performance.

Demos

Some basic examples are provided in the ECSY's website.

We were working also on building more complex examples that can be found in the following links:

Hello WebXR

Jumpy Balls

Jumpy Balls

Community

Definitely what I am most proud of is how the community has been helping us build ECSY and the projects that have emerged out of them.

There are a bunch of cool experiments around physics, networking, games, and boilerplates and bindings for pixijs, phaser, babylon, three.js, react, and many more!

It’s been especially useful for us to open a Discord where a lot of interesting discussions both ECSY or ECS as a paradigm have happened.