DEV Community

Cover image for Three.js Architecture: ECS
Ivan Babkov
Ivan Babkov

Posted on

Three.js Architecture: ECS

Over the last decade of my career, I’ve seen too many Three.js projects collapse, becoming impossible to maintain as they scale. In practice, it means that logic for input, physics, and rendering gets tangled up in a single requestAnimationFrame loop, making the codebase fragile. Sure, adding helper classes separates the logic for a while, but the structure eventually breaks down as feature requirements grow.

Most of the time, I've found the problem is less about the library we use and more about how we architect our applications and enforce the architecture. Strangely, developers often abandon the predictable architecture patterns they use in frontend/backend/native apps when they enter a 3D field. They default to deep inheritance chains and/or massive "God Objects". In this series of articles, I want to explore different solutions to this problem.

This article covers building a Three.js application using the Entity Component System (ECS) architecture. This isn't just a different way to organize files. It specifically enforces composition over inheritance and keeps concerns separated. ECS usually stores data in flat arrays to optimize memory access and allows us to swap features without rewriting the core loop.

🔗 The Final Result: GitHubDemo


The ECS Pattern

ECS stands for Entity, Component, and System. It is the standard for many high-performance game engines and is equally effective for complex web apps.

Entities: These are global identifiers (Integers). They contain no logic and no data. An entity represents a general-purpose object (e.g., Cube, 3D model, camera, etc.).

Components(Traits): These are raw data containers. For example, Position stores coordinates, and Velocity stores a vector. They contain no logic.

Systems: These are the functions that execute logic on subsets of data. A MovementSystem queries entities with Position and Velocity, then transforms them.

It separates the application state from the Three.js scene graph. You can add a new behavior by creating a new system without touching your existing code. Testing becomes a matter of validating pure functions against static data.


The Project

We are building a TypeScript application where users rotate objects by dragging the pointer. We will move from a basic setup to a functional 3D scene. Along the way, we will explore the pros and cons of ECS and when to use it.


Bootstrapping our app

We start with a standard Three.js boilerplate. This includes a renderer, a scene, some lights, and an environment texture. I’ll keep it minimalistic because diving deeper into Three.js isn’t a goal of this article. We are here to learn more about architecture, particularly the ECS.

import * as THREE from "three";
import { HDRLoader } from "three/examples/jsm/loaders/HDRLoader";

function createScene() {
  // Prepare our Renderer, Scene, and Camera
  const renderer = new THREE.WebGLRenderer({
    antialias: true,
    powerPreference: 'high-performance',
  });
  renderer.setSize(window.innerWidth, window.innerHeight);
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  const scene = new THREE.Scene();
  const aspect = window.innerWidth / window.innerHeight;
  const camera = new THREE.PerspectiveCamera(70, aspect, 0.1, 100);
  camera.position.z = 5;

  // Set up a few lights to make our future objects visible
  const ambient = new THREE.AmbientLight(0xffffff, 2);
  const directionalLight = new THREE.DirectionalLight(0xffffff, 2);
  directionalLight.position.set(2, 2, 2);
  scene.add(ambient);
  scene.add(directionalLight);

  // Set a background and add a texture for reflections
  const envUrl = new URL("./assets/env_1k.hdr", import.meta.url);
  new HDRLoader().load(envUrl.toString(), (texture) => {
    texture.mapping = THREE.EquirectangularReflectionMapping;
    scene.environment = texture;
  });
  scene.background = new THREE.Color(0x0d1117);

  document.querySelector("#app")!.appendChild(renderer.domElement);

  return { scene, camera, renderer };
}

function createApp() {
  const { scene, camera, renderer } = createScene();

  function update() {
    renderer.render(scene, camera);
    requestAnimationFrame(update);
  }
  requestAnimationFrame(update);
}

createApp();

Enter fullscreen mode Exit fullscreen mode

Setting up the World

Now we have to choose which ECS implementation to use. For the purposes of this article, let’s choose an existing library, since building an optimized entity query system from scratch is out of scope. I think Koota is a good candidate - it's a modern library with a big community (pmndrs), and its functional design avoids the class-heavy boilerplate of traditional ECS frameworks.

