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 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

  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
  • 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:

yaml
spawns:
  player_start:
    entity: player
    config:
      health: 100

When 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

  1. Select a mesh in your scene (a door, a chest, a button)
  2. Add a custom property:
    • Name: entity
    • Type: String
    • Value: The entity type (e.g., door, button, elevator)

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:

typescript
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 casePropertyWhat happens
Place a new object (player, enemy, item)spawnEmpty replaced with spawned entity
Make an existing mesh interactive (door, button)entityMesh 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

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"

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)

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 (usually a box)
  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 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_entrance is better than trigger_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 = true

Use 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 detail

The 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:

  1. Model the full-detail mesh
  2. Duplicate and reduce polygons (Decimate modifier or manual retopo)
  3. Repeat for each LOD level
  4. Parent all LOD meshes to the main mesh
  5. Name them clearly: Tree_LOD0, Tree_LOD1, Tree_LOD2
  6. Add lod_distances to 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.

typescript
// 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 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 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.

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. 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:

  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';

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.

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
  • 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:

  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
  6. 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 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
occluderBooleantrueOcclusion optimization
lod_distancesString"15,30,60"Level of detail

Entity metadata properties

PropertyTypeExamplePurpose
targetString"Door_Main"Interaction target entity name
promptString"to open"Interaction prompt text
lockedBooleantrueLock the interaction
contextString'{"floor": 2}'Extra payload (JSON)
massFloat20Physics mass for props
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
  • 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

Released under the MIT License.