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 withLightingComponent
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
: AVector3
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 bothCameraComponent
andMovementComponent
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!