Lição 18 - Refatorar com ECS
Decidi refatorar neste ponto. A partir desta lição, vamos implementar os dois pacotes npm a seguir:
- @infinite-canvas-tutorial/ecs Implementa ECS com Becsy e inclui Plugins, Components e Systems embutidos.
- @infinite-canvas-tutorial/webcomponents Implementa interfaces com base no Spectrum, substituindo o Shoelace usado na Lição 7. Não se preocupe: os dois se baseiam na implementação do Lit.
No momento, usamos TypeScript Mixins para implementar componentes, mas essa abordagem baseada em herança tem problemas evidentes: classes de componentes em camadas são difíceis de manter:
// Current approach
export const Shape = Shapable(Renderable(Sortable(Transformable(EventTarget))));
const shape = new Shape();
// ECS approach
const shape = world.spawn(Renderable, Sortable, Transformable);O ECS lida bem com composição e permite ampliar, com flexibilidade, as capacidades da entidade conforme a necessidade, como destaca Entity-Component-System in A-Frame.
Os benefícios do ECS incluem:
- Maior flexibilidade ao definir objetos, misturando e combinando partes reutilizáveis.
- Elimina os problemas de longas cadeias de herança com funcionalidade complexa entrelaçada.
O A-Frame é um framework conhecido de Three.js, com um uso declarativo de ECS que impressiona. Em motores de jogos, o ECS é usado de forma mais ampla, como em ECS for Unity e Bevy ECS, que são a principal referência da nossa implementação.
O que é a arquitetura ECS
ecs-faq reúne perguntas comuns, recursos e implementações em várias linguagens, e vale a pena consultar. Aqui citamos a introdução de Bevy ECS:
Toda a lógica do aplicativo no Bevy usa o paradigma Entity Component System, frequentemente abreviado como ECS. ECS é um padrão de software que consiste em dividir o programa em Entities, Components e Systems. Entities são "coisas" únicas às quais se atribuem grupos de Components, que então são processados por Systems.
O diagrama a seguir, de ECSY Architecture, mostra visualmente a relação entre essas partes:
A relação entre Entity e Component pode ser entendida a partir da visão de uma tabela de banco de dados relacional, em que o primeiro corresponde às linhas de dados e o segundo aos campos:

