Review fixes: a failed environment source lights nothing (#2436)

Two review points on the merge:

- env_pixels returned None both for an unconfigured light (no Source, no
  Environment Layer) and for a failed source (a deleted layer, missing
  footage, a failed decode), so a selected source that failed silently
  swapped in the default studio environment — relighting and reshadowing
  the scene behind the user's back. It now tells the three apart
  (EnvPixels::Absent/Image/Failed): only the unconfigured light uses the
  default; a failed source contributes nothing, exactly like a layer whose
  source fails renders nothing. The repair the user can act on stays where
  it already lives — missing footage is flagged on the item, with the
  relink commands to fix it.

- The docs claimed only a uniform image casts no shadow. A non-uniform one
  can be directionless too: opposing bright regions cancel, and anything
  under 1% directional is rejected. agents.md, architecture.md and the
  sun/dominant_direction doc comments now say an image without a
  sufficiently strong dominant direction.

Tests: an unreadable Environment Layer (auto-picked), an unreadable
selected Source and a deleted one all light nothing — pixel-equal to the
unlit render, no studio default, no default-environment shadow — while an
unconfigured light still lights; two equal bright spots at opposite
azimuths cancel to no sun and no key, and tilting the balance re-emerges
pointing at the brighter spot.
This commit is contained in:
Z User
2026-10-27 12:33:46 +00:00
parent 978ca419b9
commit eb34d10d21
4 changed files with 141 additions and 19 deletions
+36 -11
View File
@@ -174,8 +174,8 @@ pub struct EnvInfo {
pub rotation: f32,
/// Unit direction from the scene towards the environment's dominant (radiance-weighted)
/// bright region — the direction its shadow is cast from, like a parallel light's
/// `to_light`. `[0; 3]` when the image is directionless (uniform) or the light casts no
/// shadow (nothing reads it then): no shadow.
/// `to_light`. `[0; 3]` when the image is directionless (uniform, or opposing bright
/// regions that cancel) or the light casts no shadow (nothing reads it then): no shadow.
pub sun: [f32; 3],
/// The image's directional (key) fraction — `|Σ dir·radiance| / Σ radiance` over the
/// sphere, 0 for a uniform image and near 1 for a single bright spot. An Environment
@@ -821,7 +821,8 @@ pub fn irradiance_map(sw: u32, sh: u32, src: &[[f32; 4]], w: u32, h: u32) -> Vec
/// 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). The mean of a single bright spot
/// 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
/// 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
@@ -915,13 +916,30 @@ pub(crate) fn add_environment(
});
}
/// The environment layer's pixels as linear straight RGBA.
fn env_pixels(r: &Renderer, ctx: &EvalCtx, src: Option<u64>, lin: bool) -> Option<(u32, u32, Vec<[f32; 4]>)> {
/// The Environment light's image: `Absent` when the light has no Source and the comp has no
/// Environment Layer (the default studio environment applies — After Effects "uses a default
/// environment map" for an Environment light too), `Image` when the source renders, `Failed`
/// when a selected or found source produced no pixels (a deleted layer, missing footage, a
/// failed decode). A failed source must not silently become the default environment — that
/// would relight and reshadow the scene behind the user's back — so, like any layer whose
/// source fails renders nothing, it contributes nothing (#2436).
enum EnvPixels {
Absent,
Image((u32, u32, Vec<[f32; 4]>)),
Failed,
}
fn env_pixels(r: &Renderer, ctx: &EvalCtx, src: Option<u64>, lin: bool) -> EnvPixels {
let layer = match src {
Some(id) => ctx.comp.layer(effectcraft_project::LayerId(id))?,
None => ctx.comp.layers.iter().find(|l| l.environment && l.is_active_at(ctx.time))?,
Some(id) => ctx.comp.layer(effectcraft_project::LayerId(id)),
None => ctx.comp.layers.iter().find(|l| l.environment && l.is_active_at(ctx.time)),
};
let buf = r.blend_layer_buf(ctx, layer)?;
let Some(layer) = layer else {
// A set Source that resolves to no layer (deleted) failed; an unset one with no
// Environment Layer is simply unconfigured.
return if src.is_some() { EnvPixels::Failed } else { EnvPixels::Absent };
};
let Some(buf) = r.blend_layer_buf(ctx, layer) else { return EnvPixels::Failed };
let img = &buf.img;
let data = img
.data
@@ -935,7 +953,7 @@ fn env_pixels(r: &Renderer, ctx: &EvalCtx, src: Option<u64>, lin: bool) -> Optio
if lin { [s[0], s[1], s[2], 1.0] } else { [srgb_to_linear(s[0]), srgb_to_linear(s[1]), srgb_to_linear(s[2]), 1.0] }
})
.collect();
Some((img.width, img.height, data))
EnvPixels::Image((img.width, img.height, data))
}
/// The default environment when the light has no Source and no Environment Layer: a dim studio
@@ -1065,15 +1083,22 @@ pub(crate) fn build_at(r: &Renderer, ctx: &EvalCtx, sub: Option<&EvalCtx>, run:
b.s.ambient = amb;
if let Some(env) = env {
match env_pixels(r, ctx, env.src, lin) {
Some((w, h, d)) => add_environment(&mut b, w, h, d, env.color, env.rot, env.casts_shadows, env.shadow_darkness, env.shadow_diffusion),
EnvPixels::Image((w, h, d)) => {
add_environment(&mut b, w, h, d, env.color, env.rot, env.casts_shadows, env.shadow_darkness, env.shadow_diffusion)
}
// No Source and no Environment Layer: the default environment, a soft studio
// gradient of the light's colour × intensity — After Effects "uses a default
// environment map" for an Environment light — so the light always lights the
// scene per its parameters, and its dominant region casts shadows (#2436).
None => {
EnvPixels::Absent => {
let (w, h, d) = default_environment();
add_environment(&mut b, w, h, d, env.color, env.rot, env.casts_shadows, env.shadow_darkness, env.shadow_diffusion)
}
// A selected or found source that produced no pixels (deleted layer, missing
// footage, failed decode): no environment at all — the light contributes
// nothing, exactly like a layer whose source failed renders nothing, never a
// silent swap to the default studio's lighting and shadows (#2436).
EnvPixels::Failed => {}
}
}
if r.opts.shadows() {
+91
View File
@@ -95,6 +95,33 @@ fn environment_key_strength_tracks_directionality() {
assert!((0.35..0.55).contains(&key), "the default studio is a broad key with fill: {key}");
}
/// #2436 review: a non-uniform image can be directionless too — two equal bright spots at
/// opposite azimuths pull the radiance-weighted mean in opposite directions and cancel,
/// leaving no dominant region to shadow from (under 1 % directional): no sun, no key, so no
/// shadow, just like a uniform image. Tilt the balance and a direction re-emerges, pointing
/// at the brighter spot.
#[test]
fn opposing_bright_regions_cancel_to_no_shadow() {
let (w, h) = (64u32, 32u32);
let mut opposed = vec![[0.0f32; 4]; (w * h) as usize];
// Rows 15 and 16 mirror about the equator (v = 0.484375 / 0.515625), and columns 16 and 48
// sit half a width apart (u = 0.2578125 / 0.7578125): 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, 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));
// 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);
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:?}");
}
#[test]
fn equirect_round_trip_and_env_brdf() {
for (u, v) in [(0.1f32, 0.2f32), (0.5, 0.5), (0.9, 0.7)] {
@@ -404,6 +431,70 @@ fn environment_light_without_a_source_lights_the_scene() {
assert!(c5[0] > 0.15 && c5[1] < 0.05 && c5[2] < 0.05, "red environment: {c5:?}");
}
/// #2436 review: a selected or auto-picked environment source that fails to render (missing
/// footage, a deleted layer) must not silently swap in the default studio environment — the
/// light contributes nothing, exactly like a layer whose source failed renders nothing. Only a
/// light with no Source at all (and no Environment Layer in the comp) uses the default.
#[test]
fn a_failed_environment_source_lights_nothing() {
let (mut p, cid) = project(120, 90);
let sid = p.add_item(
"S",
Default::default(),
None,
ItemKind::Solid(effectcraft_project::Solid { color: [1.0, 1.0, 1.0], width: 60, height: 40, pixel_aspect: 1.0 }),
);
add(&mut p, cid, LayerSource::Solid { item: sid }, |l| l.switches.three_d = true);
let unlit = render(&p, cid);
// The Environment Layer's footage is unreadable (missing; NoFootage serves no frames).
let fid = p.add_item(
"F",
Default::default(),
None,
ItemKind::Footage(effectcraft_project::Footage {
path: "missing.exr".into(),
kind: effectcraft_project::FootageKind::Still,
width: 64,
height: 32,
pixel_aspect: 1.0,
duration: Tick::from_seconds_f64(1.0),
has_video: true,
missing: true,
..Default::default()
}),
);
add(&mut p, cid, LayerSource::Footage { item: fid }, |l| {
l.switches.three_d = true;
l.environment = true;
});
let env_layer = p.comp(cid).unwrap().layers.iter().find(|l| l.environment).unwrap().id;
add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| {
set(l, "lightOptions/castsShadows", Value::Bool(true));
});
fn light(p: &mut Project, cid: ItemId) -> &mut effectcraft_project::Layer {
p.comp_mut(cid).unwrap().layers.iter_mut().find(|l| l.is_light()).unwrap()
}
// Auto-picked Environment Layer, unreadable: the light is off — no studio default, no
// default-environment shadow either.
let failed = render(&p, cid);
assert_eq!(failed.data, unlit.data, "an unreadable Environment Layer lights nothing");
// The light's Source set to that unreadable layer: same.
set(light(&mut p, cid), "lightOptions/source", Value::Layer(Some(env_layer.0)));
let failed = render(&p, cid);
assert_eq!(failed.data, unlit.data, "an unreadable selected Source lights nothing");
// The Source pointing at a deleted layer: same.
set(light(&mut p, cid), "lightOptions/source", Value::Layer(Some(999_999)));
let failed = render(&p, cid);
assert_eq!(failed.data, unlit.data, "a deleted selected Source lights nothing");
// Contrast: with no Source and no Environment Layer, the default studio still lights it.
let comp = p.comp_mut(cid).unwrap();
comp.layers.retain(|l| l.is_light() || !l.environment);
set(comp.layers.iter_mut().find(|l| l.is_light()).unwrap(), "lightOptions/source", Value::Layer(None));
let lit = render(&p, cid);
let (c, c2) = (unlit.data[20 * 120 + 30], lit.data[20 * 120 + 30]);
assert!(c2[3] > 0.99 && (c2[0] - c[0]).abs() > 0.05, "the unconfigured light still lights: {c2:?} vs {c:?}");
}
/// #2436: an Environment light's Cast Shadows works like After Effects' — its image's dominant
/// (brightest) region shadows the scene. A bright disc above the image's centre, turned to
/// shine from up and behind the camera (rotation 180°), shadows a sphere onto the floor below