collective

Browse / github.com/storytold/effectcraft / patch

Light Environment lights: default environment, Casts Shadows, Advanced 3D switch

[Bug] Environment lights have no effect and lack "Cast Shadows" option **Describe the bug** In **EffectCraft**, adding or using an *Environment Light* does absolutely nothing to the scene (it has no visible visual effect). Additionally, the **"Cast Shadows"** option is completely missing from the creation pop-up/dialog when setting up this specific light type. **Expected behavior** * Environment lights should correctly illuminate the scene based on their settings. * The **Cast Shadows** option should be available to toggle at the moment of creating the environment light, just like with other light types. (except for the ambient)

Status
open
Score
0.00
Adopters
0 other operators
Author
Minher0 (imported from pull request #2474, open)
Base
dd12ed8f9c341a6081e08c8fd840aa3bb4325537
Size
+1175 −67 in 18 files
Review
pending
Shared
2026-10-28
Id
cd0e3361417a79fce1622148

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/cmd/model3d.rs crates/engine/src/cmd/three_d.rs crates/engine/src/tests/three_d.rs crates/gpu/src/adv3d.rs crates/gpu/src/shaders/advanced3d.wgsl crates/gpu/src/tests/adv3d.rs crates/project/src/build.rs crates/render/src/three_d/adv/raster.rs crates/render/src/three_d/adv/scene.rs crates/render/src/three_d/adv/shade.rs crates/render/src/three_d/adv/tests.rs crates/ui-egui/src/panels/dialogs_3d.rs crates/ui-egui/tests/it/main.rs crates/ui-egui/tests/it/ui_env_light_gpu.rs crates/ui-egui/tests/it/ui_env_light_repro.rs crates/ui-egui/tests/it/ui_light_settings.rs docs/agents.md docs/architecture.md

diff --git a/crates/engine/src/cmd/model3d.rs b/crates/engine/src/cmd/model3d.rs--- a/crates/engine/src/cmd/model3d.rs+++ b/crates/engine/src/cmd/model3d.rs@@ -64,6 +64,12 @@ fn comp_renderer(s: &mut Session, p: &Value) -> Result<Value> {     if r == Renderer::Classic3D && s.project.comp(cid).is_some_and(|c| c.layers.iter().any(|l| l.source.is_model())) {         s.toast("Classic 3D doesn't draw 3D model layers: they show with the Advanced 3D renderer");     }+    if r == Renderer::Classic3D {+        let env = |l: &Layer| matches!(l.source, LayerSource::Light { kind: effectcraft_project::LightKind::Environment });+        if s.project.comp(cid).is_some_and(|c| c.layers.iter().any(env)) {+            s.toast("Classic 3D doesn't light Environment light layers: they light with the Advanced 3D renderer");+        }+    }     Ok(json!({"renderer": renderer_id(r)})) } @@ -77,7 +83,7 @@ fn renderer_id(r: Renderer) -> &'static str { /// Only the Advanced 3D renderer draws model and primitive layers, so adding one switches a /// Classic 3D comp to it, like After Effects (inside the adding edit: one undo step). Whether /// the renderer changed.-fn use_advanced_3d(proj: &mut effectcraft_project::Project, cid: ItemId) -> Result<bool> {+pub(crate) fn use_advanced_3d(proj: &mut effectcraft_project::Project, cid: ItemId) -> Result<bool> {     let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;     let switched = c.renderer != Renderer::Advanced3D;     c.renderer = Renderer::Advanced3D;@@ -86,10 +92,10 @@ fn use_advanced_3d(proj: &mut effectcraft_project::Project, cid: ItemId) -> Resu }  /// Tell the user when [`use_advanced_3d`] changed the comp's renderer.-fn report_switch(s: &mut Session, cid: ItemId, switched: bool) {+pub(crate) fn report_switch(s: &mut Session, cid: ItemId, switched: bool, why: &str) {     if switched {         let name = s.project.item(cid).map_or("the composition", |i| i.name.as_str()).to_string();-        s.toast(format!("\"{name}\" now uses the Advanced 3D renderer, which draws 3D models"));+        s.toast(format!("\"{name}\" now uses the Advanced 3D renderer, {why}"));     } } @@ -156,7 +162,7 @@ pub(crate) fn new_model(s: &mut Session, p: &Value) -> Result<Value> {         let id = insert_layer_at(proj, st, cid, l, index)?;         Ok((id, use_advanced_3d(proj, cid)?))     })?;-    report_switch(s, cid, switched);+    report_switch(s, cid, switched, "which draws 3D models");     Ok(json!({"layer": id.0, "item": item.0, "advanced3d": true})) } @@ -226,7 +232,7 @@ fn new_primitive(s: &mut Session, p: &Value) -> Result<Value> {         let id = insert_layer(proj, st, cid, l)?;         Ok((id, use_advanced_3d(proj, cid)?))     })?;-    report_switch(s, cid, switched);+    report_switch(s, cid, switched, "which draws 3D models");     Ok(json!({"layer": id.0, "kind": kind.label(), "advanced3d": true})) } diff --git a/crates/engine/src/cmd/three_d.rs b/crates/engine/src/cmd/three_d.rs--- a/crates/engine/src/cmd/three_d.rs+++ b/crates/engine/src/cmd/three_d.rs@@ -12,6 +12,7 @@ use effectcraft_time::Tick; use serde_json::{Value, json};  use super::layer::{color_p, insert_layer};+use super::model3d::{report_switch, use_advanced_3d}; use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, layer_mut, layer_p, merge_p, str_p, time_p}; use crate::{EngineError, Result, Session, cmd, query}; @@ -227,12 +228,17 @@ fn new_light(s: &mut Session, p: &Value) -> Result<Value> {     };     let name = str_p(p, "name").unwrap_or("Light 1").to_string();     let t = time_p(s, p, Some(&comp));-    let id = s.edit("New Light", None, |proj, st| {+    // Only the Advanced 3D renderer lights with Environment lights, so adding one switches a+    // Classic 3D comp to it, like After Effects (inside the adding edit: one undo step).+    let env = kind == LightKind::Environment;+    let (id, switched) = s.edit("New Light", None, |proj, st| {         let mut l = build::layer(proj, &comp, &name, LayerSource::Light { kind }, (comp.width, comp.height), None);         apply_light(&mut l, p, t, "layer.newLight")?;-        insert_layer(proj, st, cid, l)+        let id = insert_layer(proj, st, cid, l)?;+        Ok((id, if env { use_advanced_3d(proj, cid)? } else { false }))     })?;-    Ok(json!({"layer": id.0}))+    report_switch(s, cid, switched, "which Environment lights need to light the scene");+    Ok(json!({"layer": id.0, "advanced3d": env})) }  fn light_settings(s: &mut Session, p: &Value) -> Result<Value> {@@ -248,7 +254,7 @@ fn light_settings(s: &mut Session, p: &Value) -> Result<Value> {         ),         None => None,     };-    s.edit("Light Settings", None, |proj, _| {+    let switched = s.edit("Light Settings", None, |proj, _| {         let old = proj.comp(cid).and_then(|c| c.layer(lid)).cloned().ok_or(EngineError::NoComp)?;         let mut l = old.clone();         if let Some(k) = kind@@ -288,9 +294,14 @@ fn light_settings(s: &mut Session, p: &Value) -> Result<Value> {             l.name = n.clone();         }         apply_light(&mut l, p, t, "layer.lightSettings")?;+        // A light that becomes an Environment light switches a Classic 3D comp to Advanced 3D,+        // like adding one does; editing one that already is leaves the renderer to the user.+        let was_env = matches!(old.source, LayerSource::Light { kind: LightKind::Environment });+        let is_env = matches!(l.source, LayerSource::Light { kind: LightKind::Environment });         *layer_mut(proj, cid, lid)? = l;-        Ok(())+        Ok(if !was_env && is_env { use_advanced_3d(proj, cid)? } else { false })     })?;+    report_switch(s, cid, switched, "which Environment lights need to light the scene");     Ok(Value::Null) } @@ -681,7 +692,7 @@ pub fn specs() -> Vec<CommandSpec> {             "Light...",             ["Layer", "New"],             Some("Cmd+Alt+Shift+L"),-            "{kind?: Parallel|Spot|Point|Ambient|Environment (default Spot; Environment lights and reflects from an equirectangular image layer: prop.set its lightOptions/source to that layer's id, or leave it empty for the comp's Environment Layer, see layer.environment), name?, color?, intensity?, coneAngle?, coneFeather?, falloff?: None|Smooth|Inverse Square Clamped, radius?, falloffDistance?, castsShadows?, shadowDarkness?, shadowDiffusion?, position? [x,y,z], poi? [x,y,z]}",+            "{kind?: Parallel|Spot|Point|Ambient|Environment (default Spot; Environment lights and reflects from an equirectangular image layer: prop.set its lightOptions/source to that layer's id, or leave it empty for the comp's Environment Layer or a flat default environment of its colour and intensity, see layer.environment; an Environment light switches a Classic 3D comp to Advanced 3D), name?, color?, intensity?, coneAngle?, coneFeather?, falloff?: None|Smooth|Inverse Square Clamped, radius?, falloffDistance?, castsShadows?, shadowDarkness?, shadowDiffusion?, position? [x,y,z], poi? [x,y,z]}",             has_comp,             new_light         ),@@ -708,7 +719,7 @@ pub fn specs() -> Vec<CommandSpec> {             "Light Settings...",             [],             None,-            "{layer?, name?, kind?: Parallel|Spot|Point|Ambient|Environment, color?, intensity?, coneAngle?, coneFeather?, falloff?, radius?, falloffDistance?, castsShadows?, shadowDarkness?, shadowDiffusion?, position?, poi?}",+            "{layer?, name?, kind?: Parallel|Spot|Point|Ambient|Environment (changing to Environment switches a Classic 3D comp to Advanced 3D), color?, intensity?, coneAngle?, coneFeather?, falloff?, radius?, falloffDistance?, castsShadows?, shadowDarkness?, shadowDiffusion?, position?, poi?}",             has_comp,             light_settings         ),diff --git a/crates/engine/src/tests/three_d.rs b/crates/engine/src/tests/three_d.rs--- a/crates/engine/src/tests/three_d.rs+++ b/crates/engine/src/tests/three_d.rs@@ -1,6 +1,6 @@ //! 3D commands: cameras, lights, views, camera tools (with undo/redo), and the 3D demo comp. -use effectcraft_project::{AutoOrient, LayerSource, LightKind};+use effectcraft_project::{AutoOrient, LayerSource, LightKind, Renderer}; use effectcraft_render::three_d::View3D; use serde_json::json; @@ -148,6 +148,85 @@ fn deleting_a_source_layer_clears_the_reference() {     assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(sky)).is_some()); } +/// #2436: only the Advanced 3D renderer lights with Environment lights, and new comps are+/// Classic 3D, so one had no visible effect. Adding one (or changing a light into one) switches+/// the comp to Advanced 3D (in the same undo step, with a toast), as After Effects does.+#[test]+fn environment_lights_switch_classic_comps_to_advanced_3d() {+    let toasts = |s: &mut Session| -> Vec<String> {+        s.drain_events().into_iter().filter_map(|e| if let crate::Event::Toast { message, .. } = e { Some(message) } else { None }).collect()+    };+    let mut s = session();+    let text = s.execute("layer.newText", json!({"text": "Hi"})).unwrap()["layer"].as_u64().unwrap();+    assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D);+    toasts(&mut s);+    let r = s.execute("layer.newLight", json!({"kind": "Environment"})).unwrap();+    assert_eq!(r["advanced3d"], true);+    assert_eq!(s.active_comp().unwrap().renderer, Renderer::Advanced3D);+    // As with Composition Settings: the text layer gets Geometry Options.+    assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(text)).unwrap().props.sub("geometryOptions").is_some());+    assert!(toasts(&mut s).iter().any(|m| m.contains("Advanced 3D")));+    // A second Environment light leaves the comp (and the user) alone.+    s.execute("layer.newLight", json!({"kind": "Environment"})).unwrap();+    assert!(toasts(&mut s).is_empty());+    // Undoing the creations takes the lights and the switch back together.+    s.undo();+    s.undo();+    assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D);+    assert_eq!(layer_count(&s), 1);+    // Changing a light to Environment switches too, and undoes with the type.+    let spot = s.execute("layer.newLight", json!({"kind": "Spot"})).unwrap()["layer"].as_u64().unwrap();+    assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D);+    s.execute("layer.lightSettings", json!({"layer": spot, "kind": "Environment"})).unwrap();+    assert_eq!(s.active_comp().unwrap().renderer, Renderer::Advanced3D);+    s.undo();+    assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D);+    assert_eq!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(spot)).unwrap().source, LayerSource::Light { kind: LightKind::Spot });+    // Other light types never switch it.+    s.execute("layer.newLight", json!({"kind": "Point"})).unwrap();+    assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D);+    // Switching back to Classic 3D with an Environment light in the comp explains itself.+    let env = s.execute("layer.newLight", json!({"kind": "Environment"})).unwrap()["layer"].as_u64().unwrap();+    toasts(&mut s);+    s.execute("comp.renderer", json!({"renderer": "classic3d"})).unwrap();+    assert!(toasts(&mut s).iter().any(|m| m.contains("Environment light")), "the Classic 3D limit is explained");+    // Editing that Environment light (no type change) leaves the renderer the user chose.+    s.execute("layer.lightSettings", json!({"layer": env, "intensity": 80})).unwrap();+    assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D, "an ordinary edit doesn't re-switch");+    assert!(toasts(&mut s).is_empty(), "and doesn't toast either");+}++/// An Environment light takes Casts Shadows (with Shadow Darkness and Diffusion) like every+/// other light — the Light Settings dialog offers them for all types but Ambient — and keeps no+/// Falloff, which an environment light shining from every direction has no use for (#2436).+#[test]+fn environment_light_takes_shadow_options_but_no_falloff() {+    let mut s = session();+    let env = s.execute("layer.newLight", json!({"kind": "Environment", "castsShadows": true, "shadowDarkness": 60, "shadowDiffusion": 12})).unwrap()["layer"]+        .as_u64()+        .unwrap();+    assert!(prop(&s, env, "lightOptions/castsShadows").as_bool());+    assert_eq!(prop(&s, env, "lightOptions/shadowDarkness").as_f64(), 60.0);+    assert_eq!(prop(&s, env, "lightOptions/shadowDiffusion").as_f64(), 12.0);+    assert!(+        s.active_comp().unwrap().layer(effectcraft_project::LayerId(env)).unwrap().props.prop("lightOptions/falloff").is_none(),+        "an environment light has no Falloff"+    );+    // The shadow values carry over when the type changes, both ways.+    s.execute("layer.lightSettings", json!({"layer": env, "kind": "Spot", "coneAngle": 70})).unwrap();+    assert!(prop(&s, env, "lightOptions/castsShadows").as_bool());+    assert_eq!(prop(&s, env, "lightOptions/shadowDarkness").as_f64(), 60.0);+    assert_eq!(prop(&s, env, "lightOptions/coneAngle").as_f64(), 70.0);+    s.execute("layer.lightSettings", json!({"layer": env, "kind": "Environment"})).unwrap();+    assert!(prop(&s, env, "lightOptions/castsShadows").as_bool());+    assert_eq!(prop(&s, env, "lightOptions/source").as_layer(), None, "back to Environment: it has a Source again");+    s.undo();+    assert!(+        s.active_comp().unwrap().layer(effectcraft_project::LayerId(env)).unwrap().props.prop("lightOptions/coneAngle").is_some(),+        "back to Spot: it has a Cone Angle again"+    );+}+ #[test] fn three_d_switch_undo_redo() {     let mut s = session();diff --git a/crates/gpu/src/adv3d.rs b/crates/gpu/src/adv3d.rs--- a/crates/gpu/src/adv3d.rs+++ b/crates/gpu/src/adv3d.rs@@ -77,7 +77,7 @@ fn buffer_bytes(count: u64, stride: u64, limit: u64) -> Option<u64> { /// Checking these before serialization bounds host packing as well as the GPU uploads. fn packed_bytes(counts: [u64; 6], uniform_limit: u64, storage_limit: u64, buffer_limit: u64) -> Option<[u64; 7]> {     let mut sizes = [0; 7];-    sizes[0] = buffer_bytes(40, 4, uniform_limit.min(buffer_limit))?;+    sizes[0] = buffer_bytes(44, 4, uniform_limit.min(buffer_limit))?;     for (index, (count, stride)) in counts.into_iter().zip([96, 16, 16, 80, 80, 4]).enumerate() {         let size = count.checked_mul(stride)?.max(16);         sizes[index + 1] = buffer_bytes(size, 1, storage_limit.min(buffer_limit))?;@@ -164,11 +164,11 @@ mod capability_tests {      #[test]     fn packed_arrays_preflight_exact_strides_and_empty_padding() {-        assert_eq!(packed_bytes([0; 6], 160, 16, 160), Some([160, 16, 16, 16, 16, 16, 16]));-        assert_eq!(packed_bytes([2; 6], 160, 192, 192), Some([160, 192, 32, 32, 160, 160, 16]));-        assert!(packed_bytes([0; 6], 159, 16, 160).is_none());-        assert!(packed_bytes([0; 6], 160, 15, 160).is_none());-        assert!(packed_bytes([2; 6], 160, 192, 191).is_none());+        assert_eq!(packed_bytes([0; 6], 176, 16, 176), Some([176, 16, 16, 16, 16, 16, 16]));+        assert_eq!(packed_bytes([2; 6], 176, 192, 192), Some([176, 192, 32, 32, 160, 160, 16]));+        assert!(packed_bytes([0; 6], 175, 16, 176).is_none());+        assert!(packed_bytes([0; 6], 176, 15, 176).is_none());+        assert!(packed_bytes([2; 6], 176, 192, 191).is_none());         for index in 0..6 {             let mut counts = [0; 6];             counts[index] = u64::MAX;@@ -203,7 +203,7 @@ mod capability_tests {         };         for (scene, counts) in [(Scene::default(), [0; 6]), (scene, [1; 6])] {             let actual = pack(&scene).map(|bytes| bytes.len() as u64);-            assert_eq!(Some(actual), packed_bytes(counts, 160, 160, 160));+            assert_eq!(Some(actual), packed_bytes(counts, 176, 176, 176));         }     } }@@ -315,10 +315,25 @@ fn pack(s: &Scene) -> [Vec<u8>; 7] {     globals.extend([s.eye[0], s.eye[1], s.eye[2], s.ortho as u32 as f32]);     globals.extend([s.cam_fwd[0], s.cam_fwd[1], s.cam_fwd[2], s.lights.len() as f32]);     globals.extend([s.ambient[0], s.ambient[1], s.ambient[2], s.env.is_some() as u32 as f32]);-    let e = s.env.unwrap_or(effectcraft_render::three_d::adv::EnvInfo { radiance: 0, mips: 1, irradiance: 0, intensity: 0.0, rotation: 0.0 });+    let e = s.env.unwrap_or(effectcraft_render::three_d::adv::EnvInfo {+        radiance: 0,+        mips: 1,+        irradiance: 0,+        intensity: 0.0,+        rotation: 0.0,+        sun: [0.0; 3],+        key: 0.0,+        shadow: -1,+        shadow_darkness: 0.0,+        shadow_diffusion: 0.0,+    });     globals.extend([e.radiance as f32, e.mips as f32, e.irradiance as f32, e.intensity]);     let lit = !(s.lights.is_empty() && s.env.is_none() && s.ambient == [0.0; 3]);-    globals.extend([e.rotation, lit as u32 as f32, 0.0, 0.0]);+    // 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,+    // 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) << 1diff --git a/crates/gpu/src/shaders/advanced3d.wgsl b/crates/gpu/src/shaders/advanced3d.wgsl--- a/crates/gpu/src/shaders/advanced3d.wgsl+++ b/crates/gpu/src/shaders/advanced3d.wgsl@@ -14,8 +14,10 @@ struct Globals {     ambient: vec4<f32>,     // radiance first texture, mip count, irradiance texture, intensity     env: vec4<f32>,-    // rotation, any lighting (0/1), -, -+    // rotation, any lighting (0/1), shadow map index (-1 none), shadow darkness     env2: vec4<f32>,+    // the environment's dominant direction (its shadow's source)+    env_sun: vec4<f32>, };  @group(0) @binding(0) var<uniform> g: Globals;@@ -318,6 +320,18 @@ 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). 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 * 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 {     var k = 1.0;     if (falloff == 1u && kind != 0u) {@@ -404,6 +418,10 @@ fn shadow_only_amount(unlit: bool, normal: vec3<f32>, front: bool, pos: vec3<f32         }         amount = max(amount, 1.0 - light_shadow(li, kind, l0.xyz, i32(l3.w), l4.x, pos, n, n_l));     }+    // The Environment light's shadow counts too (it reaches every surface: no falloff).+    if (g.ambient.w > 0.5) {+        amount = max(amount, 1.0 - env_shadow(pos, n, n));+    }     return amount; } @@ -543,7 +561,11 @@ fn fs(i: VOut, @builtin(front_facing) front: bool) -> FOut {         let f0 = mix(vec3<f32>(0.04 * spec_k), rgb, metallic);         let ab = env_brdf(n_v, rough);         let fs = f0 * ab.x + vec3<f32>(ab.y);-        acc = acc + (irr * rgb * (vec3<f32>(1.0) - fs) * (1.0 - metallic) * diff_k + spec * fs) * ao;+        var esh = 1.0;+        if (receives_shadows) {+            esh = env_shadow(i.pos, ng, n);+        }+        acc = acc + (irr * rgb * (vec3<f32>(1.0) - fs) * (1.0 - metallic) * diff_k + spec * fs) * ao * esh;     }     let c = acc + emissive;     out.color = vec4<f32>(c * alpha, alpha);diff --git a/crates/gpu/src/tests/adv3d.rs b/crates/gpu/src/tests/adv3d.rs--- a/crates/gpu/src/tests/adv3d.rs+++ b/crates/gpu/src/tests/adv3d.rs@@ -5,7 +5,7 @@ //! edges may be covered by the other neighbour. Interior pixels agree within 2/255; at most //! 1.5 % of pixels (silhouettes) may differ more. Skipped without an adapter. -use effectcraft_keyframe::Value;+use effectcraft_keyframe::{Gradient, Value}; use effectcraft_project::build; use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, LightKind, PrimitiveKind, Project, Renderer as R3, Solid}; use effectcraft_render::three_d::adv::{self, Scene};@@ -38,7 +38,8 @@ fn set(l: &mut effectcraft_project::Layer, path: &str, v: Value) { }  /// Primitives with metal/dielectric materials, a shadow-casting spot light and a point light,-/// an environment light, a semi-transparent textured card.+/// a shadow-casting Environment light over a sky-gradient environment layer, a semi-transparent+/// textured card. pub(crate) fn scene_project() -> (Project, ItemId) {     let mut p = Project::default();     let mut c = Comp::new(240, 160, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));@@ -73,12 +74,29 @@ pub(crate) fn scene_project() -> (Project, ItemId) {         set(l, "transform/position", Value::Vec3([120.0, 80.0, -60.0]));         set(l, "transform/rotationY", Value::Scalar(30.0));     });-    let env = p.add_item("E", Default::default(), None, ItemKind::Solid(Solid { color: [0.6, 0.7, 1.0], width: 32, height: 16, pixel_aspect: 1.0 }));-    add(&mut p, cid, LayerSource::Solid { item: env }, |l| {-        l.environment = true;+    // The Environment Layer: a sky-to-ground gradient (its dominant region, the bright sky,+    // shadows the scene from above).+    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, [240.0, 160.0], [0.0, 0.0], 0.0);+        let gfill = build::shape_gradient_fill(+            ids,+            false,+            [0.0, -80.0],+            [0.0, 80.0],+            Gradient { colors: vec![(0.0, [1.0, 1.0, 1.0, 1.0]), (1.0, [0.04, 0.05, 0.08, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] },+        );+        let g = build::shape_group(ids, "Sky", vec![rect, gfill]);+        l.props.sub_mut("contents").unwrap().children.push(g.into());+        l.environment = true;+    });+    add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| {+        set(l, "lightOptions/intensity", Value::Scalar(50.0));+        set(l, "lightOptions/castsShadows", Value::Bool(true));+        set(l, "lightOptions/shadowDiffusion", Value::Scalar(6.0));     });-    add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| set(l, "lightOptions/intensity", Value::Scalar(50.0)));     add(&mut p, cid, LayerSource::Light { kind: LightKind::Spot }, |l| {         set(l, "transform/position", Value::Vec3([40.0, -120.0, -200.0]));         set(l, "transform/poi", Value::Vec3([120.0, 100.0, 0.0]));@@ -135,6 +153,8 @@ fn gpu_matches_cpu_lit_scene() {     let (p, cid) = scene_project();     let s = scene(&p, cid);     assert!(!s.shadows.is_empty() && s.env.is_some() && s.opaque_count < s.indices.len() as u32);+    // The Environment light casts its shadow (the sky gradient's dominant direction).+    assert!(s.env.is_some_and(|e| e.shadow >= 0 && e.sun != [0.0; 3]), "the environment shadow map is built");     compare(&s, &g); } diff --git a/crates/project/src/build.rs b/crates/project/src/build.rs--- a/crates/project/src/build.rs+++ b/crates/project/src/build.rs@@ -654,10 +654,16 @@ pub fn light_options(ids: &mut Ids, kind: LightKind) -> PropGroup {     }     if kind == LightKind::Environment {         // The environment image: a layer (footage, comp…) mapped as an equirectangular panorama;-        // none = the comp's Environment Layer.+        // none = the comp's Environment Layer, or a flat default environment of the light's+        // colour × intensity when there is none. No Falloff: an environment light shines from+        // every direction at once. Environment lights cast shadows too (After Effects' Light+        // Settings shows Cast Shadows for them; not for Ambient) (#2436).         return g             .with(ids.prop("source", "Source", Value::Layer(None)).with_ui(ParamUi::Layer))-            .with(ids.prop("rotation", "Environment Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle));+            .with(ids.prop("rotation", "Environment Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle))+            .with(ids.prop("castsShadows", "Casts Shadows", Value::Bool(false)).with_ui(ParamUi::Checkbox))+            .with(ids.prop("shadowDarkness", "Shadow Darkness", Value::Scalar(100.0)).with_ui(ParamUi::Percent))+            .with(ids.prop("shadowDiffusion", "Shadow Diffusion", Value::Scalar(0.0)).with_ui(ParamUi::Pixels));     }     if kind != LightKind::Ambient {         g = gdiff --git a/crates/render/src/three_d/adv/raster.rs b/crates/render/src/three_d/adv/raster.rs--- a/crates/render/src/three_d/adv/raster.rs+++ b/crates/render/src/three_d/adv/raster.rs@@ -500,11 +500,19 @@ pub(crate) fn shadow_sizes(width: u32, height: u32, draft: bool, kinds: &[u32])     sizes.into_iter().map(|z| (z > 0).then_some(z)).collect() } -/// Build the shadow maps of every shadow-casting light (lights marked `shadow == 0`).+/// Build the shadow maps of every shadow-casting light (lights marked `shadow == 0`), and of+/// the Environment light when it casts shadows and its image has a dominant direction (marked+/// `env.shadow == 0`, `env.sun != [0; 3]`): one map from that direction, like a parallel light. pub(crate) fn shadow_maps(s: &mut Scene, draft: bool) {     let casters: Vec<usize> = (0..s.indices.len() / 3)         .filter(|&t| s.materials.get(s.vertices[s.indices[3 * t] as usize].material as usize).is_some_and(|m| m.casts_shadows))         .collect();+    // The Environment light casts a shadow when marked and its image has a dominant direction;+    // with nothing to cast one, its mark is cleared either way.+    let env_shadow = !casters.is_empty() && s.env.is_some_and(|e| e.shadow == 0 && e.sun != [0.0; 3]);+    if !env_shadow && let Some(e) = &mut s.env {+        e.shadow = -1;+    }     if casters.is_empty() {         for l in &mut s.lights {             l.shadow = -1;@@ -536,6 +544,8 @@ pub(crate) fn shadow_maps(s: &mut Scene, draft: bool) {                 1             }         })+        // The Environment light's map is a single orthographic one.+        .chain(std::iter::once(if env_shadow { 1 } else { 0 }))         .collect();     let sizes = shadow_sizes(s.width, s.height, draft, &kinds);     let mut maps: Vec<ShadowMap> = vec![];@@ -583,6 +593,38 @@ pub(crate) fn shadow_maps(s: &mut Scene, draft: bool) {         }         s.lights[li].shadow = first;     }+    // The Environment light's shadow: one orthographic map over the scene from its dominant+    // direction (the light travels from the bright region into the scene), after the lights'.+    if env_shadow && let Some(e) = s.env {+        match sizes.get(s.lights.len()).copied().flatten() {+            Some(size) => {+                let first = maps.len() as i32;+                // 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 as f32 });+                if let Some(e) = &mut s.env {+                    e.shadow = first;+                }+            }+            // The texel budget dropped it.+            None => {+                if let Some(e) = &mut s.env {+                    e.shadow = -1;+                }+            }+        }+    }     let texels: Vec<Vec<f32>> = maps.par_iter().map(|m| shadow_raster(s, m, &casters)).collect();     for (m, t) in maps.iter_mut().zip(texels) {         m.offset = s.shadow_texels.len() as u32;diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs--- a/crates/render/src/three_d/adv/scene.rs+++ b/crates/render/src/three_d/adv/scene.rs@@ -172,6 +172,24 @@ pub struct EnvInfo {     pub intensity: f32,     /// Radians about the vertical axis.     pub rotation: f32,+    /// Unit direction from the scene towards the environment's dominant (radiance-weighted)+    /// bright region — the direction its shadow is cast from, like a parallel light's+    /// `to_light`. `[0; 3]` when the image is directionless (uniform, or opposing bright+    /// regions that cancel) or the light casts no shadow (nothing reads it then): no shadow.+    pub sun: [f32; 3],+    /// The image's directional (key) fraction — `|Σ dir·radiance| / Σ radiance` over the+    /// sphere, 0 for a uniform image and near 1 for a single bright spot. An Environment+    /// 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,+    pub shadow_darkness: f32,+    /// The light's Shadow Diffusion (the penumbra width the shadow map's PCF widens to).+    pub shadow_diffusion: f32, }  #[derive(Clone, Debug, Default)]@@ -670,9 +688,20 @@ fn card_layer(r: &Renderer, ctx: &EvalCtx, layer: &Layer, b: &mut Builder) {     } } -/// Evaluated lights: (scene lights, ambient sum, environment light (layer, intensity × colour,-/// rotation)).-fn lights(ctx: &EvalCtx, lin: bool) -> (Vec<Light>, [f32; 3], Option<(Option<u64>, [f32; 3], f32)>) {+/// The evaluated Environment light: its image source, intensity × colour, rotation, and+/// shadow settings (After Effects' Light Settings shows Cast Shadows for Environment lights).+#[derive(Clone, Copy)]+struct EnvLight {+    src: Option<u64>,+    color: [f32; 3],+    rot: f32,+    casts_shadows: bool,+    shadow_darkness: f64,+    shadow_diffusion: f64,+}++/// Evaluated lights: (scene lights, ambient sum, environment light).+fn lights(ctx: &EvalCtx, lin: bool) -> (Vec<Light>, [f32; 3], Option<EnvLight>) {     let mut out = vec![];     let mut ambient = [0.0f32; 3];     let mut env = None;@@ -693,7 +722,14 @@ fn lights(ctx: &EvalCtx, lin: bool) -> (Vec<Light>, [f32; 3], Option<(Option<u64                 if env.is_none() {                     let src = g.get("source").and_then(|p| ctx.value(layer, p).as_layer());                     let rot = ctx.f(layer, g, "rotation", 0.0).to_radians() as f32;-                    env = Some((src, color, rot));+                    env = Some(EnvLight {+                        src,+                        color,+                        rot,+                        casts_shadows: ctx.b(layer, g, "castsShadows"),+                        shadow_darkness: ctx.f(layer, g, "shadowDarkness", 100.0).clamp(0.0, 100.0) / 100.0,+                        shadow_diffusion: ctx.f(layer, g, "shadowDiffusion", 0.0).max(0.0),+                    });                 }             }             _ => {@@ -783,8 +819,64 @@ pub fn irradiance_map(sw: u32, sh: u32, src: &[[f32; 4]], w: u32, h: u32) -> Vec     out } +/// The environment image's dominant direction and key strength: the mean texel direction+/// weighted by the radiance the light actually emits — solid-angle weighted, rotation applied,+/// each texel's channels scaled by `tint` (the light's colour, `[1; 3]` for a white one) — or+/// `([0; 3], 0.0)` when that radiance is directionless (a uniform environment cancels to zero,+/// and opposing bright regions that cancel leave no mean either — though a chromatic one need+/// not stay directionless once tinted: a red light over equally bright red and blue opposite+/// regions removes the blue pull and points at the red one). 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]], tint: [f32; 3], 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;+    for (i, p) in d.iter().enumerate() {+        let (tx, ty) = ((i % w as usize) as u32, (i / w as usize) as u32);+        let (u, v) = ((tx as f32 + 0.5) / w as f32, (ty as f32 + 0.5) / h as f32);+        // Tinted radiance × solid angle (∝ sin θ): the shadow must follow the light the+        // environment emits, not the raw texels.+        let lum = (p[0] * tint[0] + p[1] * tint[1] + p[2] * tint[2]) * (v * std::f32::consts::PI).sin();+        let dir = equirect_dir(u, v, rot);+        x += dir[0] * lum;+        y += dir[1] * lum;+        z += dir[2] * lum;+        weight += lum.abs();+    }+    // Under 1 % directional, the image is uniform to the eye: no dominant region to shadow+    // 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], 0.0);+    }+    ([x / len, y / len, z / len], (len / weight).clamp(0.0, 1.0))+}+ /// Prefilter an environment image into the scene's textures.-pub(crate) fn add_environment(b: &mut Builder, w: u32, h: u32, data: Vec<[f32; 4]>, intensity: [f32; 3], rotation: f32) {+pub(crate) fn add_environment(+    b: &mut Builder,+    w: u32,+    h: u32,+    data: Vec<[f32; 4]>,+    intensity: [f32; 3],+    rotation: f32,+    casts_shadows: bool,+    shadow_darkness: f64,+    shadow_diffusion: f64,+) {+    // Intensity × colour folded into the radiance (one scalar on the GPU): tint the texels.+    // A white (or off) light leaves them as they are.+    let k = (intensity[0] + intensity[1] + intensity[2]) / 3.0;+    let tint = if k > 0.0 && (intensity[0] != intensity[1] || intensity[1] != intensity[2]) { intensity.map(|c| c / k) } else { [1.0, 1.0, 1.0] };+    // 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 — and weighted by the tint, so the shadow follows the radiance the light emits:+    // opposing chromatic regions that cancel untinted need not cancel once the colour removes+    // one of them (a red light over equally bright red and blue sides shadows from the red).+    let (sun, key) = if casts_shadows { dominant_direction(w, h, &data, tint, 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 {@@ -807,10 +899,7 @@ pub(crate) fn add_environment(b: &mut Builder, w: u32, h: u32, data: Vec<[f32; 4     let (sw, sh, sd) = small.unwrap_or((w, h, d));     let irr = irradiance_map(sw, sh, &sd, 32, 16);     let ii = b.texture_from(32, 16, [0, 1], irr.into_iter()) as u32;-    // Intensity × colour folded into the radiance (one scalar on the GPU): tint the texels.-    let k = (intensity[0] + intensity[1] + intensity[2]) / 3.0;-    if k > 0.0 && (intensity[0] != intensity[1] || intensity[1] != intensity[2]) {-        let tint = intensity.map(|c| c / k);+    if tint != [1.0, 1.0, 1.0] {         for t in first..=ii {             let ti = b.s.textures[t as usize];             for p in &mut b.s.texels[ti.offset as usize..(ti.offset + ti.width * ti.height) as usize] {@@ -820,16 +909,45 @@ pub(crate) fn add_environment(b: &mut Builder, w: u32, h: u32, data: Vec<[f32; 4             }         }     }-    b.s.env = Some(EnvInfo { radiance: first, mips: levels, irradiance: ii, intensity: k, rotation });+    b.s.env = Some(EnvInfo {+        radiance: first,+        mips: levels,+        irradiance: ii,+        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,+        shadow_diffusion: shadow_diffusion as f32,+    });+}++/// The Environment light's image: `Absent` when the light has no Source and the comp has no+/// Environment Layer (the default studio environment applies — After Effects "uses a default+/// environment map" for an Environment light too), `Image` when the source renders, `Failed`+/// when a selected or found source produced no pixels (a deleted layer, missing footage, a+/// failed decode). A failed source must not silently become the default environment — that+/// would relight and reshadow the scene behind the user's back — so, like any layer whose+/// source fails renders nothing, it contributes nothing (#2436).+enum EnvPixels {+    Absent,+    Image((u32, u32, Vec<[f32; 4]>)),+    Failed, } -/// The environment layer's pixels as linear straight RGBA.-fn env_pixels(r: &Renderer, ctx: &EvalCtx, src: Option<u64>, lin: bool) -> Option<(u32, u32, Vec<[f32; 4]>)> {+fn env_pixels(r: &Renderer, ctx: &EvalCtx, src: Option<u64>, lin: bool) -> EnvPixels {     let layer = match src {-        Some(id) => ctx.comp.layer(effectcraft_project::LayerId(id))?,-        None => ctx.comp.layers.iter().find(|l| l.environment && l.is_active_at(ctx.time))?,+        Some(id) => ctx.comp.layer(effectcraft_project::LayerId(id)),+        None => ctx.comp.layers.iter().find(|l| l.environment && l.is_active_at(ctx.time)),     };-    let buf = r.blend_layer_buf(ctx, layer)?;+    let Some(layer) = layer else {+        // A set Source that resolves to no layer (deleted) failed; an unset one with no+        // Environment Layer is simply unconfigured.+        return if src.is_some() { EnvPixels::Failed } else { EnvPixels::Absent };+    };+    let Some(buf) = r.blend_layer_buf(ctx, layer) else { return EnvPixels::Failed };     let img = &buf.img;     let data = img         .data@@ -843,7 +961,43 @@ fn env_pixels(r: &Renderer, ctx: &EvalCtx, src: Option<u64>, lin: bool) -> Optio             if lin { [s[0], s[1], s[2], 1.0] } else { [srgb_to_linear(s[0]), srgb_to_linear(s[1]), srgb_to_linear(s[2]), 1.0] }         })         .collect();-    Some((img.width, img.height, data))+    EnvPixels::Image((img.width, img.height, data))+}++/// The default environment when the light has no Source and no Environment Layer: a dim studio+/// 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 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+    // 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.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;+        }+    }+    // 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;+        }+    }+    (w, h, data) }  /// Supersampling factor: 2×2 unless draft or very large.@@ -935,10 +1089,25 @@ pub(crate) fn build_at(r: &Renderer, ctx: &EvalCtx, sub: Option<&EvalCtx>, run:         let (ls, amb, env) = lights(ctx, lin);         b.s.lights = ls;         b.s.ambient = amb;-        if let Some((src, k, rot)) = env-            && let Some((w, h, d)) = env_pixels(r, ctx, src, lin)-        {-            add_environment(&mut b, w, h, d, k, rot);+        if let Some(env) = env {+            match env_pixels(r, ctx, env.src, lin) {+                EnvPixels::Image((w, h, d)) => {+                    add_environment(&mut b, w, h, d, env.color, env.rot, env.casts_shadows, env.shadow_darkness, env.shadow_diffusion)+                }+                // No Source and no Environment Layer: the default environment, a soft studio+                // gradient of the light's colour × intensity — After Effects "uses a default+                // environment map" for an Environment light — so the light always lights the+                // scene per its parameters, and its dominant region casts shadows (#2436).+                EnvPixels::Absent => {+                    let (w, h, d) = default_environment();+                    add_environment(&mut b, w, h, d, env.color, env.rot, env.casts_shadows, env.shadow_darkness, env.shadow_diffusion)+                }+                // A selected or found source that produced no pixels (deleted layer, missing+                // footage, failed decode): no environment at all — the light contributes+                // nothing, exactly like a layer whose source failed renders nothing, never a+                // silent swap to the default studio's lighting and shadows (#2436).+                EnvPixels::Failed => {}+            }         }         if r.opts.shadows() {             super::raster::shadow_maps(&mut b.s, r.opts.draft);@@ -947,6 +1116,9 @@ pub(crate) fn build_at(r: &Renderer, ctx: &EvalCtx, sub: Option<&EvalCtx>, run:             for l in &mut b.s.lights {                 l.shadow = -1;             }+            if let Some(e) = &mut b.s.env {+                e.shadow = -1;+            }         }     }     // Camera matrices.diff --git a/crates/render/src/three_d/adv/shade.rs b/crates/render/src/three_d/adv/shade.rs--- a/crates/render/src/three_d/adv/shade.rs+++ b/crates/render/src/three_d/adv/shade.rs@@ -351,6 +351,23 @@ 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 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] {+        return 1.0;+    }+    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 * e.key * (1.0 - f)+}+ /// Unit direction from `p` to light `l`, and the distance (infinite for a parallel light). fn to_light(l: &Light, p: [f32; 3]) -> ([f32; 3], f32) {     if l.kind == 0 {@@ -498,9 +515,11 @@ pub fn shade(s: &Scene, f: &Frag) -> Option<[f32; 4]> {         let (irr, spec) = env_light(s, e, n, r, rough);         let f0 = [mix(0.04 * m.specular_k, rgb[0], metallic), mix(0.04 * m.specular_k, rgb[1], metallic), mix(0.04 * m.specular_k, rgb[2], metallic)];         let (a, b) = env_brdf(n_v, rough);+        // The Environment light's shadow darkens its own diffuse and specular.+        let sh = if m.receives_shadows { env_shadow(s, e, f.pos, ng, n) } else { 1.0 };         for c in 0..3 {             let fs = f0[c] * a + b;-            out[c] += (irr[c] * rgb[c] * (1.0 - fs) * (1.0 - metallic) * m.diffuse_k + spec[c] * fs) * ao;+            out[c] += (irr[c] * rgb[c] * (1.0 - fs) * (1.0 - metallic) * m.diffuse_k + spec[c] * fs) * ao * sh;         }     }     let c = add(out, emissive);@@ -526,5 +545,9 @@ fn shadow_only(s: &Scene, m: &Material, f: &Frag, alpha: f32) -> [f32; 4] {         }         amount = amount.max(1.0 - light_shadow(s, l, f.pos, n, n_l));     }+    // The Environment light's shadow counts too (it reaches every surface: no falloff).+    if let Some(e) = &s.env {+        amount = amount.max(1.0 - env_shadow(s, e, f.pos, n, n));+    }     [0.0, 0.0, 0.0, alpha * amount] }diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs--- a/crates/render/src/three_d/adv/tests.rs+++ b/crates/render/src/three_d/adv/tests.rs@@ -2,7 +2,7 @@ use effectcraft_keyframe::Value; use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, LightKind, PrimitiveKind, Project, Renderer as R3, build}; use effectcraft_time::{FrameRate, Tick}; -use super::scene::{Material, Scene, TexInfo, Vertex, irradiance_map};+use super::scene::{Material, Scene, TexInfo, Vertex, dominant_direction, irradiance_map}; use super::shade::{self, PI}; use super::*; use crate::{NoFootage, RenderOpts, Renderer};@@ -67,6 +67,90 @@ fn constant_environment_has_constant_irradiance() {     assert!(irr[16][0] > 0.9 && irr[15 * 32 + 16][0] < 0.1, "{:?} {:?}", irr[16], irr[15 * 32 + 16]); } +/// 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 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, [1.0; 3], 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, [1.0; 3], 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, [1.0; 3], 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, [1.0; 3], 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, [1.0; 3], 0.0);+    assert!((0.35..0.55).contains(&key), "the default studio is a broad key with fill: {key}");+}++/// #2436 review: a non-uniform image can be directionless too — two equal bright spots at+/// opposite azimuths pull the radiance-weighted mean in opposite directions and cancel,+/// leaving no dominant region to shadow from (under 1 % directional): no sun, no key, so no+/// shadow, just like a uniform image. Tilt the balance and a direction re-emerges, pointing+/// at the brighter spot.+#[test]+fn opposing_bright_regions_cancel_to_no_shadow() {+    let (w, h) = (64u32, 32u32);+    let mut opposed = vec![[0.0f32; 4]; (w * h) as usize];+    // Rows 15 and 16 mirror about the equator (v = 0.484375 / 0.515625), and columns 16 and 48+    // sit half a width apart (u = 0.2578125 / 0.7578125): the two texel-centre directions are+    // exactly opposite — equal solid-angle weight, opposite pull.+    let (a, b) = ((15 * w + 16) as usize, (16 * w + 48) as usize);+    opposed[a] = [1.0, 1.0, 1.0, 1.0];+    opposed[b] = [1.0, 1.0, 1.0, 1.0];+    assert_eq!(dominant_direction(w, h, &opposed, [1.0; 3], 0.0), ([0.0; 3], 0.0));+    // One side twice as bright: the mean points at the brighter spot with its share of the+    // weight (key = 1/3 — two parts against one).+    let mut tilted = opposed.clone();+    tilted[a] = [2.0, 2.0, 2.0, 1.0];+    let (sun, key) = dominant_direction(w, h, &tilted, [1.0; 3], 0.0);+    assert!(sun != [0.0; 3] && key > 0.3, "the brighter side wins: {sun:?} key {key}");+    let want = shade::equirect_dir(16.5 / 64.0, 15.5 / 32.0, 0.0);+    let dot = sun[0] * want[0] + sun[1] * want[1] + sun[2] * want[2];+    assert!(dot > 0.999, "the sun points at the brighter spot: {sun:?} vs {want:?}");+}++/// #2436 review: the sun follows the tinted radiance, not the raw texels — equally bright red+/// and blue regions on exactly opposite texels cancel to a white light (no shadow), but the+/// red light's tint removes the blue side's pull, so the mean points straight at the red one+/// (a blue light, at the blue one): the shadow lands where the emitted light comes from.+#[test]+fn a_coloured_light_shadows_from_the_region_it_tints() {+    let (w, h) = (64u32, 32u32);+    let mut opposed = vec![[0.0f32; 4]; (w * h) as usize];+    // Rows 15 and 16 mirror about the equator, columns 16 and 48 half a width apart: the two+    // texel-centre directions are exactly opposite — equal solid-angle weight, opposite pull.+    let (a, b) = ((15 * w + 16) as usize, (16 * w + 48) as usize);+    opposed[a] = [1.0, 0.0, 0.0, 1.0];+    opposed[b] = [0.0, 0.0, 1.0, 1.0];+    // To a white light the channel sums are equal: they cancel, no shadow.+    assert_eq!(dominant_direction(w, h, &opposed, [1.0; 3], 0.0), ([0.0; 3], 0.0));+    // A red light's tint zeroes the blue texel's weight: the red one becomes a lone spot.+    // (Any positive scale of the tint — the normalised [3, 0, 0] included — points the same+    // way; only the channel ratios matter.)+    let (sun, key) = dominant_direction(w, h, &opposed, [1.0, 0.0, 0.0], 0.0);+    let want = shade::equirect_dir(16.5 / 64.0, 15.5 / 32.0, 0.0);+    let dot = sun[0] * want[0] + sun[1] * want[1] + sun[2] * want[2];+    assert!(dot > 0.999 && key > 0.95, "the red side wins: {sun:?} key {key}");+    // A blue light points at the blue one instead.+    let (sun, _) = dominant_direction(w, h, &opposed, [0.0, 0.0, 1.0], 0.0);+    let want = shade::equirect_dir(48.5 / 64.0, 16.5 / 32.0, 0.0);+    let dot = sun[0] * want[0] + sun[1] * want[1] + sun[2] * want[2];+    assert!(dot > 0.999, "the blue side wins: {sun:?}");+}+ #[test] fn equirect_round_trip_and_env_brdf() {     for (u, v) in [(0.1f32, 0.2f32), (0.5, 0.5), (0.9, 0.7)] {@@ -337,6 +421,365 @@ fn cards_and_environment_light() {     assert_eq!(lit.data[80 * 120 + 110][3], 0.0); } +/// An Environment light with no Source and no Environment Layer lights the scene with a flat+/// default environment of its colour × intensity — After Effects "uses a default environment+/// map" for an Environment light — not nothing at all (#2436).+#[test]+fn environment_light_without_a_source_lights_the_scene() {+    let (mut p, cid) = project(120, 90);+    let sid = p.add_item(+        "S",+        Default::default(),+        None,+        ItemKind::Solid(effectcraft_project::Solid { color: [0.5, 0.5, 0.5], width: 60, height: 40, pixel_aspect: 1.0 }),+    );+    add(&mut p, cid, LayerSource::Solid { item: sid }, |l| l.switches.three_d = true);+    let unlit = render(&p, cid);+    let c = unlit.data[20 * 120 + 30];+    assert!((c[0] - 0.5).abs() < 0.01 && c[3] > 0.99, "unlit card keeps its colour: {c:?}");+    // No Source, no Environment Layer: the light still lights the card (a flat white+    // environment at the light's colour × intensity; a card's Material Options shade it).+    add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |_| {});+    let lit = render(&p, cid);+    let c2 = lit.data[20 * 120 + 30];+    assert!((c2[0] - c[0]).abs() > 0.05 && c2[3] > 0.99, "the light has an effect: {c2:?} vs {c:?}");+    // The light's parameters drive it: half intensity darkens towards zero intensity's black.+    set(p.comp_mut(cid).unwrap().layers.first_mut().unwrap(), "lightOptions/intensity", Value::Scalar(50.0));+    let half = render(&p, cid);+    let c3 = half.data[20 * 120 + 30];+    assert!(c3[0] < c2[0] - 0.02, "half intensity lights less: {c3:?} vs {c2:?}");+    set(p.comp_mut(cid).unwrap().layers.first_mut().unwrap(), "lightOptions/intensity", Value::Scalar(0.0));+    let dark = render(&p, cid);+    let c4 = dark.data[20 * 120 + 30];+    assert!(c4[0] < 0.02 && c4[3] > 0.99, "zero intensity lights nothing: {c4:?}");+    // And its colour tints the light red.+    set(p.comp_mut(cid).unwrap().layers.first_mut().unwrap(), "lightOptions/intensity", Value::Scalar(100.0));+    set(p.comp_mut(cid).unwrap().layers.first_mut().unwrap(), "lightOptions/color", Value::Color([1.0, 0.0, 0.0, 1.0]));+    let red = render(&p, cid);+    let c5 = red.data[20 * 120 + 30];+    assert!(c5[0] > 0.15 && c5[1] < 0.05 && c5[2] < 0.05, "red environment: {c5:?}");+}++/// #2436 review: a selected or auto-picked environment source that fails to render (missing+/// footage, a deleted layer) must not silently swap in the default studio environment — the+/// light contributes nothing, exactly like a layer whose source failed renders nothing. Only a+/// light with no Source at all (and no Environment Layer in the comp) uses the default.+#[test]+fn a_failed_environment_source_lights_nothing() {+    let (mut p, cid) = project(120, 90);+    let sid = p.add_item(+        "S",+        Default::default(),+        None,+        ItemKind::Solid(effectcraft_project::Solid { color: [1.0, 1.0, 1.0], width: 60, height: 40, pixel_aspect: 1.0 }),+    );+    add(&mut p, cid, LayerSource::Solid { item: sid }, |l| l.switches.three_d = true);+    let unlit = render(&p, cid);+    // The Environment Layer's footage is unreadable (missing; NoFootage serves no frames).+    let fid = p.add_item(+        "F",+        Default::default(),+        None,+        ItemKind::Footage(effectcraft_project::Footage {+            path: "missing.exr".into(),+            kind: effectcraft_project::FootageKind::Still,+            width: 64,+            height: 32,+            pixel_aspect: 1.0,+            duration: Tick::from_seconds_f64(1.0),+            has_video: true,+            missing: true,+            ..Default::default()+        }),+    );+    add(&mut p, cid, LayerSource::Footage { item: fid }, |l| {+        l.switches.three_d = true;+        l.environment = true;+    });+    let env_layer = p.comp(cid).unwrap().layers.iter().find(|l| l.environment).unwrap().id;+    add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| {+        set(l, "lightOptions/castsShadows", Value::Bool(true));+    });+    fn light(p: &mut Project, cid: ItemId) -> &mut effectcraft_project::Layer {+        p.comp_mut(cid).unwrap().layers.iter_mut().find(|l| l.is_light()).unwrap()+    }+    // Auto-picked Environment Layer, unreadable: the light is off — no studio default, no+    // default-environment shadow either.+    let failed = render(&p, cid);+    assert_eq!(failed.data, unlit.data, "an unreadable Environment Layer lights nothing");+    // The light's Source set to that unreadable layer: same.+    set(light(&mut p, cid), "lightOptions/source", Value::Layer(Some(env_layer.0)));+    let failed = render(&p, cid);+    assert_eq!(failed.data, unlit.data, "an unreadable selected Source lights nothing");+    // The Source pointing at a deleted layer: same.+    set(light(&mut p, cid), "lightOptions/source", Value::Layer(Some(999_999)));+    let failed = render(&p, cid);+    assert_eq!(failed.data, unlit.data, "a deleted selected Source lights nothing");+    // Contrast: with no Source and no Environment Layer, the default studio still lights it.+    let comp = p.comp_mut(cid).unwrap();+    comp.layers.retain(|l| l.is_light() || !l.environment);+    set(comp.layers.iter_mut().find(|l| l.is_light()).unwrap(), "lightOptions/source", Value::Layer(None));+    let lit = render(&p, cid);+    let (c, c2) = (unlit.data[20 * 120 + 30], lit.data[20 * 120 + 30]);+    assert!(c2[3] > 0.99 && (c2[0] - c[0]).abs() > 0.05, "the unconfigured light still lights: {c2:?} vs {c:?}");+}++/// #2436: an Environment light's Cast Shadows works like After Effects' — its image's dominant+/// (brightest) region shadows the scene. A bright disc above the image's centre, turned to+/// shine from up and behind the camera (rotation 180°), shadows a sphere onto the floor below+/// it; Cast Shadows off lights it evenly.+#[test]+fn environment_lights_cast_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, 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| {+        set(l, "geometryOptions/radius", Value::Scalar(30.0));+        set(l, "transform/position", Value::Vec3([100.0, 75.0, 50.0]));+        set(l, "materialOptions/castsShadows", Value::Enum(2));+    });+    // 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 key = build::shape_gradient_fill(+            ids,+            true,+            [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, "Key", vec![rect, key]);+        l.props.sub_mut("contents").unwrap().children.push(g.into());+        l.environment = true;+    });+    // 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 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 = 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+/// nothing — the render is the same as with the option off.+#[test]+fn a_uniform_environment_casts_no_shadow() {+    let (mut p, cid) = project(120, 90);+    let card = p.add_item(+        "S",+        Default::default(),+        None,+        ItemKind::Solid(effectcraft_project::Solid { color: [1.0, 1.0, 1.0], width: 60, height: 40, pixel_aspect: 1.0 }),+    );+    add(&mut p, cid, LayerSource::Solid { item: card }, |l| l.switches.three_d = true);+    let env = p.add_item(+        "E",+        Default::default(),+        None,+        ItemKind::Solid(effectcraft_project::Solid { color: [1.0, 1.0, 1.0], width: 64, height: 32, pixel_aspect: 1.0 }),+    );+    add(&mut p, cid, LayerSource::Solid { item: env }, |l| {+        l.environment = true;+        l.switches.three_d = true;+    });+    add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| {+        set(l, "lightOptions/castsShadows", Value::Bool(true));+    });+    let casting = render(&p, cid);+    let comp = p.comp_mut(cid).unwrap();+    set(&mut comp.layers[0], "lightOptions/castsShadows", Value::Bool(false));+    let off = render(&p, cid);+    assert!(casting.data[20 * 120 + 30][0] > 0.3, "the card is lit");+    assert_eq!(casting.data, off.data, "a directionless image casts no shadow");+}++/// #2436 review: a chromatic environment under a coloured light — equally bright red and blue+/// regions on exactly opposite sides of the image cancel to a white light (it casts no+/// shadow), but the red Environment light's tint removes the blue region's pull, so the red+/// one becomes the key and casts the shadow the user asked for, from the red side.+#[test]+fn a_red_light_shadows_from_the_red_region_of_a_chromatic_environment() {+    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, 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| {+        set(l, "geometryOptions/radius", Value::Scalar(30.0));+        set(l, "transform/position", Value::Vec3([100.0, 75.0, 50.0]));+        set(l, "materialOptions/castsShadows", Value::Enum(2));+    });+    // The Environment Layer: a black background with a red square and an equally bright blue+    // one at exactly antipodal image positions (the blue is the point mirror of the red through+    // the antipodal map x -> x + w/2, y -> h - y: centres (14.5, 44.5) and (114.5, 105.5) in a+    // 200x150 image, 20x20 each) — equal solid-angle weight, opposite pull. The red sits up,+    // left and toward the camera; the blue down, right and away from it.+    add(&mut p, cid, LayerSource::Shape, |l| {+        l.environment = true;+        let mut next = 10_000u64;+        let ids = &mut build::Ids(&mut next);+        // Contents stack like the AE timeline — the first group is on top — so the background+        // goes in last, under the red and blue squares (and its full-comp rect keeps the+        // layer buffer full-size: a shape layer's buffer otherwise crops to its content).+        let red_rect = build::shape_rect(ids, [20.0, 20.0], [-85.5, -30.5], 0.0);+        let red_fill = build::shape_fill(ids, [1.0, 0.0, 0.0, 1.0]);+        let red = build::shape_group(ids, "Red", vec![red_rect, red_fill]);+        let blue_rect = build::shape_rect(ids, [20.0, 20.0], [14.5, 30.5], 0.0);+        let blue_fill = build::shape_fill(ids, [0.0, 0.0, 1.0, 1.0]);+        let blue = build::shape_group(ids, "Blue", vec![blue_rect, blue_fill]);+        let bg_rect = build::shape_rect(ids, [200.0, 150.0], [0.0, 0.0], 0.0);+        let bg_fill = build::shape_fill(ids, [0.0, 0.0, 0.0, 1.0]);+        let bg = build::shape_group(ids, "BG", vec![bg_rect, bg_fill]);+        for g in [red, blue, bg] {+            l.props.sub_mut("contents").unwrap().children.push(g.into());+        }+    });+    // A white light over the chromatic image: the regions cancel — Cast Shadows changes+    // nothing, exactly like a directionless image.+    add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| {+        set(l, "lightOptions/castsShadows", Value::Bool(true));+    });+    let white_casting = render(&p, cid);+    let comp = p.comp_mut(cid).unwrap();+    set(comp.layers.iter_mut().find(|l| l.is_light()).unwrap(), "lightOptions/castsShadows", Value::Bool(false));+    let white_off = render(&p, cid);+    assert_eq!(white_casting.data, white_off.data, "a white light sees no dominant region in the opposed red and blue");+    // The red light: the tint removes the blue region, the red one shadows the sphere onto the+    // floor below and right of it (the shadow falls opposite the red region).+    let comp = p.comp_mut(cid).unwrap();+    let light = comp.layers.iter_mut().find(|l| l.is_light()).unwrap();+    set(light, "lightOptions/color", Value::Color([1.0, 0.0, 0.0, 1.0]));+    set(light, "lightOptions/castsShadows", Value::Bool(true));+    let img = render(&p, cid);+    let chan = |img: &crate::Image, x: usize, y: usize| img.data[y * 200 + x][0];+    let side = chan(&img, 25, 75);+    let (mut core, mut cx) = (f32::INFINITY, 0usize);+    for y in 100..150 {+        for x in 60..170 {+            if chan(&img, x, y) < core {+                core = chan(&img, x, y);+                cx = x;+            }+        }+    }+    assert!(side > 0.1, "the floor is lit red: {side}");+    assert!(core < side * 0.65, "the red region shadows the sphere onto the floor: {core} vs {side} at x {cx}");+    assert!(cx > 100, "the shadow is opposite the red region (right of the sphere): {cx}");+}++/// #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();+    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 - 0.3).abs() < 0.01, "dim studio mean: {}", mean / weight);+    let (sun, key) = dominant_direction(w, h, &d, [1.0; 3], 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 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);+    // 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, 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 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 = luma(&img, cx, cy);+    assert!(mid > side * 0.9, "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);@@ -1047,3 +1490,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)");+    }+}diff --git a/crates/ui-egui/src/panels/dialogs_3d.rs b/crates/ui-egui/src/panels/dialogs_3d.rs--- a/crates/ui-egui/src/panels/dialogs_3d.rs+++ b/crates/ui-egui/src/panels/dialogs_3d.rs@@ -44,12 +44,19 @@ pub struct LightDraft {  const FALLOFFS: [&str; 3] = ["None", "Smooth", "Inverse Square Clamped"]; -/// Whether a light type has Falloff and Shadows options. Ambient and Environment lights come-/// from no one place, so they have neither.+/// Whether a light type has Falloff options (distance attenuation): image-based Environment+/// lights and Ambient come from no one place, so they have none. fn falls_off(kind: LightKind) -> bool {     !matches!(kind, LightKind::Ambient | LightKind::Environment) } +/// Whether a light type has Shadows options in the dialog. After Effects' Light Settings shows+/// Cast Shadows (with Shadow Darkness and Diffusion) for every light but Ambient — an+/// Environment light's shadow comes from its image, not a falloff.+fn casts_shadows(kind: LightKind) -> bool {+    kind != LightKind::Ambient+}+ fn comp_w(app: &EffectcraftApp) -> f64 {     app.session.active_comp().map(|c| c.width as f64).unwrap_or(1920.0) }@@ -306,8 +313,11 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {                     ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.falloff_distance).range(0.0..=100000.0).suffix(" px")));                     ui.end_row();                 }+            }+            if casts_shadows(d.kind) {                 ui.label(tr("Shadows"));-                ui.checkbox(&mut d.casts_shadows, tr("Casts Shadows"));+                let cb = ui.checkbox(&mut d.casts_shadows, tr("Casts Shadows"));+                app.auto.add_response("dialog.light.casts", &cb, "Casts Shadows");                 ui.end_row();                 ui.label(tr("Shadow Darkness"));                 ui.add_enabled(d.casts_shadows, crate::widgets::drag_value(egui::DragValue::new(&mut d.shadow_darkness).range(0.0..=100.0).suffix(" %")));@@ -318,7 +328,7 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {             }         });         ui.add_space(14.0);-        if falls_off(d.kind) {+        if casts_shadows(d.kind) {             ui.label(egui::RichText::new(tr("Note: Shadows are only cast from layers with Cast Shadows enabled.")).font(Tokens::ui(11.0)).color(t.text_dim));             ui.add_space(8.0);         }@@ -343,6 +353,8 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {             p["falloff"] = json!(d.falloff);             p["radius"] = json!(d.radius);             p["falloffDistance"] = json!(d.falloff_distance);+        }+        if casts_shadows(kind) {             p["castsShadows"] = json!(d.casts_shadows);             p["shadowDarkness"] = json!(d.shadow_darkness);             p["shadowDiffusion"] = json!(d.shadow_diffusion);diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs--- a/crates/ui-egui/tests/it/main.rs+++ b/crates/ui-egui/tests/it/main.rs@@ -20,6 +20,8 @@ mod ui_drag_drop; mod ui_effect_controls; mod ui_effect_drop_highlight; mod ui_effect_names;+mod ui_env_light_gpu;+mod ui_env_light_repro; mod ui_font_search; mod ui_form_fit; mod ui_frame_step;diff --git a/crates/ui-egui/tests/it/ui_env_light_gpu.rs b/crates/ui-egui/tests/it/ui_env_light_gpu.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/tests/it/ui_env_light_gpu.rs@@ -0,0 +1,79 @@+//! The Environment light through the app's GPU compositor (egui_kittest with wgpu): adding one+//! visibly changes the viewer's GPU frames (#2436). Skips without a GPU adapter (CI).++use effectcraft_engine::Session;+use effectcraft_engine::project::Renderer;+use effectcraft_ui_egui::EffectcraftApp;+use egui_kittest::Harness;+use serde_json::json;++fn settle(h: &mut Harness<'_, EffectcraftApp>) {+    for _ in 0..600 {+        h.step();+        if h.state().frames.inflight() == 0 && h.state().frames.last_ms.lock().map(|v| *v > 0.0).unwrap_or(false) {+            break;+        }+        std::thread::sleep(std::time::Duration::from_millis(20));+    }+    for _ in 0..4 {+        h.step();+    }+}++/// egui_kittest's wgpu setup on the adapter the headless compositor picked, so the viewer's GPU+/// compositor attaches (see ui_gpu.rs).+fn same_adapter(name: String) -> eframe::egui_wgpu::WgpuSetup {+    let mut setup = egui_kittest::wgpu::default_wgpu_setup();+    if let eframe::egui_wgpu::WgpuSetup::CreateNew(c) = &mut setup {+        let fallback = c.native_adapter_selector.take();+        c.native_adapter_selector = Some(std::sync::Arc::new(move |adapters, surface| {+            let named = |a: &&eframe::wgpu::Adapter| format!("{} ({:?})", a.get_info().name, a.get_info().backend) == name;+            match (adapters.iter().find(named), &fallback) {+                (Some(a), _) => Ok(a.clone()),+                (None, Some(f)) => f(adapters, surface),+                (None, None) => adapters.first().cloned().ok_or_else(|| "No adapter found".to_owned()),+            }+        }));+    }+    setup+}++/// A red Environment light at full intensity lights the scene on the GPU compositor too: the+/// viewer's GPU frames change when the light is added.+#[test]+fn environment_light_lights_the_scene_on_the_gpu() {+    let Some(probe) = effectcraft_gpu::Gpu::headless() else {+        eprintln!("no GPU adapter: skipping");+        return;+    };+    let adapter = effectcraft_engine::render::Accelerator::name(&probe);+    eprintln!("headless compositor adapter: {adapter}");+    let gl = adapter.ends_with("(Gl)");+    drop(probe);+    let mut s = Session::default();+    s.execute("file.openDemoProject", json!({})).unwrap();+    s.execute("comp.open", json!({"comp": "3D Showcase"})).unwrap();+    s.execute("time.set", json!({"time": 5.0})).unwrap();+    s.execute("comp.settings", json!({"renderer": "classic3D"})).unwrap();+    let mut h = Harness::builder()+        .with_size(egui::vec2(1400.0, 900.0))+        .with_pixels_per_point(1.0)+        .wgpu_setup(same_adapter(adapter))+        .build_eframe(|_| EffectcraftApp::new(s));+    settle(&mut h);+    if gl && h.state().gpu_adapter().is_none() {+        eprintln!("egui-wgpu's GL device has WebGL2 limits: the viewer stays on the CPU, skipping");+        return;+    }+    assert!(h.state().gpu_adapter().is_some(), "GPU compositor on egui-wgpu's device");+    let before = h.state_mut().viewer_pixels().expect("GPU frame before adding the light").pixels.clone();++    h.state_mut().session.execute("layer.newLight", json!({"kind": "Environment", "color": [1.0, 0.0, 0.0], "intensity": 100.0})).unwrap();+    assert_eq!(h.state().session.active_comp().unwrap().renderer, Renderer::Advanced3D);+    settle(&mut h);+    let after = h.state_mut().viewer_pixels().expect("GPU frame after adding the light").pixels.clone();++    assert_eq!(before.len(), after.len());+    let diff = before.iter().zip(&after).filter(|(a, b)| a != b).count();+    assert!(diff > 500, "a red Environment light at 100 % changed only {diff} GPU pixels");+}diff --git a/crates/ui-egui/tests/it/ui_env_light_repro.rs b/crates/ui-egui/tests/it/ui_env_light_repro.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/tests/it/ui_env_light_repro.rs@@ -0,0 +1,75 @@+//! Reproduction of issue #2436's report: an Environment light added through the real command+//! path (what the Light Settings dialog runs) must visibly light the scene in the viewer.++use effectcraft_engine::Session;+use effectcraft_engine::project::Renderer;+use effectcraft_ui_egui::EffectcraftApp;+use egui_kittest::Harness;+use serde_json::json;++fn app() -> EffectcraftApp {+    let mut s = Session::default();+    s.execute("file.openDemoProject", json!({})).unwrap();+    s.execute("comp.open", json!({"comp": "3D Showcase"})).unwrap();+    s.execute("time.set", json!({"time": 5.0})).unwrap();+    EffectcraftApp::new(s)+}++/// Step the UI until background frame renders have landed.+fn settle(h: &mut Harness<'_, EffectcraftApp>) {+    for _ in 0..600 {+        h.step();+        if h.state().frames.inflight() == 0 && h.state().frames.last_ms.lock().map(|v| *v > 0.0).unwrap_or(false) {+            break;+        }+        std::thread::sleep(std::time::Duration::from_millis(20));+    }+    for _ in 0..4 {+        h.step();+    }+}++fn changed_pixels(before: &[egui::Color32], after: &[egui::Color32]) -> usize {+    before.iter().zip(after).filter(|(a, b)| a != b).count()+}++/// An Environment light (red, full intensity, no source: the flat default environment) added to+/// a Classic 3D comp switches it to Advanced 3D and visibly changes the viewer's pixels.+#[test]+fn environment_light_lights_the_scene() {+    let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app());+    h.state_mut().session.execute("comp.settings", json!({"renderer": "classic3D"})).unwrap();+    h.run_steps(3);+    settle(&mut h);+    let before = h.state_mut().viewer_pixels().expect("frame before adding the light").pixels.clone();++    h.state_mut().session.execute("layer.newLight", json!({"kind": "Environment", "color": [1.0, 0.0, 0.0], "intensity": 100.0})).unwrap();++    assert_eq!(h.state().session.active_comp().unwrap().renderer, Renderer::Advanced3D, "the comp switched to Advanced 3D");+    settle(&mut h);+    let after = h.state_mut().viewer_pixels().expect("frame after adding the light").pixels.clone();+    assert_eq!(before.len(), after.len());+    let diff = changed_pixels(&before, &after);+    assert!(diff > 500, "a red Environment light at 100 % changed only {diff} pixels");+}++/// The literal action from the issue: default white Environment light, defaults everywhere —+/// even then the lighting model changes (Advanced 3D IBL instead of unlit/Classic), so pixels+/// must change somewhere in the comp frame.+#[test]+fn default_environment_light_changes_the_scene() {+    let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| app());+    h.state_mut().session.execute("comp.settings", json!({"renderer": "classic3D"})).unwrap();+    h.run_steps(3);+    settle(&mut h);+    let before = h.state_mut().viewer_pixels().expect("frame before adding the light").pixels.clone();++    h.state_mut().session.execute("layer.newLight", json!({"kind": "Environment"})).unwrap();++    assert_eq!(h.state().session.active_comp().unwrap().renderer, Renderer::Advanced3D);+    settle(&mut h);+    let after = h.state_mut().viewer_pixels().expect("frame after adding the light").pixels.clone();+    assert_eq!(before.len(), after.len());+    let diff = changed_pixels(&before, &after);+    assert!(diff > 500, "a default white Environment light changed only {diff} pixels");+}diff --git a/crates/ui-egui/tests/it/ui_light_settings.rs b/crates/ui-egui/tests/it/ui_light_settings.rs--- a/crates/ui-egui/tests/it/ui_light_settings.rs+++ b/crates/ui-egui/tests/it/ui_light_settings.rs@@ -177,13 +177,33 @@ fn falloff_and_shadow_options_show_only_for_lights_that_have_them() {     invoke(&mut h, "layer.newLight", json!({}));     for k in LightKind::ALL {         choose(&mut h, k);-        let shown = !matches!(k, LightKind::Ambient | LightKind::Environment);-        for label in ["Falloff", "Casts Shadows", "Shadow Darkness"] {-            assert_eq!(h.query_by_label(label).is_some(), shown, "{label} for {}", k.label());+        // Falloff is distance attenuation: image-based Environment lights and Ambient have none.+        let falloff = !matches!(k, LightKind::Ambient | LightKind::Environment);+        assert_eq!(h.query_by_label("Falloff").is_some(), falloff, "Falloff for {}", k.label());+        // Cast Shadows shows for every light but Ambient, Environment included (#2436).+        let shadows = k != LightKind::Ambient;+        for label in ["Casts Shadows", "Shadow Darkness"] {+            assert_eq!(h.query_by_label(label).is_some(), shadows, "{label} for {}", k.label());         }     } } +/// #2436: an Environment light created through the dialog keeps its Cast Shadows, which the+/// engine stores on the layer.+#[test]+fn an_environment_light_takes_cast_shadows_from_the_dialog() {+    let mut h = harness();+    invoke(&mut h, "layer.newLight", json!({}));+    choose(&mut h, LightKind::Environment);+    click_id(&mut h, "dialog.light.casts");+    ok(&mut h);+    let id = last_layer(&h);+    assert_eq!(kind(&h, id), LightKind::Environment);+    let l = layer(&h, id);+    assert!(l.props.prop("lightOptions/castsShadows").unwrap().value.as_bool(), "the dialog's checkbox reached the layer");+    assert!(l.props.prop("lightOptions/shadowDarkness").is_some() && l.props.prop("lightOptions/falloff").is_none());+}+ #[test] fn changing_an_environment_light_to_spot_undoes_to_its_source_and_rotation() {     let mut h = harness();diff --git a/docs/agents.md b/docs/agents.md--- a/docs/agents.md+++ b/docs/agents.md@@ -212,8 +212,25 @@ Things that tripped up a long agent-driven session (a multi-scene 3D piece built   or call `save_project {"path": …}` after each milestone. - **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`)-  ([#261](https://github.com/storytold/effectcraft/issues/261)).+  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, at a dim 0.3 mean level — of the light's colour × intensity when there is none; either+  way the light brightens the scene. A selected Source or Environment Layer that fails to+  render — a deleted layer, missing footage — lights nothing instead: the light never silently+  falls back to the studio default. 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; an image without a+  sufficiently strong dominant direction has none — a uniform one, or opposing bright regions+  that cancel — so it casts no shadow) — the region weighted by the light's colour, so a+  coloured light shadows from the regions it actually lights (opposing red and blue regions+  cancel to a white light, but a red light's shadow comes from the red one) — 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),+  [#2436](https://github.com/storytold/effectcraft/issues/2436)).  ### Cookbook (from end-to-end QA) diff --git a/docs/architecture.md b/docs/architecture.md--- a/docs/architecture.md+++ b/docs/architecture.md@@ -158,9 +158,22 @@ 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) 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). Cards keep+Environment Layer, or a default studio environment — bright above and toward the initial+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 lacks a+sufficiently strong dominant direction (a uniform one, or opposing bright regions that+cancel) — the region weighted by the light's colour, so a coloured light shadows from the+regions it actually lights (opposing red and blue regions cancel to a white light, but a red+light's shadow comes from the red one) — 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). A Source or Environment Layer+that fails to render — a deleted layer, missing footage — lights nothing: no silent fallback+to the default studio environment. 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); models and primitives add Base Color, Metallic, Roughness, Emissive. Rasterisation runs at 2×2 supersampling with a reversed-Z depth buffer; opaque triangles resolve visibility first, the