Skip to content

Light

Light is the unified type covering the three glTF KHR_lights_punctual kinds: directional (parallel rays for sun, sky, fill), point (radiates from a position with inverse-square falloff: lamps, candles, fireballs), and spot (point light constrained to a cone: flashlights, headlamps, theatre lighting). The kind is set once at construction via Light::directional, Light::point, or Light::spot.

The unified surface keeps the public API simple: one type, one Scene::add method, one Pass::add method, and one binding per platform. Setters fall into two groups:

The matching getters use Option<T> for the kind-specific fields: position, direction, range, inner_cone_angle, outer_cone_angle return Some only on the kinds that carry the value, None otherwise.

Lights hold Arc-shared state, so a single handle can be absorbed by multiple Passes; later mutators propagate to every shader the Light has been wired into.

The default Material::pbr shader uses forward shading. Every fragment loops over every active light and accumulates the contribution. That makes the per-frame cost roughly O(fragments × lights), so the practical ceiling depends on resolution and overdraw. The current cap of 32 lights fits the forward path comfortably; for scenes with hundreds of lights, a clustered / storage-buffer path is on the roadmap.

Cap semantics: lights bind to the shader, not the Pass

Section titled “Cap semantics: lights bind to the shader, not the Pass”

The 32-light cap is enforced per ShaderObject, not per Pass. Two Passes that share the same Material (and therefore the same shader) share its light slots:

  • Adding the same Light to Pass A and Pass B reuses one shader slot (the dedup is by shader-pointer identity). Both Passes render with that light visible.
  • Adding two different Lights, one to Pass A, one to Pass B, packs them into two separate slots on the shared shader. Both lights illuminate both Passes, even though each was only add(&...)-ed to one. That’s surprising if you think of a Pass as an isolated scene; it’s the right answer if you think of a Material as the lighting model and the Passes as views into the same lit world.
  • The cap exceeds at 32 distinct Lights on one shader. The 33rd returns Err(PassError::LightCapReached { cap: 32 }).

For genuinely independent lighting setups, build a separate Material per scene (each Material::pbr() allocates a fresh shader, so its 32 slots are independent) or pass your own shader to Material::custom. A single scene with one Material covers the typical case. The per- shader semantic is what lets Scene::add(&light) propagate through every Pass that renders the Scene without re-attaching.

name what it does
directional parallel rays from a world-space direction
point radiates from a world-space position with distance falloff
spot point light constrained to a cone

Construct a directional light. direction is the world-space vector the light travels along (so [0, -1, 0] is straight down, the noon sun); color is linear-RGB intensity ([1, 1, 1] for full white, scaled values for dimmer or tinted lights).

Use this for sun / moon / fill / key lights: anything where every shaded surface should receive parallel rays from a fixed direction. For positioned sources, see Light::point and Light::spot.

A directional light carries no position. Calling set_position, set_range, or set_cone_angles on a directional light returns LightError::FieldNotApplicable.

1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::Light;
let sun = Light::directional([0.3, -1.0, -0.4], [1.0, 0.95, 0.9]);
5 collapsed lines
assert_eq!(sun.direction(), Some([0.3, -1.0, -0.4]));
assert_eq!(sun.color(), [1.0, 0.95, 0.9]);
assert!(sun.position().is_none());
Ok(())
}

Construct a point light. position is the world-space location the light radiates from; color is linear-RGB intensity ([1, 1, 1] for full white). Falloff is inverse-square distance. Bring set_range in to cap the influence radius, and set_intensity to scale the color uniformly.

Use this for lamps, candles, fireballs: anything where light radiates outward from a fixed point. For a parallel-ray sun-like light see Light::directional; for a cone-constrained beam see Light::spot.

A point light carries no direction or cone. Calling set_direction or set_cone_angles on a point light returns LightError::FieldNotApplicable.