Do ponto de vista de quem chama, é mais fácil entender o princípio de Composition over inheritance. Ao mesmo tempo, dá para ver que Entity e Component não contêm lógica de processamento específica, apenas dados associados.
commands.spawn(Position(10, 20), Color(255, 0, 0)); // Entity #1
commands.spawn(Position(30, 40), Color(0, 255, 0)); // Entity #2Cada System seleciona, por meio de uma Query, uma lista de Entities com os Components específicos de que se importa, comparável a usar SQL para consultar uma tabela de dados:
// @see https://bevyengine.org/learn/quick-start/getting-started/ecs/#your-first-query
fn print_position_system(query: Query<&Position>) {
for position in &query {
println!("position: {} {}", position.x, position.y);
}
}De uma perspectiva global, a aplicação inteira é dividida em vários Systems:
Em aplicações reais, e não em demos simples, surgem as questões a seguir, que vamos detalhar mais adiante e que formam a base da nossa escolha de implementação de ECS:
- Como construir uma Query complexa com uma sintaxe intuitiva e amigável?
- Como controlar a ordem de execução de vários Systems?
- Como os Systems se comunicam entre si?
Vamos primeiro olhar alguns complementos do ECS.
Plugins
No Capítulo 2 Plugin System, usamos essa abordagem para demonstrar alta coesão e melhorar a extensibilidade. Bevy Plugins também usa uma abordagem parecida para organizar a funcionalidade embutida, e cada Plugin contém Components e Systems. Os usuários também podem desenvolver os próprios plugins dessa forma:
Um dos princípios centrais do Bevy é a modularidade. Todos os recursos do motor Bevy são implementados como plugins: coleções de código que modificam um App.
Na prática, é fácil estender:
fn main() {
App::new()
.add_plugins(DefaultPlugins) // Default plugin set provided by the official team
.add_systems(Startup, add_people)
.add_systems(Update, (hello_world, (update_people, greet_people).chain()))
.run();
}No nosso projeto, os plugins embutidos ficam em /plugins e são expostos como uma coleção de plugins por meio de DefaultPlugins, que pode ser usada no momento da inicialização. É claro que você também pode personalizar plugins para estendê-los:
import { App, DefaultPlugins } from '@infinite-canvas-tutorial/ecs';
new App().addPlugins(...DefaultPlugins, MyPlugin).run();Recursos
Já apresentamos como as combinações de Entity e Component formam uma estrutura parecida com uma tabela de dados, mas as aplicações inevitavelmente usam alguns objetos globalmente únicos. Veja: Bevy Resources. Na implementação seguinte, vamos usar o conceito de singleton.
Implementação de ECS no frontend
Há várias implementações de ECS prontas para uso no frontend. Embora eu goste muito dos diagramas da documentação de ECSY Architecture (que referenciei acima), percebi que o ECSY não serve para o projeto atual. Depois de comparar koota e Becsy, decidi usar o segundo no projeto. A seguir, gostaria de apresentar alguns recursos que me interessam.
| Recurso | Becsy | koota | ECSY | bitECS |
|---|---|---|---|---|
| Modificadores de Query | ✅ | ✅ | ❌ | ❌ |
| Query reativa | ✅ | ✅ | ✅ | ✅ |
| Ordem de execução dos Systems | ✅ | ❌ | ✅ | ✅ |
| Comunicação entre Systems | ✅ | ❌ | ❌ | ❌ |
Modificadores de Query
A construção simples de Query tem suporte tanto no ECSY quanto no bitECS:
// https://ecsyjs.github.io/ecsy/docs/#/manual/Architecture?id=queries
SystemName.queries = {
boxes: { components: [Box] },
spheres: { components: [Sphere] },
};
// https://github.com/NateTheGreatt/bitECS/blob/master/docs/API.md#defineQuery
const movementQuery = defineQuery([Position, Velocity]);Então, o que é uma Query complexa? Em resumo, ela permite combinar várias condições de consulta por meio de modificadores como Not, Or e And. Por exemplo, em koota e Becsy:
// https://github.com/pmndrs/koota?tab=readme-ov-file#query-modifiers
const staticEntities = world.query(Position, Not(Velocity));
// https://lastolivegames.github.io/becsy/guide/architecture/queries#basic-query-syntax
private activeEnemies = this.query(
q => q.current.with(Enemy).and.withAny(stateEnum).but.without(Dead));Outro recurso importante é a Query reativa, que se atualiza automaticamente quando os Components de uma Entity são adicionados, modificados ou removidos.
// koota
const Added = createAdded();
const newPositions = world.query(Added(Position));
// https://lastolivegames.github.io/becsy/guide/architecture/queries#reactive-queries
private boxes = this.query(q => q.added.and.removed.with(Box, Transform));Ordem de execução dos Systems
No caso simples, o agendador do Becsy calcula a ordem final de execução com base na relação de leitura e escrita de cada System com o Component. Porém, se os dois Systems precisarem escrever no mesmo tipo de Component, então é preciso especificar explicitamente a ordem em que devem ser executados. Além das restrições before/after, também dá para usar algumas restrições mais intuitivas.
Por exemplo, queremos que o System ComputeCamera rode depois de todos os Systems que escrevem em Camera:
// CameraPlugin
system((s) => s.afterWritersOf(Camera))(ComputeCamera);Por fim, no modo de desenvolvimento, o Becsy imprime no console a ordem atual de execução dos systems e informa um erro se existir um ciclo.
Comunicação entre Systems
O ideal é que cada System precise se preocupar apenas com as próprias leituras e escritas em Components, mas Attaching systems nos dá a capacidade de referenciar outros Systems de forma mais direta:
export class ZoomLevel extends System {
private readonly cameraControl = this.attach(CameraControl);
}Vamos olhar um exemplo da conveniência do padrão de projeto ECS.
Hierarquia
Na Lição 3 apresentamos estruturas de árvore como grafos de cena, em que as estruturas pai-filho são mantidas na forma de referências, que são a base de propriedades como transformações calculadas, visibilidade em cascata e assim por diante:
export abstract class Shape {
parent: Shape;
readonly children: Shape[] = [];
}Já na arquitetura ECS, o armazenamento é linear, quer a camada inferior guarde as entidades na forma de AoS ou de SoA. Como representar essa estrutura hierárquica em uma estrutura de dados plana?

