Skip to content

Search

Documentation

Rendering: 2D lights, shadows and post-processing

Lit 2D scenes in Balaur: light2d and occluder2d build a light map every sprite, polygon and tile is multiplied by, plus camera ambient and screen-space post.

Op deze pagina

A 2D scene draws unlit until it has a light2d. One light2d starts a light map at the camera's resolution. Everything drawn is multiplied by that map plus the camera's ambient_color. Sprites, polygons and tile maps are lit with no material.

A warm point light and a cool fill light over a backdrop, a rect and a circle occluder each casting a shadow away from the warm light, and the rest of the frame fallen to a dark ambient

A light

[[nodes]]
id = "n_lamp"
name = "Lamp"

[nodes.transform]
position = [0, 2.5, 0]

[nodes.light2d]
kind = "point"          # point | directional
color = "#ffd28a"
range = 8.0             # world units; a point light fades to nothing here
intensity = 1.6
shadow_enabled = true
  • The node's transform places a light and its rotation aims it. At rest a light2d shines along −y; the node's z rotation turns it.
  • A point light fades quadratically to zero at radius.
  • A directional light ignores radius and lights the whole view.
  • intensity above 1 pushes past white, which bloom picks up.

Ambient

[nodes.camera2d]
pixels_per_unit = 60.0
ambient_color = "#181c22"   # what an unlit surface is multiplied by
  • A scene with no light2d costs no extra pass and no extra draw.
  • With a light, everything the lights do not reach falls to the camera's ambient_color, black by default.
  • White ambient_color is the unlit look.

Occluders and shadows

[nodes.occluder2d]
# mesh = "meshes/wall.toml"   # points in order; empty derives the outline
# closed = true               # the last point joins the first
  • A light with shadow_enabled = true is blocked by any node with an occluder2d. Each edge is extruded away from the light into a polygon that masks the light.
  • Left empty, the outline follows the node's collider2d, else its circle, capsule, rect or sprite shape.
  • mesh names an outline of its own. A polyline or polygon node needs one.
  • Every edge casts, so an occluder stands in its own shadow. A crate that should stay lit needs a smaller outline than its sprite, or a light with shadow_enabled = false.
  • A directional light's shadows are parallel.

Three occluders under one directional light, each casting a parallel shadow strip at the light's angle: a rect, a circle and a capsule, their outlines traced faithfully

The editor with an occluder node selected: only the Points mode chip, an Occluder section in the inspector, and the light gizmos over the lit scene

  • The Polygon tool traces an occluder2d in Points mode. Triangles, UVs and weights modes are not offered.
  • The Lights chip in the Scene workspace draws every light's reach and every occluder's resolved outline, derived ones included.

Post-processing

[nodes.camera]
kind = "2d"
post = ["bloom"]
bloom_threshold = 0.9   # brightness a pixel has to pass
bloom_intensity = 0.7   # how much is added back
  • bloom is the one that applies in 2D; the light map gives it bright pixels to bleed.
  • ssao, ssr and dof are the other three, 3D only.
  • post spans both dimensions (ambient_color does not). The last current camera of either kind sets it.

Custom passes

post runs in list order. An unknown name is a material drawn over the whole frame.

[nodes.camera]
post = ["fog", "tonemap", "bloom", "materials/grade.toml"]
  • The engine's own passes run at fixed points: ssao and ssr feed shading, bloom rides the tonemap. Naming one switches it on.
  • Order applies to materials. Before tonemap a pass reads linear light and its output blooms. After it a pass reads the tonemapped picture.
  • A list without tonemap has it at the head: a plain list of materials runs over the finished frame.
  • A pass that fails to compile is reported and left out.

See Shaders for what a post material imports.

Light map coverage

  • The multiply covers sprites, polygons, tile maps, and a 3D scene under a 2D one.
  • Debug lines and particles draw after it and stay unlit.
  • Lights never touch the simulation. A lit game and a headless one tick identically.

Lights in 3D

[[nodes]]
name = "Key"

[nodes.transform]
position = [4, 7, 4]
rotation_euler = [-0.87266, 0.69813, 0]   # radians: -50° and 40°

[nodes.light3d]
kind = "spot"
color = "#fff2e0"
intensity = 6
range = 30
inner_angle_degrees = 25
outer_angle_degrees = 45

A spot light selected in the editor, its cone drawn in the viewport and its light3d rows in the inspector

  • The node's transform places it and its rotation aims it. kind is directional, point or spot.
  • A scene with no light3d keeps the engine's default key light. The first light3d replaces it.
  • visible on the node turns it off.
  • shadow_enabled says whether it casts. light_layers is a bitmask; a mesh or shape3d is lit when it shares a bit.

The scene's atmosphere

[nodes.environment]
sky = "skies/studio.hdr"
ambient_color = "#20242a"
tonemap = "neutral"
exposure = 1.1
fog_mode = "exponential"
fog_color = "#9fb4c8"
fog_density = 0.012
  • environment carries scene-wide settings: sky, ambient light, fog, exposure, tonemap, colour grading, shadow budget.
  • The last current one in tree order wins.
  • sky is a project-relative equirectangular image: .hdr, .exr or .png. It draws behind the scene. sky_enabled = false hides it; orientation and intensity still apply.
  • Image-based lighting from the sky is not bound yet. A scene is lit by its light3d nodes and its ambient.
  • The editor's Library dock ships three gradient skies.

A physically based material

A 3D material imports package::pbr for metallic, roughness, emissive and reflectance. The default is Lambert.


struct Params { metallic: f32, roughness: f32, reflectance: f32 }
@group(3) @binding(0) var<uniform> params: Params;

@fragment fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
    var s = default_surface(in);
    s.metallic = params.metallic;
    s.roughness = max(params.roughness, 0.045);
    s.reflectance = params.reflectance;
    return shade_pbr(in, s);
}
  • A [params] key named for one of six texture slots and given an image path binds that slot: albedo, normal, metallic_roughness, occlusion, emissive, height.
  • A slot left out gets a one-pixel neutral texture.
  • The editor's library ships four materials over one such shader.

Drawing only when asked

[window] low_processor = true makes a window draw a frame only when something asks for one. Input asks, and so do ui.request_repaint() and a repaint scheduled with ui.request_repaint(#{ after: seconds }).

Work that finishes on another thread wakes it when it reports, and so do a log line and a changed script. Between frames no script runs and nothing is drawn, and a browser tab checks every 16 ms. It suits a tool or a turn-based game. The editor runs this way; a game that moves every frame leaves it off.

[window]
low_processor = true

Limits

  • A frame draws at most 64 2D lights. Each shadow-casting one costs a pass.
  • A scene past that is told once in the log.
  • The 3D contract carries sixteen lights in its frame uniforms.