Review fixes: the environment shadow follows the tinted radiance (#2436)

dominant_direction weighted texels by their raw channel sum before the
light's colour tinted the radiance, so equally bright red and blue regions
on opposite sides cancelled to no sun and no key — while a red Environment
light's tint removed the blue contribution and left the scene lit
directionally red, with no shadow for its Cast Shadows to show. The tint
now weights the direction sum (a white light weights [1; 3], a coloured
one its normalised colour), so the sun and key are derived from the
radiance the light actually emits; add_environment computes the tint once,
before the direction pass, and reuses it for the texel tinting. An
achromatic image is unaffected (its weights scale uniformly), so the
default studio and white-lit scenes keep their shadows exactly.

Tests: opposed red and blue texels cancel to a white light but a red tint
points the sun straight at the red one (a blue tint, at the blue one); end
to end, a red Environment light over an image with equally bright red and
blue regions on exactly antipodal positions casts the shadow a white light
over the same image does not — and the shadow falls opposite the red
region, the side the tinted radiance comes from.
This commit is contained in:
Z User
2026-10-27 14:11:55 +00:00
parent eb34d10d21
commit a8cba23edc
4 changed files with 142 additions and 23 deletions
+21 -13
View File
@@ -819,23 +819,27 @@ pub fn irradiance_map(sw: u32, sh: u32, src: &[[f32; 4]], w: u32, h: u32) -> Vec
out
}
/// The environment image's dominant direction and key strength: the radiance-weighted mean
/// texel direction (solid-angle weighted, rotation applied), or `([0; 3], 0.0)` when the image
/// is directionless (a uniform environment cancels to zero, and opposing bright regions that
/// cancel leave no mean either). The mean of a single bright spot
/// The environment image's dominant direction and key strength: the mean texel direction
/// weighted by the radiance the light actually emits — solid-angle weighted, rotation applied,
/// each texel's channels scaled by `tint` (the light's colour, `[1; 3]` for a white one) — or
/// `([0; 3], 0.0)` when that radiance is directionless (a uniform environment cancels to zero,
/// and opposing bright regions that cancel leave no mean either — though a chromatic one need
/// not stay directionless once tinted: a red light over equally bright red and blue opposite
/// regions removes the blue pull and points at the red one). The mean of a single bright spot
/// on black points straight at it; a gradient points somewhere between its ends. The returned
/// key fraction is the mean vector's length over the radiance weight — how directional the
/// image is, 0 (uniform) to ~1 (a lone spot) — and is the most of its light an Environment
/// light's shadow can remove.
pub(crate) fn dominant_direction(w: u32, h: u32, d: &[[f32; 4]], rot: f32) -> ([f32; 3], f32) {
pub(crate) fn dominant_direction(w: u32, h: u32, d: &[[f32; 4]], tint: [f32; 3], rot: f32) -> ([f32; 3], f32) {
use super::shade::equirect_dir;
let (mut x, mut y, mut z) = (0.0f32, 0.0f32, 0.0f32);
let mut weight = 0.0f32;
for (i, p) in d.iter().enumerate() {
let (tx, ty) = ((i % w as usize) as u32, (i / w as usize) as u32);
let (u, v) = ((tx as f32 + 0.5) / w as f32, (ty as f32 + 0.5) / h as f32);
// Luminance × solid angle (∝ sin θ).
let lum = (p[0] + p[1] + p[2]) * (v * std::f32::consts::PI).sin();
// Tinted radiance × solid angle (∝ sin θ): the shadow must follow the light the
// environment emits, not the raw texels.
let lum = (p[0] * tint[0] + p[1] * tint[1] + p[2] * tint[2]) * (v * std::f32::consts::PI).sin();
let dir = equirect_dir(u, v, rot);
x += dir[0] * lum;
y += dir[1] * lum;
@@ -863,9 +867,16 @@ pub(crate) fn add_environment(
shadow_darkness: f64,
shadow_diffusion: f64,
) {
// Intensity × colour folded into the radiance (one scalar on the GPU): tint the texels.
// A white (or off) light leaves them as they are.
let k = (intensity[0] + intensity[1] + intensity[2]) / 3.0;
let tint = if k > 0.0 && (intensity[0] != intensity[1] || intensity[1] != intensity[2]) { intensity.map(|c| c / k) } else { [1.0, 1.0, 1.0] };
// The shadow direction and key strength from the full-resolution image, before the mip
// chain averages it — only a shadow-casting light reads them, so only one pays for the pass.
let (sun, key) = if casts_shadows { dominant_direction(w, h, &data, rotation) } else { ([0.0; 3], 0.0) };
// chain averages it — only a shadow-casting light reads them, so only one pays for the
// pass — and weighted by the tint, so the shadow follows the radiance the light emits:
// opposing chromatic regions that cancel untinted need not cancel once the colour removes
// one of them (a red light over equally bright red and blue sides shadows from the red).
let (sun, key) = if casts_shadows { dominant_direction(w, h, &data, tint, rotation) } else { ([0.0; 3], 0.0) };
// Radiance level 0 at most 1024 wide.
let (mut w, mut h, mut d) = (w, h, data);
while w > 1024 {
@@ -888,10 +899,7 @@ pub(crate) fn add_environment(
let (sw, sh, sd) = small.unwrap_or((w, h, d));
let irr = irradiance_map(sw, sh, &sd, 32, 16);
let ii = b.texture_from(32, 16, [0, 1], irr.into_iter()) as u32;
// Intensity × colour folded into the radiance (one scalar on the GPU): tint the texels.
let k = (intensity[0] + intensity[1] + intensity[2]) / 3.0;
if k > 0.0 && (intensity[0] != intensity[1] || intensity[1] != intensity[2]) {
let tint = intensity.map(|c| c / k);
if tint != [1.0, 1.0, 1.0] {
for t in first..=ii {
let ti = b.s.textures[t as usize];
for p in &mut b.s.texels[ti.offset as usize..(ti.offset + ti.width * ti.height) as usize] {
+114 -8
View File
@@ -74,9 +74,9 @@ fn constant_environment_has_constant_irradiance() {
#[test]
fn a_non_finite_environment_has_no_sun_direction() {
let nan = vec![[f32::NAN; 4]; 8 * 4];
assert_eq!(dominant_direction(8, 4, &nan, 0.0), ([0.0; 3], 0.0));
assert_eq!(dominant_direction(8, 4, &nan, [1.0; 3], 0.0), ([0.0; 3], 0.0));
let inf = vec![[f32::INFINITY, 0.0, 0.0, 1.0]; 8 * 4];
assert_eq!(dominant_direction(8, 4, &inf, 0.0), ([0.0; 3], 0.0));
assert_eq!(dominant_direction(8, 4, &inf, [1.0; 3], 0.0), ([0.0; 3], 0.0));
}
/// The key fraction: a uniform image is directionless (key 0), a lone bright spot on black is
@@ -85,13 +85,13 @@ fn a_non_finite_environment_has_no_sun_direction() {
#[test]
fn environment_key_strength_tracks_directionality() {
let uniform = vec![[0.5f32, 0.5, 0.5, 1.0]; 8 * 4];
assert_eq!(dominant_direction(8, 4, &uniform, 0.0).1, 0.0);
assert_eq!(dominant_direction(8, 4, &uniform, [1.0; 3], 0.0).1, 0.0);
let mut spot = vec![[0.0f32; 4]; 64 * 32];
spot[16 * 64 + 32] = [1.0, 1.0, 1.0, 1.0];
let (_, key) = dominant_direction(64, 32, &spot, 0.0);
let (_, key) = dominant_direction(64, 32, &spot, [1.0; 3], 0.0);
assert!(key > 0.95, "a lone spot is almost fully directional: {key}");
let (w, h, d) = scene::default_environment();
let (_, key) = dominant_direction(w, h, &d, 0.0);
let (_, key) = dominant_direction(w, h, &d, [1.0; 3], 0.0);
assert!((0.35..0.55).contains(&key), "the default studio is a broad key with fill: {key}");
}
@@ -110,18 +110,47 @@ fn opposing_bright_regions_cancel_to_no_shadow() {
let (a, b) = ((15 * w + 16) as usize, (16 * w + 48) as usize);
opposed[a] = [1.0, 1.0, 1.0, 1.0];
opposed[b] = [1.0, 1.0, 1.0, 1.0];
assert_eq!(dominant_direction(w, h, &opposed, 0.0), ([0.0; 3], 0.0));
assert_eq!(dominant_direction(w, h, &opposed, [1.0; 3], 0.0), ([0.0; 3], 0.0));
// One side twice as bright: the mean points at the brighter spot with its share of the
// weight (key = 1/3 — two parts against one).
let mut tilted = opposed.clone();
tilted[a] = [2.0, 2.0, 2.0, 1.0];
let (sun, key) = dominant_direction(w, h, &tilted, 0.0);
let (sun, key) = dominant_direction(w, h, &tilted, [1.0; 3], 0.0);
assert!(sun != [0.0; 3] && key > 0.3, "the brighter side wins: {sun:?} key {key}");
let want = shade::equirect_dir(16.5 / 64.0, 15.5 / 32.0, 0.0);
let dot = sun[0] * want[0] + sun[1] * want[1] + sun[2] * want[2];
assert!(dot > 0.999, "the sun points at the brighter spot: {sun:?} vs {want:?}");
}
/// #2436 review: the sun follows the tinted radiance, not the raw texels — equally bright red
/// and blue regions on exactly opposite texels cancel to a white light (no shadow), but the
/// red light's tint removes the blue side's pull, so the mean points straight at the red one
/// (a blue light, at the blue one): the shadow lands where the emitted light comes from.
#[test]
fn a_coloured_light_shadows_from_the_region_it_tints() {
let (w, h) = (64u32, 32u32);
let mut opposed = vec![[0.0f32; 4]; (w * h) as usize];
// Rows 15 and 16 mirror about the equator, columns 16 and 48 half a width apart: the two
// texel-centre directions are exactly opposite — equal solid-angle weight, opposite pull.
let (a, b) = ((15 * w + 16) as usize, (16 * w + 48) as usize);
opposed[a] = [1.0, 0.0, 0.0, 1.0];
opposed[b] = [0.0, 0.0, 1.0, 1.0];
// To a white light the channel sums are equal: they cancel, no shadow.
assert_eq!(dominant_direction(w, h, &opposed, [1.0; 3], 0.0), ([0.0; 3], 0.0));
// A red light's tint zeroes the blue texel's weight: the red one becomes a lone spot.
// (Any positive scale of the tint — the normalised [3, 0, 0] included — points the same
// way; only the channel ratios matter.)
let (sun, key) = dominant_direction(w, h, &opposed, [1.0, 0.0, 0.0], 0.0);
let want = shade::equirect_dir(16.5 / 64.0, 15.5 / 32.0, 0.0);
let dot = sun[0] * want[0] + sun[1] * want[1] + sun[2] * want[2];
assert!(dot > 0.999 && key > 0.95, "the red side wins: {sun:?} key {key}");
// A blue light points at the blue one instead.
let (sun, _) = dominant_direction(w, h, &opposed, [0.0, 0.0, 1.0], 0.0);
let want = shade::equirect_dir(48.5 / 64.0, 16.5 / 32.0, 0.0);
let dot = sun[0] * want[0] + sun[1] * want[1] + sun[2] * want[2];
assert!(dot > 0.999, "the blue side wins: {sun:?}");
}
#[test]
fn equirect_round_trip_and_env_brdf() {
for (u, v) in [(0.1f32, 0.2f32), (0.5, 0.5), (0.9, 0.7)] {
@@ -596,6 +625,83 @@ fn a_uniform_environment_casts_no_shadow() {
assert_eq!(casting.data, off.data, "a directionless image casts no shadow");
}
/// #2436 review: a chromatic environment under a coloured light — equally bright red and blue
/// regions on exactly opposite sides of the image cancel to a white light (it casts no
/// shadow), but the red Environment light's tint removes the blue region's pull, so the red
/// one becomes the key and casts the shadow the user asked for, from the red side.
#[test]
fn a_red_light_shadows_from_the_red_region_of_a_chromatic_environment() {
let (mut p, cid) = project(200, 150);
// The floor facing the camera, a sphere in front of it casting shadows only.
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Plane }, |l| {
set(l, "geometryOptions/width", Value::Scalar(400.0));
set(l, "geometryOptions/height", Value::Scalar(400.0));
set(l, "transform/position", Value::Vec3([100.0, 75.0, 110.0]));
set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0]));
});
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Sphere }, |l| {
set(l, "geometryOptions/radius", Value::Scalar(30.0));
set(l, "transform/position", Value::Vec3([100.0, 75.0, 50.0]));
set(l, "materialOptions/castsShadows", Value::Enum(2));
});
// The Environment Layer: a black background with a red square and an equally bright blue
// one at exactly antipodal image positions (the blue is the point mirror of the red through
// the antipodal map x -> x + w/2, y -> h - y: centres (14.5, 44.5) and (114.5, 105.5) in a
// 200x150 image, 20x20 each) — equal solid-angle weight, opposite pull. The red sits up,
// left and toward the camera; the blue down, right and away from it.
add(&mut p, cid, LayerSource::Shape, |l| {
l.environment = true;
let mut next = 10_000u64;
let ids = &mut build::Ids(&mut next);
// Contents stack like the AE timeline — the first group is on top — so the background
// goes in last, under the red and blue squares (and its full-comp rect keeps the
// layer buffer full-size: a shape layer's buffer otherwise crops to its content).
let red_rect = build::shape_rect(ids, [20.0, 20.0], [-85.5, -30.5], 0.0);
let red_fill = build::shape_fill(ids, [1.0, 0.0, 0.0, 1.0]);
let red = build::shape_group(ids, "Red", vec![red_rect, red_fill]);
let blue_rect = build::shape_rect(ids, [20.0, 20.0], [14.5, 30.5], 0.0);
let blue_fill = build::shape_fill(ids, [0.0, 0.0, 1.0, 1.0]);
let blue = build::shape_group(ids, "Blue", vec![blue_rect, blue_fill]);
let bg_rect = build::shape_rect(ids, [200.0, 150.0], [0.0, 0.0], 0.0);
let bg_fill = build::shape_fill(ids, [0.0, 0.0, 0.0, 1.0]);
let bg = build::shape_group(ids, "BG", vec![bg_rect, bg_fill]);
for g in [red, blue, bg] {
l.props.sub_mut("contents").unwrap().children.push(g.into());
}
});
// A white light over the chromatic image: the regions cancel — Cast Shadows changes
// nothing, exactly like a directionless image.
add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| {
set(l, "lightOptions/castsShadows", Value::Bool(true));
});
let white_casting = render(&p, cid);
let comp = p.comp_mut(cid).unwrap();
set(comp.layers.iter_mut().find(|l| l.is_light()).unwrap(), "lightOptions/castsShadows", Value::Bool(false));
let white_off = render(&p, cid);
assert_eq!(white_casting.data, white_off.data, "a white light sees no dominant region in the opposed red and blue");
// The red light: the tint removes the blue region, the red one shadows the sphere onto the
// floor below and right of it (the shadow falls opposite the red region).
let comp = p.comp_mut(cid).unwrap();
let light = comp.layers.iter_mut().find(|l| l.is_light()).unwrap();
set(light, "lightOptions/color", Value::Color([1.0, 0.0, 0.0, 1.0]));
set(light, "lightOptions/castsShadows", Value::Bool(true));
let img = render(&p, cid);
let chan = |img: &crate::Image, x: usize, y: usize| img.data[y * 200 + x][0];
let side = chan(&img, 25, 75);
let (mut core, mut cx) = (f32::INFINITY, 0usize);
for y in 100..150 {
for x in 60..170 {
if chan(&img, x, y) < core {
core = chan(&img, x, y);
cx = x;
}
}
}
assert!(side > 0.1, "the floor is lit red: {side}");
assert!(core < side * 0.65, "the red region shadows the sphere onto the floor: {core} vs {side} at x {cx}");
assert!(cx > 100, "the shadow is opposite the red region (right of the sphere): {cx}");
}
/// #2436: the default environment (no Source, no Environment Layer) is a dim studio — a mean
/// of 0.3, not the 1.0 that would act like a 100 % white Ambient light and wash the scene out
/// (After Effects' default does not) — and directional: its dominant region points up and
@@ -613,7 +719,7 @@ fn default_environment_keeps_the_level_and_points_up() {
weight += sa;
}
assert!((mean / weight - 0.3).abs() < 0.01, "dim studio mean: {}", mean / weight);
let (sun, key) = dominant_direction(w, h, &d, 0.0);
let (sun, key) = dominant_direction(w, h, &d, [1.0; 3], 0.0);
assert!(sun[1] < -0.5, "the key is up: {sun:?}");
assert!(sun[2] < -0.3, "the key is toward the camera: {sun:?}");
assert!(sun[0].abs() < 0.1, "symmetric left-right: {sun:?}");
+4 -1
View File
@@ -221,7 +221,10 @@ Things that tripped up a long agent-driven session (a multi-scene 3D piece built
like the other lights: Cast Shadows shades the scene from the image's dominant bright region
(the default environment's key, or a source image's brightest area; an image without a
sufficiently strong dominant direction has none — a uniform one, or opposing bright regions
that cancel — so it casts no shadow) — occluding only that key's share of the light, so the
that cancel — so it casts no shadow) — the region weighted by the light's colour, so a
coloured light shadows from the regions it actually lights (opposing red and blue regions
cancel to a white light, but a red light's shadow comes from the red one) — occluding only
that key's share of the light, so the
shadow stays partial like After Effects' area-light shadows, and soft by default (a penumbra
of at least 8 pixels or four map texels, plus Shadow Diffusion). It only lights
Advanced 3D comps, so creating one (or changing a light's type to Environment) switches a
+3 -1
View File
@@ -165,7 +165,9 @@ mip chain with the split-sum environment BRDF fit), and percentage-closer-filter
(spot: perspective, parallel: orthographic over the scene, point: six cube faces, environment:
one orthographic from the image's dominant bright region — none only when the image lacks a
sufficiently strong dominant direction (a uniform one, or opposing bright regions that
cancel) — occluding only the image's directional key fraction of
cancel) — the region weighted by the light's colour, so a coloured light shadows from the
regions it actually lights (opposing red and blue regions cancel to a white light, but a red
light's shadow comes from the red one) — occluding only the image's directional key fraction of
the light, so the shadow stays partial like After Effects' area-light shadows, with a soft
default penumbra of at least eight pixels or four map texels). A Source or Environment Layer
that fails to render — a deleted layer, missing footage — lights nothing: no silent fallback