mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 05:27:08 -06:00
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:
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user