Lighting & materials
roxlap lights a scene in two layers. Baked lighting lives in each
voxel’s VoxColor brightness byte — remember from
chapter 2: that byte is shading intensity, not alpha — and
costs nothing per frame. Runtime lighting is a per-frame rig of
sun + point + spot lights with stylized hard voxel shadows, composited
on top. Both layers, and the transparent-voxel materials at the end of
this chapter, run on both backends.
The snippets come from a runnable example — a courtyard under a sweeping shadow-casting sun, orbiting coloured points, a spot cone, a glass wall and a volumetric fog cloud:
cargo run --release -p roxlap-render --example book_lighting
The formula that ties the layers together:
pixel = albedo × (baked byte × rig.ambient) + Σ direct light terms
The baked byte is the ambient/AO channel. With lights: None (the
default) you get exactly the classic render — baked byte only.
Baked lighting & ambient occlusion
Grid::bake(mode) walks the grid once and writes shading into every
voxel’s brightness byte. The two BakeModes:
BakeMode::Directional— estnorm shading, the classic Voxlap look (surface-normal-based sun shading, baked). Use it standalone when you don’t run a light rig — the cave and terrain demos ship this.BakeMode::AmbientOcclusion(AoParams)— crevices, pillar bases and inner corners darken. This is the right bake under a runtime rig: the rig treats the byte as its ambient fill, so AO gives contact shading everywhere the dynamic lights don’t reach.
/// A courtyard: floor, four pillars, a central monument and a glass
/// wall — then bake ambient occlusion into the brightness byte.
fn build_scene() -> Scene {
let mut scene = Scene::new();
let id = scene.add_grid(GridTransform::at(DVec3::ZERO));
let grid = scene.grid_mut(id).expect("grid just added");
grid.set_rect(
IVec3::new(-96, -96, 210),
IVec3::new(95, 95, 254),
Some(GRASS),
);
for (px, py) in [(-60, -60), (40, -60), (-60, 40), (40, 40)] {
grid.set_rect(
IVec3::new(px, py, 165),
IVec3::new(px + 12, py + 12, 209),
Some(STONE),
);
}
grid.set_rect(
IVec3::new(-10, -10, 150),
IVec3::new(10, 10, 209),
Some(MONUMENT),
);
// A glass wall (the colour is mapped to a translucent material at
// renderer setup — the terrain itself just stores the colour).
grid.set_rect(
IVec3::new(-70, -2, 170),
IVec3::new(-30, 1, 209),
Some(GLASS_RGB),
);
// A crystal capping the monument — the colour maps to an EMISSIVE
// material, so it glows through every lighting mode below.
grid.set_sphere(IVec3::new(0, 0, 147), 4, Some(CRYSTAL_RGB));
// Bake ambient occlusion into every voxel's brightness byte:
// crevices, pillar bases and inner corners darken. The runtime
// lights below read this byte as their ambient/AO fill. Re-bake
// after bulk edits (for small runtime carves use `bake_bbox` —
// it re-bakes just the hole).
grid.bake(BakeMode::AmbientOcclusion(AoParams {
strength: 0.85, // fraction of ambient removed in a crevice
radius: 1, // contact reach in voxels (1 = tight edges)
..AoParams::default()
}));
scene
}
The bake is neighbour-aware across chunk seams in all three axes (no brightness discontinuities at chunk borders) and rayon-parallel. Costs and cadence:
- Bake once after building a grid — it is a bulk operation.
- After a runtime carve, don’t re-bake the grid: pass the edit’s
bbox to
bake_bbox— it re-bakes a few hundred columns instead of whole chunks (the cave demo measured ~0.04 ms against 4–7 ms). - Streaming grids bake per chunk as they stream in — a scene-wide bake at startup would miss every chunk generated later.
The runtime rig
Lighting is per-frame state: build a
LightRig and set it on
FrameParams::lights. There are deliberately no light setters on the
renderer — lights flow the same way sky and fog already do, so
“remove the light” is just “stop passing it”.
// Lighting is per-frame: build a LightRig and hand it to
// FrameParams — there are no stateful light setters.
// The sun sweeps overhead (+z is down, so a positive z
// component keeps it above the horizon).
let a = t * 0.4;
let sun = DirectionalLight {
direction: [0.7 * a.cos() as f32, 0.7 * a.sin() as f32, 0.55],
color: [1.0, 0.93, 0.82], // warm daylight
intensity: 1.25,
casts_shadow: true,
};
// Two orbiting coloured fills; only one casts shadows
// (shadow casters share a small per-frame budget —
// excess casters are demoted with a log warning).
let b = t * 0.8;
let points = [
PointLight {
position: [70.0 * b.cos() as f32, 70.0 * b.sin() as f32, 195.0],
color: [1.0, 0.25, 0.2],
intensity: 4.0,
radius: 80.0,
casts_shadow: true,
},
PointLight {
position: [-70.0 * b.cos() as f32, -70.0 * b.sin() as f32, 195.0],
color: [0.25, 0.45, 1.0],
intensity: 4.0,
radius: 80.0,
casts_shadow: false,
},
];
// A spot cone pinning the monument: position + axis +
// inner/outer half-angles (soft edge between them).
let spots = [SpotLight {
position: [0.0, 0.0, 110.0],
direction: [0.0, 0.0, 1.0], // straight down
color: [1.0, 0.9, 0.6],
intensity: 6.0,
radius: 120.0,
inner_angle_deg: 12.0,
outer_angle_deg: 22.0,
casts_shadow: false,
}];
frame.lights = Some(LightRig {
sun: Some(sun),
points: &points,
spots: &spots,
// Dim the baked ambient so the runtime lights read
// as the key; the baked byte is the ambient/AO fill.
ambient: [0.32, 0.34, 0.4],
shadow_strength: 0.85,
shadow_bias_voxels: 1.5,
shadow_max_dist: 256.0,
// Stylized cel look: quantize diffuse into 6 bands
// and ramp shadows toward a cool tint instead of
// black. `bands: 0` = smooth diffuse.
bands: 6,
shadow_tint: [0.16, 0.2, 0.34],
});
The three light types:
- Sun (
DirectionalLight) — one per scene,directionis the way the light travels. With +z down, keep a positive z component to stay above the horizon. - Point (
PointLight) — world position, hardradiuscutoff (zero contribution beyond it; keep radii tight, they bound the shading work). - Spot (
SpotLight) — a point light with a cone:directionis the axis,inner_angle_deg/outer_angle_degare half-angles with a smoothstep falloff between them. Internally a spot is a point light (a point is the 180°-cone degenerate), so spots share the point-light count and shadow budgets.
Shadows are stylized hard voxel shadows, flagged per light with
casts_shadow. Casters are budgeted per frame: only the first few
flagged lights actually cast; the rest are demoted to shadowless with
a log warning — never dropped. The rig-level knobs:
shadow_strength (how dark), shadow_bias_voxels (~1.5 kills
self-shadow acne), shadow_max_dist (sun-ray length cap).
Stylized mode: bands ≥ 1 quantizes the diffuse into discrete
cel levels, and the sun term drives a gradient from shadow_tint
(cool, unlit) to the sun colour (warm, lit) — hue-shifted shadows
instead of plain darkening, which keeps the retro identity instead of
reading as generic Phong. bands: 0 is smooth diffuse. Compare them
live in the Lighting demo scene (J toggles, [/] change bands).
Materials: transparent voxels
Both backends march rays strictly front-to-back, which makes order-correct transparency free — no depth sorting, no OIT scheme: a translucent voxel just composites over whatever the ray finds behind it.
Materials stay out of the colour word (its high byte is taken —
that’s the brightness/ambient story above). Instead the renderer owns
a 256-entry material palette; a voxel carries a one-byte material
id. Id 0 is permanently opaque, so anything without material data
renders exactly as before. A Material is an opacity plus a
BlendMode:
Opaque— first hit wins (the default path).AlphaBlend— front-to-backovercompositing. Glass, shells, windows. Opacity is per surface, independent of thickness.Additive— adds light without occluding, order-independent. Spell glows, fire, muzzle flashes.Volumetric— Beer–Lambert absorption: per-cell opacity is1 − (1 − α)^path_length, so a filled volume reads denser through its core than its rim. True smoke, fog, murky water.
Materials attach at three levels:
// The global material palette: 256 slots, id 0 permanently
// opaque. A material is opacity + blend mode; voxels reference
// it by id.
renderer.define_material(MAT_GLASS, Material::alpha_blend(110));
renderer.define_material(MAT_FOG, Material::volumetric(28));
// Emissive (EV): the crystal renders at over-bright albedo —
// immune to the bake, the runtime rig, shadows and side shades
// (only fog still applies). `with_emissive` composes with any
// blend mode; `Material::glow(e)` is the opaque shorthand.
renderer.define_material(MAT_CRYSTAL, Material::alpha_blend(180).with_emissive(255));
// Terrain: map voxel colours (low 24 bits) → material ids. Every
// grid voxel in the glass colour now composites translucently.
renderer.set_terrain_materials(&[
(GLASS_RGB.rgb_part(), MAT_GLASS),
(CRYSTAL_RGB.rgb_part(), MAT_CRYSTAL),
]);
- Terrain —
set_terrain_materialsmaps voxel colours (low 24 bits) to material ids: glass walls and water pools built with plainset_rectcalls. - Whole sprite instance —
set_sprite_instance_material(id, mat); pair withset_sprite_instance_alphato pulse opacity per frame without touching the volume. - Per voxel —
add_sprite_model_with_materials/add_voxel_clip_with_materialstake a colour→material map, so one model mixes opaque and translucent voxels (a window: opaque frame, glass panes — static or animated).
The hollow-shell trap
Volumetric needs actual interior voxels to absorb through — and the
standard Kv6::from_fn constructor culls interiors (a normal sprite
only needs its shell). Build filled volumes with
from_fn_keep_interior:
/// A **filled** fog ball for the volumetric material. The default
/// `Kv6::from_fn` culls interior voxels (a sprite normally only needs
/// its shell) — but Beer–Lambert opacity accumulates along the ray's
/// path *through* the volume, so a hollow shell would read as two thin
/// films. `from_fn_keep_interior` keeps the inside; its second closure
/// says which colours to keep (translucent ones — opaque interiors
/// would still be dead weight).
fn fog_cloud() -> Kv6 {
const DIM: u32 = 28;
let c = (DIM as f32 - 1.0) * 0.5;
let r2 = (DIM as f32 * 0.48).powi(2);
Kv6::from_fn_keep_interior(
DIM,
DIM,
DIM,
|x, y, z| {
let (dx, dy, dz) = (x as f32 - c, y as f32 - c, z as f32 - c);
(dx * dx + dy * dy + dz * dz <= r2).then_some(FOG_RGB)
},
|color| color == FOG_RGB,
)
}
If your volumetric cloud renders as two thin films (front and back face only), this is why.
Backend parity
Translucent compositing runs on both backends — CPU since the TV
stage’s march rework, GPU via its own per-span accumulation paths
(sprites and terrain alike). The residual gaps are stylistic, not
structural: translucent sprites stay flat-lit (no side-shades, fog
or tint on the translucent layers), and compositing is per-pass — a
translucent sprite over translucent terrain resolves per pass rather
than through one unified blend. Neither has mattered in practice; if
one bites you, supports() (chapter 4) is still the
place a future split would surface.
Emissive voxels & baked glow
A material can also emit: Material { emissive, .. } (or the
Material::glow(e) shorthand, or .with_emissive(e) on any blend
mode) renders the voxel at albedo × (128 + e/2) / 128 — from 1× up
to ~2× over-bright at 255 — and skips everything that would darken
it: the baked brightness byte, per-face side shades, the runtime rig,
shadows, cel bands. Only fog still applies, so a distant glow fades
like the rest of the world. Emissive composes with translucency: an
AlphaBlend crystal glows through its own body. Both backends
render it identically; the terrain material map above is all the
wiring it needs (the MAT_CRYSTAL lines in the snippet).
Making the voxel bright is half the effect — a glow that doesn’t
light its surroundings reads as a sticker. The other half is the
point-light bake (voxlap’s lightmode 2): register the glow
sources on the grid and bake with BakeMode::PointLights, and each
light writes a cube-law Lambertian pool into the surrounding
brightness bytes over a deliberately dim directional base — light
pools reading against gloom. Baked means free at render time, on both
backends:
grid.set_sphere(p, params.crystal_radius, Some(params.color));
// The light floats in the air a few voxels off the
// surface so the pool spreads over the wall around it.
let lp = air + dir * (k - 3).max(0);
grid.bake_lights.push(crate::BakeLight {
pos: glam::Vec3::new(lp.x as f32, lp.y as f32, lp.z as f32),
radius: params.light_radius,
strength: params.light_strength,
});
// EV.4 — plant the glowing crystals (voxels + their bake lights)
// BEFORE the bake so the first bake already writes their pools.
plant_crystals(grid, preset, seed);
// WT.4 — flood the lower cave: after the crystals, before the
// bake (see the fn doc for why the order is load-bearing).
flood_below_waterline(grid);
// EV.4 — point-light bake: the dim voxlap lightmode-2 base plus a
// glow pool around every crystal. The gloom is deliberate — the
// cave now reads by crystal light.
grid.bake(roxlap_scene::BakeMode::PointLights);
Grid::bake_bbox — the incremental relight primitive for runtime
carves — picks the grid’s bake_lights up automatically, so shooting
a hole next to a crystal re-bakes the crater with its glow pool
intact. A BakeLight’s strength is on the brightness-byte scale:
the gain at distance d is roughly strength / d², so 2000 is a
reading-torch and 8000 floods a small cavern. For a light that
moves or flickers, use a runtime PointLight from the rig instead —
the bake is for scenery that stays put.
The cave demo is the live showcase: crystal clusters planted on
cavity walls, each one an emissive translucent blob plus a
BakeLight (cargo run --release -p roxlap-cave-demo).
Further reading
- The Lighting, Spotlight and Transparency demo scenes —
every effect in this chapter, live with toggles
(
ROXLAP_SCENE=Lighting cargo run --release -p roxlap-scene-demo). PORTING-DYNLIGHT.md,PORTING-SPOTLIGHT.md,PORTING-TRANSPARENCY.md— design history: why shadows are hard-edged, why spots fold into the point path, why transparency needed no OIT.