1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::Light;
let bulb = Light::point([0.0, 2.5, 0.0], [1.0, 0.95, 0.8]).set_intensity(15.0);
5 collapsed lines
assert_eq!(bulb.position(), Some([0.0, 2.5, 0.0]));
assert_eq!(bulb.color(), [1.0, 0.95, 0.8]);
assert!((bulb.intensity() - 15.0).abs() < 1.0e-6);
Ok(())
}

Construct a spot light. position is the world-space location the light radiates from; direction is the world-space vector the cone aims along (the cone axis is -direction); color is linear-RGB intensity.

Falloff is inverse-square distance with a smooth cone roll-off between an inner and outer cone angle (defaults: 0 and π/4). Tighten the beam with set_cone_angles, cap the influence with set_range, and scale the radiance with set_intensity.

Use this for flashlights, headlamps, theatre lighting, mood key-lights: anything where the lit volume is a cone aimed at a target. For an unconstrained point source see Light::point; for parallel-ray illumination see Light::directional.

A spot light carries every kind-specific field, so every kind-specific setter (set_position, set_direction, set_range, set_cone_angles) succeeds with Ok(self).

1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::Light;
let torch = Light::spot([0.0, 1.8, 1.0], [0.0, -0.3, -1.0], [1.0, 0.9, 0.7])
.set_intensity(5.0)
.set_cone_angles(0.15, 0.4)?;
5 collapsed lines
assert_eq!(torch.position(), Some([0.0, 1.8, 1.0]));
assert_eq!(torch.direction(), Some([0.0, -0.3, -1.0]));
assert!((torch.outer_cone_angle().unwrap() - 0.4).abs() < 1.0e-6);
Ok(())
}

Read which kind this Light was constructed as. Returns a LightKind value (Directional, Point, or Spot). Bindings expose this as a string literal ("directional", "point", "spot") so the kind tag round-trips the same way it does in glTF’s KHR_lights_punctual.

Use this when inspecting a Light built elsewhere (e.g. loaded from a glTF scene) to decide which kind-specific setters or getters to call.

1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::{Light, LightKind};
let sun = Light::directional([0.0, -1.0, 0.0], [1.0, 1.0, 1.0]);
let bulb = Light::point([0.0, 2.5, 0.0], [1.0, 1.0, 1.0]);
let torch = Light::spot([0.0, 1.8, 1.0], [0.0, -1.0, 0.0], [1.0, 1.0, 1.0]);
5 collapsed lines
assert_eq!(sun.kind(), LightKind::Directional);
assert_eq!(bulb.kind(), LightKind::Point);
assert_eq!(torch.kind(), LightKind::Spot);
Ok(())
}

Read the linear-RGB color the light emits. Defined for every kind. The returned value is the [r, g, b] triple stored on the Light. Call set_color to update it. The scalar intensity multiplier is separate and multiplies the color uniformly in the shader.

1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::Light;
let warm_lamp = Light::point([0.0, 2.0, 0.0], [1.0, 0.7, 0.4]);
3 collapsed lines
assert_eq!(warm_lamp.color(), [1.0, 0.7, 0.4]);
Ok(())
}

Read the scalar intensity multiplier. Defined for every kind. The shader multiplies the color by this value when accumulating the light’s contribution, so doubling intensity doubles every channel uniformly. Call set_intensity to update it.

1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::Light;
let bright = Light::point([0.0, 2.0, 0.0], [1.0, 1.0, 1.0]).set_intensity(5.0);
3 collapsed lines
assert!((bright.intensity() - 5.0).abs() < 1.0e-6);
Ok(())
}

Update the linear-RGB color the light emits. Defined for every kind; returns Self for chaining. The new value propagates live to every shader the Light has already been wired into; no re-attach needed.

1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::Light;
let lamp = Light::point([0.0, 2.0, 0.0], [1.0, 1.0, 1.0]);
// Warm-tint the lamp later — every Pass that absorbed `lamp` sees the
// new color on the next render.
lamp.set_color([1.0, 0.7, 0.4]);
3 collapsed lines
assert_eq!(lamp.color(), [1.0, 0.7, 0.4]);
Ok(())
}

