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 occluders 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
- Scale -- passed through (useful for size variations)
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: player
config:
health: 100When the level loads, SAGE finds the Blender empty named player_start, creates a player entity at its position, and merges the config values into the entity's initial state.
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 interactive game objects. Unlike spawn points (which replace an empty with a new entity), entity markers wrap the mesh itself as the entity's visual.
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 entity type (e.g.,
door,button,elevator)
- Name:
The Blender object name becomes the entity's name at runtime. You can look up entities by name with entityManager.getByName('Door_Main'), which is how interaction targets are resolved.
Additional metadata
Entity markers can carry extra metadata that behaviors read in onNodeAttached():
Mesh: "ElevatorButton_F2"
entity = "elevator_button"
target = "Elevator"
prompt = "to call elevator"
locked = true
context = '{"floor": 2}'The behavior reads these from node.metadata:
onNodeAttached(node : TransformNode) : void
{
this.target = (node.metadata?.target as string) ?? null;
this.locked = !!(node.metadata?.locked);
}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 spawned entity |
| Make an existing mesh interactive (door, button) | entity | Mesh wrapped with entity behaviors |
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"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)
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 (usually a box)
- 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 with the trigger name and entering entity. When it exits, trigger:exit fires.
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
Occluders
Occluders are invisible meshes that help the engine skip rendering objects hidden behind them.
Mesh: "Wall_Large"
occluder = trueUse occluders for large, solid objects like walls and pillars. Keep the occluder shapes simple.
Level of detail (LOD)
LOD switches between mesh detail levels based on camera distance.
Mesh: "Tree" (parent)
lod_distances = "15,30,60"
Mesh: "Tree_LOD0" (child) -- full detail
Mesh: "Tree_LOD1" (child) -- medium detail
Mesh: "Tree_LOD2" (child) -- low detailThe distances define the transition points:
- 0 to 15 units: LOD0 (full detail)
- 15 to 30 units: LOD1
- 30 to 60 units: LOD2
- Beyond 60: not rendered
To create LOD meshes:
- Model the full-detail mesh
- Duplicate and reduce polygons (Decimate modifier or manual retopo)
- Repeat for each LOD level
- Parent all LOD meshes to the main mesh
- Name them clearly:
Tree_LOD0,Tree_LOD1,Tree_LOD2 - Add
lod_distancesto the parent
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 single most common reason a scene looks washed out, pitch black, or inconsistent between Blender-authored and code-created objects.
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 mat = new StandardMaterial('crate-mat', scene);
mat.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 reasonable 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. It is not a hack -- real-time games use emissive materials constantly for UI elements, collectibles, and interactive objects.
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 — technically 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';
protected 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 - Entity names in Blender become entity names at runtime -- keep them meaningful
- Spawn point empty names 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
- The dev server hot-reloads the GLB on the next level load
Physics debugging
The sandbox example includes an F3 toggle for the Havok physics viewer. Use it to visualize collision shapes and verify that your colliders match the visual meshes.
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 |
occluder | Boolean | true | Occlusion optimization |
lod_distances | String | "15,30,60" | Level of detail |
Entity metadata properties
| Property | Type | Example | Purpose |
|---|---|---|---|
target | String | "Door_Main" | Interaction target entity name |
prompt | String | "to open" | Interaction prompt text |
locked | Boolean | true | Lock the interaction |
context | String | '{"floor": 2}' | Extra payload (JSON) |
mass | Float | 20 | Physics mass for props |
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
- The GLB root node applies a coordinate system conversion -- use
Vector3.TransformCoordinates(localPos, root.getWorldMatrix())to convert to world space
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
