Skip to content

Blender Workflow ​

This guide covers how to create SAGE levels in Blender. You will learn the GLB export settings, the custom properties system that maps Blender objects to SAGE game functionality, and the recommended workflow for building levels that load cleanly into the engine.

Overview ​

SAGE uses glTF/GLB files exported from Blender as level scene files. By adding custom properties to objects in Blender, you control game behavior without writing code:

  • Define spawn points for players, enemies, and items
  • Mark meshes as interactive entities
  • Attach spatial sounds to objects
  • Configure collision shapes
  • Create trigger zones for gameplay events
  • Optimize performance with occlusion culling and LOD

When a level loads, SAGE walks the scene graph, reads these properties, and automatically configures everything. The programmer defines entity types and behaviors in code; the artist places and configures instances in Blender.

GLB export format and settings ​

Export your levels as GLB (glTF Binary) files. This format bundles geometry, materials, textures, and metadata into a single file.

Export procedure ​

  1. Go to File > Export > glTF 2.0 (.glb/.gltf)
  2. Set Format to glTF Binary (.glb)
  3. Configure the remaining settings:

Include:

SettingValueWhy
Selected ObjectsOffExport the entire scene
Visible ObjectsOnSkip hidden helper objects
Renderable ObjectsOn
Active CollectionOff
Include Nested CollectionsOn
Custom PropertiesOnRequired for SAGE metadata

Transform:

SettingValue
+Y UpOn

Data > Lighting:

SettingValueWhy
Lighting ModeUnitlessStandard mode produces values that are massively overexposed in BabylonJS. See Blender lights.

Data > Mesh:

SettingValue
Apply ModifiersOn
UVsOn
NormalsOn
Vertex ColorsOn (if used)

Data > Materials:

SettingValue
ExportOn
Images FormatAutomatic

Animation (if needed):

SettingValue
Use Current FrameOff
Export AnimationsOn

Custom Properties must be enabled

If "Custom Properties" is not checked, none of the SAGE metadata will be included in the export. This is the single most common cause of "it works in Blender but not in the game."

Custom properties in Blender ​

Custom properties are the bridge between Blender and SAGE. You add them in the Properties panel:

  1. Select an object in the 3D viewport
  2. Open the Properties panel (right side)
  3. Click the Object Properties tab (orange square icon)
  4. Scroll down to Custom Properties
  5. Click New to add a property

Property types ​

TypeExampleUse case
String"assets/audio/music.mp3"File paths, names, JSON context
Integer100Distances, counts, floor numbers
Float0.75Volume, scale factors
BooleanCheckboxEnable/disable features

Naming convention ​

SAGE properties use a flat naming pattern:

  • Main property: property_name (e.g., sound, collider, trigger)
  • Sub-properties: property_subname (e.g., sound_volume, sound_loop)

Property names are case-sensitive.

Spawn points ​

Spawn points mark locations where game entities appear when the level loads. They are the primary way to place players, enemies, and items.

Creating a spawn point ​

  1. Add an Empty object (Add > Empty > Plain Axes)
  2. Position and rotate it where you want the entity to appear
  3. Add a custom property:
    • Name: spawn
    • Type: String
    • Value: A unique name (e.g., player_start, goblin_patrol_1)

The empty's transform is passed to the spawned entity:

  • Position -- where the entity appears
  • Rotation -- which direction it faces

The empty's scale is not carried into the entity's state.

How spawns connect to code ​

The spawn name maps to the YAML level config. In Blender you place spawn = "player_start". In the YAML:

yaml
spawns:
  player_start:
    entity: PlayerEntity
    displayName: Hero
    config:
      health: 100

When the level loads, SAGE finds the Blender empty marked player_start, spawns a PlayerEntity at its position, writes displayName into the entity's state, and layers the config values over the class's defaults.

Example spawn points ​

Empty: "PlayerSpawn"
  spawn = "player_start"

Empty: "Enemy_Patrol_01"
  spawn = "goblin_patrol_1"

Empty: "Treasure_Location"
  spawn = "chest_01"

Entity markers ​

Entity markers turn existing meshes into game objects. Unlike spawn points (which replace an empty with a new entity), an entity marker's node becomes the entity's own node: the entity adopts it instead of building one.