Update the scalar intensity multiplier. Defined for every kind; returns Self for chaining. The shader multiplies the color by this value when accumulating, so doubling intensity doubles every channel uniformly. The new value propagates live to every shader the Light has already been wired into.

1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::Light;
let torch = Light::spot([0.0, 1.8, 1.0], [0.0, -1.0, 0.0], [1.0, 1.0, 1.0]);
torch.set_intensity(8.0);
3 collapsed lines
assert!((torch.intensity() - 8.0).abs() < 1.0e-6);
Ok(())
}

Read the world-space position the light radiates from. Returns Some([x, y, z]) for a point or spot light, None for a directional light (directional rays travel from infinitely far away, so there’s no position to report). Call set_position to update it.

1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::Light;
let lamp = Light::point([0.0, 2.5, 0.0], [1.0, 1.0, 1.0]);
let sun = Light::directional([0.0, -1.0, 0.0], [1.0, 1.0, 1.0]);
4 collapsed lines
assert_eq!(lamp.position(), Some([0.0, 2.5, 0.0]));
assert!(sun.position().is_none());
Ok(())
}

Read the world-space direction the light travels along. Returns Some([x, y, z]) for a directional or spot light, None for a point light (point sources radiate omnidirectionally, so there’s no direction to report). Call set_direction to update it.

1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::Light;
let sun = Light::directional([0.3, -1.0, -0.4], [1.0, 1.0, 1.0]);
let lamp = Light::point([0.0, 2.0, 0.0], [1.0, 1.0, 1.0]);
4 collapsed lines
assert_eq!(sun.direction(), Some([0.3, -1.0, -0.4]));
assert!(lamp.direction().is_none());
Ok(())
}

Read the influence-radius cap. Returns Some(value) for a point or spot light, None for a directional light (parallel rays have no distance falloff). A value of 0.0 means “unlimited”, matching glTF KHR_lights_punctual’s default; positive values cut the contribution off smoothly past that distance. Call set_range to update it.

1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::Light;
let lamp = Light::point([0.0, 2.0, 0.0], [1.0, 1.0, 1.0]);
let sun = Light::directional([0.0, -1.0, 0.0], [1.0, 1.0, 1.0]);
4 collapsed lines
assert_eq!(lamp.range(), Some(0.0));
assert!(sun.range().is_none());
Ok(())
}

Read the inner cone half-angle (radians). Returns Some(value) only for a spot light, None for directional and point lights. The inner cone is the band where the light reaches full intensity; beyond it the contribution smoothly rolls off until it reaches zero at the outer cone (see outer_cone_angle). Call set_cone_angles to update both at once.

1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::Light;
let torch = Light::spot([0.0, 1.8, 1.0], [0.0, -1.0, 0.0], [1.0, 1.0, 1.0])
.set_cone_angles(0.15, 0.4)?;
let lamp = Light::point([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]);
4 collapsed lines
assert!((torch.inner_cone_angle().unwrap() - 0.15).abs() < 1.0e-6);
assert!(lamp.inner_cone_angle().is_none());
Ok(())
}

Read the outer cone half-angle (radians). Returns Some(value) only for a spot light, None for directional and point lights. The outer cone is where the light’s contribution reaches zero. Beyond this angle the fragment receives nothing from this light. Between the inner and outer cones the contribution smoothly rolls off; see inner_cone_angle. Call set_cone_angles to update both at once.

1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::Light;
let torch = Light::spot([0.0, 1.8, 1.0], [0.0, -1.0, 0.0], [1.0, 1.0, 1.0])
.set_cone_angles(0.15, 0.4)?;
let sun = Light::directional([0.0, -1.0, 0.0], [1.0, 1.0, 1.0]);
4 collapsed lines
assert!((torch.outer_cone_angle().unwrap() - 0.4).abs() < 1.0e-6);
assert!(sun.outer_cone_angle().is_none());
Ok(())
}

