mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 04:57:54 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user