Creating an entity marker ​

  1. Select a mesh in your scene (a door, a chest, a button)
  2. Add a custom property:
    • Name: entity
    • Type: String
    • Value: The marker name the level config's entities section maps to a class (e.g., door, button, elevator)

The Blender object name is recorded in the entity's nodeName state field, which is how the entity finds its node again when a save is restored. A per-instance label comes from the level config's displayName, which lands in the entity's state under that name. NamedBehavior is what declares the field, so a class that composes it reads displayName as typed state with a default, and sage.list prints it in its own column.

Additional metadata ​

Entity markers can carry extra metadata that behaviors read from the adopted node in onMeshLoaded():

Mesh: "ElevatorButton_F2"
  entity = "elevator_button"
  target = "Elevator"
  prompt = "to call elevator"
  locked = true
  context = '{"floor": 2}'

The behavior reads these from node.metadata, naming NodeEntity as the third type parameter so this.entity is already typed as one and needs no cast:

typescript
import { Behavior } from '@skewedaspect/sage';
import type { NodeBehaviorHooks, NodeEntity } from '@skewedaspect/sage';

interface ElevatorButtonState
{
    target : string;
    locked : boolean;
}

export class ElevatorButtonBehavior extends Behavior<ElevatorButtonState, object, NodeEntity>
    implements NodeBehaviorHooks
{
    readonly defaults : ElevatorButtonState = { target: '', locked: false };

    onMeshLoaded() : void
    {
        const node = this.entity.node;

        this.state.target = (node.metadata?.target as string) ?? '';
        this.state.locked = Boolean(node.metadata?.locked);
    }
}

Writing them into state, rather than into behavior instance fields, keeps them across a save and restore. This pattern keeps per-instance configuration in Blender and generic logic in code.

When to use entity vs spawn ​

Use casePropertyWhat happens
Place a new object (player, enemy, item)spawnEmpty replaced with a spawned entity that builds its own node
Make an existing mesh interactive (door, button)entityThe entity adopts the mesh's node

Sounds ​

Attach ambient or spatial sounds to objects in your scene.

Basic sound ​

Mesh: "Waterfall"
  sound = "assets/audio/waterfall.mp3"

Sound options ​

PropertyTypeDefaultDescription
soundString--Path to audio file (required)
sound_volumeFloat1.0Volume (0.0 to 1.0)
sound_loopBooleantrueLoop the audio
sound_spatialBooleantrueUse 3D positional audio
sound_distanceInteger100Maximum audible distance for spatial audio
sound_autoplayBooleantrueStart playing on load
sound_channelString"ambient"Audio channel name

Example: Ambient campfire ​

Mesh: "Campfire"
  sound = "assets/audio/fire_crackling.mp3"
  sound_volume = 0.6
  sound_loop = true
  sound_spatial = true
  sound_distance = 20
  sound_channel = "ambient"

Example: One-shot announcement ​

Mesh: "Announcement_Speaker"
  sound = "assets/audio/welcome.mp3"
  sound_loop = false
  sound_autoplay = true
  sound_channel = "voice"

Tips ​

  • Use .mp3 or .ogg for best compatibility
  • Set sound_spatial = true for environmental sounds, false for music or UI
  • Set sound_distance based on the object's size and importance in the scene

Colliders ​

Colliders define physical boundaries for collision detection.

Collider types ​

TypePerformanceBest for
boxFastCrates, walls, rectangular objects
sphereFastBalls, barrels, rounded objects
meshSlowComplex shapes, terrain
noneN/AVisual only (disable collision)

Basic usage ​

Mesh: "Crate"
  collider = "box"

Mesh: "Boulder"
  collider = "sphere"

Mesh: "Decoration_Flowers"
  collider = "none"

Motion and mass ​

A collider is static unless the node says otherwise.

PropertyTypeDefaultPurpose
collider_motionString"static"static, dynamic, or kinematic -- the same three words HavokPhysicsBodyBehavior's own motion field takes. An unrecognized value warns and builds a static body.
collider_massFloat1Mass in kilograms. Sets the mass and nothing else -- a static body's mass is always 0 regardless of what this names.
Mesh: "Barrel"
  collider = "box"
  collider_motion = "dynamic"
  collider_mass = 300.0