Update the world-space position. Defined for point and spot lights only; returns Ok(self) for chaining. Calling this on a directional light returns Err(LightError::FieldNotApplicable { kind: Directional, field: "set_position" }) because directional rays carry no position. The new value propagates live to every shader the Light has already been wired into; no re-attach needed.

1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::Light;
let lamp = Light::point([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]);
lamp.set_position([3.0, 1.5, -2.0])?;
8 collapsed lines
assert_eq!(lamp.position(), Some([3.0, 1.5, -2.0]));
// Directional lights have no position; the call errors.
let sun = Light::directional([0.0, -1.0, 0.0], [1.0, 1.0, 1.0]);
let result = sun.set_position([0.0, 0.0, 0.0]);
assert!(result.is_err());
Ok(())
}

Update the world-space direction. Defined for directional and spot lights only; returns Ok(self) for chaining. Calling this on a point light returns Err(LightError::FieldNotApplicable { kind: Point, field: "set_direction" }) because point sources radiate omnidirectionally. The new value propagates live to every shader the Light has already been wired into; no re-attach needed.

1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::Light;
let sun = Light::directional([0.0, -1.0, 0.0], [1.0, 1.0, 1.0]);
sun.set_direction([0.3, -0.8, -0.5])?;
8 collapsed lines
assert_eq!(sun.direction(), Some([0.3, -0.8, -0.5]));
// Point lights have no direction; the call errors.
let lamp = Light::point([0.0, 2.0, 0.0], [1.0, 1.0, 1.0]);
let result = lamp.set_direction([0.0, -1.0, 0.0]);
assert!(result.is_err());
Ok(())
}

Update the influence-radius cap. Defined for point and spot lights only; returns Ok(self) for chaining. Calling this on a directional light returns Err(LightError::FieldNotApplicable { kind: Directional, field: "set_range" }) because parallel rays have no distance falloff. Passing a negative value returns Err(LightError::NegativeRange(value)) regardless of kind.

A value of 0.0 means “unlimited”, matching glTF KHR_lights_punctual’s default; positive values cut the contribution off smoothly past that distance. The new value propagates live to every shader the Light has already been wired into.

1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::Light;
let lamp = Light::point([0.0, 2.0, 0.0], [1.0, 1.0, 1.0]);
lamp.set_range(8.0)?;
12 collapsed lines
assert_eq!(lamp.range(), Some(8.0));
// Negative values error regardless of kind.
let negative = lamp.set_range(-1.0);
assert!(negative.is_err());
// Directional lights have no range; the call errors.
let sun = Light::directional([0.0, -1.0, 0.0], [1.0, 1.0, 1.0]);
let unsupported = sun.set_range(5.0);
assert!(unsupported.is_err());
Ok(())
}

Update the inner and outer cone half-angles (radians). Defined for spot lights only; returns Ok(self) for chaining. Calling this on a directional or point light returns Err(LightError::FieldNotApplicable { kind, field: "set_cone_angles" }).

The inner half-angle is the band where the light reaches full intensity; between the inner and outer the contribution smoothly rolls off; beyond the outer the fragment receives nothing from this light. Pass equal values for a hard-edged spot, or pass (0.0, π/4) for the default soft 45° cone.

The new values propagate live to every shader the Light has already been wired into; no re-attach needed.

1 collapsed line
fn main() -> Result<(), Box<dyn std::error::Error>> {
use fragmentcolor::Light;
let torch = Light::spot([0.0, 1.8, 1.0], [0.0, -1.0, 0.0], [1.0, 1.0, 1.0]);
torch.set_cone_angles(0.15, 0.4)?;
9 collapsed lines
assert!((torch.inner_cone_angle().unwrap() - 0.15).abs() < 1.0e-6);
assert!((torch.outer_cone_angle().unwrap() - 0.4).abs() < 1.0e-6);
// Non-spot lights error.
let lamp = Light::point([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]);
let unsupported = lamp.set_cone_angles(0.15, 0.4);
assert!(unsupported.is_err());
Ok(())
}