Give the Environment light After Effects' look (#2436)

The default studio environment's level-preserving mean of 1 acted like a
100% white Ambient light: with only the Environment light on, a white floor
rendered at full white (95% of the frame saturated) while its shadow core
went to pitch black — a fog over the scene, nothing like After Effects'
default. Three changes bring the look in line, CPU and GPU alike:

- The default environment is a dim studio now: a solid-angle-weighted mean
  of 0.3 (and a stronger key gradient), so a white floor reads a mid gray
  and the light's colour x intensity still scales the level.
- The shadow occludes only the image's directional (key) fraction of the
  light — |sum of dir x radiance| / sum of radiance, computed beside the
  dominant direction and carried in the scene — so the fill keeps shining
  inside the shadow: an area-light shadow, partial like After Effects'
  (a lone bright spot on black still shadows almost fully; a uniform image
  not at all).
- The environment shadow map's penumbra has a floor — eight output pixels,
  or four map texels where those are coarser — because the map covers the
  whole scene bounding sphere rather than the view, and its texels would
  otherwise stair-step the edge (all the more in a draft render's flat 512
  budget). Shadow Diffusion widens it further, as before.

Tests: the key fraction tracks directionality; the default keeps its dim
level, its direction and its key; the default and a bright key both shadow
the floor (the default partial, the key deep) with Cast Shadows off lit
everywhere; and the full look — a mid-gray lit floor (no fog), a partial
core, and a soft edge ramp in quality and draft renders alike.
This commit is contained in:
Minher0
2026-10-26 23:36:19 +00:00
parent aff46dabb6
commit 743119d6e5
8 changed files with 205 additions and 66 deletions
+4 -2
View File
@@ -322,6 +322,7 @@ fn pack(s: &Scene) -> [Vec<u8>; 7] {
intensity: 0.0,
rotation: 0.0,
sun: [0.0; 3],
key: 0.0,
shadow: -1,
shadow_darkness: 0.0,
shadow_diffusion: 0.0,
@@ -330,8 +331,9 @@ fn pack(s: &Scene) -> [Vec<u8>; 7] {
let lit = !(s.lights.is_empty() && s.env.is_none() && s.ambient == [0.0; 3]);
// env2: rotation, any lighting (0/1), shadow map index (-1 none), shadow darkness.
globals.extend([e.rotation, lit as u32 as f32, e.shadow as f32, e.shadow_darkness]);
// The environment's dominant direction (its shadow's source), for the shadow bias slope.
globals.extend([e.sun[0], e.sun[1], e.sun[2], 0.0]);
// The environment's dominant direction (its shadow's source), for the shadow bias slope,
// and its key fraction — how much of its light the shadow can remove (shade::env_shadow).
globals.extend([e.sun[0], e.sun[1], e.sun[2], e.key]);
let mats = s.materials.iter().flat_map(|m| {
let flags = (m.double_sided as u32)
| (m.unlit as u32) << 1
+4 -2
View File
@@ -320,14 +320,16 @@ fn light_shadow(li: u32, kind: u32, lpos: vec3<f32>, shadow: i32, darkness: f32,
return 1.0 - darkness * (1.0 - f);
}
// The Environment light's shadow from its dominant direction (shade::env_shadow).
// The Environment light's shadow from its dominant direction (shade::env_shadow). Only the
// image's key fraction (g.env_sun.w) of the light is occluded — the fill keeps shining — so
// the shadow stays partial the way After Effects renders an area-light shadow.
fn env_shadow(p: vec3<f32>, ng: vec3<f32>, n: vec3<f32>) -> f32 {
if (g.env2.z < 0.0) {
return 1.0;
}
let n_l = clamp(dot(n, g.env_sun.xyz), 0.0, 1.0);
let f = shadow_factor(u32(g.env2.z + 0.5) * SHADOW_STRIDE, p, ng, n_l);
return 1.0 - g.env2.w * (1.0 - f);
return 1.0 - g.env2.w * g.env_sun.w * (1.0 - f);
}
fn attenuation(kind: u32, falloff: u32, radius: f32, falloff_distance: f32, cos_inner: f32, cos_outer: f32, dir: vec3<f32>, to_l: vec3<f32>, dist: f32) -> f32 {
+9 -2
View File
@@ -599,13 +599,20 @@ pub(crate) fn shadow_maps(s: &mut Scene, draft: bool) {
match sizes.get(s.lights.len()).copied().flatten() {
Some(size) => {
let first = maps.len() as i32;
let soft = 1.0 + (e.shadow_diffusion * 0.5).min(24.0);
// After Effects' environment-light shadows are soft out of the box, and this
// map covers the whole scene bounding sphere — not the view — so its texels
// can be coarse (all the more in a draft render's flat 512 budget). A
// penumbra of at least eight output pixels, or four map texels where those
// are coarser, keeps the edge smooth at any map resolution; Shadow Diffusion
// widens it further, as for the other lights.
let texel = 2.0 * radius / size as f64;
let soft = (4.0 * texel).max(8.0) + (e.shadow_diffusion as f64 * 0.5).min(24.0);
let sun = e.sun.map(|v| v as f64);
let dir = [-sun[0], -sun[1], -sun[2]];
let eye = [centre[0] + sun[0] * radius * 2.0, centre[1] + sun[1] * radius * 2.0, centre[2] + sun[2] * radius * 2.0];
let hint = if dir[1].abs() < 0.9 { [0.0, 1.0, 0.0] } else { [0.0, 0.0, 1.0] };
let focal = (size as f64 * 0.5 / radius) as f32;
maps.push(ShadowMap { view: basis(eye, dir, hint), focal, ortho: true, size, offset: 0, bias: 1.5, radius: soft });
maps.push(ShadowMap { view: basis(eye, dir, hint), focal, ortho: true, size, offset: 0, bias: 1.5, radius: soft as f32 });
if let Some(e) = &mut s.env {
e.shadow = first;
}
+30 -18
View File
@@ -177,6 +177,13 @@ pub struct EnvInfo {
/// `to_light`. `[0; 3]` when the image is directionless (uniform) 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
/// light's shadow occludes only this much of its light: the key lobe is blocked where
/// the map says so, the remaining fill keeps shining, so the shadow stays partial the
/// way After Effects renders an area-light shadow (a broad environment like the default
/// studio shadows softly, a lone bright spot on black almost fully).
pub key: f32,
/// Shadow map index into [`Scene::shadows`]: 0 marked (assigned by `raster::shadow_maps`),
/// -1 casts no shadow. Gated by the light's Cast Shadows.
pub shadow: i32,
@@ -812,11 +819,14 @@ pub fn irradiance_map(sw: u32, sh: u32, src: &[[f32; 4]], w: u32, h: u32) -> Vec
out
}
/// The environment image's dominant direction: the radiance-weighted mean texel direction
/// (solid-angle weighted, rotation applied), or `[0; 3]` when the image is directionless
/// (a uniform environment cancels to zero). The mean of a single bright spot on black points
/// straight at it; a gradient points somewhere between its ends.
pub(crate) fn dominant_direction(w: u32, h: u32, d: &[[f32; 4]], rot: f32) -> [f32; 3] {
/// 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
/// 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) {
use super::shade::equirect_dir;
let (mut x, mut y, mut z) = (0.0f32, 0.0f32, 0.0f32);
let mut weight = 0.0f32;
@@ -835,9 +845,9 @@ pub(crate) fn dominant_direction(w: u32, h: u32, d: &[[f32; 4]], rot: f32) -> [f
// from. A non-finite sum (a NaN or infinite texel) is garbage, not a direction: none then.
let len = (x * x + y * y + z * z).sqrt();
if !weight.is_finite() || !len.is_finite() || weight <= 1e-9 || len < weight * 0.01 {
return [0.0; 3];
return ([0.0; 3], 0.0);
}
[x / len, y / len, z / len]
([x / len, y / len, z / len], (len / weight).clamp(0.0, 1.0))
}
/// Prefilter an environment image into the scene's textures.
@@ -852,9 +862,9 @@ pub(crate) fn add_environment(
shadow_darkness: f64,
shadow_diffusion: f64,
) {
// The shadow direction from the full-resolution image, before the mip chain averages it —
// only a shadow-casting light reads it, so only one pays for the pass.
let sun = if casts_shadows { dominant_direction(w, h, &data, rotation) } else { [0.0; 3] };
// 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) };
// Radiance level 0 at most 1024 wide.
let (mut w, mut h, mut d) = (w, h, data);
while w > 1024 {
@@ -897,6 +907,7 @@ pub(crate) fn add_environment(
intensity: k,
rotation,
sun,
key,
// Marked; `raster::shadow_maps` assigns the map (a directionless image casts none).
shadow: if casts_shadows { 0 } else { -1 },
shadow_darkness: shadow_darkness as f32,
@@ -927,12 +938,13 @@ fn env_pixels(r: &Renderer, ctx: &EvalCtx, src: Option<u64>, lin: bool) -> Optio
Some((img.width, img.height, data))
}
/// The default environment when the light has no Source and no Environment Layer: a soft studio
/// gradient, bright above and toward the initial camera, dark below and behind it — After
/// The default environment when the light has no Source and no Environment Layer: a dim studio
/// gradient, bright above and toward the initial camera, dark below and behind — After
/// Effects "uses a default environment map" for an Environment light too, and its default
/// casts shadows out of the box. The solid-angle-weighted mean is 1, so the light's colour ×
/// intensity still sets the overall level, like the flat default it replaces; the light's
/// Rotation turns the key around the scene (#2436).
/// casts shadows out of the box. The solid-angle-weighted mean is 0.3, a soft studio ambience
/// (a mean of 1 would act like a 100 % white Ambient light and wash the whole scene out, which
/// is not what After Effects' default looks like); the light's colour × intensity still scales
/// the overall level, and its Rotation turns the key around the scene (#2436).
pub(crate) fn default_environment() -> (u32, u32, Vec<[f32; 4]>) {
use super::shade::{dot, equirect_dir};
// The key region's centre: three quarters up, two thirds toward the viewer (the default
@@ -946,14 +958,14 @@ pub(crate) fn default_environment() -> (u32, u32, Vec<[f32; 4]>) {
let sa = (v * std::f32::consts::PI).sin();
for x in 0..w {
let u = (x as f32 + 0.5) / w as f32;
let l = 0.5 + 0.9 * dot(equirect_dir(u, v, 0.0), key).max(0.0);
let l = 0.15 + 1.2 * dot(equirect_dir(u, v, 0.0), key).max(0.0);
data.push([l, l, l, 1.0]);
mean += l * sa;
weight += sa;
}
}
// Keep the flat default's level: scale the gradient to a weighted mean of exactly 1.
let k = if mean.is_finite() && mean > 1e-6 { weight / mean } else { 1.0 };
// A dim studio: scale the gradient to a weighted mean of exactly 0.3.
let k = if mean.is_finite() && mean > 1e-6 { 0.3 * weight / mean } else { 1.0 };
for p in &mut data {
for c in 0..3 {
p[c] *= k;
+7 -3
View File
@@ -351,8 +351,12 @@ pub fn light_shadow(s: &Scene, l: &Light, p: [f32; 3], ng: [f32; 3], n_l: f32) -
1.0 - l.shadow_darkness * (1.0 - f)
}
/// The visibility of the Environment light's dominant direction at `p` (1 = unshadowed),
/// darkened by its Shadow Darkness. A directionless image (or a non-casting light) has no map
/// The visibility of the Environment light at `p` (1 = unshadowed), darkened by its Shadow
/// Darkness — but only by the image's key fraction: the shadow map occludes the dominant
/// (key) lobe, while the environment's fill keeps shining on the shadowed surface, so an
/// Environment light's shadow stays partial the way After Effects renders an area-light
/// shadow (a broad environment like the default studio shadows softly, a lone bright spot
/// on black almost fully). A directionless image (or a non-casting light) has no map
/// and shades unshadowed.
pub fn env_shadow(s: &Scene, e: &EnvInfo, p: [f32; 3], ng: [f32; 3], n: [f32; 3]) -> f32 {
if e.shadow < 0 || e.sun == [0.0; 3] {
@@ -361,7 +365,7 @@ pub fn env_shadow(s: &Scene, e: &EnvInfo, p: [f32; 3], ng: [f32; 3], n: [f32; 3]
let Some(m) = s.shadows.get(e.shadow as usize) else { return 1.0 };
let n_l = dot(n, e.sun).clamp(0.0, 1.0);
let f = shadow_factor(m, &s.shadow_texels, p, ng, n_l);
1.0 - e.shadow_darkness * (1.0 - f)
1.0 - e.shadow_darkness * e.key * (1.0 - f)
}
/// Unit direction from `p` to light `l`, and the distance (infinite for a parallel light).
+140 -32
View File
@@ -69,13 +69,30 @@ fn constant_environment_has_constant_irradiance() {
/// A NaN or infinite texel poisons the sums (every NaN comparison is false, so the thresholds
/// alone would pass it through as a NaN direction); an infinite one even where sin θ = 0 turns
/// into NaN. Either way the sum is garbage, not a direction: no sun, so no shadow (#2436).
/// into NaN. Either way the sum is garbage, not a direction: no sun and no key, so no shadow
/// (#2436).
#[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]);
assert_eq!(dominant_direction(8, 4, &nan, 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]);
assert_eq!(dominant_direction(8, 4, &inf, 0.0), ([0.0; 3], 0.0));
}
/// The key fraction: a uniform image is directionless (key 0), a lone bright spot on black is
/// almost fully directional, and a broad gradient sits in between — the soft studio default
/// shadows visibly but keeps most of its light as fill (#2436).
#[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);
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);
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);
assert!((0.35..0.55).contains(&key), "the default studio is a broad key with fill: {key}");
}
#[test]
@@ -388,8 +405,9 @@ fn environment_light_without_a_source_lights_the_scene() {
}
/// #2436: an Environment light's Cast Shadows works like After Effects' — its image's dominant
/// (brightest) region shadows the scene. A bright disc turned to shine from behind the camera
/// (rotation 180°) shadows a sphere onto the plane behind it; Cast Shadows off lights it evenly.
/// (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
/// it; Cast Shadows off lights it evenly.
#[test]
fn environment_lights_cast_shadows() {
let (mut p, cid) = project(200, 150);
@@ -397,7 +415,7 @@ fn environment_lights_cast_shadows() {
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, 200.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| {
@@ -405,38 +423,53 @@ fn environment_lights_cast_shadows() {
set(l, "transform/position", Value::Vec3([100.0, 75.0, 50.0]));
set(l, "materialOptions/castsShadows", Value::Enum(2));
});
// The Environment Layer: a bright disc at the image's centre on black.
// The Environment Layer: a tight bright key above the image's centre, on black — a
// full-comp rect keeps the layer buffer full-size, the radial gradient is the key.
add(&mut p, cid, LayerSource::Shape, |l| {
l.switches.three_d = true;
let mut next = 10_000u64;
let ids = &mut build::Ids(&mut next);
let rect = build::shape_rect(ids, [200.0, 150.0], [0.0, 0.0], 0.0);
let gfill = build::shape_gradient_fill(
let key = build::shape_gradient_fill(
ids,
true,
[0.0, 0.0],
[0.0, 75.0],
[0.0, -30.0],
[0.0, 5.0],
effectcraft_keyframe::Gradient { colors: vec![(0.0, [1.0, 1.0, 1.0, 1.0]), (1.0, [0.0, 0.0, 0.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] },
);
let g = build::shape_group(ids, "Sky", vec![rect, gfill]);
let g = build::shape_group(ids, "Key", vec![rect, key]);
l.props.sub_mut("contents").unwrap().children.push(g.into());
l.environment = true;
});
// Turned 180°, the disc shines from behind the camera, straight at the floor.
// Turned 180°, the key shines from up and behind the camera onto the sphere and floor.
add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| {
set(l, "lightOptions/rotation", Value::Scalar(180.0));
set(l, "lightOptions/castsShadows", Value::Bool(true));
});
// The shadow lands below the sphere on the floor: its core is the darkest floor pixel
// below the sphere; the lit floor is the sample to the left. The frame is sRGB-encoded,
// so the ratios below are gamma-compressed (a linear halving reads as ~0.73).
let luma = |img: &crate::Image, x: usize, y: usize| (img.data[y * 200 + x][0] + img.data[y * 200 + x][1] + img.data[y * 200 + x][2]) / 3.0;
let img = render(&p, cid);
let (mid, side) = (img.data[75 * 200 + 100], img.data[75 * 200 + 20]);
assert!(mid[3] > 0.99 && side[3] > 0.99);
assert!(mid[0] < side[0] * 0.5, "the disc shadows the sphere onto the floor: {mid:?} vs {side:?}");
// Cast Shadows off: the floor is lit everywhere.
let side = luma(&img, 25, 75);
let (mut core, mut cx, mut cy) = (f32::INFINITY, 0usize, 0usize);
for y in 100..150 {
for x in 60..140 {
if luma(&img, x, y) < core {
core = luma(&img, x, y);
cx = x;
cy = y;
}
}
}
assert!(side > 0.1, "the floor is lit: {side}");
assert!(core < side * 0.65, "the key shadows the sphere onto the floor: {core} vs {side} at ({cx}, {cy})");
// Cast Shadows off: the floor is lit everywhere, core included.
let comp = p.comp_mut(cid).unwrap();
set(&mut comp.layers[0], "lightOptions/castsShadows", Value::Bool(false));
let img = render(&p, cid);
let mid = img.data[75 * 200 + 100];
assert!(mid[0] > side[0] * 0.8, "no shadow without Cast Shadows: {mid:?} vs {side:?}");
let mid = luma(&img, cx, cy);
assert!(mid > side * 0.9, "no shadow without Cast Shadows: {mid} vs {side}");
}
/// #2436: a uniform environment has no dominant region, so even with Cast Shadows on it casts
@@ -472,9 +505,11 @@ fn a_uniform_environment_casts_no_shadow() {
assert_eq!(casting.data, off.data, "a directionless image casts no shadow");
}
/// #2436: the default environment (no Source, no Environment Layer) is level-preserving and
/// directional — its dominant region points up and toward the initial camera, so a
/// shadow-casting Environment light casts shadows out of the box, like After Effects' default.
/// #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
/// toward the initial camera, so a shadow-casting Environment light casts soft, partial
/// shadows out of the box, like After Effects' default.
#[test]
fn default_environment_keeps_the_level_and_points_up() {
let (w, h, d) = scene::default_environment();
@@ -486,16 +521,21 @@ fn default_environment_keeps_the_level_and_points_up() {
mean += (p[0] + p[1] + p[2]) / 3.0 * sa;
weight += sa;
}
assert!((mean / weight - 1.0).abs() < 0.01, "level-preserving mean: {}", mean / weight);
let sun = dominant_direction(w, h, &d, 0.0);
assert!((mean / weight - 0.3).abs() < 0.01, "dim studio mean: {}", mean / weight);
let (sun, key) = dominant_direction(w, h, &d, 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:?}");
assert!((0.35..0.55).contains(&key), "a broad key with fill: {key}");
// No texel clips past white at the default intensity (the fog of a mean-1 environment).
assert!(d.iter().all(|p| p[0] <= 1.0 && p[0] > 0.0), "0 < radiance <= 1");
}
/// #2436: with no Source and no Environment Layer, the Environment light's default environment
/// casts shadows like After Effects' default environment map — a sphere shadows the plane behind
/// it; Cast Shadows off lights the floor evenly.
/// casts soft, partial shadows like After Effects' default environment map — a sphere shadows
/// the plane behind it, darkening part of the light while the fill keeps the floor visible
/// (an area-light shadow, not the pitch black a point light casts); Cast Shadows off lights
/// the floor evenly.
#[test]
fn the_default_environment_casts_shadows() {
let (mut p, cid) = project(200, 150);
@@ -508,22 +548,39 @@ fn the_default_environment_casts_shadows() {
});
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, 60.0, 50.0]));
set(l, "transform/position", Value::Vec3([100.0, 45.0, 50.0]));
set(l, "materialOptions/castsShadows", Value::Enum(2));
});
add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| {
set(l, "lightOptions/castsShadows", Value::Bool(true));
});
// The default key (up and toward the camera) drops the shadow below the sphere: the core
// is the darkest floor pixel below it, the lit floor the sample to the left.
let luma = |img: &crate::Image, x: usize, y: usize| (img.data[y * 200 + x][0] + img.data[y * 200 + x][1] + img.data[y * 200 + x][2]) / 3.0;
let img = render(&p, cid);
let (mid, side) = (img.data[120 * 200 + 100], img.data[120 * 200 + 30]);
assert!(mid[3] > 0.99 && side[3] > 0.99, "the floor covers both: {mid:?} {side:?}");
assert!(mid[0] < side[0] * 0.5, "the default environment shadows the sphere onto the floor: {mid:?} vs {side:?}");
// Cast Shadows off: the floor is lit everywhere.
let side = luma(&img, 30, 100);
let (mut core, mut cx, mut cy) = (f32::INFINITY, 0usize, 0usize);
for y in 110..150 {
for x in 80..120 {
if luma(&img, x, y) < core {
core = luma(&img, x, y);
cx = x;
cy = y;
}
}
}
assert!(side > 0.1, "the floor is lit: {side}");
// Partial: the key lobe is occluded (a clear darkening) but the fill keeps shining —
// not the pitch black a point light's shadow would cast at the same darkness. The frame
// is sRGB-encoded, so the ratios are gamma-compressed (a linear halving reads as ~0.73).
assert!(core < side * 0.8, "the default environment shadows the sphere onto the floor: {core} vs {side} at ({cx}, {cy})");
assert!(core > side * 0.45, "an area-light shadow, partial like After Effects': {core} vs {side}");
// Cast Shadows off: the floor is lit everywhere, core included.
let comp = p.comp_mut(cid).unwrap();
set(&mut comp.layers[0], "lightOptions/castsShadows", Value::Bool(false));
let img = render(&p, cid);
let mid = img.data[120 * 200 + 100];
assert!(mid[0] > side[0] * 0.8, "no shadow without Cast Shadows: {mid:?} vs {side:?}");
let mid = luma(&img, cx, cy);
assert!(mid > side * 0.9, "no shadow without Cast Shadows: {mid} vs {side}");
}
#[test]
@@ -1236,3 +1293,54 @@ fn hard_edges_ramp_over_two_texels() {
assert!((1.7..3.0).contains(&w), "v {v0}: hard edge ramps over {w} texels");
}
}
// ---------------------------------------------------------------- the After Effects look (#2436)
/// #2436: an Environment light with every option at its default renders like After Effects':
/// a dim studio that leaves the lit floor a mid gray instead of washing it out to white (the
/// fog of a mean-1 default), a partial shadow (the key lobe occluded, the fill still shining —
/// not the pitch black a point light casts), and a soft edge in both quality and draft renders
/// (the map's texel grid never shows through as stair-steps).
#[test]
fn environment_shadows_match_the_after_effects_look() {
let (mut p, cid) = project(800, 600);
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Plane }, |l| {
set(l, "geometryOptions/width", Value::Scalar(1200.0));
set(l, "geometryOptions/height", Value::Scalar(900.0));
set(l, "transform/position", Value::Vec3([400.0, 300.0, 300.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(80.0));
set(l, "transform/position", Value::Vec3([400.0, 300.0, 120.0]));
set(l, "materialOptions/castsShadows", Value::Enum(2));
});
add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| {
set(l, "lightOptions/castsShadows", Value::Bool(true));
});
let luma = |img: &crate::Image, x: usize, y: usize| (img.data[y * 800 + x][0] + img.data[y * 800 + x][1] + img.data[y * 800 + x][2]) / 3.0;
for (name, opts) in [("quality", RenderOpts::default()), ("draft", RenderOpts { draft: true, ..Default::default() })] {
let img = Renderer::new(&p, &NoFootage, opts).comp_frame(cid, Tick::ZERO);
// The shadow core: the darkest pixel of the lower half (the key drops the shadow
// below the sphere); the lit floor is the mean of the band to its left.
let (mut core, mut cx, mut cy) = (f32::INFINITY, 0usize, 0usize);
for y in 420..580 {
for x in 300..500 {
if luma(&img, x, y) < core {
core = luma(&img, x, y);
cx = x;
cy = y;
}
}
}
let lit: f32 = (60..260).map(|x| luma(&img, x, cy)).sum::<f32>() / 200.0;
assert!(lit > 0.15 && lit < 0.85, "{name}: the lit floor is a mid gray, no white fog: {lit}");
// The frame is sRGB-encoded, so the ratio is gamma-compressed (a linear halving
// reads as ~0.73): partial means clearly darker than lit, far from black.
assert!(core > lit * 0.45 && core < lit * 0.85, "{name}: a partial area-light shadow: core {core} vs lit {lit}");
// The edge's 10-90 % ramp from the lit plateau (left) down into the shadow.
let first_below = |t: f32| (260..cx).position(|x| luma(&img, x, cy) < core + (lit - core) * t).map(|i| 260 + i);
let w = (first_below(0.1).unwrap() as f32) - (first_below(0.9).unwrap() as f32);
assert!(w >= 3.5, "{name}: the edge ramps over {w} px (After Effects' env shadows are soft)");
}
}
+5 -3
View File
@@ -214,11 +214,13 @@ Things that tripped up a long agent-driven session (a multi-scene 3D piece built
image that metallic surfaces reflect. Set its `lightOptions/source` to a footage, comp or solid
layer by layer id; with no source it uses the comp's Environment Layer (`layer.environment`),
or a default studio environment — bright above and toward the initial camera, dark below and
behind — of the light's colour × intensity when there is none; either way the light brightens
the scene. It has `castsShadows`/`shadowDarkness`/`shadowDiffusion`
behind, at a dim 0.3 mean level — of the light's colour × intensity when there is none; either
way the light brightens the scene. It has `castsShadows`/`shadowDarkness`/`shadowDiffusion`
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; only a uniform image has
none, so it casts no shadow). It only lights
none, so it casts no shadow) — 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
Classic 3D comp to Advanced 3D
([#261](https://github.com/storytold/effectcraft/issues/261),
+6 -4
View File
@@ -159,12 +159,14 @@ GGX specular, Smith–Schlick geometry, Schlick Fresnel, Lambert diffuse, after
parallel, spot and point lights with After Effects' falloffs, ambient lights, image-based
light from an Environment light whose Source (or the comp's Environment Layer, Layer ▸
Environment Layer, or a default studio environment — bright above and toward the initial
camera, dark below and behind — of the light's colour × intensity when
there is neither) is an equirectangular image (cosine irradiance map; roughness-indexed mip
chain with the split-sum environment BRDF fit), and percentage-closer-filtered shadow maps
camera, dark below and behind, at a dim 0.3 mean level — of the light's colour × intensity
when there is neither) is an equirectangular image (cosine irradiance map; roughness-indexed
mip chain with the split-sum environment BRDF fit), and percentage-closer-filtered shadow maps
(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 is
directionless, as a uniform one is). An Environment light only lights Advanced 3D comps,
directionless, as a uniform one is — 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). An Environment light only lights Advanced 3D comps,
so adding one (or changing a light's type to it) switches a Classic 3D comp to Advanced 3D,
as model layers do. Cards keep
Material Options (Accepts Lights/Shadows, Casts Shadows On/Only, Ambient, Diffuse, Specular);