Mesh: "Door - Spawn Room"
  collider = "box"
  collider_motion = "kinematic"

Without collider_motion = "kinematic", a door's collider stays in the doorway while the door slides open. A kinematic collider follows its node exactly, placed there every step, and reports no velocity -- it is for anything code moves without expecting Havok's own dynamics: doors, hatches, gates. A platform meant to carry a rider, like the sandbox example's elevator, needs the game to also call setTargetTransform on the body itself; see Physics.

Simplified collision meshes ​

For detailed visual models, create a simpler child mesh for collision:

Mesh: "Statue_Detailed" (parent)
  collider = "mesh"

  Mesh: "Statue_Collision" (child)
    collider_mesh = true

Setup:

  1. Create your detailed visual mesh
  2. Create a simplified version for collision
  3. Parent the simplified mesh to the detailed mesh
  4. On the parent: set collider = "mesh"
  5. On the child: set collider_mesh = true (Boolean, checked). A mesh with more than one material slot needs this: Babylon imports it as one primitive per material under a plain transform node, so the node itself has no triangles for a mesh collider to read, and the level logs an error for it on every load.

The collision child is automatically hidden in-game. Only its geometry is used for physics.

Performance

Use box or sphere whenever possible. Mesh colliders are significantly slower and should be reserved for terrain or complex static geometry.

Trigger zones ​

Triggers are invisible areas that fire events when objects enter or exit them.

Creating a trigger ​

  1. Create a mesh that defines the trigger area (a box, in most cases)
  2. Add a custom property:
    • Name: trigger
    • Type: String
    • Value: A unique trigger name
Mesh: "TriggerZone_BossRoom"
  trigger = "boss_arena"

The mesh becomes invisible automatically. When any object enters, a trigger:enter event fires carrying the trigger's own name and the mesh that crossed it. When it exits, trigger:exit fires the same way. The payload carries a Babylon mesh, not a resolved entity -- look one up with NodeSimulation.getEntityForMesh if the handler needs one.

Tips ​

  • Make trigger zones slightly larger than the area you want to detect (accounts for player size)
  • Use descriptive names: door_1_entrance is better than trigger_3
  • Simple box shapes are the most efficient

Performance optimization ​

Occlusion culling ​

occlusion sets a mesh up for BabylonJS's occlusion culling: the engine skips drawing a mesh on a frame the previous frame's query found it fully hidden behind other geometry.

Mesh: "Wall_Large"
  occlusion = "strict"

The value is optimistic or strict, BabylonJS's own occlusion query types. Use it on large, solid objects like walls and pillars -- what a camera is likely to stand behind for several frames at a time.

Level of detail (LOD) ​

LOD switches between mesh detail levels based on camera distance.

Mesh: "Tree" (parent) -- full detail
  lod_distances = "15,30,60"

  Mesh: "Tree_LOD1" (child) -- medium detail
  Mesh: "Tree_LOD2" (child) -- low detail
  Mesh: "Tree_LOD3" (child) -- lowest detail

Each distance is the point past which the next child takes over, so the parent is the full-detail mesh:

  • 0 to 15 units: the parent, "Tree"
  • 15 to 30 units: LOD1
  • 30 to 60 units: LOD2
  • Beyond 60: LOD3

Nothing is hidden there: every distance has a child to fill it. To stop rendering the tree in the distance, give it one more distance than it has children. lod_distances = "15,30,60,120" puts LOD3 beyond 60 and hides the tree past 120.

To create LOD meshes:

  1. Model the full-detail mesh -- this one is the parent
  2. Duplicate and reduce polygons (Decimate modifier or manual retopo)
  3. Repeat for each LOD level
  4. Parent all reduced meshes to the full-detail mesh
  5. Name them clearly: Tree_LOD1, Tree_LOD2, Tree_LOD3
  6. Add lod_distances to the parent

Complete example: Forest clearing scene ​

Here is how the properties above compose into a single level:

