Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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, direction is the way the light travels. With +z down, keep a positive z component to stay above the horizon.
  • Point (PointLight) — world position, hard radius cutoff (zero contribution beyond it; keep radii tight, they bound the shading work).
  • Spot (SpotLight) — a point light with a cone: direction is the axis, inner_angle_deg/outer_angle_deg are 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-back over compositing. 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 is 1 − (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),
        ]);
  • Terrainset_terrain_materials maps voxel colours (low 24 bits) to material ids: glass walls and water pools built with plain set_rect calls.
  • Whole sprite instanceset_sprite_instance_material(id, mat); pair with set_sprite_instance_alpha to pulse opacity per frame without touching the volume.
  • Per voxeladd_sprite_model_with_materials / add_voxel_clip_with_materials take 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.