3D game engine in web – part 1

Introduction

In this blog post series, I’m going to craft using various libs a small 3d game engine and possibly an actual game with it.

In this part, I’m going to create a simple entity-component system (basis of all modern games), then I’…



Introduction

In this blog post series, I’m going to craft using various libs a small 3d game engine and possibly an actual game with it.

In this part, I’m going to create a simple entity-component system (basis of all modern games), then I’ll make a basic scene using ThreeJs as graphic lib with some entities in it.



Entity Component System (ECS)

If you already know gamedev or played with Unity or any other commercial game engine you’ve already seen how easy it is to modify a game object by adding or removing a component.

Image description

But why do we even need such architecture? Can we just use simple OOP for this purpose instead?

Well yes, but actually no

An ECS is a great way to decouple the responsibilities of aspects of an entity. Without it, traditional OOP will block you in terms of flexibility and maintainability.



About chickens and robot chickens

Say you are making a game about chickens vs robot chickens (actually I need to save this idea…) and you need to model the abilities of both entities. Here is a table that summarizes the differences between those two:

Eat Detonate Move Attack
chicken X X
robot-chicken X X X

So you may be tempted to write something like this:

class Chicken {
    ...
    eat(foodValue: number) {
        this._hp += foodValue;
    }
    ...
}

class RobotChicken extends Chicken {
    ...        
    eat(foodValue: number) {
        throw new Error("Unsupported operation");
    }

    attack(target: Chicken) {
        target.applyDmg(this._dmg)
    }
    ...
}

But don’t, you broke the Liskov principle.

So lets cut our functionalities into multiple interfaces:

interface Living {
    isLiving(): boolean;
    applyDmg(dmg: number): void;
    applyHeal(heal: number): void;
}

interface AbleToEat {
    eat(foodValue: number): void;
}

class Chicken implements Living, AbleToEat{
    constructor(protected _hp: number) {
    }

    eat(foodValue: number) {
        this.applyHeal(foodValue);
    }

    applyDmg(dmg: number): void {
        this._hp -= dmg;
    }

    applyHeal(heal: number): void {
        this._hp += heal;
    }

    isLiving(): boolean {
        return this._hp > 0;
    }
}

class RobotChicken {

    constructor(private _dmg: number) {
    }

    attack(target: Living) {
        target.applyDmg(this._dmg)
    }
}

That’s better but still bad: If you need to change dynamically attributes of your chicken its gonna get messy. Say you need to implement times where your robots will get invincible, where you want to deactivate their IA, where you want to change their texture, etc. OFC you can write all this rules in the classes and interfaces like:

interface IAOnlyWhen {
    toggle(resolver: IAActivationResolver): void;
}

and then inject whatever IAActivationResolver you want in. Works but lack the ability to totally change behavior at runtime. What if we want to allow an IA controlled entity to be briefly controlled by the player?

With an ECS it would look something like this:

EntityManager.removeComponentForEntity("entit1", "GenericEnnemyIA");
EntityManager.addComponentForEntity("entity1", "PlayerControls");

It is my personal belief that you get more flexibility and maintainability that way.

Now lets implement it!



Actually writing the ECS

So we have components and entities… ANNNND we need to model all that! After reading this I’ve decided to create a use-case that would drive all the add/remove/update of the components and classes that would contain the instances of said components (like the TransformSystem would track which entities have a transform).

So the base of a component system (which I’ve named EntitySystem) would be like :

export abstract class EntitySystem<TType, TArg> {
    private _entities: Record<EntityId, TType> = {};

    abstract getIdentifier(): string;

    abstract createEntity(args: TArg, id: string, scene: SceneUseCase): TType;

    addEntity(args: TArg, id: string, scene: SceneUseCase): void {
        this._entities[id] = this.createEntity(args, id, scene);
    }

    hasEntity(id: EntityId) {
        return this._entities[id] !== undefined
    }

    get entities(): Record<EntityId, TType> {
        return this._entities;
    }
}

Notice that I’m passing the SceneUseCase as parameter to the createEntity method so i can query the scene for an other entity system. For instance if want to get the transform for an entity I would call

const transform = scene.getRefToExternalEntity<Transform>(TransformSystem.TYPE, id)

Now to implement the scene object that will hold all those entity systems:

export type EntitySystemIdentifier = string;

export class SceneUseCase {
    private _systems: Record<EntitySystemIdentifier, EntitySystem<any, any>> = {};

    constructor(private _idProvider: IdProviderPort) {
    }

    registerSystem(system: EntitySystem<any, any>) {
        if (this._systems[system.getIdentifier()] !== undefined) {
            throw new SceneValidationException(`[${system.getIdentifier()}] is already registered`);
        }
        this._systems[system.getIdentifier()] = system;
    }