Collection: "Environment"
  Mesh: "Ground"
    collider = "box"

  Mesh: "Wall_Large"
    collider = "mesh"
    occlusion = "strict"

  Mesh: "Tree" (parent) -- full detail
    lod_distances = "15,30,60"

    Mesh: "Tree_LOD1" (child) -- medium detail
    Mesh: "Tree_LOD2" (child) -- low detail
    Mesh: "Tree_LOD3" (child) -- lowest detail

Collection: "Audio"
  Mesh: "Waterfall"
    sound = "assets/audio/waterfall.mp3"
    sound_volume = 0.6
    sound_distance = 30

Collection: "Gameplay"
  Empty: "PlayerSpawn"
    spawn = "player_start"

  Empty: "Enemy_Patrol_01"
    spawn = "goblin_patrol_1"

  Mesh: "TriggerZone_BossRoom"
    trigger = "boss_arena"

Collection: "Interactive"
  Mesh: "Door_Main"
    entity = "door"
    collider = "box"

  Mesh: "Campfire"
    entity = "light_source"
    sound = "assets/audio/fire_crackling.mp3"
    sound_volume = 0.6
    sound_distance = 20

Lighting and materials ​

Blender's glTF exporter produces PBR (physically based rendering) materials exclusively. This has implications for how you set up lights and create materials in code. Getting this wrong is the most common reason a scene looks washed out or pitch black, and it is why a scene can look inconsistent between its Blender-authored objects and its code-created ones.

The core rule ​

Any scene that contains Blender-exported content is a PBR scene. All code-created meshes in that scene must use PBRMaterial, not StandardMaterial. Mixing the two produces inconsistent lighting because they respond to lights differently.

typescript
// Wrong -- StandardMaterial in a scene with Blender PBR content
const wrongMat = new StandardMaterial('crate-mat', scene);
wrongMat.diffuseColor = new Color3(0.6, 0.4, 0.2);

// Correct -- PBRMaterial matches the Blender-exported materials
const mat = new PBRMaterial('crate-mat', scene);
mat.albedoColor = new Color3(0.6, 0.4, 0.2);
mat.metallic = 0.1;
mat.roughness = 0.8;

Light intensity values ​

BabylonJS PBR uses physical light units. The key thing to know: PBR rendering divides incoming light by PI for energy conservation. This affects how you think about intensity values.

Light typeUnitNotes
HemisphericLightluxAmbient fill. 0.7 -- 1.0 typical.
DirectionalLightluxSun/key light. Set to Math.PI (~3.14) for "full" brightness, since PBR divides by PI.
PointLightcandelaUses inverse square falloff. Values like 100 -- 500 are typical. 0.6 will be invisible.
SpotLightcandelaSame as PointLight. 150 is a typical spotlight.

The trap: tutorials and AI-generated code often use PointLight with intensity = 0.6, which works fine with StandardMaterial but produces no visible light with PBR materials. Similarly, DirectionalLight at 1.0 looks like a dim overcast day with PBR, not bright sun.

typescript
// PBR-correct lighting setup:
const hemi = new HemisphericLight('ambient', new Vector3(0, 1, 0), scene);
hemi.intensity = 0.7;
hemi.groundColor = new Color3(0.15, 0.15, 0.2);

// Math.PI compensates for the PBR 1/PI energy conservation factor
const sun = new DirectionalLight('sun', new Vector3(-1, -2, 1).normalize(), scene);
sun.intensity = Math.PI;

// Point/Spot lights use candela -- much higher numbers
const spot = new SpotLight('spotlight', position, direction, angle, exponent, scene);
spot.intensity = 150;

PBRMaterial essentials ​

When creating PBR materials in code, you must set metallic and roughness explicitly. The defaults (metallic = 0, roughness = 1) produce flat matte plastic that looks wrong in most contexts.

PropertyTypeRangeDescription
albedoColorColor30--1 per channelBase color (equivalent to diffuse)
metallicnumber0--10 = dielectric (plastic, wood), 1 = metal
roughnessnumber0--10 = mirror-smooth, 1 = matte
emissiveColorColor30--1 per channelSelf-illumination color
emissiveIntensitynumber0+Emissive brightness multiplier