Definir componente
Hierarchical relationships in an Entity Component System apresenta várias abordagens:
- Entidades hierárquicas, também conhecidas como "grafo de cena com componentes". Guarda as relações hierárquicas nas entidades e percorre de cima para baixo, como de costume.
- Componentes hierárquicos. Guarda as relações hierárquicas nos componentes.
Escolhemos a segunda abordagem e a implementamos com a capacidade Referencing entities do Becsy:
import { Entity, field } from '@lastolivegames/becsy';
export class Parent {
@field.backrefs(Children, 'parent') declare children: Entity[];
}
export class Children {
@field.ref declare parent: Entity;
}Ao adicionar um nó filho, basta associar a entidade Parent no lado do Child, e a associação passa a valer do outro lado:
// addChild(parent, child)
if (!parent.has(Parent)) {
parent.add(Parent);
}
child.add(Children, {
parent,
});
// getChildren()
parent.read(Parent).children; // [child]O Bevy usa as novas ECS Relationships desde a versão 0.16:
commands.spawn(ChildOf(some_parent));
commands.entity(some_entity).insert(ChildOf(new_parent));Na nossa implementação, seguimos Bevy Hierarchy e encapsulamos EntityCommand sobre Entity para construir a relação pai-filho acima de um jeito mais amigável para o usuário. O AddChild contém a implementação acima:
export class EntityCommands {
appendChild(child: EntityCommands) {
this.commands.add(new AddChild(this.id(), child.id()));
return this;
}
}
parent.appendChild(child);Por fim, concluímos o registro dos dois Components no plugin:
import { component } from '@lastolivegames/becsy';
export const HierarchyPlugin: Plugin = () => {
component(Parent);
component(Children);
};Calcular a transformação de mundo
A sintaxe de Query do System é bem autoexplicativa. Por exemplo, aqui queremos selecionar todas as entidades que contêm Transform e Parent, e calcular e atualizar o GlobalTransform (dos nós filhos) quando eles são adicionados pela primeira vez e quando mudam.
import { System } from '@lastolivegames/becsy';
export class PropagateTransforms extends System {
queries = this.query(
(q) =>
q
.with(Transform, Parent)
.addedOrChanged.trackWrites.using(GlobalTransform).write,
);
}Responder a eventos
Se um System é chamado a cada quadro, como respondemos a eventos assíncronos?
Eventos do Bevy
Vamos olhar um exemplo de resposta a eventos de redimensionamento de janela no Bevy: window-resizing, usando um EventReader para ler os dados carregados pelo evento WindowResized:
fn on_resize_system(
mut text: Single<&mut Text, With<ResolutionText>>,
mut resize_reader: EventReader<WindowResized>,
) {
for e in resize_reader.read() {
// When resolution is being changed
}
}O evento WindowResized é escrito por um EventWriter:
// https://github.com/bevyengine/bevy/blob/main/crates/bevy_winit/src/state.rs#L945-L949
// window_resized: &mut EventWriter<WindowResized>,
window_resized.write(WindowResized {
window: window_entity,
width: window.width(),
height: window.height(),
});Dá para ver que o mecanismo de eventos facilita a comunicação entre systems. Veja os detalhes em Bevy Events.
Corrotinas do Becsy
Escute eventos pela API DOM no System EventWriter:
$el.addEventListener('pointerup', onPointerUp, true);
const onPointerUp = (e: PointerEvent) => {
this.setInputTrigger(input, 'pointerUpTrigger');
};Quando um evento é disparado, a flag correspondente é escrita no componente Input. Esse componente também registra o estado de disparo de outros eventos, o estado das teclas Ctrl e Shift e as coordenadas do evento. Esses estados serão usados nos comportamentos de interação seguintes, como clicar para selecionar formas, arrastar para selecionar formas e assim por diante:
export class Input {
@field.boolean declare pointerUpTrigger: boolean;
}Então, como garantir que o estado seja escrito apenas uma vez em um quadro? O Becsy oferece coroutines para tratar lógica que atravessa vários quadros, inclusive a capacidade de esperar um certo número de quadros ou segundos. Aqui usamos o uso mais básico:
@co private *setInputTrigger(entity: Entity, triggerKey: string): Generator {
const input = entity.write(Input);
if (!(triggerKey in input)) return;
Object.assign(input, { [triggerKey]: true });
yield;
{
const input = entity.hold().write(Input);
Object.assign(input, { [triggerKey]: false });
}
yield;
}Por fim, em systems como CameraControl, que precisam tratar eventos, dá para processar os eventos correspondentes no quadro atual por meio de flags:
execute() {
const input = canvas.write(Input);
if (input.pointerDownTrigger) {}
if (input.pointerUpTrigger) {}
}