This content originally appeared on DEV Community and was authored by Ioannis Noukakis
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.
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:
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
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);
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 ^^
This content originally appeared on DEV Community and was authored by Ioannis Noukakis
Ioannis Noukakis | Sciencx (2022-01-19T17:00:27+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/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.