Common material recipes:

typescript
// Shiny metal
mat.metallic = 0.8;
mat.roughness = 0.2;

// Wood / plastic
mat.metallic = 0.1;
mat.roughness = 0.7;

// Glowing collectible (visible regardless of scene lighting)
mat.albedoColor = new Color3(1, 0.85, 0);
mat.emissiveColor = new Color3(0.3, 0.25, 0);
mat.emissiveIntensity = 1.0;
mat.metallic = 0.8;
mat.roughness = 0.2;

Emissive as a visibility tool ​

In PBR scenes with low ambient light, objects can be hard to see. Adding emissiveColor makes objects glow independently of scene lighting. This is how the level-loading example makes keys and magic platforms visible in darker areas. Real-time games use emissive materials this way for UI elements and collectibles too.

Blender lights ​

Blender lights export via the KHR_lights_punctual glTF extension. By default, Blender's exporter uses "Standard" lighting mode, which converts watts to physical photometric units using a 683 lm/W factor. This produces values like 683,000 lux for a 1000W sun. That is correct per the glTF spec, but BabylonJS (and most glTF viewers) don't apply the exposure compensation needed to display those values, so the scene is massively overexposed.

The fix: In Blender's GLB export dialog, change the lighting mode to Unitless:

  1. File > Export > glTF 2.0
  2. Under Data > Lighting, set Lighting Mode to Unitless

Unitless mode skips the 683 lm/W conversion and produces intensity values that look correct in BabylonJS at default exposure. This is the recommended setting for SAGE projects.

Area lights

Blender area lights are not part of the glTF spec and are silently dropped on export. Use point or spot lights instead.

Working with GLBs exported in Standard mode ​

If you receive a GLB that was exported with Standard lighting mode and can't re-export it, you can correct the intensities in a GameLevel subclass. The conversion factor is 683; divide by it to undo the Standard mode conversion:

typescript
import { DirectionalLight, PointLight, SpotLight } from '@babylonjs/core';
import type { Scene } from '@babylonjs/core';
import { GameLevel } from '@skewedaspect/sage';

export class CorrectedLightingLevel extends GameLevel
{
    protected override async buildScene() : Promise<Scene>
    {
        const scene = await super.buildScene();

        const LUMENS_PER_WATT = 683;

        for(const light of scene.lights)
        {
            if(light instanceof DirectionalLight)
            {
                // Standard mode: watts * 683 = lux. Undo it.
                light.intensity /= LUMENS_PER_WATT;
            }
            else if(light instanceof PointLight || light instanceof SpotLight)
            {
                // Standard mode: watts * 683 / (4 * PI) = candela. Undo it.
                light.intensity /= LUMENS_PER_WATT / (4 * Math.PI);
            }
        }

        return scene;
    }
}

Code-only scenes (no Blender) ​

If your scene has no Blender content at all (everything is created in code), you can use either StandardMaterial or PBRMaterial. StandardMaterial with traditional intensity values (0--1) works fine when there are no PBR materials to conflict with. The entity-behaviors example demonstrates this approach.

However, if you plan to add Blender content later, start with PBR from the beginning. Migrating from StandardMaterial to PBRMaterial requires adjusting every material and light in the scene.

Scene organization ​

Organize your Blender scene with collections:

Collection: "Environment"
  Ground, walls, terrain, static props

Collection: "Audio"
  Empties or meshes with sound properties

Collection: "Gameplay"
  Spawn points, entity markers, trigger zones

Collection: "Interactive"
  Doors, buttons, elevators, chests

This keeps the outliner manageable and makes it easy to hide/show groups while working.

Naming conventions ​

  • Use descriptive names: Door_Main, ElevatorButton_F2, Crate_Storage_01
  • An entity marker's object name is what a restored save looks the node up by, so keep it stable once a level ships
  • Spawn markers' spawn values should match the YAML config keys

Transform hygiene ​

Before export:

  1. Apply all transforms (Ctrl+A > All Transforms) on meshes that use collision
  2. Check for non-manifold edges if using mesh colliders
  3. Ensure scale is (1, 1, 1) on objects where precise collision matters

