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
- Go to File > Export > glTF 2.0 (.glb/.gltf)
- Set Format to glTF Binary (.glb)
- Configure the remaining settings:
Include:
| Setting | Value | Why |
|---|---|---|
| Selected Objects | Off | Export the entire scene |
| Visible Objects | On | Skip hidden helper objects |
| Renderable Objects | On | |
| Active Collection | Off | |
| Include Nested Collections | On | |
| Custom Properties | On | Required for SAGE metadata |
Transform:
| Setting | Value |
|---|---|
| +Y Up | On |
Data > Lighting:
| Setting | Value | Why |
|---|---|---|
| Lighting Mode | Unitless | Standard mode produces values that are massively overexposed in BabylonJS. See Blender lights. |
Data > Mesh:
| Setting | Value |
|---|---|
| Apply Modifiers | On |
| UVs | On |
| Normals | On |
| Vertex Colors | On (if used) |
Data > Materials:
| Setting | Value |
|---|---|
| Export | On |
| Images Format | Automatic |
Animation (if needed):
| Setting | Value |
|---|---|
| Use Current Frame | Off |
| Export Animations | On |
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:
- Select an object in the 3D viewport
- Open the Properties panel (right side)
- Click the Object Properties tab (orange square icon)
- Scroll down to Custom Properties
- Click New to add a property
Property types
| Type | Example | Use case |
|---|---|---|
| String | "assets/audio/music.mp3" | File paths, names, JSON context |
| Integer | 100 | Distances, counts, floor numbers |
| Float | 0.75 | Volume, scale factors |
| Boolean | Checkbox | Enable/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
- Add an Empty object (Add > Empty > Plain Axes)
- Position and rotate it where you want the entity to appear
- Add a custom property:
- Name:
spawn - Type: String
- Value: A unique name (e.g.,
player_start,goblin_patrol_1)
- Name:
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:
spawns:
player_start:
entity: PlayerEntity
displayName: Hero
config:
health: 100When 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
- Select a mesh in your scene (a door, a chest, a button)
- Add a custom property:
- Name:
entity - Type: String
- Value: The marker name the level config's
entitiessection maps to a class (e.g.,door,button,elevator)
- Name:
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:
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 case | Property | What happens |
|---|---|---|
| Place a new object (player, enemy, item) | spawn | Empty replaced with a spawned entity that builds its own node |
| Make an existing mesh interactive (door, button) | entity | The 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
| Property | Type | Default | Description |
|---|---|---|---|
sound | String | -- | Path to audio file (required) |
sound_volume | Float | 1.0 | Volume (0.0 to 1.0) |
sound_loop | Boolean | true | Loop the audio |
sound_spatial | Boolean | true | Use 3D positional audio |
sound_distance | Integer | 100 | Maximum audible distance for spatial audio |
sound_autoplay | Boolean | true | Start playing on load |
sound_channel | String | "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
.mp3or.oggfor best compatibility - Set
sound_spatial = truefor environmental sounds,falsefor music or UI - Set
sound_distancebased on the object's size and importance in the scene
Colliders
Colliders define physical boundaries for collision detection.
Collider types
| Type | Performance | Best for |
|---|---|---|
box | Fast | Crates, walls, rectangular objects |
sphere | Fast | Balls, barrels, rounded objects |
mesh | Slow | Complex shapes, terrain |
none | N/A | Visual 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.
| Property | Type | Default | Purpose |
|---|---|---|---|
collider_motion | String | "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_mass | Float | 1 | Mass 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 = trueSetup:
- Create your detailed visual mesh
- Create a simplified version for collision
- Parent the simplified mesh to the detailed mesh
- On the parent: set
collider = "mesh" - 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 ameshcollider 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
- Create a mesh that defines the trigger area (a box, in most cases)
- Add a custom property:
- Name:
trigger - Type: String
- Value: A unique trigger name
- 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_entranceis better thantrigger_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 detailEach 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:
- Model the full-detail mesh -- this one is the parent
- Duplicate and reduce polygons (Decimate modifier or manual retopo)
- Repeat for each LOD level
- Parent all reduced meshes to the full-detail mesh
- Name them clearly:
Tree_LOD1,Tree_LOD2,Tree_LOD3 - Add
lod_distancesto 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 = 20Lighting 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.
// 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 type | Unit | Notes |
|---|---|---|
HemisphericLight | lux | Ambient fill. 0.7 -- 1.0 typical. |
DirectionalLight | lux | Sun/key light. Set to Math.PI (~3.14) for "full" brightness, since PBR divides by PI. |
PointLight | candela | Uses inverse square falloff. Values like 100 -- 500 are typical. 0.6 will be invisible. |
SpotLight | candela | Same 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.
// 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.
| Property | Type | Range | Description |
|---|---|---|---|
albedoColor | Color3 | 0--1 per channel | Base color (equivalent to diffuse) |
metallic | number | 0--1 | 0 = dielectric (plastic, wood), 1 = metal |
roughness | number | 0--1 | 0 = mirror-smooth, 1 = matte |
emissiveColor | Color3 | 0--1 per channel | Self-illumination color |
emissiveIntensity | number | 0+ | Emissive brightness multiplier |
Common material recipes:
// 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:
- File > Export > glTF 2.0
- 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:
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.
Recommended Blender workflow
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, chestsThis 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'
spawnvalues should match the YAML config keys
Transform hygiene
Before export:
- Apply all transforms (Ctrl+A > All Transforms) on meshes that use collision
- Check for non-manifold edges if using mesh colliders
- Ensure scale is (1, 1, 1) on objects where precise collision matters
Iteration workflow
- Author the scene in Blender with custom properties
- Export as GLB to the project's public assets directory
- Run the dev server (
npm run dev) - Test in-browser -- check spawn positions, collision, sounds, and triggers
- Adjust in Blender and re-export over the same path
- 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 configKeep 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
| Property | Type | Example | Purpose |
|---|---|---|---|
spawn | String | "player_start" | Mark spawn location |
entity | String | "door" | Mark mesh as entity |
sound | String | "audio/fire.mp3" | Attach sound |
collider | String | "box" | Add collision |
trigger | String | "boss_room" | Create trigger zone |
occlusion | String | "strict" | Occlusion culling |
lod_distances | String | "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:
| Property | Type | Example | Purpose |
|---|---|---|---|
target | String | "Door_Main" | The node name of the entity an interaction activates |
prompt | String | "to open" | Interaction prompt text |
locked | Boolean | true | Lock the interaction |
context | String | '{"floor": 2}' | Extra payload (JSON) |
floor_count | Integer | 4 | Elevator floor count |
floor_spacing | Float | 3.0 | Elevator floor height |
Sound sub-properties
| Property | Type | Default |
|---|---|---|
sound_volume | Float | 1.0 |
sound_loop | Boolean | true |
sound_spatial | Boolean | true |
sound_distance | Integer | 100 |
sound_autoplay | Boolean | true |
sound_channel | String | "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
collideris 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_volumeis not 0 - For spatial sounds, check
sound_distanceis large enough - Ensure
sound_autoplayis 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 whatGameLevelreads a marker's transform with.
LOD not switching
- Verify child meshes are properly parented
- Check the
lod_distancesformat (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
