Skip to content

Scenes

The scene system provides the visual foundation for your game. Powered by BabylonJS, the SceneEngine wraps common scene creation tasks while giving you full access to the underlying BabylonJS API when needed.

SceneEngine

The SceneEngine is available through the engine:

typescript
const sceneEngine = gameEngine.engines.sceneEngine;

Scene Methods

MethodSignatureDescription
createScene() => SceneCreate a new BabylonJS scene
enablePhysics(scene, gravity?) => voidEnable Havok physics on a scene

Camera Methods

MethodSignatureDescription
createFreeCamera(name, position, scene) => FreeCameraFirst-person camera with 6DOF

Light Methods

MethodSignatureDescription
createHemisphericLight(name, direction, scene, intensity?) => HemisphericLightAmbient light from a hemisphere
createDirectionalLight(name, direction, scene, intensity?) => DirectionalLightDirectional light (sun)
createPointLight(name, position, scene, intensity?) => PointLightOmnidirectional point light

Mesh Methods

MethodSignatureDescription
createSphere(name, options, scene) => MeshCreate a sphere mesh
createBox(name, options, scene) => MeshCreate a box mesh
createGround(name, options, scene) => MeshCreate a ground plane
createCylinder(name, options, scene) => MeshCreate a cylinder mesh

Physics Methods

MethodSignatureDescription
addPhysics(mesh, shapeType, options, scene) => PhysicsAggregateAdd a physics body to a mesh

Model Loading

MethodSignatureDescription
loadModel(path, scene) => Promise<AssetContainer>Load a GLB/glTF model
importMeshes(path, scene) => Promise<ImportMeshResult>Import meshes from a file

Creating a Scene

In most cases, scenes are created inside a Level's buildScene() method. Here is the general pattern:

typescript
const scene = sceneEngine.createScene();

sceneEngine.enablePhysics(scene);

const camera = sceneEngine.createFreeCamera(
    'mainCamera',
    new Vector3(0, 5, -10),
    scene
);
camera.setTarget(Vector3.Zero());

sceneEngine.createHemisphericLight('ambient', new Vector3(0, 1, 0), scene, 0.4);
sceneEngine.createDirectionalLight('sun', new Vector3(0.5, -0.6, 0.5), scene);

const ground = sceneEngine.createGround('ground', { width: 50, height: 50 }, scene);
sceneEngine.addPhysics(ground, PhysicsShapeType.BOX, { mass: 0 }, scene);

Cameras

BabylonJS cameras define the player's viewport. Common types:

CameraUse Case
FreeCameraFirst-person, 6 degrees of freedom
ArcRotateCameraThird-person, orbits around a target
FollowCameraFollows a specific mesh
typescript
import { FreeCamera, ArcRotateCamera, Vector3 } from '@babylonjs/core';

// First-person
const fps = new FreeCamera('fps', new Vector3(0, 5, -10), scene);
fps.setTarget(Vector3.Zero());
fps.attachControl(canvas, true);

// Third-person orbit
const orbit = new ArcRotateCamera(
    'orbit',
    Math.PI / 4,   // alpha
    Math.PI / 3,   // beta
    10,             // radius
    Vector3.Zero(), // target
    scene
);
orbit.attachControl(canvas, true);

Lighting

typescript
import { HemisphericLight, DirectionalLight, PointLight, Vector3 } from '@babylonjs/core';

// Ambient hemisphere light
const ambient = new HemisphericLight('ambient', new Vector3(0, 1, 0), scene);
ambient.intensity = 0.4;

// Directional sunlight
const sun = new DirectionalLight('sun', new Vector3(0.5, -0.6, 0.5), scene);
sun.intensity = 0.7;

// Point light
const torch = new PointLight('torch', new Vector3(5, 3, 0), scene);
torch.intensity = 0.8;

Meshes and Materials

Create meshes with the SceneEngine utilities or BabylonJS MeshBuilder directly:

typescript
import { MeshBuilder, StandardMaterial, PBRMaterial, Color3 } from '@babylonjs/core';

const sphere = MeshBuilder.CreateSphere('sphere', { diameter: 2, segments: 32 }, scene);
sphere.position.y = 1;

// Standard material
const mat = new StandardMaterial('mat', scene);
mat.diffuseColor = new Color3(0.2, 0.4, 0.8);
mat.specularColor = new Color3(0.3, 0.3, 0.3);
sphere.material = mat;

// PBR material
const pbr = new PBRMaterial('pbr', scene);
pbr.albedoColor = new Color3(0.8, 0.2, 0.1);
pbr.metallic = 0.7;
pbr.roughness = 0.3;

Model Loading

Load external 3D models (GLB/glTF):

typescript
// Via SceneEngine
const container = await sceneEngine.loadModel('assets/models/character.glb', scene);
container.addAllToScene();

// Via BabylonJS SceneLoader directly
import { SceneLoader } from '@babylonjs/core';

const result = await SceneLoader.ImportMeshAsync('', 'assets/models/', 'character.glb', scene);

AssetManager

For production games, use the AssetManager for centralized loading with caching, reference counting, and GLB fragment extraction:

typescript
const assetManager = gameEngine.managers.assetManager;

Loading Assets

typescript
// Load a full GLB
const container = await assetManager.load('models/environment.glb');
container.addAllToScene();

Fragment Syntax

Extract individual meshes from a multi-object GLB using path#meshName:

typescript
const chestLid = await assetManager.load('models/props.glb#chest_lid');
const torch = await assetManager.load('models/props.glb#wall_torch');

The underlying container is loaded only once, regardless of how many fragments you extract.

Instancing and Cloning

typescript
// GPU instances -- share geometry and material, very cheap
const barrel1 = assetManager.instance('models/props.glb#barrel');
const barrel2 = assetManager.instance('models/props.glb#barrel');
barrel1.position.x = 5;
barrel2.position.x = -5;

// Independent clone -- for meshes that need modification
const unique = assetManager.clone('models/props.glb#barrel');
unique.scaling.y = 1.5;

Use instance() for many identical copies (props, trees, crates). Use clone() when a copy needs its own geometry or material.

Preloading

Batch-load assets with event bus progress tracking:

typescript
gameEngine.eventBus.subscribe('asset:progress', (event) =>
{
    const { path, loaded, total } = event.payload;
    console.log(`Loaded ${ loaded }/${ total }: ${ path }`);
});

await assetManager.preload([
    'models/environment.glb',
    'models/props.glb#barrel',
    'models/props.glb#crate',
    'models/characters.glb#knight',
]);

Reference Counting and Disposal

Each load() call increments a reference count. Call dispose() to decrement -- the container is freed only when the count reaches zero:

typescript
await assetManager.load('models/props.glb#barrel');
await assetManager.load('models/props.glb#barrel');

assetManager.dispose('models/props.glb#barrel'); // refCount: 2 -> 1
assetManager.dispose('models/props.glb#barrel'); // refCount: 0, freed

// Or release everything at once
assetManager.disposeAll();

disposeAll() is called automatically during level teardown.

Raycasting

SAGE wraps BabylonJS picking with entity-aware raycasting that resolves picked meshes back to their owning entities:

typescript
const raycast = gameEngine.raycast;

Screen Picking

typescript
const result = raycast.pickEntity(scene, pointerX, pointerY);
if(result)
{
    console.log('Clicked:', result.entity.type);
    console.log('Hit point:', result.point);
}

// Pick all entities under the cursor
const results = raycast.pickEntities(scene, pointerX, pointerY);

Ray Picking

typescript
import { Ray, Vector3 } from '@babylonjs/core';

const ray = new Ray(
    new Vector3(0, 5, 0),
    new Vector3(0, -1, 0),
    100
);

const result = raycast.pickEntityWithRay(scene, ray);
const results = raycast.pickEntitiesWithRay(scene, ray);

Forward Picking (FPS Style)

Cast a ray from the camera's forward direction:

typescript
const result = raycast.pickEntityForward(scene, camera, 50);
if(result)
{
    console.log('Looking at:', result.entity.type);
}

The third parameter is maxDistance (default 1000).

EntityPickFilter

Filter results by type, tags, or custom predicates. All conditions are AND'd:

typescript
import type { EntityPickFilter } from '@skewedaspect/sage';

// By type
const enemies = raycast.pickEntities(scene, x, y, { type: 'enemy' });

// By tags (entity must have ALL listed tags)
const interactable = raycast.pickEntity(scene, x, y, {
    tags: [ 'interactable', 'unlocked' ],
});

// Custom predicate
const alive = raycast.pickEntity(scene, x, y, {
    predicate: (entity) => entity.state.health > 0,
});

// Combined
const target = raycast.pickEntityForward(scene, camera, 100, {
    type: 'enemy',
    tags: [ 'visible' ],
    predicate: (entity) => entity.state.health > 0,
});

EntityPickResult

typescript
interface EntityPickResult
{
    entity : GameEntity;
    point : Vector3;
    distance : number;
    normal : Vector3;
    mesh : AbstractMesh;
    pickingInfo : PickingInfo;
}
FieldTypeDescription
entityGameEntityThe resolved entity that owns the picked mesh
pointVector3World-space intersection point
distancenumberDistance from ray origin to hit point
normalVector3Surface normal at the hit point
meshAbstractMeshThe specific mesh that was picked
pickingInfoPickingInfoThe raw BabylonJS picking result

Parent-Chain Resolution

When a picked mesh belongs to a child entity, the child entity is returned. If the mesh is not directly associated with an entity, the raycast helper walks up the scene graph parent chain until it finds one.

Environment Effects

Fog

typescript
scene.fogEnabled = true;
scene.fogColor = new Color3(0.8, 0.9, 0.8);
scene.fogDensity = 0.01;

Particle Systems

typescript
import { ParticleSystem, Color4, Texture, Vector3 } from '@babylonjs/core';

const fire = new ParticleSystem('fire', 2000, scene);
fire.particleTexture = new Texture('assets/textures/flame.png', scene);
fire.emitter = new Vector3(5, 0, 10);
fire.color1 = new Color4(1, 0.9, 0.3, 1);
fire.color2 = new Color4(1, 0.5, 0.2, 1);
fire.minSize = 0.3;
fire.maxSize = 1.5;
fire.emitRate = 500;
fire.start();

Debugging

Use the BabylonJS inspector for visual debugging:

typescript
import '@babylonjs/inspector';

window.addEventListener('keydown', (e) =>
{
    if(e.key === 'F12')
    {
        if(scene.debugLayer.isVisible)
        {
            scene.debugLayer.hide();
        }
        else
        {
            scene.debugLayer.show();
        }
    }
});

Released under the MIT License.