    addEntityForSystem<TArg>(identifier: EntitySystemIdentifier, args: TArg, userId: string | undefined = undefined) {
        const id = userId ? userId : this._idProvider.next()
        if (this._systems[identifier] === undefined) {
            throw new SceneValidationException(`System [${identifier}] isn't registered, cannot add entity [${id}].`)
        }
        if (this._systems[identifier].hasEntity(id)) {
            throw new SceneValidationException(`Entity [${id}] for system [${identifier}] is already registered`)
        }
        this._systems[identifier].addEntity(args, id, this);
    }

    update(delta: number) {
        Object.values(this._systems).forEach(sys => {
                if (SceneUseCase.instanceOfUpdatable(sys)) {
                    sys.update(delta);
                }
            }
        )
    }

    get systems(): Record<EntitySystemIdentifier, EntitySystem<any, any>> {
        return this._systems;
    }

    private static instanceOfUpdatable(system: EntitySystem<any, any>): system is EntitySystem<any, any> & Updatable {
        return 'update' in system;
    }

    getRefToExternalEntity<TReturn>(identifier: EntitySystemIdentifier, id: EntityId): TReturn {
        return this._systems[identifier].entities[id];
    }
}

I don’t show it here but ofc I used the TDD sauce along the way so now I have:

Image description

Image description

Here are the tests btw

I can probably do better, by implementing heterogeneous lists or something to avoid theses nasty “any” but for now I deem it “GOOD ENOUGH”.



Adding ThreeJs and some basic entity systems

This is good and all but! We don’t display anything at the moment. So let’s get a basic ThreeJs scene going really quickly:

export class ThreeJSContext {
    readonly scene: THREE.Scene;
    readonly camera: THREE.PerspectiveCamera;
    readonly renderer: THREE.WebGLRenderer;
    readonly controls: OrbitControls;

    constructor() {
        this.scene = new THREE.Scene();
        this.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
        this.renderer = new THREE.WebGLRenderer();
        this.renderer.setSize(window.innerWidth, window.innerHeight);
        document.querySelector("#app")!.appendChild(this.renderer.domElement);

        // helpers
        this.scene.add(new THREE.AxesHelper(500));

        this.controls = new OrbitControls(this.camera, this.renderer.domElement);
        this.camera.position.set(0, 20, 50);
        this.controls.update();

        this.animate();
    }

    animate() {
        requestAnimationFrame(() => this.animate());
        this.controls.update();
        this.renderer.render(this.scene, this.camera);
    }
}

Of course we need an transform system before anything else:

export type Position = {
    x: number,
    y: number,
    z: number,
}

export type RotationQuaternion = {
    x: number,
    y: number,
    z: number,
    w: number,
}

export type Transform = {
    position: Position,
    rotation: RotationQuaternion,
}

export class TransformSystem extends EntitySystem<Transform, Transform> {
    static readonly TYPE = "TRANSFORM_SYS";

    getIdentifier(): string {
        return TransformSystem.TYPE;
    }

    createEntity(args: Transform): Transform {
        return args;
    }
}

It is really convenient to represent rotation as a quaternion. If you don’t know what it is they explain it really well here: https://eater.net/quaternions

So now we can add the ThreeJs sauce as an entity system to represent meshes in the scene:

export type ThreeJsArgs = {
    geometry: THREE.BufferGeometry,
    material: THREE.Material,
}

export type ThreeJsEntity = {
    mesh: THREE.Mesh,
    transform: Transform,
}

export class ThreeJsDynamicMeshSystem extends EntitySystem<ThreeJsEntity, ThreeJsArgs> implements Updatable {
    static readonly TYPE = "THREE_RENDERER_SYS";

    constructor(private _context: ThreeJSContext) {
        super();
    }

    getIdentifier(): string {
        return ThreeJsDynamicMeshSystem.TYPE;
    }

    createEntity(args: ThreeJsArgs, id: EntityId, scene: SceneUseCase): ThreeJsEntity {
        const mesh = new THREE.Mesh(args.geometry, args.material);
        const transform = scene.getRefToExternalEntity<Transform>(TransformSystem.TYPE, id);

        this._context.scene.add(mesh);
        return {
            mesh,
            transform,
        };
    }

    update(_: number): void {
        Object.values(this.entities).forEach(entity => {
            entity.mesh.position.x = entity.transform.position.x;
            entity.mesh.position.y = entity.transform.position.y;
            entity.mesh.position.z = entity.transform.position.z;

            entity.mesh.quaternion.x = entity.transform.rotation.x;
            entity.mesh.quaternion.y = entity.transform.rotation.y;
            entity.mesh.quaternion.z = entity.transform.rotation.z;
            entity.mesh.quaternion.w = entity.transform.rotation.w;
        });
    }
}

And now lets just bind everything together!

