From 743119d6e5241139639079700713cc673acd0995 Mon Sep 17 00:00:00 2001 From: Minher0 Date: Mon, 26 Oct 2026 23:36:19 +0000 Subject: [PATCH] Give the Environment light After Effects' look (#2436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/gpu/src/adv3d.rs | 6 +- crates/gpu/src/shaders/advanced3d.wgsl | 6 +- crates/render/src/three_d/adv/raster.rs | 11 +- crates/render/src/three_d/adv/scene.rs | 48 ++++--- crates/render/src/three_d/adv/shade.rs | 10 +- crates/render/src/three_d/adv/tests.rs | 172 +++++++++++++++++++----- docs/agents.md | 8 +- docs/architecture.md | 10 +- 8 files changed, 205 insertions(+), 66 deletions(-) diff --git a/crates/gpu/src/adv3d.rs b/crates/gpu/src/adv3d.rs index f526c6653..c96e57d2d 100644 --- a/crates/gpu/src/adv3d.rs +++ b/crates/gpu/src/adv3d.rs @@ -322,6 +322,7 @@ fn pack(s: &Scene) -> [Vec; 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; 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 diff --git a/crates/gpu/src/shaders/advanced3d.wgsl b/crates/gpu/src/shaders/advanced3d.wgsl index 47c3a65e1..c1a6af4e9 100644 --- a/crates/gpu/src/shaders/advanced3d.wgsl +++ b/crates/gpu/src/shaders/advanced3d.wgsl @@ -320,14 +320,16 @@ fn light_shadow(li: u32, kind: u32, lpos: vec3, 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, ng: vec3, n: vec3) -> 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, to_l: vec3, dist: f32) -> f32 { diff --git a/crates/render/src/three_d/adv/raster.rs b/crates/render/src/three_d/adv/raster.rs index 8c1572d73..91bf45307 100644 --- a/crates/render/src/three_d/adv/raster.rs +++ b/crates/render/src/three_d/adv/raster.rs @@ -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; } diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index 3d63db500..8a120f0d9 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -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, 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; diff --git a/crates/render/src/three_d/adv/shade.rs b/crates/render/src/three_d/adv/shade.rs index af4653f6a..232756a9d 100644 --- a/crates/render/src/three_d/adv/shade.rs +++ b/crates/render/src/three_d/adv/shade.rs @@ -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). diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs index d9e749d31..5a0e35ac8 100644 --- a/crates/render/src/three_d/adv/tests.rs +++ b/crates/render/src/three_d/adv/tests.rs @@ -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::() / 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)"); + } +} diff --git a/docs/agents.md b/docs/agents.md index d3b3adbef..a9d755616 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -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), diff --git a/docs/architecture.md b/docs/architecture.md index 37e7d11d9..93645f9cf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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);