According to the ECS philosophy, every non-static object in the scene should be represented by an entity (simple ID). But in order to create new entities, we need a container to store them. We call this container world.

// file: src/traits/world.ts

import { createWorld } from 'koota';

const world = createWorld();
export { world };

Enter fullscreen mode Exit fullscreen mode

Adding 3D objects

Standard Three.js implementations often involve creating classes that extend THREE.Mesh or THREE.Object3D. This couples the logic directly to the visual object. This is an inheritance-based design.

In ECS, we favor composition over inheritance by decoupling state (Components) from logic (Systems), using Entities only as unique identifiers. We use Entities as unique IDs and Components as pure data containers. A System is then responsible for the logic, such as spawning these objects into the scene. This separation ensures that our rendering and business logic remain decoupled and easy to extend.

We define a MeshRef trait (Koota's term for component) to hold the Three.js mesh instance.

// file: src/traits/mesh.ts

import * as THREE from 'three';
import { trait } from 'koota';

export const MeshRef = trait(() => new THREE.Mesh());

Enter fullscreen mode Exit fullscreen mode

In Three.js, we nest objects to create parent-child relationships. In ECS, we use relations to define this hierarchy. To link objects and query children based on their parents, we use a ChildOf Relation. This allows systems to handle hierarchy without embedding behavior directly into the entities.

// file: src/traits/world.ts

import { createWorld } from "koota";

const world = createWorld();
export { world };

Enter fullscreen mode Exit fullscreen mode

Now we need a place to handle our spawning logic. In ECS, it’s the system’s responsibility. We can run a system once, twice, or every frame - it doesn’t matter. The major restriction - every system should operate on ECS data. So let’s create a spawnSystem. The spawnSystem is the function that actually puts our objects into the world using the world.spawn method from koota. It creates Three.js geometries, spawns entities with their associated traits, establishes a hierarchy based on our relations, and uses MeshRef as a bridge between logic and rendering.

// file: src/systems/spawnSystem.ts

import * as THREE from "three";

import { world } from "../traits/world";
import * as MeshTraits from "../traits/mesh";
import * as GlobalTraits from "../traits/global";

export function spawnSystem(scene: THREE.Scene) {
  // Create the visual representation of a cube and a torus
  const cubeMesh = new THREE.Mesh(
    new THREE.BoxGeometry(1.5, 1.5, 1.5),
    new THREE.MeshBasicMaterial({ color: 0x44aaff, wireframe: true }),
  );

  const torusMesh = new THREE.Mesh(
    new THREE.TorusKnotGeometry(0.3, 0.08, 128, 16),
    new THREE.MeshStandardMaterial({
      color: 0x4e8ab3,
      metalness: 1,
      roughness: 0.1,
    }),
  );

  // Spawn entities and attach traits
  const cubeEntity = world.spawn(MeshTraits.MeshRef(cubeMesh));
  world.spawn(
    MeshTraits.MeshRef(torusMesh),
    GlobalTraits.ChildOf(cubeEntity),
  );

  // Add the meshes to our three.js scene
  cubeMesh.add(torusMesh);
  scene.add(cubeMesh);
}

Enter fullscreen mode Exit fullscreen mode

To make these objects appear, we execute spawnSystem once during initialization.

// file: src/index.ts

// ... existing imports
import { spawnSystem } from './systems/spawnSystem';

function createScene() {
 // ... createScene function remains the same
}

function createApp() {
 const { scene, camera, renderer } = createScene();

 // We execute the spawn system once to initialize our entities.
 spawnSystem(scene);

 function update() {
 // ... update function remains the same
 }
 requestAnimationFrame(update);
}

Enter fullscreen mode Exit fullscreen mode

The cube and torus are now visible:

Three.js Architecture: ECS / Basic 3D Visualization


Bringing Interactivity

With static objects rendered, the next step is adding motion. The traditional inheritance approach often attaches event listeners directly inside the component class, coupling interaction logic to visual objects. In contrast, ECS treats input as pure data. Since there is only one pointer, we store its state globally. Any system can read it, eliminating the need for prop drilling.

Let’s define an Input trait for normalized coordinates and a Rotation trait to track orientation.

// file: src/traits/global.ts

import { trait, relation } from 'koota';

export const ChildOf = relation();
// Input is a singleton because we only have one pointer
export const Input = trait({ x: 0, y: 0, deltaX: 0, deltaY: 0 });

Enter fullscreen mode Exit fullscreen mode
// file: src/traits/mesh.ts

import * as THREE from "three";
import { trait } from "koota";

export const MeshRef = trait(() => new THREE.Mesh());
export const Rotation = trait({ x: 0, y: 0, z: 0 });

Enter fullscreen mode Exit fullscreen mode

We have the data structures, and now we need a system to populate them. The new inputSystem adds an Input singleton trait to the world, listens to DOM events, and updates the trait.

The event handler retrieves the current Input state via world.get to calculate deltas from previous coordinates. world.set pushes the new values back to the store, making changes immediately visible to other systems.

// file: src/systems/inputSystem.ts

import type * as THREE from 'three';
import { world } from '../traits/world';
import * as GlobalTraits from '../traits/global';

export function inputSystem(renderer: THREE.WebGLRenderer) {
  const calculatePointer = (e: PointerEvent) => ({
    x: (e.clientX / window.innerWidth) * 2 - 1,
    y: -(e.clientY / window.innerHeight) * 2 + 1,
  });

  const handlePointerMove = (e: PointerEvent) => {
    if (!e.isPrimary) return;

    const { x, y } = calculatePointer(e);

    world.set(GlobalTraits.Input, { x, y });
  };

  world.add(GlobalTraits.Input);

  renderer.domElement.addEventListener(
    'pointermove',
    handlePointerMove,
  );
}

Enter fullscreen mode Exit fullscreen mode

With the input data in place, we use it to rotate the objects. The movementSystem reads the Input and updates the Rotation trait. It uses world.onChange to create a reactive subscription, triggering only on data changes. It avoids wasted CPU cycles polling for static pointers.

We will apply different logic based on relations. The parent cube rotates on the Y axis while the child torus rotates on the X axis. The callback uses world.query rather than world.get. Note the distinction: get retrieves specific entities by ID, while query operates on all entities that match the criteria. Here, we want to find everything with the Rotation trait. The system functions regardless of whether the entity is a cube or a torus. Any entity with Rotation is updated.

I would like to highlight that this system focuses entirely on changing the rotation based on the input. ECS mandates that each system has a single responsibility. This enforces separation of concerns, ensuring high scalability and maintainability.

// file: src/systems/movementSystem.ts

import { world } from '../traits/world';
import * as MeshTraits from '../traits/mesh';
import * as GlobalTraits from '../traits/global';

export function movementSystem() {
  // Reactively run when Input changes
  world.onChange(GlobalTraits.Input, () => {
    const handleMovement = (
      [rotation]: [typeof MeshTraits.Rotation['schema']],
      e: Entity,
    ) => {
      const { x, y } = world.get(GlobalTraits.Input)!;

      if (Boolean(e.targetFor(GlobalTraits.ChildOf))) {
        // Rotate the torus horizontally
        rotation.x = y * Math.PI;
      } else {
        // Rotate the cube vertically
        rotation.y = x * Math.PI;
      }
    };

    world
      .query(MeshTraits.MeshRef, MeshTraits.Rotation)
      .select(MeshTraits.Rotation)
      .updateEach(handleMovement);
  });
}

Enter fullscreen mode Exit fullscreen mode

The next step is syncing ECS data with Three.js. The new transformSystem runs every frame, using world.query to locate entities with both MeshRef (visual) and Rotation (data).

This system has a distinct concern from the previous one. movementSystem handles the simulation (changing numbers), while transformSystem handles the side effects (updating the scene graph).

// file: src/systems/transformSystem.ts
import { world } from '../traits/world';
import * as MeshTraits from '../traits/mesh';

export function transformSystem() {
  world.query(MeshTraits.MeshRef, MeshTraits.Rotation)
    .updateEach(([mesh, rotation]) => {
      mesh.rotation.set(rotation.x, rotation.y, rotation.z);
    });
}

Enter fullscreen mode Exit fullscreen mode

The final step is wire everything up in our entry point. The entry point initializes the spawn, input, and movement systems once. The transform system runs inside the loop to sync the visual state.

// file: src/index.ts

// ... existing imports
import { inputSystem } from './systems/inputSystem';
import { movementSystem } from './systems/movementSystem';
import { transformSystem } from './systems/transformSystem';

function createScene() {
 // ... createScene function remains the same
}`

function createApp() {
  const { scene, camera, renderer } = createScene();

  // Initialize Systems
  inputSystem(renderer);
  spawnSystem(scene);
  movementSystem();

  function update() {
    transformSystem(); // Sync ECS data to Three.js objects
    renderer.render(scene, camera);
    requestAnimationFrame(update);
  }
  requestAnimationFrame(update);
}

Enter fullscreen mode Exit fullscreen mode

Now, let’s check the final result:
Three.js Architecture: ECS / Final 3D Visualization

🔗 GitHubDemo


Final File Structure

src/
├── systems/
│   ├── inputSystem.ts
│   ├── movementSystem.ts
│   ├── spawnSystem.ts
│   └── transformSystem.ts
├── traits/
│   ├── global.ts
│   ├── mesh.ts
│   └── world.ts
└── index.ts

Enter fullscreen mode Exit fullscreen mode

Benchmarks

Now let’s validate the results for different numbers of objects. For this purpose, I have to use an InstancedMesh istead of the regular Mesh, which is outside the scope of the article. The architecture remains the same, but high-object-count scenes require InstancedMesh for the rendering layer to keep up with the ECS logic. Performance profiling over a 10-second interval on a Mac M1 Pro (Safari) yields the following results:

Metric 1 torus + 
1 cube
10 tori + 
10 cubes
100 tori + 
100 cubes
FPS (Avg/Min/Max) 120 120 120
Input > Render Latency (Avg) 0.67 ms 0.33 0.9
Input > Render Latency (Max) 2 ms 1.9ms 2.7ms
Memory Footprint 17 MB 17 MB 17 MB

The results confirm that the overhead associated with the abstraction layers of Koota and ECS is negligible at this scale. The application maintains a stable 120 FPS with sub-millisecond input latency. The memory footprint remains a flat 17 MB across 1, 20, and 200 entities due to InstancedMesh implementation.


Beyond Basic ECS

While the functional approach with Koota is efficient, complex production applications often require more granular control over the system architecture:

  • System Groups: Grouping systems (e.g., PhysicsGroup, RenderGroup) dictates when logic blocks execute.
  • Execution Order: Priorities are strictly ordered in the pipeline: Input > Movement > Rendering.
  • Lifecycle Methods: Class-based ECS implementations can support init(), update(), and destroy() methods for each system/group for precise resource management.

Conclusion

We moved beyond a simple demo. By abandoning standard inheritance in favor of an Entity Component System (ECS) architecture, we gained a codebase where logic and rendering exist in separate, testable units.

Benchmarks indicate that this structure incurs almost no performance penalty, maintaining stable frame rates and sub-millisecond latency.

Pros

  • Strict Structure: The architecture enforces a strict structure, preventing logic-rendering coupling.
  • Separation of Concerns: Systems remain isolated. Modifying one leaves others intact.
  • Testability: Testing pure functions against static data is simpler than testing stateful classes.
  • Performance: The abstraction layer is lightweight, creating efficient data pipeline processing.

Cons

  • Paradigm Shift: Requires shifting from Object-Oriented to Data-Oriented thinking.
  • Indirectness: Execution flow is implicit. Debugging involves tracing data changes rather than the call stack.
  • Setup Cost: The initial boilerplate (world, queries, systems) is substantial for simple scenes.

What’s Next

ECS is a powerful tool, but it is not the only valid architecture for Three.js. In future articles of this series, we'll explore alternative patterns that offer different trade-offs for web applications.


About the Author
Ivan Babkov - Ivan Babkov - 2D/3D Graphics Software Engineer from Canada, building interactive web experiences | 3D, WebGL, GLTF, Three.js

LinkedInPortfolioGitHub

Top comments (0)