const idProvider = new UuidV4IdProviderPort();
const scene = new SceneUseCase(idProvider);

const threeJsContext = new ThreeJSContext();

scene.registerSystem(new TransformSystem());
scene.registerSystem(new ThreeJsDynamicMeshSystem(threeJsContext));
scene.registerSystem(new UpAndDownSinSystem());

// cube
const cubeId = "cube";
scene.addEntityForSystem<Transform>(TransformSystem.TYPE, {
    position: {x: 10, y: 10, z: 10,},
    rotation: {x: 0, y: 0, z: 0, w: 1,}
}, cubeId);

scene.addEntityForSystem<ThreeJsArgs>(ThreeJsDynamicMeshSystem.TYPE, {
    geometry: new BoxGeometry(5, 5, 5),
    material: new MeshBasicMaterial({color: 0xE6E1C5}),
}, cubeId);

// floor
const floorId = "floor1";
scene.addEntityForSystem<Transform>(TransformSystem.TYPE, {
    position: {x: 0, y: -5, z: 0,},
    rotation: {x: 0, y: 0, z: 0, w: 1,}
}, floorId);

scene.addEntityForSystem<ThreeJsArgs>(ThreeJsDynamicMeshSystem.TYPE, {
    geometry: new BoxGeometry(200, 1, 200),
    material: new MeshBasicMaterial({color: 0xBCD3F2}),
}, floorId);

setInterval(() => scene.update(16), 16)

ANNNND

Image description

Bravo! But our scene is BOOORING because nothing is happening. And when we show it to our relatives, they ask why we didn’t make the last call of duty despite our great feat of engineering!

So lets make a “wiggling system”:

export type UpAndDownSinEntity = {
    transform: Transform;
    speed: number;
    time: number;
    base: number;
};

export class UpAndDownSinSystem extends EntitySystem<UpAndDownSinEntity, number> implements Updatable {
    static readonly TYPE = "UP_AND_DOWN_SINE_SYS";

    getIdentifier(): string {
        return UpAndDownSinSystem.TYPE;
    }

    createEntity(args: number, id: string, scene: SceneUseCase): UpAndDownSinEntity {
        const transform = scene.getRefToExternalEntity<Transform>(TransformSystem.TYPE, id);
        return {
            transform,
            speed: args,
            time: 0,
            base: transform.position.y,
        };
    }

    update(delta: number): void {
        Object.values(this.entities).forEach(e => {
            e.time = (e.time + (e.speed * delta)) % Math.PI;
            e.transform.position.y = e.base + Math.sin(e.time);
        })
    }
}

And make the cube wiggle:

scene.addEntityForSystem<number>(UpAndDownSinSystem.TYPE, 0.01, cubeId);

Image description

Amazing!



Conclusion

Of course this is but a simple implementation of an ECS and I am by no means an expert but I enjoyed doing it! In the next post we are going to test our threeJs scene with automated tools and other convoluted things!

Here is the code: https://gitlab.noukakis.ch/smallworld/smallworldsclient

Until next time ^^

Image description


Print Share Comment Cite Upload Translate
APA
Ioannis Noukakis | Sciencx (2024-03-29T09:44:44+00:00) » 3D game engine in web – part 1. Retrieved from https://www.scien.cx/2022/01/19/3d-game-engine-in-web-part-1/.
MLA
" » 3D game engine in web – part 1." Ioannis Noukakis | Sciencx - Wednesday January 19, 2022, https://www.scien.cx/2022/01/19/3d-game-engine-in-web-part-1/
HARVARD
Ioannis Noukakis | Sciencx Wednesday January 19, 2022 » 3D game engine in web – part 1., viewed 2024-03-29T09:44:44+00:00,<https://www.scien.cx/2022/01/19/3d-game-engine-in-web-part-1/>
VANCOUVER
Ioannis Noukakis | Sciencx - » 3D game engine in web – part 1. [Internet]. [Accessed 2024-03-29T09:44:44+00:00]. Available from: https://www.scien.cx/2022/01/19/3d-game-engine-in-web-part-1/
CHICAGO
" » 3D game engine in web – part 1." Ioannis Noukakis | Sciencx - Accessed 2024-03-29T09:44:44+00:00. https://www.scien.cx/2022/01/19/3d-game-engine-in-web-part-1/
IEEE
" » 3D game engine in web – part 1." Ioannis Noukakis | Sciencx [Online]. Available: https://www.scien.cx/2022/01/19/3d-game-engine-in-web-part-1/. [Accessed: 2024-03-29T09:44:44+00:00]
rf:citation
» 3D game engine in web – part 1 | Ioannis Noukakis | Sciencx | https://www.scien.cx/2022/01/19/3d-game-engine-in-web-part-1/ | 2024-03-29T09:44:44+00:00
https://github.com/addpipe/simple-recorderjs-demo