GAME DEV
REACT
ECS
R3F
THREEJS

Simplifying React Three Fiber with Entity Component System

· Michael Dougall · 4 min read

So this is a bit of a short and simple one but I wanted to finish the year with one last blog post about how I used Entity Component System (ECS) to simplify my camera system in FAZE, a point & click game I'm hacking on as a side project.

If this is your first time hearing ECS, well, think of it as both as a design pattern akin to Model-View-Controller that constrains the code we write to promote flexibility and better performance, and libraries that implement the pattern. I'm using miniplex by Hendrik, you should check it out. On the tin ECS is a performant compositional alternative to inheritance for extending behavior but in a React world where we already lean on composition to compose components together I find its value props to be:

  • simplify how code that interacts with multiple areas is wired up, such as a camera system
  • enable systems that need to operate on all world entities, such as collision detection

In this post I'll talk through how I implemented a camera system using idiomatic React patterns and how I refactored it to use ECS.

Idiomatic React

Initially FAZE didn't use ECS at all, relying instead on idiomatic React patterns (state and context). For the camera system I had one primary goal: that the camera should smoothly transition from one entity to another. To accomplish this I figured it made the most sense to use a single camera and then lean on React context for the wiring up.

Using a single top level camera provider and child camera target component to mark entities that can be focused. The following code is slightly simplified but hopefully you get the picture.

const TargetContext = createContext();

function FollowingCamera({ children }) {
  const ref = useRef();
  // Hold an array for all focused targets.
  const [targets] = useState([]);
  // The last element is considered the target.
  const [target] = targets.at(-1);

  useFrame((_, delta) => {
    // Every frame damp towards the target position.
    damp(ref.current.position, target.position, 3, delta);
  });

  return (
    <TargetContext.Provider value={targets}>
      <PerspectiveCamera ref={ref} />
      {children}
    </TargetContext.Provider>
  );
}
The following camera uses state and context and moves the camera towards the target every frame.
function CameraTarget({ children, disabled }) {
  const targets = useContext(TargetContext);
  const ref = useRef(null);

  useLayoutEffect(() => {
    // On first effect when enabled add the target to the provider.
    if (disabled) {
      return;
    }

    // Traverse through children until we find a mesh with "camera-target" as its name.
    const target = findTarget(ref.current.children);
    targets.push(target);

    return () => {
      // On last effect remove the target from the provider.
      targets.splice(targets.indexOf(data), 1);
    };
  }, [disabled, targets, zoom]);

  return <group ref={ref}>{children}</group>;
}
The camera target finds and adds the target to the following camera on mount and removes on unmount.
function PlayerEntity({ position }) {
  return (
    // The player is always focused.
    <CameraTarget>
      <mesh position={position} name="camera-target">
        <boxGeometry args={[1, 1, 1]} />
      </mesh>
    </CameraTarget>
  );
}
The player entity wraps itself with the camera target and names the target with "camera-target", it is always focused.
function NPCEntity({ position }) {
  // NPCs are conditionally focused depending on some state.
  const [focused] = useState(false);

  return (
    <CameraTarget disabled={!focused}>
      <mesh position={position} name="camera-target">
        <boxGeometry args={[1, 1, 1]} />
      </mesh>
    </CameraTarget>
  );
}
The npc entity wraps itself with the camera target and names the target with "camera-target", it is conditionally focused.
<FollowingCamera>
  <PlayerEntity />
  <NPCEntity />
</FollowingCamera>

The main point here is that there is a lot of wiring up, and some leaky abstractions - namely meshes needing the name "camera-target". We can't just get the top most child of the CameraTarget component and call it done - we need the mesh that has its position set else the target will always have [0,0,0] world coordinates!

Refactor to ECS

Initially I moved to ECS as I needed to implement collision detection and a basic physics system in FAZE and for this there needed to be a single place that owns the current world state, lest it become unwieldy. Afterwards I wanted to see what it would take for other systems to move to ECS and the camera was something I was keen to try out.

For the camera system we're interested in a few components:

  1. sceneObject — the Object3D that is to be operated on, e.g. have its position updated
  2. focused — the entity is currently focused
  3. camera — the entity is a camera

This is why ECS is considered declarative, we can add any number of components to an entity to enable behavior. With miniplex this comes down to declaring a few components (...on the Component, component). Entities now become simple declarations describing what it is:

function CameraEntity() {
  return (
    <Entity>
      <Component name="camera" data={true} />
      <Component name="sceneObject">
        <PerspectiveCamera />
      </Component>
    </Entity>
  );
}
The camera entity declares the camera tag, and scene object component.
function PlayerEntity({ position }) {
  return (
    <Entity>
      {/* The player is always focused. */}
      <Component name="focused" data={true} />
      <Component name="sceneObject">
        <mesh position={position}>
          <boxGeometry args={[1, 1, 1]} />
        </mesh>
      </Component>
    </Entity>
  );
}
The player entity declares the focused tag, and scene object component.
function NPCEntity({ position }) {
  const [focused] = useState(false);

  return (
    <Entity>
      {/* NPCs are conditionally focused depending on some state. */}
      {focused && <Component name="focused" data={true} />}
      <Component name="sceneObject">
        <mesh position={position}>
          <boxGeometry args={[1, 1, 1]} />
        </mesh>
      </Component>
    </Entity>
  );
}
The npc entity conditionally declares the focused tag, and scene object component.
<CameraEntity />
<PlayerEntity />
<NPCEntity />
Here we've used two kinds of components according to ECS. An entity component is used to hold arbitrary data while an entity tag is used to categorize entities. Miniplex doesn't currently differentiate between them.

Now it's just a matter of writing some systems (functions) to act on the world, thankfully miniplex provides hooks for this. Hooks in, hooks out!

function useCamera() {
  const { entities: focused } = useEntities(world.with('focused', 'sceneObject'));
  const { entities: cameras } = useEntities(world.with('camera', 'sceneObject'));

  // The first element is considered the primary camera.
  const camera = cameras[0];
  // The last element is considered the target.
  const target = focused.at(-1);

  useFrame((_, delta) => {
    // Every frame damp towards the target position.
    damp(camera.sceneObject.position, target.sceneObject.position, 3, delta);
  });
}
The camera system moves the first camera entity towards the last focused entity every frame.

We define a component to house our systems. If this component is mounted the world ticks along one frame at a time, if it isn't mounted the world is frozen in time.

function WorldSystems() {
  useCamera();
  return null;
}
<WorldSystems />

Comparing the ECS implementation to the idiomatic React implementation you can see the separation of data and behavior being very effective. We also got rid of the leaky abstraction now as each entity declares the scene object that is to be acted on.

I'm a big fan of this pattern and would love to hear if you've started using it too, why not join Web Game Dev's discord and share? Hope to see you soon, have a happy new year!

Don't miss a post!

Join others and get notified early when new content is available unsubscribe at any time.