Dynamically Loading Game Assets with Babylon.js AssetManager

We're diving deeper into the data-driven wonderland of Babylon.js, focusing on dynamically loading game assets using components and systems. Our trusty SceneManager will orchestrate the show, reading our scene blueprints from JSON and magically bringing our game world to life.

A Refresher: Components and Systems

Before we embark on our asset-loading quest, let's recap the dynamic duo powering our data-driven game:

  • Components: Think of components as Lego bricks, each encapsulating a specific aspect of a game object. A "Position" component might hold x, y, z coordinates, while an "Asset" component could store the path to a 3D model.
  • Systems: Systems are the master builders, responsible for processing entities with specific components and implementing game logic. For example, a "RenderingSystem" might draw all entities with "Mesh" and "Position" components.

The SceneManager: Our Game's Stage Manager

Here's a condensed version of our SceneManager, which we'll be using to load our scene data and kickstart the asset loading process:

import { Scene } from "@babylonjs/core";
import { EntityManager } from "./Entity";

export class SceneManager {
  public currentScene: Scene;
  entityManager: EntityManager;
  scenes: Map<Scene>;
  sceneCode = { systems: [], componentTypes: [], entities: [] };

  // ... (Other methods like switchToScene, unloadScene, etc.)

  async loadSceneData(sceneName: string, gameName: string, onSceneLoaded = null) {
    // ... (Check if scene is already loaded)

    const response = await fetch(`/gamedata/${gameName}/scenes/${sceneName}.json`);
    const sceneData = await response.json();

    if (sceneData) {
      this.sceneCode = await this.loadSceneCode(this.entityManager, sceneData);
    }

    // ... (Scene setup and onSceneLoaded logic)
  }

  async loadSceneCode(entityManager: EntityManager, data: any) {
    // ... (Process entities, components, and systems)
  }

  async importComponent(componentType) {
    try {
      const module = await import(`../components/${componentType}Component.ts`);
      return module[componentType + `Component`];
    } catch (error) {
      console.error(`Failed to import component: ${componentType}`, error);
      return null;
    }
  }

 // ... (Other methods like updateSystems, deriveRequiredSystems)
}

The Key: The loadSceneData method fetches our scene data from a JSON file. Then hands off to loadSceneCode, which dynamically imports components and systems based on the data.

The scene.json File: Blueprinting Our Game World

Let's define our "World" and "Player" entities, each with an Asset component:

We can call the file anything we want. level1.json, scene1.json, rpg-game.json, etc. We'll stick with scene.json for now.

{
  "entities": {
    "World": {
      "components": {
        "Asset": {
          "name": "World",
          "path": "glb/world.glb"
        }
      }
    },
    "Player": {
      "components": {
        "Asset": {
          "name": "Player",
          "path": "glb/player.glb"
        }
      }
    }
  }
}

The AssetComponent: Storing Our 3D Goodies

Just like before, we'll create a simple AssetComponent to hold our loaded assets:

// AssetComponent.ts
import { Animation, Mesh, ShadowGenerator } from "@babylonjs/core";
import { Component } from "../lib";

export interface AssetComponentInput {
  name: string;
  path: string;
  meshes: Map<String, Mesh>;
  animations: Map<String, Mesh>;
}

export class AssetComponent extends Component {
  mainMesh: Mesh;
  colliderMesh: Mesh;
  loaded: boolean;
  loading: boolean;
  constructor(data: any) {
    super(data);
    this.name = data.name;
    this.meshes = new Map();
    this.animations = new Map();
  }
}

The AssetSystem: The Asset-Loading Maestro

Now for the real workhorse – our AssetSystem:

// AssetSystem.ts
import { Scene, AssetsManager } from "@babylonjs/core";
import "@babylonjs/loaders";
import { Entity, EntityManager, System } from "../lib";
import { AssetComponent } from "../components/AssetComponent";

export class AssetSystem extends System {
  constructor(
    entityManager: EntityManager,
    componentClasses = [AssetComponent],
  ) {
    super(entityManager, componentClasses);
    console.log("AssetSystem initialized");
  }

  processEntity(entity: Entity, deltaTime: number): void {
    const assetComponent = entity.getComponent(AssetComponent);
    if (!assetComponent.loading && !assetComponent.loaded) {
      assetComponent.loading = true;
      this.loadAssets(this.scene, assetComponent);
    }
  }

  loadAssets(scene: Scene, assetComponent: AssetComponent): void {
    const assetsManager = new AssetsManager(scene);
    let { path } = assetComponent;
    if (!Array.isArray(path)) path = [path];
    let i = 0;
    for (const p of path) {
      i++;
      this.newTask("assetTask" + i, p, assetsManager, assetComponent);
    }
    assetsManager.load();
    assetComponent.loaded = true;
    assetComponent.loading = false;
  }

  newTask(name, path, assetsManager, assetComponent) {
    const meshTask = assetsManager.addMeshTask(name, "", "", path);
    meshTask.onSuccess = (task) => {
      task.loadedMeshes.forEach((mesh) => {
        if (mesh.name == "__root__") {
          assetComponent.mainMesh = mesh;
        }
        if (mesh.name.includes("__collider__")) {
          mesh.isVisible = false;
          mesh.parent = assetComponent.mainMesh;
          assetComponent.colliderMesh = mesh;
        }
        assetComponent.meshes.set(mesh.name, mesh);
        console.log("Added", mesh.name);
      });
      task.loadedAnimationGroups.forEach((a) => {
        a.stop();
        assetComponent.animations.set(a.name, a);
      });
    };
    meshTask.onError = (task, message, exception) => {
      console.error(message, exception);
    };
  }
}

Explanation:

  1. The AssetSystem gets instantiated by the SceneManager based on our scene.json data.
  2. The processEntity method is called by the EntityManager (not shown here) for each entity with an AssetComponent.
  3. Inside processEntity, we initiate the asset loading process if the asset isn't already loaded or being loaded.
  4. The loadAssets method handles the actual loading using Babylon.js's AssetsManager.

The Data-Driven Powerhouse in Action

With this setup, here's how the magic unfolds:

  1. We call loadSceneData on our SceneManager.
  2. The SceneManager reads our scene.json, dynamically imports the AssetComponent and AssetSystem.
  3. The EntityManager processes entities, calling the AssetSystem's processEntity for entities with Asset components.
  4. The AssetSystem loads the assets in the background, populating the AssetComponents with our 3D models.

In Sum

You've come so far – we've built a flexible, data-driven asset loading system with Babylon.js. Next we're going to dive into the heart of our game – the player's movement and interaction with player input.