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

Live Demos: Lights | Shadows | Spotlight Shadows

Nightshade supports three types of lights: directional, point, and spot.

Light types

Directional light (sun)

A directional light illuminates the entire scene from a single direction. It is the standard way to model distant light sources like the sun, where the rays are effectively parallel.

#![allow(unused)]
fn main() {
use nightshade::prelude::*;

let sun = spawn_sun(world);
}

spawn_sun returns the Entity for the directional light. The light can be reconfigured at any time.

#![allow(unused)]
fn main() {
if let Some(light) = world.core.get_light_mut(sun) {
    light.color = Vec3::new(1.0, 0.98, 0.95);
    light.intensity = 2.0;
}
}

Point light

A point light emits in all directions from a single position.

#![allow(unused)]
fn main() {
fn create_point_light(world: &mut World, position: Vec3, color: Vec3, intensity: f32) -> Entity {
    let entity = world.spawn_entities(
        LIGHT | LOCAL_TRANSFORM | GLOBAL_TRANSFORM | LOCAL_TRANSFORM_DIRTY,
        1
    )[0];

    world.core.set_light(entity, Light {
        light_type: LightType::Point,
        color,
        intensity,
        range: 10.0,
        cast_shadows: false,
        ..Default::default()
    });

    world.core.set_local_transform(entity, LocalTransform {
        translation: position,
        ..Default::default()
    });

    entity
}
}

Spot light

A spot light is a cone-shaped emitter. Flashlights, stage lighting, and headlights all fit this model.

#![allow(unused)]
fn main() {
fn create_spotlight(world: &mut World, position: Vec3, direction: Vec3) -> Entity {
    let entity = world.spawn_entities(
        LIGHT | LOCAL_TRANSFORM | GLOBAL_TRANSFORM | LOCAL_TRANSFORM_DIRTY,
        1
    )[0];

    world.core.set_light(entity, Light {
        light_type: LightType::Spot,
        color: Vec3::new(1.0, 0.95, 0.9),
        intensity: 15.0,
        range: 20.0,
        inner_cone_angle: 0.2,  // Full intensity cone
        outer_cone_angle: 0.5,  // Falloff cone
        cast_shadows: true,
        shadow_bias: 0.002,
    });

    world.core.set_local_transform(entity, LocalTransform {
        translation: position,
        rotation: nalgebra_glm::quat_look_at(&direction.normalize(), &Vec3::y()),
        ..Default::default()
    });

    entity
}
}

Light properties

PropertyDescription
colorRGB color of the light
intensityBrightness multiplier
rangeMaximum distance for point/spot lights
cast_shadowsWhether this light creates shadows
shadow_biasOffset to reduce shadow acne
inner_cone_angleSpot light inner cone (full intensity)
outer_cone_angleSpot light outer cone (falloff edge)

Dynamic lighting

Flickering light

A flickering fire or torch effect is three out-of-phase sine waves summed onto a base intensity.

#![allow(unused)]
fn main() {
fn update_flickering_light(world: &mut World, light_entity: Entity) {
    let time = world.resources.window.timing.uptime_milliseconds as f32 / 1000.0;

    if let Some(light) = world.core.get_light_mut(light_entity) {
        let flicker1 = (time * 8.0).sin() * 0.15;
        let flicker2 = (time * 12.5).sin() * 0.1;
        let flicker3 = (time * 23.0).sin() * 0.08;

        let base_intensity = 3.5;
        light.intensity = base_intensity + flicker1 + flicker2 + flicker3;
    }
}
}

Color cycling

Three phase-offset sines on the RGB channels cycle through the color wheel.

#![allow(unused)]
fn main() {
fn update_disco_light(world: &mut World, light_entity: Entity) {
    let time = world.resources.window.timing.uptime_milliseconds as f32 / 1000.0;

    if let Some(light) = world.core.get_light_mut(light_entity) {
        light.color = Vec3::new(
            (time * 2.0).sin() * 0.5 + 0.5,
            (time * 2.0 + 2.094).sin() * 0.5 + 0.5,
            (time * 2.0 + 4.188).sin() * 0.5 + 0.5,
        );
    }
}
}

Flashlight (camera-attached spotlight)

A flashlight is a spotlight whose transform tracks the active camera every frame.

#![allow(unused)]
fn main() {
fn update_flashlight(world: &mut World, flashlight: Entity) {
    let Some(camera) = world.resources.active_camera else { return };
    let Some(camera_transform) = world.core.get_global_transform(camera) else { return };

    let position = camera_transform.translation();
    let forward = camera_transform.forward_vector();

    if let Some(transform) = world.core.get_local_transform_mut(flashlight) {
        transform.translation = position;
        transform.rotation = nalgebra_glm::quat_look_at(&forward, &Vec3::y());
    }
    mark_local_transform_dirty(world, flashlight);
}
}

How lighting works

Nightshade uses a clustered forward rendering pipeline. The view frustum is divided into a 16x9x24 grid of clusters. A compute shader assigns each light to the clusters it overlaps, producing a per-cluster light list with a cap of 256 lights per cluster. During the mesh pass, each fragment looks up its cluster and only evaluates the lights assigned to it. The cost is bounded by the lights that actually reach the pixel rather than the total light count in the scene.

PBR lighting model

All lights are evaluated using the Cook-Torrance microfacet BRDF. The model has three parts.

The Normal Distribution Function (D) is Trowbridge-Reitz GGX. It models the statistical distribution of microfacet orientations. The squared roughness parameter (a = roughness * roughness) controls how concentrated the specular highlight is.

The Geometry Function (G) is the Schlick-Beckmann approximation combined with Smith's method. It accounts for self-shadowing between microfacets. Two terms get combined, one for the view direction and one for the light direction.

The Fresnel term (F) is Schlick's approximation. It computes how reflectivity changes with viewing angle. For dielectrics, F0 is derived from the index of refraction. For metals, F0 equals the base color.

The final light contribution per light is:

(kD * albedo / PI + specular) * radiance * NdotL

where kD = (1 - F) * (1 - metallic) ensures metals have no diffuse component.

Image-based lighting

Ambient lighting comes from two pre-computed cubemaps. The irradiance map is a pre-convolved diffuse environment, sampled in the surface normal direction. The prefiltered environment map is five mip levels of increasingly blurred specular reflections, sampled in the reflection direction at a mip level chosen by roughness.

A 2D BRDF lookup texture (the split-sum approximation) combines with the prefiltered map to produce the final specular IBL contribution.

Atmosphere

Set the sky rendering mode.

#![allow(unused)]
fn main() {
world.resources.graphics.atmosphere = Atmosphere::Sky;
}

Multiple lights

Nightshade supports many lights in a scene. Combine the spawn helpers above to build out an environment.

#![allow(unused)]
fn main() {
fn setup_lighting(world: &mut World) {
    spawn_sun(world);

    create_point_light(world, Vec3::new(5.0, 3.0, 5.0), Vec3::new(0.8, 0.9, 1.0), 2.0);
    create_point_light(world, Vec3::new(-5.0, 3.0, -5.0), Vec3::new(1.0, 0.8, 0.7), 1.5);

    create_spotlight(world, Vec3::new(0.0, 5.0, 0.0), Vec3::new(0.0, -1.0, 0.0));
}
}