Lights and Cameras using Components in Babylon.js

We've successfully loaded our game assets and implemented a robust movement system. Now, let's shed some light on our virtual world and provide players with a window to experience it all. We'll introduce Camera and Lighting components, along with their corresponding systems, to manage the visual aspects of our game.

The World Data

Let's add a World Entity and give it some components in the same way we did for the Player entity.

"entities": {
  "World": {
    "components": {
      "Asset": {
        "name": "World",
        "path": "glb/world.glb"
      },
      "Lighting": {
        "types": ["ambient", "directional"],
        "offset": [5, 10, -10]
      }
    }
  },

The LightingSystem: Let There Be Light!

Now, let's create a LightingSystem to handle light creation and updates:

import {
  HemisphericLight,
  Vector3,
  DirectionalLight,
  ShadowGenerator,
} from "@babylonjs/core";

import { LightingComponent } from "../components/LightingComponent";
import { AssetComponent } from "../components/AssetComponent";
import { CameraComponent } from "../components/CameraComponent";
import { MovementComponent } from "../components/MovementComponent";
import { Entity, EntityManager, System } from "../lib";
import { WorldComponent } from "../components/WorldComponent";
import { TerrainSystem } from "./TerrainSystem";
import { TerrainComponent } from "../components/TerrainComponent";

export class LightingSystem extends System {
  public shadows: ShadowGenerator | null = null;
  constructor(
    entityManager: EntityManager,
    componentClasses = [LightingComponent],
  ) {
    super(entityManager, componentClasses);
    console.log("LightingSystem initialized");
  }
  processEntity(entity: Entity, deltaTime: number) {
    const playerEntity = this.entityManager.playerEntity;
    const playerAssets = playerEntity.getComponent(AssetComponent);
    const worldAssets = entity.getComponent(AssetComponent);
    if (!playerAssets.meshes.size || !worldAssets.meshes.size) return;
    const camera = playerEntity.getComponent(CameraComponent).camera;
    const lightingComponent = entity.getComponent(LightingComponent);
    const { lights, types, loading, offset } = lightingComponent;
    const lightOffset = Vector3.FromArray(offset) || new Vector3(0, 5, 0);
    if (!loading && lights.length < types.length) {
      lightingComponent.loading = true;
      types.forEach((type, index) => {
        if (type === "ambient") {
          const light = new HemisphericLight(
            `HemisphericLight-${lights.length}`,
            new Vector3(0, -1, 0),
            this.scene,
          );
          light.intensity = 4;
          lightingComponent.lights.push(light);
        }
        if (type === "directional") {
          const light = new DirectionalLight(
            `DirectionalLight-${lights.length}`,
            new Vector3(1, -10, 0),
            this.scene,
          );
          light.intensity = 0.8;
          light.position = new Vector3(0, 50, -3);
          light.autoCalcShadowZBounds = true;
          lightingComponent.lights.push(light);
        }
      });
      this.shadows = new ShadowGenerator(
        4096,
        lightingComponent.lights[1],
        true,
      );
      this.shadows.useBlurExponentialShadowMap = true;
      this.shadows.blurKernel = 32;
      this.shadows.enableSoftTransparentShadow = true;
      lightingComponent.loading = false;
      return;
    }
    const mesh = playerAssets.mainMesh;
    if (mesh) {
      lights.forEach((light) => {
        light.position = mesh.position.add(lightOffset);
      });
    }
    const pos = playerEntity.getComponent(MovementComponent).position;
    // lightingComponent.lights[1].setDirectionToTarget(pos);
    this.manageShadowMap();
  }
  manageShadowMap() {
    const { scene, shadows } = this;
    for (let i in scene.meshes) {
      var mesh = scene.meshes[i];
      if (mesh.castsShadows || mesh.name.includes("ground")) continue;
      if (scene.activeCamera.isInFrustum(mesh) && mesh.isVisible) {
        if (!mesh.name.includes("-UI")) {
          shadows.addShadowCaster(mesh, true);
          shadows.getShadowMap().renderList.push(mesh);
        }
        mesh.castsShadows = true;
      }
    }
  }
}

Explanation:

  • The LightingSystem processes entities with LightingComponent components.
  • It creates lights based on the types specified in the component (e.g., HemisphericLight, DirectionalLight).
  • The processEntity method updates the lights' positions, potentially based on other entities like the player.

Camera Component on the Player Entity

Now, let's update our scene.json to include Camera and Lighting components for our player entity:

{
  "entities": {
    // ... other entities

    "Player": {
      "components": {
        // ... other components

        "Camera": {
          "type": "follow",
          "offset": [0, 7, -7]
        },
      }
    }
  }
}

The CameraComponent: Capturing the View

Let's define a CameraComponent to store information about our game camera:

// CameraComponent.ts
import { Camera, Vector3 } from "@babylonjs/core";
import { Component } from "../lib";

export interface CameraComponentInput {
  type: "follow";
  offset: [0, 0, 10];
}

export class CameraComponent extends Component {
  camera: Camera;
  camera2: Camera;
  offset: Vector3;

  constructor(data: CameraComponentInput) {
    super(data);
    this.offset = Vector3.FromArray(data.offset);
  }
}

Key Properties:

  • camera: A reference to the Babylon.js camera object.
  • offset: A Vector3 representing the camera's offset relative to the entity it's attached to.

The LightingComponent: Setting the Mood

Next, let's define a LightingComponent to manage our game's lighting:

// LightComponent.ts
import { Component } from "../lib";

export interface LightingComponentInput {
  types: ["ambient", "directional"];
}

export class LightingComponent extends Component {
  types = ["ambient", "directional"];
  lights = [];
  loading = false;

  constructor(data: LightingComponentInput) {
    super(data);
    this.types = data.types;
  }
}

Key Properties:

  • types: An array of strings specifying the types of lights to create (e.g., "ambient," "directional").
  • lights: An array to store references to the created Babylon.js light objects.

The CameraSystem: Keeping an Eye on the Action

Now, let's create a CameraSystem to handle camera creation, positioning, and updates:

// CameraSystem.ts
import { Vector3, FreeCamera, Viewport, Camera } from "@babylonjs/core";
import { Entity, EntityManager, System } from "../lib";
import { MovementComponent } from "../components/MovementComponent";
import { CameraComponent } from "../components/CameraComponent";

export class CameraSystem extends System {
  entityManager: EntityManager;
  viewport1: Viewport;
  viewport2: Viewport;
  viewportFull: Viewport;
  saveCamera2: Camera;

  constructor(
    entityManager: EntityManager,
    componentClasses = [CameraComponent, MovementComponent],
  ) {
    super(entityManager, componentClasses);
    this.componentClasses = componentClasses;
    this.entityManager = entityManager;
    this.viewport1 = new Viewport(0, 0, 0.5, 1.0); // Left half
    this.viewport2 = new Viewport(0.5, 0, 0.5, 1.0); // Right half
    this.viewportFull = new Viewport(0, 0, 1.0, 1.0); // Full screen

    console.log("CameraSystem initialized");
  }

  adjustCameraHeight(camera, players) {
    let minX = Infinity,
      maxX = -Infinity;
    let minZ = Infinity,
      maxZ = -Infinity;

    players.forEach((player) => {
      const position = player.getComponent(MovementComponent).position;
      const { x, y, z } = position;
      if (x < minX) minX = x;
      if (z < minZ) minZ = z;
      if (x > maxX) maxX = x;
      if (z > maxZ) maxZ = z;
    });

    const boundingBoxSize = Math.max(maxX - minX, maxZ - minZ);
    const distance = boundingBoxSize / (2 * Math.tan(camera.fov / 4));
    camera.position.y = Math.min(distance, 30);
    return distance;
  }

  processEntity(entity: Entity, deltaTime: number): void {
    const movementComponent = entity.getComponent(MovementComponent);
    const cameraComponent = entity.getComponent(CameraComponent);
    const { position } = movementComponent;
    const { offset } = cameraComponent;
    const targetPosition = position;
    const cameraPosition = position.add(offset);
    if (!cameraComponent.camera) {
      const camera = new FreeCamera("Camera", cameraPosition, this.scene);
      cameraComponent.camera = camera;

      // Add both cameras to the scene
      const ac = this.scene.activeCameras;
      // if (ac.length == 0) {
      camera.viewport = this.viewportFull;
      // } else if (ac.length == 1) {
      //   camera.viewport = this.viewport2;
      //   ac[0].viewport = this.viewport1;
      // }
      // this.scene.activeCameras.push(camera);
      // const canvas = this.scene.getEngine().getRenderingCanvas();
      // camera.attachControl(canvas, true);
    }

    // Assuming the player's position is where we want the camera to look at
    const players = this.entityManager.getPlayers();
    const camera = cameraComponent.camera;
    if (players[0] && players[1]) {
      const distance = this.adjustCameraHeight(cameraComponent.camera, players);
      if (distance < 10 && this.scene.activeCameras.length > 1) {
        this.saveCamera2 = this.scene.activeCameras[1];
        this.scene.activeCameras = [camera];
        camera.viewport = this.viewportFull;
      } else {
        camera.viewport = this.viewport1;
        this.saveCamera2.viewport = this.viewport2;
        this.scene.activeCameras = [camera, this.saveCamera2];
      }
    }
    // Update camera's target to the player's position
    this.updateCameraPosition(camera, targetPosition, cameraPosition);
    // this.updateCameraPosition(camera2, target2Position, camera2Position);
  }
  updateCameraPosition(
    camera: Camera,
    targetsPosition: Vector3,
    cameraPosition: Vector3,
  ) {
    camera.position = cameraPosition;
    let cy = camera.position.y;
    camera.setTarget(targetsPosition);
    // cy = Math.max(cy, 4);
    // cy = Math.min(cy, 100);
    // z = Math.max(z, 4);
    // z = Math.min(z, 12);
    // camera.position.y = cy;
  }
}

Explanation:

  • The CameraSystem processes entities with both CameraComponent and MovementComponent components.
  • It creates a FreeCamera (you can choose different camera types) if one doesn't exist for the entity.
  • The processEntity method updates the camera's position based on the entity's position and the camera's offset.
  • The camera is set to follow the entity by targeting its position.

In Sum

We've successfully added cameras and lights to our game world using a data-driven approach! By defining camera and lighting properties in our scene.json and using components and systems, we've enhanced our game's visual presentation and provided players with a way to experience our virtual world. Keep experimenting, keep refining, and watch your game world become more immersive and engaging!