Iteration workflow ​

  1. Author the scene in Blender with custom properties
  2. Export as GLB to the project's public assets directory
  3. Run the dev server (npm run dev)
  4. Test in-browser -- check spawn positions, collision, sounds, and triggers
  5. Adjust in Blender and re-export over the same path
  6. Reload the page and load the level again

Physics debugging

ColliderDebugManager at engine.debug.colliders wraps the Havok physics viewer. Use it to visualize collision shapes and verify that your colliders match the visual meshes. See the Debug Console page.

File organization ​

Organize your project assets alongside the source:

public/
  assets/
    sandbox/
      SAGE_dev-box.glb          # Level scene
      door-open.mp3             # Sound effects
      door-close.mp3
      ambient-factory.mp3       # Background audio
      unlock.mp3
    level-loading/
      simple-arena.glb
    physics-playground/
      bulldozer.glb

examples/src/examples/sandbox/
  levels/
    source/
      SAGE_dev-box.blend        # Blender source (not exported)
    sandbox.yaml                # Level config

Keep Blender source files in a source/ directory adjacent to the YAML configs. These are not exported or served -- they are your editable originals.

Quick reference ​

Essential properties ​

PropertyTypeExamplePurpose
spawnString"player_start"Mark spawn location
entityString"door"Mark mesh as entity
soundString"audio/fire.mp3"Attach sound
colliderString"box"Add collision
triggerString"boss_room"Create trigger zone
occlusionString"strict"Occlusion culling
lod_distancesString"15,30,60"Level of detail

Entity metadata properties ​

These are the sandbox example's own conventions, read by its behaviors, not properties SAGE itself interprets:

PropertyTypeExamplePurpose
targetString"Door_Main"The node name of the entity an interaction activates
promptString"to open"Interaction prompt text
lockedBooleantrueLock the interaction
contextString'{"floor": 2}'Extra payload (JSON)
floor_countInteger4Elevator floor count
floor_spacingFloat3.0Elevator floor height

Sound sub-properties ​

PropertyTypeDefault
sound_volumeFloat1.0
sound_loopBooleantrue
sound_spatialBooleantrue
sound_distanceInteger100
sound_autoplayBooleantrue
sound_channelString"ambient"

Troubleshooting ​

Properties not recognized ​

  • Verify "Custom Properties" is enabled in export settings
  • Check property names for typos (case-sensitive)
  • Re-export the GLB after changes

Collision issues ​

  • Check that collider is set on the correct object
  • For mesh colliders, ensure geometry is manifold (no holes)
  • Try a simpler collider type (box instead of mesh)
  • Apply transforms before export

Sound not playing ​

  • Verify the audio file path is correct and the file exists in public/
  • Check sound_volume is not 0
  • For spatial sounds, check sound_distance is large enough
  • Ensure sound_autoplay is true if expecting immediate playback

Trigger not firing ​

  • Verify the trigger mesh has actual geometry (not an Empty)
  • Check the mesh is large enough to intersect with moving objects
  • Verify the trigger name matches what the code expects

Entity not spawning at correct position ​

  • Check that the Blender empty/mesh transform is applied
  • BabylonJS's glTF loader parents every imported model to a __root__ whose transform mirrors one axis, and folds that mirror into the world matrix of everything under it. Decomposing such a matrix answers with a negative scaling and a rotation of the wrong handedness. getCanonicalTransform(node), exported from @skewedaspect/sage, takes the mirror back out and answers with the world position exactly, a proper rotation, and a positive scaling. It is what GameLevel reads a marker's transform with.

LOD not switching ​

  • Verify child meshes are properly parented
  • Check the lod_distances format (comma-separated numbers)
  • Ensure child meshes exist at the specified indices
  • Test by moving the camera through the distance thresholds

Export errors ​

  • Apply all transforms (Ctrl+A > All Transforms)
  • Check for invalid geometry (non-manifold edges)
  • Remove unused materials and textures
  • Try exporting a smaller portion of the scene
  • Update Blender's glTF exporter add-on

Next steps ​

  • Level Loading -- the YAML config format and level transitions in detail
  • Sandbox -- see these Blender workflows in action with a complete first-person environment

Released under the MIT License.