From aff46dabb646849a71b56bd3ba6564a2c923817d Mon Sep 17 00:00:00 2001 From: Minher0 Date: Mon, 26 Oct 2026 20:25:00 +0000 Subject: [PATCH] Cast shadows from the Environment light's default environment (#2436) After Effects' default environment map casts shadows out of the box: an Environment light with Cast Shadows on shadows the scene with no Source set. The flat default cancelled its dominant direction to zero, so the option did nothing until a Source image arrived. Give the default a soft studio gradient (bright above and toward the initial camera, dark below and behind), level-normalised so the light's colour x intensity still sets the overall brightness; its dominant region drives the existing shadow pipeline, CPU and GPU alike. --- crates/render/src/three_d/adv/scene.rs | 58 +++++++++++++++++++------- crates/render/src/three_d/adv/tests.rs | 54 ++++++++++++++++++++++++ docs/agents.md | 8 ++-- docs/architecture.md | 7 ++-- 4 files changed, 106 insertions(+), 21 deletions(-) diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index 46264fcb1..3d63db500 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -927,6 +927,41 @@ 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 +/// 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). +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 + // camera looks +z from -z). + let key = [0.0f32, -0.75, -0.66]; + let (w, h) = (8u32, 4u32); + let mut data = Vec::with_capacity((w * h) as usize); + let (mut mean, mut weight) = (0.0f32, 0.0f32); + for y in 0..h { + let v = (y as f32 + 0.5) / h as f32; + 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); + 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 }; + for p in &mut data { + for c in 0..3 { + p[c] *= k; + } + } + (w, h, data) +} + /// Supersampling factor: 2×2 unless draft or very large. fn ssaa_for(r: &Renderer, out: (u32, u32)) -> u32 { if r.opts.draft || (out.0 as u64 * out.1 as u64) > 8_000_000 { 1 } else { 2 } @@ -1019,21 +1054,14 @@ pub(crate) fn build_at(r: &Renderer, ctx: &EvalCtx, sub: Option<&EvalCtx>, run: if let Some(env) = env { match env_pixels(r, ctx, env.src, lin) { Some((w, h, d)) => add_environment(&mut b, w, h, d, env.color, env.rot, env.casts_shadows, env.shadow_darkness, env.shadow_diffusion), - // No Source and no Environment Layer: a flat default environment of the light's - // colour × intensity — After Effects "uses a default environment map" for an - // Environment light — so the light always lights the scene per its parameters. - // A uniform environment has no dominant region, so it casts no shadow (#2436). - None => add_environment( - &mut b, - 8, - 4, - vec![[1.0, 1.0, 1.0, 1.0]; 32], - env.color, - env.rot, - env.casts_shadows, - env.shadow_darkness, - env.shadow_diffusion, - ), + // No Source and no Environment Layer: the default environment, a soft studio + // gradient of the light's colour × intensity — After Effects "uses a default + // environment map" for an Environment light — so the light always lights the + // scene per its parameters, and its dominant region casts shadows (#2436). + None => { + let (w, h, d) = default_environment(); + add_environment(&mut b, w, h, d, env.color, env.rot, env.casts_shadows, env.shadow_darkness, env.shadow_diffusion) + } } } if r.opts.shadows() { diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs index 7463839a2..d9e749d31 100644 --- a/crates/render/src/three_d/adv/tests.rs +++ b/crates/render/src/three_d/adv/tests.rs @@ -472,6 +472,60 @@ 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. +#[test] +fn default_environment_keeps_the_level_and_points_up() { + let (w, h, d) = scene::default_environment(); + assert_eq!((w, h), (8, 4)); + let (mut mean, mut weight) = (0.0f32, 0.0f32); + for (i, p) in d.iter().enumerate() { + let v = ((i / w as usize) as f32 + 0.5) / h as f32; + let sa = (v * PI).sin(); + 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!(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:?}"); +} + +/// #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. +#[test] +fn the_default_environment_casts_shadows() { + let (mut p, cid) = project(200, 150); + // The floor facing the camera, a sphere in front of it casting shadows only. + 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, 120.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(30.0)); + set(l, "transform/position", Value::Vec3([100.0, 60.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)); + }); + 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 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:?}"); +} + #[test] fn depth_of_field_blurs_out_of_focus_surfaces() { let mut img = Image::new(64, 64); diff --git a/docs/agents.md b/docs/agents.md index 1ce30f076..d3b3adbef 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -213,10 +213,12 @@ Things that tripped up a long agent-driven session (a multi-scene 3D piece built - **Environment light.** `layer.newLight` also accepts `"kind":"Environment"`: an equirectangular 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 flat default environment of the light's colour × intensity when there is none — either - way the light brightens the scene. It has `castsShadows`/`shadowDarkness`/`shadowDiffusion` + 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` like the other lights: Cast Shadows shades the scene from the image's dominant bright region - (a flat or otherwise uniform environment has none, so it casts no shadow). It only lights + (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 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 f8b2c2bb5..37e7d11d9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -158,12 +158,13 @@ comp to Advanced 3D in the same undo step, as in After Effects. Shading is physi GGX specular, Smith–Schlick geometry, Schlick Fresnel, Lambert diffuse, after Karis 2013): 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 flat default environment of the light's colour × intensity when +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 (spot: perspective, parallel: orthographic over the scene, point: six cube faces, environment: -one orthographic from the image's dominant bright region — none when the image is -directionless, as a flat environment is). An Environment light only lights Advanced 3D comps, +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, 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);