From 1599790ac9b302f90a4345f0fe3ba0f5ade780e6 Mon Sep 17 00:00:00 2001 From: Minher0 Date: Mon, 26 Oct 2026 09:43:47 +0000 Subject: [PATCH 1/9] Light Environment lights: default environment, Casts Shadows, Advanced 3D switch (#2436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An Environment light had no visible effect, and its Light Settings dialog had no Casts Shadows option — both halves of #2436. No effect had two causes: - Classic 3D (the default for new comps) skips Environment lights entirely, so creating one did nothing. Adding one (or changing a light's type to it) now switches the comp to Advanced 3D in the same undo step, with a toast, exactly as model and primitive layers already do; switching back to Classic 3D with an Environment light in the comp explains itself too. - In Advanced 3D, a light with no Source and no Environment Layer produced no environment at all. It now falls back to a flat default environment of the light's colour × intensity, as After Effects does, so the light always lights the scene per its parameters. Cast Shadows was missing because the dialog and the light's property groups shared one gate (Falloff and Shadows together) that excluded Environment. The two are now split: every light but Ambient gets Casts Shadows, Shadow Darkness and Shadow Diffusion (stored, edited and carried over on type changes), while Falloff stays on Parallel/Spot/Point only — an environment light shines from every direction at once and has no use for a falloff. Tests: the renderer lights a card from a sourceless Environment light (colour and intensity drive it, zero intensity lights nothing), the engine switches Classic comps to Advanced 3D on creation and type change (one undo step, toast, and the Classic 3D switch-back toast), Environment lights take and carry shadow options but no Falloff, and the dialog ticks Casts Shadows on a new Environment light. --- crates/engine/src/commands/model3d.rs | 16 +++-- crates/engine/src/commands/three_d.rs | 25 +++++-- crates/engine/src/tests_3d.rs | 71 ++++++++++++++++++++ crates/project/src/build.rs | 9 ++- crates/render/src/three_d/adv/scene.rs | 12 ++-- crates/render/src/three_d/adv/tests.rs | 39 +++++++++++ crates/ui-egui/src/panels/dialogs_3d.rs | 16 ++++- crates/ui-egui/tests/it/ui_light_settings.rs | 23 ++++++- docs/agents.md | 9 ++- docs/architecture.md | 7 +- 10 files changed, 199 insertions(+), 28 deletions(-) diff --git a/crates/engine/src/commands/model3d.rs b/crates/engine/src/commands/model3d.rs index 28b7274ef..1994aef13 100644 --- a/crates/engine/src/commands/model3d.rs +++ b/crates/engine/src/commands/model3d.rs @@ -64,6 +64,12 @@ fn comp_renderer(s: &mut Session, p: &Value) -> Result { 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 { +pub(crate) fn use_advanced_3d(proj: &mut effectcraft_project::Project, cid: ItemId) -> Result { 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, because: &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, which {because}")); } } @@ -156,7 +162,7 @@ pub(crate) fn new_model(s: &mut Session, p: &Value) -> Result { 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, "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 { 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, "draws 3D models"); Ok(json!({"layer": id.0, "kind": kind.label(), "advanced3d": true})) } diff --git a/crates/engine/src/commands/three_d.rs b/crates/engine/src/commands/three_d.rs index e143690d3..6e1f2d936 100644 --- a/crates/engine/src/commands/three_d.rs +++ b/crates/engine/src/commands/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 { }; 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| { + 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)?; + // An Environment light's image-based light only exists in Advanced 3D, so adding one + // switches a Classic 3D comp to it, as model layers do (#2436). + Ok((id, env && use_advanced_3d(proj, cid)?)) })?; - Ok(json!({"layer": id.0})) + report_switch(s, cid, switched, "lights Environment lights"); + Ok(json!({"layer": id.0, "advanced3d": env})) } fn light_settings(s: &mut Session, p: &Value) -> Result { @@ -248,12 +254,14 @@ fn light_settings(s: &mut Session, p: &Value) -> Result { ), 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(); + let mut env = false; if let Some(k) = kind && old.source != (LayerSource::Light { kind: k }) { + env = k == LightKind::Environment; // Light Type change: rebuild the light's groups, carrying over shared values. let fresh = build::layer(proj, &comp, &old.name, LayerSource::Light { kind: k }, (comp.width, comp.height), None); l.source = fresh.source.clone(); @@ -289,8 +297,11 @@ fn light_settings(s: &mut Session, p: &Value) -> Result { } apply_light(&mut l, p, t, "layer.lightSettings")?; *layer_mut(proj, cid, lid)? = l; - Ok(()) + // Changing the type to Environment: its image-based light only exists in Advanced 3D, + // so the comp switches to it, as model layers do (#2436). + Ok(env && use_advanced_3d(proj, cid)?) })?; + report_switch(s, cid, switched, "lights Environment lights"); Ok(Value::Null) } @@ -681,7 +692,7 @@ pub fn specs() -> Vec { "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 { "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_3d.rs b/crates/engine/src/tests_3d.rs index 07a48af1c..1b5deceda 100644 --- a/crates/engine/src/tests_3d.rs +++ b/crates/engine/src/tests_3d.rs @@ -148,6 +148,77 @@ fn deleting_a_source_layer_clears_the_reference() { assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(sky)).is_some()); } +/// An Environment light only lights Advanced 3D comps, so adding one (or changing a light's type +/// to Environment) switches a Classic 3D comp to it in the same undo step, with a toast, as +/// model layers do (#2436). +#[test] +fn environment_light_switches_classic_comps_to_advanced_3d() { + let toasts = |s: &mut Session| -> Vec { + 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, effectcraft_project::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, effectcraft_project::Renderer::Advanced3D); + assert!(toasts(&mut s).iter().any(|m| m.contains("Advanced 3D")), "the switch is explained"); + // 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()); + // One undo step removes the light and the switch together. + s.undo(); + assert_eq!(s.active_comp().unwrap().layers.len(), 2, "the second light is gone, the first stays"); + assert_eq!(s.active_comp().unwrap().renderer, effectcraft_project::Renderer::Advanced3D, "the first light's switch still stands"); + s.undo(); + assert_eq!(s.active_comp().unwrap().layers.len(), 1); + assert_eq!(s.active_comp().unwrap().renderer, effectcraft_project::Renderer::Classic3D); + assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(text)).is_some()); + // Changing a light's type to Environment switches too, and undoes back. + let spot = s.execute("layer.newLight", json!({"kind": "Spot"})).unwrap()["layer"].as_u64().unwrap(); + assert_eq!(s.active_comp().unwrap().renderer, effectcraft_project::Renderer::Classic3D); + s.execute("layer.lightSettings", json!({"layer": spot, "kind": "Environment"})).unwrap(); + assert_eq!(s.active_comp().unwrap().renderer, effectcraft_project::Renderer::Advanced3D); + s.undo(); + assert_eq!(s.active_comp().unwrap().renderer, effectcraft_project::Renderer::Classic3D); + assert_eq!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(spot)).unwrap().source, LayerSource::Light { kind: LightKind::Spot }); + // Switching back to Classic 3D with an Environment light in the comp explains itself. + s.execute("layer.newLight", json!({"kind": "Environment"})).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"); +} + +/// 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); + let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(env)).unwrap(); + assert!(l.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/project/src/build.rs b/crates/project/src/build.rs index 935c03b0e..d01116816 100644 --- a/crates/project/src/build.rs +++ b/crates/project/src/build.rs @@ -654,10 +654,15 @@ 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 (#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 = g diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index a97272434..56a5e6605 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -935,10 +935,14 @@ 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((src, k, rot)) = env { + match env_pixels(r, ctx, src, lin) { + Some((w, h, d)) => add_environment(&mut b, w, h, d, k, rot), + // No Source and no Environment Layer: a flat default environment, as After + // Effects does — the light's colour × intensity light the scene from every + // direction (#2436). + None => add_environment(&mut b, 8, 4, vec![[1.0, 1.0, 1.0, 1.0]; 32], k, rot), + } } if r.opts.shadows() { super::raster::shadow_maps(&mut b.s, r.opts.draft); diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs index 3f5ee4813..2fb22a6e4 100644 --- a/crates/render/src/three_d/adv/tests.rs +++ b/crates/render/src/three_d/adv/tests.rs @@ -337,6 +337,45 @@ 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, as After Effects does (#2436) — not nothing +/// at all. +#[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:?}"); +} + #[test] fn depth_of_field_blurs_out_of_focus_surfaces() { let mut img = Image::new(64, 64); diff --git a/crates/ui-egui/src/panels/dialogs_3d.rs b/crates/ui-egui/src/panels/dialogs_3d.rs index 01fd5b9e8..80e18ce30 100644 --- a/crates/ui-egui/src/panels/dialogs_3d.rs +++ b/crates/ui-egui/src/panels/dialogs_3d.rs @@ -44,12 +44,18 @@ 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: Ambient and Environment lights come from no one +/// place, so they have none — an environment light shines from every direction at once. fn falls_off(kind: LightKind) -> bool { !matches!(kind, LightKind::Ambient | LightKind::Environment) } +/// Whether a light type has Shadows options. Every light but Ambient can cast shadows, +/// Environment lights included (their image-based light occludes) — as in After Effects (#2436). +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,6 +312,8 @@ 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")); ui.end_row(); @@ -318,7 +326,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 +351,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/ui_light_settings.rs b/crates/ui-egui/tests/it/ui_light_settings.rs index fe1392be1..0183e0324 100644 --- a/crates/ui-egui/tests/it/ui_light_settings.rs +++ b/crates/ui-egui/tests/it/ui_light_settings.rs @@ -177,13 +177,30 @@ 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()); + let falloff = !matches!(k, LightKind::Ambient | LightKind::Environment); + let shadows = k != LightKind::Ambient; + assert_eq!(h.query_by_label("Falloff").is_some(), falloff, "Falloff for {}", k.label()); + for label in ["Casts Shadows", "Shadow Darkness"] { + assert_eq!(h.query_by_label(label).is_some(), shadows, "{label} for {}", k.label()); } } } +/// An Environment light created through the dialog takes a Casts Shadows tick, like the other +/// light types (only Ambient has none) — #2436. +#[test] +fn an_environment_light_created_with_casts_shadow_takes_it() { + let mut h = harness(); + invoke(&mut h, "layer.newLight", json!({})); + choose(&mut h, LightKind::Environment); + h.get_by_label("Casts Shadows").click(); + ok(&mut h); + let id = last_layer(&h); + assert_eq!(kind(&h, id), LightKind::Environment); + assert!(layer(&h, id).props.prop("lightOptions/castsShadows").unwrap().value.as_bool()); + assert!(layer(&h, id).props.prop("lightOptions/falloff").is_none(), "but no Falloff"); +} + #[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 index aae8be12c..b14c135fc 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -212,8 +212,13 @@ 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 flat default environment of the light's colour × intensity when there is none — either + way the light brightens the scene, and it has `castsShadows`/`shadowDarkness`/`shadowDiffusion` + like the other lights. 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 index d3dd4db69..9b8964505 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -158,9 +158,12 @@ 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 +Environment Layer, or a flat default environment 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). Cards keep +(spot: perspective, parallel: orthographic over the scene, point: six cube faces). 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 From 863f0e49c1f22c5ff8e87d0432bc2c1e8054ea05 Mon Sep 17 00:00:00 2001 From: Minher0 Date: Mon, 26 Oct 2026 13:05:16 +0000 Subject: [PATCH 2/9] Render the Environment light's shadow from its image (#2436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Casts Shadows option Environment lights got is only half a fix if nothing shades for it: an image lights the scene from every direction, and what occludes that light is the image itself — its dominant bright region, the sun in a sky map. - dominant_direction finds it: the radiance-weighted mean texel direction (solid-angle weighted, rotation applied). A uniform image cancels to zero — a flat default environment shines from everywhere at once and casts no shadow, which a uniform environment test pins. - The shadow is one orthographic map over the scene from that direction, after the lights' maps, like a parallel light's: PCF widened by Shadow Diffusion, darkened by Shadow Darkness, cleared when nothing casts, the image is directionless, or the texel budget drops it. - It darkens the environment's own diffuse and specular light on Accepts Shadows surfaces and counts in Cast Shadows Only. CPU and GPU (WGSL) do the same; Globals carries the environment's sun direction and shadow settings (40 → 44 floats, preflight updated). A bright-disc environment turned to shine from behind the camera now shadows a sphere onto the floor behind it, Cast Shadows off lights it evenly again, and the GPU scene's sky gradient takes a shadow-casting Environment light. Also: the renderer-switch toasts read "...renderer, which draws 3D models", the dialog's Casts Shadows checkbox gets an auto id (dialog.light.casts) the tests click, and the docs' Environment notes cover the shadow. --- crates/engine/src/commands/model3d.rs | 8 +- crates/engine/src/commands/three_d.rs | 19 ++- crates/engine/src/tests_3d.rs | 44 +++---- crates/gpu/src/adv3d.rs | 31 +++-- crates/gpu/src/shaders/advanced3d.wgsl | 24 +++- crates/gpu/src/tests_adv3d.rs | 32 ++++- crates/project/src/build.rs | 3 +- crates/render/src/three_d/adv/raster.rs | 37 +++++- crates/render/src/three_d/adv/scene.rs | 118 +++++++++++++++++-- crates/render/src/three_d/adv/shade.rs | 21 +++- crates/render/src/three_d/adv/tests.rs | 89 +++++++++++++- crates/ui-egui/src/panels/dialogs_3d.rs | 12 +- crates/ui-egui/tests/it/ui_light_settings.rs | 17 +-- docs/agents.md | 8 +- docs/architecture.md | 8 +- 15 files changed, 384 insertions(+), 87 deletions(-) diff --git a/crates/engine/src/commands/model3d.rs b/crates/engine/src/commands/model3d.rs index 1994aef13..2cc4d34e6 100644 --- a/crates/engine/src/commands/model3d.rs +++ b/crates/engine/src/commands/model3d.rs @@ -92,10 +92,10 @@ pub(crate) fn use_advanced_3d(proj: &mut effectcraft_project::Project, cid: Item } /// Tell the user when [`use_advanced_3d`] changed the comp's renderer. -pub(crate) fn report_switch(s: &mut Session, cid: ItemId, switched: bool, because: &str) { +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 {because}")); + s.toast(format!("\"{name}\" now uses the Advanced 3D renderer, {why}")); } } @@ -162,7 +162,7 @@ pub(crate) fn new_model(s: &mut Session, p: &Value) -> Result { let id = insert_layer_at(proj, st, cid, l, index)?; Ok((id, use_advanced_3d(proj, cid)?)) })?; - report_switch(s, cid, switched, "draws 3D models"); + report_switch(s, cid, switched, "which draws 3D models"); Ok(json!({"layer": id.0, "item": item.0, "advanced3d": true})) } @@ -232,7 +232,7 @@ fn new_primitive(s: &mut Session, p: &Value) -> Result { let id = insert_layer(proj, st, cid, l)?; Ok((id, use_advanced_3d(proj, cid)?)) })?; - report_switch(s, cid, switched, "draws 3D models"); + 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/commands/three_d.rs b/crates/engine/src/commands/three_d.rs index 6e1f2d936..c5a46b6c3 100644 --- a/crates/engine/src/commands/three_d.rs +++ b/crates/engine/src/commands/three_d.rs @@ -228,16 +228,16 @@ fn new_light(s: &mut Session, p: &Value) -> Result { }; let name = str_p(p, "name").unwrap_or("Light 1").to_string(); let t = time_p(s, p, Some(&comp)); + // 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")?; let id = insert_layer(proj, st, cid, l)?; - // An Environment light's image-based light only exists in Advanced 3D, so adding one - // switches a Classic 3D comp to it, as model layers do (#2436). - Ok((id, env && use_advanced_3d(proj, cid)?)) + Ok((id, if env { use_advanced_3d(proj, cid)? } else { false })) })?; - report_switch(s, cid, switched, "lights Environment lights"); + report_switch(s, cid, switched, "which Environment lights need to light the scene"); Ok(json!({"layer": id.0, "advanced3d": env})) } @@ -257,11 +257,9 @@ fn light_settings(s: &mut Session, p: &Value) -> Result { 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(); - let mut env = false; if let Some(k) = kind && old.source != (LayerSource::Light { kind: k }) { - env = k == LightKind::Environment; // Light Type change: rebuild the light's groups, carrying over shared values. let fresh = build::layer(proj, &comp, &old.name, LayerSource::Light { kind: k }, (comp.width, comp.height), None); l.source = fresh.source.clone(); @@ -296,12 +294,13 @@ fn light_settings(s: &mut Session, p: &Value) -> Result { l.name = n.clone(); } apply_light(&mut l, p, t, "layer.lightSettings")?; + // The resulting kind (kept or changed to): an Environment light in a Classic 3D comp + // switches it, like adding one does. + let env = matches!(l.source, LayerSource::Light { kind: LightKind::Environment }); *layer_mut(proj, cid, lid)? = l; - // Changing the type to Environment: its image-based light only exists in Advanced 3D, - // so the comp switches to it, as model layers do (#2436). - Ok(env && use_advanced_3d(proj, cid)?) + Ok(if env { use_advanced_3d(proj, cid)? } else { false }) })?; - report_switch(s, cid, switched, "lights Environment lights"); + report_switch(s, cid, switched, "which Environment lights need to light the scene"); Ok(Value::Null) } diff --git a/crates/engine/src/tests_3d.rs b/crates/engine/src/tests_3d.rs index 1b5deceda..1cef04014 100644 --- a/crates/engine/src/tests_3d.rs +++ b/crates/engine/src/tests_3d.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,41 +148,43 @@ fn deleting_a_source_layer_clears_the_reference() { assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(sky)).is_some()); } -/// An Environment light only lights Advanced 3D comps, so adding one (or changing a light's type -/// to Environment) switches a Classic 3D comp to it in the same undo step, with a toast, as -/// model layers do (#2436). +/// #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_light_switches_classic_comps_to_advanced_3d() { +fn environment_lights_switch_classic_comps_to_advanced_3d() { let toasts = |s: &mut Session| -> Vec { 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, effectcraft_project::Renderer::Classic3D); + 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, effectcraft_project::Renderer::Advanced3D); - assert!(toasts(&mut s).iter().any(|m| m.contains("Advanced 3D")), "the switch is explained"); + 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()); - // One undo step removes the light and the switch together. + // Undoing the creations takes the lights and the switch back together. s.undo(); - assert_eq!(s.active_comp().unwrap().layers.len(), 2, "the second light is gone, the first stays"); - assert_eq!(s.active_comp().unwrap().renderer, effectcraft_project::Renderer::Advanced3D, "the first light's switch still stands"); s.undo(); - assert_eq!(s.active_comp().unwrap().layers.len(), 1); - assert_eq!(s.active_comp().unwrap().renderer, effectcraft_project::Renderer::Classic3D); - assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(text)).is_some()); - // Changing a light's type to Environment switches too, and undoes back. + 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, effectcraft_project::Renderer::Classic3D); + 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, effectcraft_project::Renderer::Advanced3D); + assert_eq!(s.active_comp().unwrap().renderer, Renderer::Advanced3D); s.undo(); - assert_eq!(s.active_comp().unwrap().renderer, effectcraft_project::Renderer::Classic3D); + 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. s.execute("layer.newLight", json!({"kind": "Environment"})).unwrap(); toasts(&mut s); @@ -202,8 +204,10 @@ fn environment_light_takes_shadow_options_but_no_falloff() { 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); - let l = s.active_comp().unwrap().layer(effectcraft_project::LayerId(env)).unwrap(); - assert!(l.props.prop("lightOptions/falloff").is_none(), "an environment light has no Falloff"); + 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()); diff --git a/crates/gpu/src/adv3d.rs b/crates/gpu/src/adv3d.rs index 24c5a3b35..f526c6653 100644 --- 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 { /// 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,23 @@ fn pack(s: &Scene) -> [Vec; 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], + 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. + globals.extend([e.sun[0], e.sun[1], e.sun[2], 0.0]); let mats = s.materials.iter().flat_map(|m| { let flags = (m.double_sided as u32) | (m.unlit as u32) << 1 diff --git a/crates/gpu/src/shaders/advanced3d.wgsl b/crates/gpu/src/shaders/advanced3d.wgsl index 4eea78c8c..47c3a65e1 100644 --- a/crates/gpu/src/shaders/advanced3d.wgsl +++ b/crates/gpu/src/shaders/advanced3d.wgsl @@ -14,8 +14,10 @@ struct Globals { ambient: vec4, // radiance first texture, mip count, irradiance texture, intensity env: vec4, - // rotation, any lighting (0/1), -, - + // rotation, any lighting (0/1), shadow map index (-1 none), shadow darkness env2: vec4, + // the environment's dominant direction (its shadow's source) + env_sun: vec4, }; @group(0) @binding(0) var g: Globals; @@ -318,6 +320,16 @@ fn light_shadow(li: u32, kind: u32, lpos: vec3, shadow: i32, darkness: f32, return 1.0 - darkness * (1.0 - f); } +// The Environment light's shadow from its dominant direction (shade::env_shadow). +fn env_shadow(p: vec3, ng: vec3, n: vec3) -> f32 { + if (g.env2.z < 0.0) { + return 1.0; + } + let n_l = clamp(dot(n, g.env_sun.xyz), 0.0, 1.0); + let f = shadow_factor(u32(g.env2.z + 0.5) * SHADOW_STRIDE, p, ng, n_l); + return 1.0 - g.env2.w * (1.0 - f); +} + fn attenuation(kind: u32, falloff: u32, radius: f32, falloff_distance: f32, cos_inner: f32, cos_outer: f32, dir: vec3, to_l: vec3, dist: f32) -> f32 { var k = 1.0; if (falloff == 1u && kind != 0u) { @@ -404,6 +416,10 @@ fn shadow_only_amount(unlit: bool, normal: vec3, front: bool, pos: vec3 0.5) { + amount = max(amount, 1.0 - env_shadow(pos, n, n)); + } return amount; } @@ -543,7 +559,11 @@ fn fs(i: VOut, @builtin(front_facing) front: bool) -> FOut { let f0 = mix(vec3(0.04 * spec_k), rgb, metallic); let ab = env_brdf(n_v, rough); let fs = f0 * ab.x + vec3(ab.y); - acc = acc + (irr * rgb * (vec3(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(1.0) - fs) * (1.0 - metallic) * diff_k + spec * fs) * ao * esh; } let c = acc + emissive; out.color = vec4(c * alpha, alpha); diff --git a/crates/gpu/src/tests_adv3d.rs b/crates/gpu/src/tests_adv3d.rs index 21422aa6b..3ca3c8130 100644 --- 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 index d01116816..d7f0ab191 100644 --- a/crates/project/src/build.rs +++ b/crates/project/src/build.rs @@ -656,7 +656,8 @@ pub fn light_options(ids: &mut Ids, kind: LightKind) -> PropGroup { // The environment image: a layer (footage, comp…) mapped as an equirectangular panorama; // 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 (#2436). + // 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)) diff --git a/crates/render/src/three_d/adv/raster.rs b/crates/render/src/three_d/adv/raster.rs index 395057935..8c1572d73 100644 --- 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 = (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 = vec![]; @@ -583,6 +593,31 @@ 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; + let soft = 1.0 + (e.shadow_diffusion * 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 }); + 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> = 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 index 56a5e6605..121254fd2 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -172,6 +172,16 @@ 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): no shadow then. + pub sun: [f32; 3], + /// 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 +680,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, [f32; 3], Option<(Option, [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, + 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, [f32; 3], Option) { let mut out = vec![]; let mut ambient = [0.0f32; 3]; let mut env = None; @@ -693,7 +714,14 @@ fn lights(ctx: &EvalCtx, lin: bool) -> (Vec, [f32; 3], Option<(Option { @@ -783,8 +811,47 @@ pub fn irradiance_map(sw: u32, sh: u32, src: &[[f32; 4]], w: u32, h: u32) -> Vec out } +/// The environment image's dominant direction: the radiance-weighted mean texel direction +/// (solid-angle weighted, rotation applied), or `[0; 3]` when the image is directionless +/// (a uniform environment cancels to zero). The mean of a single bright spot on black points +/// straight at it; a gradient points somewhere between its ends. +fn dominant_direction(w: u32, h: u32, d: &[[f32; 4]], rot: f32) -> [f32; 3] { + 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); + // Luminance × solid angle (∝ sin θ). + let lum = (p[0] + p[1] + p[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. + let len = (x * x + y * y + z * z).sqrt(); + if weight <= 1e-9 || len < weight * 0.01 { + return [0.0; 3]; + } + [x / len, y / len, z / len] +} + /// 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, +) { + // The shadow direction from the full-resolution image, before the mip chain averages it. + let sun = dominant_direction(w, h, &data, rotation); // Radiance level 0 at most 1024 wide. let (mut w, mut h, mut d) = (w, h, data); while w > 1024 { @@ -820,7 +887,18 @@ 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, + // 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 layer's pixels as linear straight RGBA. @@ -935,13 +1013,24 @@ 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 { - match env_pixels(r, ctx, src, lin) { - Some((w, h, d)) => add_environment(&mut b, w, h, d, k, rot), - // No Source and no Environment Layer: a flat default environment, as After - // Effects does — the light's colour × intensity light the scene from every - // direction (#2436). - None => add_environment(&mut b, 8, 4, vec![[1.0, 1.0, 1.0, 1.0]; 32], k, rot), + if let Some(env) = env { + match env_pixels(r, ctx, env.src, lin) { + Some((w, h, d)) => add_environment(&mut b, w, h, d, env.color, env.rot, env.casts_shadows, env.shadow_darkness, env.shadow_diffusion), + // No Source and no Environment Layer: a flat default environment of the light's + // colour × intensity — After Effects "uses a default environment map" for an + // Environment light — so the light always lights the scene per its parameters. + // A uniform environment has no dominant region, so it casts no shadow (#2436). + None => add_environment( + &mut b, + 8, + 4, + vec![[1.0, 1.0, 1.0, 1.0]; 32], + env.color, + env.rot, + env.casts_shadows, + env.shadow_darkness, + env.shadow_diffusion, + ), } } if r.opts.shadows() { @@ -951,6 +1040,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 index c7c1a40f1..af4653f6a 100644 --- a/crates/render/src/three_d/adv/shade.rs +++ b/crates/render/src/three_d/adv/shade.rs @@ -351,6 +351,19 @@ pub fn light_shadow(s: &Scene, l: &Light, p: [f32; 3], ng: [f32; 3], n_l: f32) - 1.0 - l.shadow_darkness * (1.0 - f) } +/// The visibility of the Environment light's dominant direction at `p` (1 = unshadowed), +/// darkened by its Shadow Darkness. A directionless image (or a non-casting light) has no map +/// 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 * (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 +511,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 +541,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 index 2fb22a6e4..26ac78cda 100644 --- a/crates/render/src/three_d/adv/tests.rs +++ b/crates/render/src/three_d/adv/tests.rs @@ -338,8 +338,8 @@ fn cards_and_environment_light() { } /// An Environment light with no Source and no Environment Layer lights the scene with a flat -/// default environment of its colour × intensity, as After Effects does (#2436) — not nothing -/// at all. +/// 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); @@ -376,6 +376,91 @@ fn environment_light_without_a_source_lights_the_scene() { assert!(c5[0] > 0.15 && c5[1] < 0.05 && c5[2] < 0.05, "red environment: {c5:?}"); } +/// #2436: an Environment light's Cast Shadows works like After Effects' — its image's dominant +/// (brightest) region shadows the scene. A bright disc turned to shine from behind the camera +/// (rotation 180°) shadows a sphere onto the plane behind it; Cast Shadows off lights it evenly. +#[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, 200.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 bright disc at the image's centre on black. + add(&mut p, cid, LayerSource::Shape, |l| { + l.switches.three_d = true; + let mut next = 10_000u64; + let ids = &mut build::Ids(&mut next); + let rect = build::shape_rect(ids, [200.0, 150.0], [0.0, 0.0], 0.0); + let gfill = build::shape_gradient_fill( + ids, + true, + [0.0, 0.0], + [0.0, 75.0], + effectcraft_keyframe::Gradient { colors: vec![(0.0, [1.0, 1.0, 1.0, 1.0]), (1.0, [0.0, 0.0, 0.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] }, + ); + let g = build::shape_group(ids, "Sky", vec![rect, gfill]); + l.props.sub_mut("contents").unwrap().children.push(g.into()); + l.environment = true; + }); + // Turned 180°, the disc shines from behind the camera, straight at the floor. + 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)); + }); + let img = render(&p, cid); + let (mid, side) = (img.data[75 * 200 + 100], img.data[75 * 200 + 20]); + assert!(mid[3] > 0.99 && side[3] > 0.99); + assert!(mid[0] < side[0] * 0.5, "the disc shadows the sphere onto the floor: {mid:?} vs {side:?}"); + // Cast Shadows off: the floor is lit everywhere. + let comp = p.comp_mut(cid).unwrap(); + set(&mut comp.layers[0], "lightOptions/castsShadows", Value::Bool(false)); + let img = render(&p, cid); + let mid = img.data[75 * 200 + 100]; + assert!(mid[0] > side[0] * 0.8, "no shadow without Cast Shadows: {mid:?} vs {side:?}"); +} + +/// #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"); +} + #[test] fn depth_of_field_blurs_out_of_focus_surfaces() { let mut img = Image::new(64, 64); diff --git a/crates/ui-egui/src/panels/dialogs_3d.rs b/crates/ui-egui/src/panels/dialogs_3d.rs index 80e18ce30..2a9590872 100644 --- a/crates/ui-egui/src/panels/dialogs_3d.rs +++ b/crates/ui-egui/src/panels/dialogs_3d.rs @@ -44,14 +44,15 @@ pub struct LightDraft { const FALLOFFS: [&str; 3] = ["None", "Smooth", "Inverse Square Clamped"]; -/// Whether a light type has Falloff options: Ambient and Environment lights come from no one -/// place, so they have none — an environment light shines from every direction at once. +/// 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. Every light but Ambient can cast shadows, -/// Environment lights included (their image-based light occludes) — as in After Effects (#2436). +/// 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 } @@ -315,7 +316,8 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) { } 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(" %"))); diff --git a/crates/ui-egui/tests/it/ui_light_settings.rs b/crates/ui-egui/tests/it/ui_light_settings.rs index 0183e0324..aac090802 100644 --- a/crates/ui-egui/tests/it/ui_light_settings.rs +++ b/crates/ui-egui/tests/it/ui_light_settings.rs @@ -177,28 +177,31 @@ 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); + // Falloff is distance attenuation: image-based Environment lights and Ambient have none. let falloff = !matches!(k, LightKind::Ambient | LightKind::Environment); - let shadows = k != LightKind::Ambient; 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()); } } } -/// An Environment light created through the dialog takes a Casts Shadows tick, like the other -/// light types (only Ambient has none) — #2436. +/// #2436: an Environment light created through the dialog keeps its Cast Shadows, which the +/// engine stores on the layer. #[test] -fn an_environment_light_created_with_casts_shadow_takes_it() { +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); - h.get_by_label("Casts Shadows").click(); + click_id(&mut h, "dialog.light.casts"); ok(&mut h); let id = last_layer(&h); assert_eq!(kind(&h, id), LightKind::Environment); - assert!(layer(&h, id).props.prop("lightOptions/castsShadows").unwrap().value.as_bool()); - assert!(layer(&h, id).props.prop("lightOptions/falloff").is_none(), "but no Falloff"); + 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] diff --git a/docs/agents.md b/docs/agents.md index b14c135fc..1ce30f076 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -214,9 +214,11 @@ Things that tripped up a long agent-driven session (a multi-scene 3D piece built image that metallic surfaces reflect. Set its `lightOptions/source` to a footage, comp or solid layer by layer id; with no source it uses the comp's Environment Layer (`layer.environment`), or a flat default environment of the light's colour × intensity when there is none — either - way the light brightens the scene, and it has `castsShadows`/`shadowDarkness`/`shadowDiffusion` - like the other lights. 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 + way the light brightens the scene. It has `castsShadows`/`shadowDarkness`/`shadowDiffusion` + like the other lights: Cast Shadows shades the scene from the image's dominant bright region + (a flat or otherwise uniform environment has none, so it casts no shadow). It only lights + 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)). diff --git a/docs/architecture.md b/docs/architecture.md index 9b8964505..f8b2c2bb5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -161,9 +161,11 @@ light from an Environment light whose Source (or the comp's Environment Layer, L Environment Layer, or a flat default environment 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). 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 +(spot: perspective, parallel: orthographic over the scene, point: six cube faces, environment: +one orthographic from the image's dominant bright region — none when the image is +directionless, as a flat environment is). An Environment light only lights Advanced 3D comps, +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 From 05390988873499c83372590e84180c350891baec Mon Sep 17 00:00:00 2001 From: Minher0 Date: Mon, 26 Oct 2026 13:40:53 +0000 Subject: [PATCH 3/9] =?UTF-8?q?Environment=20lights:=20review=20fixes=20?= =?UTF-8?q?=E2=80=94=20edit=20keeps=20Classic=203D,=20NaN=20sun=20guarded?= =?UTF-8?q?=20(#2436)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small fixes from the review of the shadow commit: - light_settings switched a Classic 3D comp to Advanced 3D whenever the edited light was an Environment light (the resulting kind), so editing the intensity of one in a comp the user had switched back to Classic 3D dragged the renderer back. The switch now happens only on the non-Environment to Environment transition (the sources before and after), matching the docs; ordinary edits leave the renderer to the user. - dominant_direction's thresholds let NaN through (every NaN comparison is false): a NaN or infinite texel produced a NaN sun direction and NaN shading with it. A non-finite weight or length now returns no direction — garbage casts no shadow. - add_environment walked the full-resolution image for the dominant direction even when the light casts no shadows, where nothing reads it. Only a shadow-casting light pays for the pass now. Tests: editing an existing Environment light in a Classic 3D comp keeps the renderer and stays toast-silent; a NaN and an infinite environment image yield no sun direction. --- crates/engine/src/commands/three_d.rs | 9 +++++---- crates/engine/src/tests_3d.rs | 6 +++++- crates/render/src/three_d/adv/scene.rs | 15 +++++++++------ crates/render/src/three_d/adv/tests.rs | 13 ++++++++++++- 4 files changed, 31 insertions(+), 12 deletions(-) diff --git a/crates/engine/src/commands/three_d.rs b/crates/engine/src/commands/three_d.rs index c5a46b6c3..ed2df85a4 100644 --- a/crates/engine/src/commands/three_d.rs +++ b/crates/engine/src/commands/three_d.rs @@ -294,11 +294,12 @@ fn light_settings(s: &mut Session, p: &Value) -> Result { l.name = n.clone(); } apply_light(&mut l, p, t, "layer.lightSettings")?; - // The resulting kind (kept or changed to): an Environment light in a Classic 3D comp - // switches it, like adding one does. - let env = matches!(l.source, LayerSource::Light { kind: LightKind::Environment }); + // 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(if env { use_advanced_3d(proj, cid)? } else { false }) + 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) diff --git a/crates/engine/src/tests_3d.rs b/crates/engine/src/tests_3d.rs index 1cef04014..748f8a32f 100644 --- a/crates/engine/src/tests_3d.rs +++ b/crates/engine/src/tests_3d.rs @@ -186,10 +186,14 @@ fn environment_lights_switch_classic_comps_to_advanced_3d() { 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. - s.execute("layer.newLight", json!({"kind": "Environment"})).unwrap(); + 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 diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index 121254fd2..46264fcb1 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -174,7 +174,8 @@ pub struct EnvInfo { 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): no shadow then. + /// `to_light`. `[0; 3]` when the image is directionless (uniform) or the light casts no + /// shadow (nothing reads it then): no shadow. pub sun: [f32; 3], /// Shadow map index into [`Scene::shadows`]: 0 marked (assigned by `raster::shadow_maps`), /// -1 casts no shadow. Gated by the light's Cast Shadows. @@ -815,7 +816,7 @@ pub fn irradiance_map(sw: u32, sh: u32, src: &[[f32; 4]], w: u32, h: u32) -> Vec /// (solid-angle weighted, rotation applied), or `[0; 3]` when the image is directionless /// (a uniform environment cancels to zero). The mean of a single bright spot on black points /// straight at it; a gradient points somewhere between its ends. -fn dominant_direction(w: u32, h: u32, d: &[[f32; 4]], rot: f32) -> [f32; 3] { +pub(crate) fn dominant_direction(w: u32, h: u32, d: &[[f32; 4]], rot: f32) -> [f32; 3] { use super::shade::equirect_dir; let (mut x, mut y, mut z) = (0.0f32, 0.0f32, 0.0f32); let mut weight = 0.0f32; @@ -830,9 +831,10 @@ fn dominant_direction(w: u32, h: u32, d: &[[f32; 4]], rot: f32) -> [f32; 3] { z += dir[2] * lum; weight += lum.abs(); } - // Under 1 % directional, the image is uniform to the eye: no dominant region to shadow from. + // 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 <= 1e-9 || len < weight * 0.01 { + if !weight.is_finite() || !len.is_finite() || weight <= 1e-9 || len < weight * 0.01 { return [0.0; 3]; } [x / len, y / len, z / len] @@ -850,8 +852,9 @@ pub(crate) fn add_environment( shadow_darkness: f64, shadow_diffusion: f64, ) { - // The shadow direction from the full-resolution image, before the mip chain averages it. - let sun = dominant_direction(w, h, &data, rotation); + // The shadow direction from the full-resolution image, before the mip chain averages it — + // only a shadow-casting light reads it, so only one pays for the pass. + let sun = if casts_shadows { dominant_direction(w, h, &data, rotation) } else { [0.0; 3] }; // Radiance level 0 at most 1024 wide. let (mut w, mut h, mut d) = (w, h, data); while w > 1024 { diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs index 26ac78cda..7463839a2 100644 --- 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,17 @@ 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, so no shadow (#2436). +#[test] +fn a_non_finite_environment_has_no_sun_direction() { + let nan = vec![[f32::NAN; 4]; 8 * 4]; + assert_eq!(dominant_direction(8, 4, &nan, 0.0), [0.0; 3]); + let inf = vec![[f32::INFINITY, 0.0, 0.0, 1.0]; 8 * 4]; + assert_eq!(dominant_direction(8, 4, &inf, 0.0), [0.0; 3]); +} + #[test] fn equirect_round_trip_and_env_brdf() { for (u, v) in [(0.1f32, 0.2f32), (0.5, 0.5), (0.9, 0.7)] { From 95e6ca9a2d456b88e8db97ac79d48c4200f99379 Mon Sep 17 00:00:00 2001 From: Minher0 Date: Mon, 26 Oct 2026 19:15:29 +0000 Subject: [PATCH 4/9] Test that an Environment light visibly lights the scene (#2436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end regression tests for the issue's report, through the real command path (what the Light Settings dialog runs) and the real viewer: adding an Environment light to a Classic 3D comp switches it to Advanced 3D and changes the viewer's pixels — a red light at full intensity, the default white light, and the same on the GPU compositor (skips without an adapter, like ui_gpu). --- crates/ui-egui/tests/it/main.rs | 2 + crates/ui-egui/tests/it/ui_env_light_gpu.rs | 79 +++++++++++++++++++ crates/ui-egui/tests/it/ui_env_light_repro.rs | 75 ++++++++++++++++++ 3 files changed, 156 insertions(+) create mode 100644 crates/ui-egui/tests/it/ui_env_light_gpu.rs create mode 100644 crates/ui-egui/tests/it/ui_env_light_repro.rs diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 6b4c08912..724b64b08 100644 --- 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.rs new file mode 100644 index 000000000..171cc5728 --- /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.rs new file mode 100644 index 000000000..78a26160f --- /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"); +} From aff46dabb646849a71b56bd3ba6564a2c923817d Mon Sep 17 00:00:00 2001 From: Minher0 Date: Mon, 26 Oct 2026 20:25:00 +0000 Subject: [PATCH 5/9] Cast shadows from the Environment light's default environment (#2436) After Effects' default environment map casts shadows out of the box: an Environment light with Cast Shadows on shadows the scene with no Source set. The flat default cancelled its dominant direction to zero, so the option did nothing until a Source image arrived. Give the default a soft studio gradient (bright above and toward the initial camera, dark below and behind), level-normalised so the light's colour x intensity still sets the overall brightness; its dominant region drives the existing shadow pipeline, CPU and GPU alike. --- crates/render/src/three_d/adv/scene.rs | 58 +++++++++++++++++++------- crates/render/src/three_d/adv/tests.rs | 54 ++++++++++++++++++++++++ docs/agents.md | 8 ++-- docs/architecture.md | 7 ++-- 4 files changed, 106 insertions(+), 21 deletions(-) diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index 46264fcb1..3d63db500 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -927,6 +927,41 @@ fn env_pixels(r: &Renderer, ctx: &EvalCtx, src: Option, lin: bool) -> Optio Some((img.width, img.height, data)) } +/// The default environment when the light has no Source and no Environment Layer: a soft studio +/// gradient, bright above and toward the initial camera, dark below and behind it — After +/// Effects "uses a default environment map" for an Environment light too, and its default +/// casts shadows out of the box. The solid-angle-weighted mean is 1, so the light's colour × +/// intensity still sets the overall level, like the flat default it replaces; the light's +/// Rotation turns the key around the scene (#2436). +pub(crate) fn default_environment() -> (u32, u32, Vec<[f32; 4]>) { + use super::shade::{dot, equirect_dir}; + // The key region's centre: three quarters up, two thirds toward the viewer (the default + // camera looks +z from -z). + let key = [0.0f32, -0.75, -0.66]; + let (w, h) = (8u32, 4u32); + let mut data = Vec::with_capacity((w * h) as usize); + let (mut mean, mut weight) = (0.0f32, 0.0f32); + for y in 0..h { + let v = (y as f32 + 0.5) / h as f32; + let sa = (v * std::f32::consts::PI).sin(); + for x in 0..w { + let u = (x as f32 + 0.5) / w as f32; + let l = 0.5 + 0.9 * dot(equirect_dir(u, v, 0.0), key).max(0.0); + data.push([l, l, l, 1.0]); + mean += l * sa; + weight += sa; + } + } + // Keep the flat default's level: scale the gradient to a weighted mean of exactly 1. + let k = if mean.is_finite() && mean > 1e-6 { weight / mean } else { 1.0 }; + for p in &mut data { + for c in 0..3 { + p[c] *= k; + } + } + (w, h, data) +} + /// Supersampling factor: 2×2 unless draft or very large. fn ssaa_for(r: &Renderer, out: (u32, u32)) -> u32 { if r.opts.draft || (out.0 as u64 * out.1 as u64) > 8_000_000 { 1 } else { 2 } @@ -1019,21 +1054,14 @@ pub(crate) fn build_at(r: &Renderer, ctx: &EvalCtx, sub: Option<&EvalCtx>, run: if let Some(env) = env { match env_pixels(r, ctx, env.src, lin) { Some((w, h, d)) => add_environment(&mut b, w, h, d, env.color, env.rot, env.casts_shadows, env.shadow_darkness, env.shadow_diffusion), - // No Source and no Environment Layer: a flat default environment of the light's - // colour × intensity — After Effects "uses a default environment map" for an - // Environment light — so the light always lights the scene per its parameters. - // A uniform environment has no dominant region, so it casts no shadow (#2436). - None => add_environment( - &mut b, - 8, - 4, - vec![[1.0, 1.0, 1.0, 1.0]; 32], - env.color, - env.rot, - env.casts_shadows, - env.shadow_darkness, - env.shadow_diffusion, - ), + // No Source and no Environment Layer: the default environment, a soft studio + // gradient of the light's colour × intensity — After Effects "uses a default + // environment map" for an Environment light — so the light always lights the + // scene per its parameters, and its dominant region casts shadows (#2436). + None => { + let (w, h, d) = default_environment(); + add_environment(&mut b, w, h, d, env.color, env.rot, env.casts_shadows, env.shadow_darkness, env.shadow_diffusion) + } } } if r.opts.shadows() { diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs index 7463839a2..d9e749d31 100644 --- a/crates/render/src/three_d/adv/tests.rs +++ b/crates/render/src/three_d/adv/tests.rs @@ -472,6 +472,60 @@ fn a_uniform_environment_casts_no_shadow() { assert_eq!(casting.data, off.data, "a directionless image casts no shadow"); } +/// #2436: the default environment (no Source, no Environment Layer) is level-preserving and +/// directional — its dominant region points up and toward the initial camera, so a +/// shadow-casting Environment light casts shadows out of the box, like After Effects' default. +#[test] +fn default_environment_keeps_the_level_and_points_up() { + let (w, h, d) = scene::default_environment(); + assert_eq!((w, h), (8, 4)); + let (mut mean, mut weight) = (0.0f32, 0.0f32); + for (i, p) in d.iter().enumerate() { + let v = ((i / w as usize) as f32 + 0.5) / h as f32; + let sa = (v * PI).sin(); + mean += (p[0] + p[1] + p[2]) / 3.0 * sa; + weight += sa; + } + assert!((mean / weight - 1.0).abs() < 0.01, "level-preserving mean: {}", mean / weight); + let sun = dominant_direction(w, h, &d, 0.0); + assert!(sun[1] < -0.5, "the key is up: {sun:?}"); + assert!(sun[2] < -0.3, "the key is toward the camera: {sun:?}"); + assert!(sun[0].abs() < 0.1, "symmetric left-right: {sun:?}"); +} + +/// #2436: with no Source and no Environment Layer, the Environment light's default environment +/// casts shadows like After Effects' default environment map — a sphere shadows the plane behind +/// it; Cast Shadows off lights the floor evenly. +#[test] +fn the_default_environment_casts_shadows() { + let (mut p, cid) = project(200, 150); + // The floor facing the camera, a sphere in front of it casting shadows only. + add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Plane }, |l| { + set(l, "geometryOptions/width", Value::Scalar(400.0)); + set(l, "geometryOptions/height", Value::Scalar(400.0)); + set(l, "transform/position", Value::Vec3([100.0, 75.0, 120.0])); + set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0])); + }); + add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Sphere }, |l| { + set(l, "geometryOptions/radius", Value::Scalar(30.0)); + set(l, "transform/position", Value::Vec3([100.0, 60.0, 50.0])); + set(l, "materialOptions/castsShadows", Value::Enum(2)); + }); + add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| { + set(l, "lightOptions/castsShadows", Value::Bool(true)); + }); + let img = render(&p, cid); + let (mid, side) = (img.data[120 * 200 + 100], img.data[120 * 200 + 30]); + assert!(mid[3] > 0.99 && side[3] > 0.99, "the floor covers both: {mid:?} {side:?}"); + assert!(mid[0] < side[0] * 0.5, "the default environment shadows the sphere onto the floor: {mid:?} vs {side:?}"); + // Cast Shadows off: the floor is lit everywhere. + let comp = p.comp_mut(cid).unwrap(); + set(&mut comp.layers[0], "lightOptions/castsShadows", Value::Bool(false)); + let img = render(&p, cid); + let mid = img.data[120 * 200 + 100]; + assert!(mid[0] > side[0] * 0.8, "no shadow without Cast Shadows: {mid:?} vs {side:?}"); +} + #[test] fn depth_of_field_blurs_out_of_focus_surfaces() { let mut img = Image::new(64, 64); diff --git a/docs/agents.md b/docs/agents.md index 1ce30f076..d3b3adbef 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -213,10 +213,12 @@ Things that tripped up a long agent-driven session (a multi-scene 3D piece built - **Environment light.** `layer.newLight` also accepts `"kind":"Environment"`: an equirectangular image that metallic surfaces reflect. Set its `lightOptions/source` to a footage, comp or solid layer by layer id; with no source it uses the comp's Environment Layer (`layer.environment`), - or a flat default environment of the light's colour × intensity when there is none — either - way the light brightens the scene. It has `castsShadows`/`shadowDarkness`/`shadowDiffusion` + or a default studio environment — bright above and toward the initial camera, dark below and + behind — of the light's colour × intensity when there is none; either way the light brightens + the scene. It has `castsShadows`/`shadowDarkness`/`shadowDiffusion` like the other lights: Cast Shadows shades the scene from the image's dominant bright region - (a flat or otherwise uniform environment has none, so it casts no shadow). It only lights + (the default environment's key, or a source image's brightest area; only a uniform image has + none, so it casts no shadow). It only lights Advanced 3D comps, so creating one (or changing a light's type to Environment) switches a Classic 3D comp to Advanced 3D ([#261](https://github.com/storytold/effectcraft/issues/261), diff --git a/docs/architecture.md b/docs/architecture.md index f8b2c2bb5..37e7d11d9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -158,12 +158,13 @@ comp to Advanced 3D in the same undo step, as in After Effects. Shading is physi GGX specular, Smith–Schlick geometry, Schlick Fresnel, Lambert diffuse, after Karis 2013): parallel, spot and point lights with After Effects' falloffs, ambient lights, image-based light from an Environment light whose Source (or the comp's Environment Layer, Layer ▸ -Environment Layer, or a flat default environment of the light's colour × intensity when +Environment Layer, or a default studio environment — bright above and toward the initial +camera, dark below and behind — of the light's colour × intensity when there is neither) is an equirectangular image (cosine irradiance map; roughness-indexed mip chain with the split-sum environment BRDF fit), and percentage-closer-filtered shadow maps (spot: perspective, parallel: orthographic over the scene, point: six cube faces, environment: -one orthographic from the image's dominant bright region — none when the image is -directionless, as a flat environment is). An Environment light only lights Advanced 3D comps, +one orthographic from the image's dominant bright region — none only when the image is +directionless, as a uniform one is). An Environment light only lights Advanced 3D comps, so adding one (or changing a light's type to it) switches a Classic 3D comp to Advanced 3D, as model layers do. Cards keep Material Options (Accepts Lights/Shadows, Casts Shadows On/Only, Ambient, Diffuse, Specular); From 743119d6e5241139639079700713cc673acd0995 Mon Sep 17 00:00:00 2001 From: Minher0 Date: Mon, 26 Oct 2026 23:36:19 +0000 Subject: [PATCH 6/9] Give the Environment light After Effects' look (#2436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The default studio environment's level-preserving mean of 1 acted like a 100% white Ambient light: with only the Environment light on, a white floor rendered at full white (95% of the frame saturated) while its shadow core went to pitch black — a fog over the scene, nothing like After Effects' default. Three changes bring the look in line, CPU and GPU alike: - The default environment is a dim studio now: a solid-angle-weighted mean of 0.3 (and a stronger key gradient), so a white floor reads a mid gray and the light's colour x intensity still scales the level. - The shadow occludes only the image's directional (key) fraction of the light — |sum of dir x radiance| / sum of radiance, computed beside the dominant direction and carried in the scene — so the fill keeps shining inside the shadow: an area-light shadow, partial like After Effects' (a lone bright spot on black still shadows almost fully; a uniform image not at all). - The environment shadow map's penumbra has a floor — eight output pixels, or four map texels where those are coarser — because the map covers the whole scene bounding sphere rather than the view, and its texels would otherwise stair-step the edge (all the more in a draft render's flat 512 budget). Shadow Diffusion widens it further, as before. Tests: the key fraction tracks directionality; the default keeps its dim level, its direction and its key; the default and a bright key both shadow the floor (the default partial, the key deep) with Cast Shadows off lit everywhere; and the full look — a mid-gray lit floor (no fog), a partial core, and a soft edge ramp in quality and draft renders alike. --- crates/gpu/src/adv3d.rs | 6 +- crates/gpu/src/shaders/advanced3d.wgsl | 6 +- crates/render/src/three_d/adv/raster.rs | 11 +- crates/render/src/three_d/adv/scene.rs | 48 ++++--- crates/render/src/three_d/adv/shade.rs | 10 +- crates/render/src/three_d/adv/tests.rs | 172 +++++++++++++++++++----- docs/agents.md | 8 +- docs/architecture.md | 10 +- 8 files changed, 205 insertions(+), 66 deletions(-) diff --git a/crates/gpu/src/adv3d.rs b/crates/gpu/src/adv3d.rs index f526c6653..c96e57d2d 100644 --- a/crates/gpu/src/adv3d.rs +++ b/crates/gpu/src/adv3d.rs @@ -322,6 +322,7 @@ fn pack(s: &Scene) -> [Vec; 7] { intensity: 0.0, rotation: 0.0, sun: [0.0; 3], + key: 0.0, shadow: -1, shadow_darkness: 0.0, shadow_diffusion: 0.0, @@ -330,8 +331,9 @@ fn pack(s: &Scene) -> [Vec; 7] { let lit = !(s.lights.is_empty() && s.env.is_none() && s.ambient == [0.0; 3]); // env2: rotation, any lighting (0/1), shadow map index (-1 none), shadow darkness. globals.extend([e.rotation, lit as u32 as f32, e.shadow as f32, e.shadow_darkness]); - // The environment's dominant direction (its shadow's source), for the shadow bias slope. - globals.extend([e.sun[0], e.sun[1], e.sun[2], 0.0]); + // The environment's dominant direction (its shadow's source), for the shadow bias slope, + // and its key fraction — how much of its light the shadow can remove (shade::env_shadow). + globals.extend([e.sun[0], e.sun[1], e.sun[2], e.key]); let mats = s.materials.iter().flat_map(|m| { let flags = (m.double_sided as u32) | (m.unlit as u32) << 1 diff --git a/crates/gpu/src/shaders/advanced3d.wgsl b/crates/gpu/src/shaders/advanced3d.wgsl index 47c3a65e1..c1a6af4e9 100644 --- a/crates/gpu/src/shaders/advanced3d.wgsl +++ b/crates/gpu/src/shaders/advanced3d.wgsl @@ -320,14 +320,16 @@ fn light_shadow(li: u32, kind: u32, lpos: vec3, shadow: i32, darkness: f32, return 1.0 - darkness * (1.0 - f); } -// The Environment light's shadow from its dominant direction (shade::env_shadow). +// The Environment light's shadow from its dominant direction (shade::env_shadow). Only the +// image's key fraction (g.env_sun.w) of the light is occluded — the fill keeps shining — so +// the shadow stays partial the way After Effects renders an area-light shadow. fn env_shadow(p: vec3, ng: vec3, n: vec3) -> f32 { if (g.env2.z < 0.0) { return 1.0; } let n_l = clamp(dot(n, g.env_sun.xyz), 0.0, 1.0); let f = shadow_factor(u32(g.env2.z + 0.5) * SHADOW_STRIDE, p, ng, n_l); - return 1.0 - g.env2.w * (1.0 - f); + return 1.0 - g.env2.w * g.env_sun.w * (1.0 - f); } fn attenuation(kind: u32, falloff: u32, radius: f32, falloff_distance: f32, cos_inner: f32, cos_outer: f32, dir: vec3, to_l: vec3, dist: f32) -> f32 { diff --git a/crates/render/src/three_d/adv/raster.rs b/crates/render/src/three_d/adv/raster.rs index 8c1572d73..91bf45307 100644 --- a/crates/render/src/three_d/adv/raster.rs +++ b/crates/render/src/three_d/adv/raster.rs @@ -599,13 +599,20 @@ pub(crate) fn shadow_maps(s: &mut Scene, draft: bool) { match sizes.get(s.lights.len()).copied().flatten() { Some(size) => { let first = maps.len() as i32; - let soft = 1.0 + (e.shadow_diffusion * 0.5).min(24.0); + // After Effects' environment-light shadows are soft out of the box, and this + // map covers the whole scene bounding sphere — not the view — so its texels + // can be coarse (all the more in a draft render's flat 512 budget). A + // penumbra of at least eight output pixels, or four map texels where those + // are coarser, keeps the edge smooth at any map resolution; Shadow Diffusion + // widens it further, as for the other lights. + let texel = 2.0 * radius / size as f64; + let soft = (4.0 * texel).max(8.0) + (e.shadow_diffusion as f64 * 0.5).min(24.0); let sun = e.sun.map(|v| v as f64); let dir = [-sun[0], -sun[1], -sun[2]]; let eye = [centre[0] + sun[0] * radius * 2.0, centre[1] + sun[1] * radius * 2.0, centre[2] + sun[2] * radius * 2.0]; let hint = if dir[1].abs() < 0.9 { [0.0, 1.0, 0.0] } else { [0.0, 0.0, 1.0] }; let focal = (size as f64 * 0.5 / radius) as f32; - maps.push(ShadowMap { view: basis(eye, dir, hint), focal, ortho: true, size, offset: 0, bias: 1.5, radius: soft }); + maps.push(ShadowMap { view: basis(eye, dir, hint), focal, ortho: true, size, offset: 0, bias: 1.5, radius: soft as f32 }); if let Some(e) = &mut s.env { e.shadow = first; } diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index 3d63db500..8a120f0d9 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -177,6 +177,13 @@ pub struct EnvInfo { /// `to_light`. `[0; 3]` when the image is directionless (uniform) or the light casts no /// shadow (nothing reads it then): no shadow. pub sun: [f32; 3], + /// The image's directional (key) fraction — `|Σ dir·radiance| / Σ radiance` over the + /// sphere, 0 for a uniform image and near 1 for a single bright spot. An Environment + /// light's shadow occludes only this much of its light: the key lobe is blocked where + /// the map says so, the remaining fill keeps shining, so the shadow stays partial the + /// way After Effects renders an area-light shadow (a broad environment like the default + /// studio shadows softly, a lone bright spot on black almost fully). + pub key: f32, /// Shadow map index into [`Scene::shadows`]: 0 marked (assigned by `raster::shadow_maps`), /// -1 casts no shadow. Gated by the light's Cast Shadows. pub shadow: i32, @@ -812,11 +819,14 @@ pub fn irradiance_map(sw: u32, sh: u32, src: &[[f32; 4]], w: u32, h: u32) -> Vec out } -/// The environment image's dominant direction: the radiance-weighted mean texel direction -/// (solid-angle weighted, rotation applied), or `[0; 3]` when the image is directionless -/// (a uniform environment cancels to zero). The mean of a single bright spot on black points -/// straight at it; a gradient points somewhere between its ends. -pub(crate) fn dominant_direction(w: u32, h: u32, d: &[[f32; 4]], rot: f32) -> [f32; 3] { +/// The environment image's dominant direction and key strength: the radiance-weighted mean +/// texel direction (solid-angle weighted, rotation applied), or `([0; 3], 0.0)` when the image +/// is directionless (a uniform environment cancels to zero). The mean of a single bright spot +/// on black points straight at it; a gradient points somewhere between its ends. The returned +/// key fraction is the mean vector's length over the radiance weight — how directional the +/// image is, 0 (uniform) to ~1 (a lone spot) — and is the most of its light an Environment +/// light's shadow can remove. +pub(crate) fn dominant_direction(w: u32, h: u32, d: &[[f32; 4]], rot: f32) -> ([f32; 3], f32) { use super::shade::equirect_dir; let (mut x, mut y, mut z) = (0.0f32, 0.0f32, 0.0f32); let mut weight = 0.0f32; @@ -835,9 +845,9 @@ pub(crate) fn dominant_direction(w: u32, h: u32, d: &[[f32; 4]], rot: f32) -> [f // from. A non-finite sum (a NaN or infinite texel) is garbage, not a direction: none then. let len = (x * x + y * y + z * z).sqrt(); if !weight.is_finite() || !len.is_finite() || weight <= 1e-9 || len < weight * 0.01 { - return [0.0; 3]; + return ([0.0; 3], 0.0); } - [x / len, y / len, z / len] + ([x / len, y / len, z / len], (len / weight).clamp(0.0, 1.0)) } /// Prefilter an environment image into the scene's textures. @@ -852,9 +862,9 @@ pub(crate) fn add_environment( shadow_darkness: f64, shadow_diffusion: f64, ) { - // The shadow direction from the full-resolution image, before the mip chain averages it — - // only a shadow-casting light reads it, so only one pays for the pass. - let sun = if casts_shadows { dominant_direction(w, h, &data, rotation) } else { [0.0; 3] }; + // The shadow direction and key strength from the full-resolution image, before the mip + // chain averages it — only a shadow-casting light reads them, so only one pays for the pass. + let (sun, key) = if casts_shadows { dominant_direction(w, h, &data, rotation) } else { ([0.0; 3], 0.0) }; // Radiance level 0 at most 1024 wide. let (mut w, mut h, mut d) = (w, h, data); while w > 1024 { @@ -897,6 +907,7 @@ pub(crate) fn add_environment( intensity: k, rotation, sun, + key, // Marked; `raster::shadow_maps` assigns the map (a directionless image casts none). shadow: if casts_shadows { 0 } else { -1 }, shadow_darkness: shadow_darkness as f32, @@ -927,12 +938,13 @@ fn env_pixels(r: &Renderer, ctx: &EvalCtx, src: Option, lin: bool) -> Optio Some((img.width, img.height, data)) } -/// The default environment when the light has no Source and no Environment Layer: a soft studio -/// gradient, bright above and toward the initial camera, dark below and behind it — After +/// The default environment when the light has no Source and no Environment Layer: a dim studio +/// gradient, bright above and toward the initial camera, dark below and behind — After /// Effects "uses a default environment map" for an Environment light too, and its default -/// casts shadows out of the box. The solid-angle-weighted mean is 1, so the light's colour × -/// intensity still sets the overall level, like the flat default it replaces; the light's -/// Rotation turns the key around the scene (#2436). +/// casts shadows out of the box. The solid-angle-weighted mean is 0.3, a soft studio ambience +/// (a mean of 1 would act like a 100 % white Ambient light and wash the whole scene out, which +/// is not what After Effects' default looks like); the light's colour × intensity still scales +/// the overall level, and its Rotation turns the key around the scene (#2436). pub(crate) fn default_environment() -> (u32, u32, Vec<[f32; 4]>) { use super::shade::{dot, equirect_dir}; // The key region's centre: three quarters up, two thirds toward the viewer (the default @@ -946,14 +958,14 @@ pub(crate) fn default_environment() -> (u32, u32, Vec<[f32; 4]>) { let sa = (v * std::f32::consts::PI).sin(); for x in 0..w { let u = (x as f32 + 0.5) / w as f32; - let l = 0.5 + 0.9 * dot(equirect_dir(u, v, 0.0), key).max(0.0); + let l = 0.15 + 1.2 * dot(equirect_dir(u, v, 0.0), key).max(0.0); data.push([l, l, l, 1.0]); mean += l * sa; weight += sa; } } - // Keep the flat default's level: scale the gradient to a weighted mean of exactly 1. - let k = if mean.is_finite() && mean > 1e-6 { weight / mean } else { 1.0 }; + // A dim studio: scale the gradient to a weighted mean of exactly 0.3. + let k = if mean.is_finite() && mean > 1e-6 { 0.3 * weight / mean } else { 1.0 }; for p in &mut data { for c in 0..3 { p[c] *= k; diff --git a/crates/render/src/three_d/adv/shade.rs b/crates/render/src/three_d/adv/shade.rs index af4653f6a..232756a9d 100644 --- a/crates/render/src/three_d/adv/shade.rs +++ b/crates/render/src/three_d/adv/shade.rs @@ -351,8 +351,12 @@ pub fn light_shadow(s: &Scene, l: &Light, p: [f32; 3], ng: [f32; 3], n_l: f32) - 1.0 - l.shadow_darkness * (1.0 - f) } -/// The visibility of the Environment light's dominant direction at `p` (1 = unshadowed), -/// darkened by its Shadow Darkness. A directionless image (or a non-casting light) has no map +/// The visibility of the Environment light at `p` (1 = unshadowed), darkened by its Shadow +/// Darkness — but only by the image's key fraction: the shadow map occludes the dominant +/// (key) lobe, while the environment's fill keeps shining on the shadowed surface, so an +/// Environment light's shadow stays partial the way After Effects renders an area-light +/// shadow (a broad environment like the default studio shadows softly, a lone bright spot +/// on black almost fully). A directionless image (or a non-casting light) has no map /// and shades unshadowed. pub fn env_shadow(s: &Scene, e: &EnvInfo, p: [f32; 3], ng: [f32; 3], n: [f32; 3]) -> f32 { if e.shadow < 0 || e.sun == [0.0; 3] { @@ -361,7 +365,7 @@ pub fn env_shadow(s: &Scene, e: &EnvInfo, p: [f32; 3], ng: [f32; 3], n: [f32; 3] let Some(m) = s.shadows.get(e.shadow as usize) else { return 1.0 }; let n_l = dot(n, e.sun).clamp(0.0, 1.0); let f = shadow_factor(m, &s.shadow_texels, p, ng, n_l); - 1.0 - e.shadow_darkness * (1.0 - f) + 1.0 - e.shadow_darkness * e.key * (1.0 - f) } /// Unit direction from `p` to light `l`, and the distance (infinite for a parallel light). diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs index d9e749d31..5a0e35ac8 100644 --- a/crates/render/src/three_d/adv/tests.rs +++ b/crates/render/src/three_d/adv/tests.rs @@ -69,13 +69,30 @@ fn constant_environment_has_constant_irradiance() { /// A NaN or infinite texel poisons the sums (every NaN comparison is false, so the thresholds /// alone would pass it through as a NaN direction); an infinite one even where sin θ = 0 turns -/// into NaN. Either way the sum is garbage, not a direction: no sun, so no shadow (#2436). +/// into NaN. Either way the sum is garbage, not a direction: no sun and no key, so no shadow +/// (#2436). #[test] fn a_non_finite_environment_has_no_sun_direction() { let nan = vec![[f32::NAN; 4]; 8 * 4]; - assert_eq!(dominant_direction(8, 4, &nan, 0.0), [0.0; 3]); + assert_eq!(dominant_direction(8, 4, &nan, 0.0), ([0.0; 3], 0.0)); let inf = vec![[f32::INFINITY, 0.0, 0.0, 1.0]; 8 * 4]; - assert_eq!(dominant_direction(8, 4, &inf, 0.0), [0.0; 3]); + assert_eq!(dominant_direction(8, 4, &inf, 0.0), ([0.0; 3], 0.0)); +} + +/// The key fraction: a uniform image is directionless (key 0), a lone bright spot on black is +/// almost fully directional, and a broad gradient sits in between — the soft studio default +/// shadows visibly but keeps most of its light as fill (#2436). +#[test] +fn environment_key_strength_tracks_directionality() { + let uniform = vec![[0.5f32, 0.5, 0.5, 1.0]; 8 * 4]; + assert_eq!(dominant_direction(8, 4, &uniform, 0.0).1, 0.0); + let mut spot = vec![[0.0f32; 4]; 64 * 32]; + spot[16 * 64 + 32] = [1.0, 1.0, 1.0, 1.0]; + let (_, key) = dominant_direction(64, 32, &spot, 0.0); + assert!(key > 0.95, "a lone spot is almost fully directional: {key}"); + let (w, h, d) = scene::default_environment(); + let (_, key) = dominant_direction(w, h, &d, 0.0); + assert!((0.35..0.55).contains(&key), "the default studio is a broad key with fill: {key}"); } #[test] @@ -388,8 +405,9 @@ fn environment_light_without_a_source_lights_the_scene() { } /// #2436: an Environment light's Cast Shadows works like After Effects' — its image's dominant -/// (brightest) region shadows the scene. A bright disc turned to shine from behind the camera -/// (rotation 180°) shadows a sphere onto the plane behind it; Cast Shadows off lights it evenly. +/// (brightest) region shadows the scene. A bright disc above the image's centre, turned to +/// shine from up and behind the camera (rotation 180°), shadows a sphere onto the floor below +/// it; Cast Shadows off lights it evenly. #[test] fn environment_lights_cast_shadows() { let (mut p, cid) = project(200, 150); @@ -397,7 +415,7 @@ fn environment_lights_cast_shadows() { add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Plane }, |l| { set(l, "geometryOptions/width", Value::Scalar(400.0)); set(l, "geometryOptions/height", Value::Scalar(400.0)); - set(l, "transform/position", Value::Vec3([100.0, 75.0, 200.0])); + set(l, "transform/position", Value::Vec3([100.0, 75.0, 110.0])); set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0])); }); add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Sphere }, |l| { @@ -405,38 +423,53 @@ fn environment_lights_cast_shadows() { set(l, "transform/position", Value::Vec3([100.0, 75.0, 50.0])); set(l, "materialOptions/castsShadows", Value::Enum(2)); }); - // The Environment Layer: a bright disc at the image's centre on black. + // The Environment Layer: a tight bright key above the image's centre, on black — a + // full-comp rect keeps the layer buffer full-size, the radial gradient is the key. add(&mut p, cid, LayerSource::Shape, |l| { l.switches.three_d = true; let mut next = 10_000u64; let ids = &mut build::Ids(&mut next); let rect = build::shape_rect(ids, [200.0, 150.0], [0.0, 0.0], 0.0); - let gfill = build::shape_gradient_fill( + let key = build::shape_gradient_fill( ids, true, - [0.0, 0.0], - [0.0, 75.0], + [0.0, -30.0], + [0.0, 5.0], effectcraft_keyframe::Gradient { colors: vec![(0.0, [1.0, 1.0, 1.0, 1.0]), (1.0, [0.0, 0.0, 0.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] }, ); - let g = build::shape_group(ids, "Sky", vec![rect, gfill]); + let g = build::shape_group(ids, "Key", vec![rect, key]); l.props.sub_mut("contents").unwrap().children.push(g.into()); l.environment = true; }); - // Turned 180°, the disc shines from behind the camera, straight at the floor. + // Turned 180°, the key shines from up and behind the camera onto the sphere and floor. add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| { set(l, "lightOptions/rotation", Value::Scalar(180.0)); set(l, "lightOptions/castsShadows", Value::Bool(true)); }); + // The shadow lands below the sphere on the floor: its core is the darkest floor pixel + // below the sphere; the lit floor is the sample to the left. The frame is sRGB-encoded, + // so the ratios below are gamma-compressed (a linear halving reads as ~0.73). + let luma = |img: &crate::Image, x: usize, y: usize| (img.data[y * 200 + x][0] + img.data[y * 200 + x][1] + img.data[y * 200 + x][2]) / 3.0; let img = render(&p, cid); - let (mid, side) = (img.data[75 * 200 + 100], img.data[75 * 200 + 20]); - assert!(mid[3] > 0.99 && side[3] > 0.99); - assert!(mid[0] < side[0] * 0.5, "the disc shadows the sphere onto the floor: {mid:?} vs {side:?}"); - // Cast Shadows off: the floor is lit everywhere. + let side = luma(&img, 25, 75); + let (mut core, mut cx, mut cy) = (f32::INFINITY, 0usize, 0usize); + for y in 100..150 { + for x in 60..140 { + if luma(&img, x, y) < core { + core = luma(&img, x, y); + cx = x; + cy = y; + } + } + } + assert!(side > 0.1, "the floor is lit: {side}"); + assert!(core < side * 0.65, "the key shadows the sphere onto the floor: {core} vs {side} at ({cx}, {cy})"); + // Cast Shadows off: the floor is lit everywhere, core included. let comp = p.comp_mut(cid).unwrap(); set(&mut comp.layers[0], "lightOptions/castsShadows", Value::Bool(false)); let img = render(&p, cid); - let mid = img.data[75 * 200 + 100]; - assert!(mid[0] > side[0] * 0.8, "no shadow without Cast Shadows: {mid:?} vs {side:?}"); + let mid = luma(&img, cx, cy); + assert!(mid > side * 0.9, "no shadow without Cast Shadows: {mid} vs {side}"); } /// #2436: a uniform environment has no dominant region, so even with Cast Shadows on it casts @@ -472,9 +505,11 @@ fn a_uniform_environment_casts_no_shadow() { assert_eq!(casting.data, off.data, "a directionless image casts no shadow"); } -/// #2436: the default environment (no Source, no Environment Layer) is level-preserving and -/// directional — its dominant region points up and toward the initial camera, so a -/// shadow-casting Environment light casts shadows out of the box, like After Effects' default. +/// #2436: the default environment (no Source, no Environment Layer) is a dim studio — a mean +/// of 0.3, not the 1.0 that would act like a 100 % white Ambient light and wash the scene out +/// (After Effects' default does not) — and directional: its dominant region points up and +/// toward the initial camera, so a shadow-casting Environment light casts soft, partial +/// shadows out of the box, like After Effects' default. #[test] fn default_environment_keeps_the_level_and_points_up() { let (w, h, d) = scene::default_environment(); @@ -486,16 +521,21 @@ fn default_environment_keeps_the_level_and_points_up() { mean += (p[0] + p[1] + p[2]) / 3.0 * sa; weight += sa; } - assert!((mean / weight - 1.0).abs() < 0.01, "level-preserving mean: {}", mean / weight); - let sun = dominant_direction(w, h, &d, 0.0); + assert!((mean / weight - 0.3).abs() < 0.01, "dim studio mean: {}", mean / weight); + let (sun, key) = dominant_direction(w, h, &d, 0.0); assert!(sun[1] < -0.5, "the key is up: {sun:?}"); assert!(sun[2] < -0.3, "the key is toward the camera: {sun:?}"); assert!(sun[0].abs() < 0.1, "symmetric left-right: {sun:?}"); + assert!((0.35..0.55).contains(&key), "a broad key with fill: {key}"); + // No texel clips past white at the default intensity (the fog of a mean-1 environment). + assert!(d.iter().all(|p| p[0] <= 1.0 && p[0] > 0.0), "0 < radiance <= 1"); } /// #2436: with no Source and no Environment Layer, the Environment light's default environment -/// casts shadows like After Effects' default environment map — a sphere shadows the plane behind -/// it; Cast Shadows off lights the floor evenly. +/// casts soft, partial shadows like After Effects' default environment map — a sphere shadows +/// the plane behind it, darkening part of the light while the fill keeps the floor visible +/// (an area-light shadow, not the pitch black a point light casts); Cast Shadows off lights +/// the floor evenly. #[test] fn the_default_environment_casts_shadows() { let (mut p, cid) = project(200, 150); @@ -508,22 +548,39 @@ fn the_default_environment_casts_shadows() { }); add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Sphere }, |l| { set(l, "geometryOptions/radius", Value::Scalar(30.0)); - set(l, "transform/position", Value::Vec3([100.0, 60.0, 50.0])); + set(l, "transform/position", Value::Vec3([100.0, 45.0, 50.0])); set(l, "materialOptions/castsShadows", Value::Enum(2)); }); add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| { set(l, "lightOptions/castsShadows", Value::Bool(true)); }); + // The default key (up and toward the camera) drops the shadow below the sphere: the core + // is the darkest floor pixel below it, the lit floor the sample to the left. + let luma = |img: &crate::Image, x: usize, y: usize| (img.data[y * 200 + x][0] + img.data[y * 200 + x][1] + img.data[y * 200 + x][2]) / 3.0; let img = render(&p, cid); - let (mid, side) = (img.data[120 * 200 + 100], img.data[120 * 200 + 30]); - assert!(mid[3] > 0.99 && side[3] > 0.99, "the floor covers both: {mid:?} {side:?}"); - assert!(mid[0] < side[0] * 0.5, "the default environment shadows the sphere onto the floor: {mid:?} vs {side:?}"); - // Cast Shadows off: the floor is lit everywhere. + let side = luma(&img, 30, 100); + let (mut core, mut cx, mut cy) = (f32::INFINITY, 0usize, 0usize); + for y in 110..150 { + for x in 80..120 { + if luma(&img, x, y) < core { + core = luma(&img, x, y); + cx = x; + cy = y; + } + } + } + assert!(side > 0.1, "the floor is lit: {side}"); + // Partial: the key lobe is occluded (a clear darkening) but the fill keeps shining — + // not the pitch black a point light's shadow would cast at the same darkness. The frame + // is sRGB-encoded, so the ratios are gamma-compressed (a linear halving reads as ~0.73). + assert!(core < side * 0.8, "the default environment shadows the sphere onto the floor: {core} vs {side} at ({cx}, {cy})"); + assert!(core > side * 0.45, "an area-light shadow, partial like After Effects': {core} vs {side}"); + // Cast Shadows off: the floor is lit everywhere, core included. let comp = p.comp_mut(cid).unwrap(); set(&mut comp.layers[0], "lightOptions/castsShadows", Value::Bool(false)); let img = render(&p, cid); - let mid = img.data[120 * 200 + 100]; - assert!(mid[0] > side[0] * 0.8, "no shadow without Cast Shadows: {mid:?} vs {side:?}"); + let mid = luma(&img, cx, cy); + assert!(mid > side * 0.9, "no shadow without Cast Shadows: {mid} vs {side}"); } #[test] @@ -1236,3 +1293,54 @@ fn hard_edges_ramp_over_two_texels() { assert!((1.7..3.0).contains(&w), "v {v0}: hard edge ramps over {w} texels"); } } + +// ---------------------------------------------------------------- the After Effects look (#2436) + +/// #2436: an Environment light with every option at its default renders like After Effects': +/// a dim studio that leaves the lit floor a mid gray instead of washing it out to white (the +/// fog of a mean-1 default), a partial shadow (the key lobe occluded, the fill still shining — +/// not the pitch black a point light casts), and a soft edge in both quality and draft renders +/// (the map's texel grid never shows through as stair-steps). +#[test] +fn environment_shadows_match_the_after_effects_look() { + let (mut p, cid) = project(800, 600); + add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Plane }, |l| { + set(l, "geometryOptions/width", Value::Scalar(1200.0)); + set(l, "geometryOptions/height", Value::Scalar(900.0)); + set(l, "transform/position", Value::Vec3([400.0, 300.0, 300.0])); + set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0])); + }); + add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Sphere }, |l| { + set(l, "geometryOptions/radius", Value::Scalar(80.0)); + set(l, "transform/position", Value::Vec3([400.0, 300.0, 120.0])); + set(l, "materialOptions/castsShadows", Value::Enum(2)); + }); + add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| { + set(l, "lightOptions/castsShadows", Value::Bool(true)); + }); + let luma = |img: &crate::Image, x: usize, y: usize| (img.data[y * 800 + x][0] + img.data[y * 800 + x][1] + img.data[y * 800 + x][2]) / 3.0; + for (name, opts) in [("quality", RenderOpts::default()), ("draft", RenderOpts { draft: true, ..Default::default() })] { + let img = Renderer::new(&p, &NoFootage, opts).comp_frame(cid, Tick::ZERO); + // The shadow core: the darkest pixel of the lower half (the key drops the shadow + // below the sphere); the lit floor is the mean of the band to its left. + let (mut core, mut cx, mut cy) = (f32::INFINITY, 0usize, 0usize); + for y in 420..580 { + for x in 300..500 { + if luma(&img, x, y) < core { + core = luma(&img, x, y); + cx = x; + cy = y; + } + } + } + let lit: f32 = (60..260).map(|x| luma(&img, x, cy)).sum::() / 200.0; + assert!(lit > 0.15 && lit < 0.85, "{name}: the lit floor is a mid gray, no white fog: {lit}"); + // The frame is sRGB-encoded, so the ratio is gamma-compressed (a linear halving + // reads as ~0.73): partial means clearly darker than lit, far from black. + assert!(core > lit * 0.45 && core < lit * 0.85, "{name}: a partial area-light shadow: core {core} vs lit {lit}"); + // The edge's 10-90 % ramp from the lit plateau (left) down into the shadow. + let first_below = |t: f32| (260..cx).position(|x| luma(&img, x, cy) < core + (lit - core) * t).map(|i| 260 + i); + let w = (first_below(0.1).unwrap() as f32) - (first_below(0.9).unwrap() as f32); + assert!(w >= 3.5, "{name}: the edge ramps over {w} px (After Effects' env shadows are soft)"); + } +} diff --git a/docs/agents.md b/docs/agents.md index d3b3adbef..a9d755616 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -214,11 +214,13 @@ Things that tripped up a long agent-driven session (a multi-scene 3D piece built image that metallic surfaces reflect. Set its `lightOptions/source` to a footage, comp or solid layer by layer id; with no source it uses the comp's Environment Layer (`layer.environment`), or a default studio environment — bright above and toward the initial camera, dark below and - behind — of the light's colour × intensity when there is none; either way the light brightens - the scene. It has `castsShadows`/`shadowDarkness`/`shadowDiffusion` + behind, at a dim 0.3 mean level — of the light's colour × intensity when there is none; either + way the light brightens the scene. It has `castsShadows`/`shadowDarkness`/`shadowDiffusion` like the other lights: Cast Shadows shades the scene from the image's dominant bright region (the default environment's key, or a source image's brightest area; only a uniform image has - none, so it casts no shadow). It only lights + none, so it casts no shadow) — occluding only that key's share of the light, so the shadow + stays partial like After Effects' area-light shadows, and soft by default (a penumbra of at + least 8 pixels or four map texels, plus Shadow Diffusion). It only lights Advanced 3D comps, so creating one (or changing a light's type to Environment) switches a Classic 3D comp to Advanced 3D ([#261](https://github.com/storytold/effectcraft/issues/261), diff --git a/docs/architecture.md b/docs/architecture.md index 37e7d11d9..93645f9cf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -159,12 +159,14 @@ GGX specular, Smith–Schlick geometry, Schlick Fresnel, Lambert diffuse, after parallel, spot and point lights with After Effects' falloffs, ambient lights, image-based light from an Environment light whose Source (or the comp's Environment Layer, Layer ▸ Environment Layer, or a default studio environment — bright above and toward the initial -camera, dark below and behind — of the light's colour × intensity when -there is neither) is an equirectangular image (cosine irradiance map; roughness-indexed mip -chain with the split-sum environment BRDF fit), and percentage-closer-filtered shadow maps +camera, dark below and behind, at a dim 0.3 mean level — of the light's colour × intensity +when there is neither) is an equirectangular image (cosine irradiance map; roughness-indexed +mip chain with the split-sum environment BRDF fit), and percentage-closer-filtered shadow maps (spot: perspective, parallel: orthographic over the scene, point: six cube faces, environment: one orthographic from the image's dominant bright region — none only when the image is -directionless, as a uniform one is). An Environment light only lights Advanced 3D comps, +directionless, as a uniform one is — occluding only the image's directional key fraction of +the light, so the shadow stays partial like After Effects' area-light shadows, with a soft +default penumbra of at least eight pixels or four map texels). An Environment light only lights Advanced 3D comps, so adding one (or changing a light's type to it) switches a Classic 3D comp to Advanced 3D, as model layers do. Cards keep Material Options (Accepts Lights/Shadows, Casts Shadows On/Only, Ambient, Diffuse, Specular); From eb34d10d21f8e25a7bfd2802bddbb24781afaeee Mon Sep 17 00:00:00 2001 From: Z User Date: Tue, 27 Oct 2026 12:33:46 +0000 Subject: [PATCH 7/9] Review fixes: a failed environment source lights nothing (#2436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review points on the merge: - env_pixels returned None both for an unconfigured light (no Source, no Environment Layer) and for a failed source (a deleted layer, missing footage, a failed decode), so a selected source that failed silently swapped in the default studio environment — relighting and reshadowing the scene behind the user's back. It now tells the three apart (EnvPixels::Absent/Image/Failed): only the unconfigured light uses the default; a failed source contributes nothing, exactly like a layer whose source fails renders nothing. The repair the user can act on stays where it already lives — missing footage is flagged on the item, with the relink commands to fix it. - The docs claimed only a uniform image casts no shadow. A non-uniform one can be directionless too: opposing bright regions cancel, and anything under 1% directional is rejected. agents.md, architecture.md and the sun/dominant_direction doc comments now say an image without a sufficiently strong dominant direction. Tests: an unreadable Environment Layer (auto-picked), an unreadable selected Source and a deleted one all light nothing — pixel-equal to the unlit render, no studio default, no default-environment shadow — while an unconfigured light still lights; two equal bright spots at opposite azimuths cancel to no sun and no key, and tilting the balance re-emerges pointing at the brighter spot. --- crates/render/src/three_d/adv/scene.rs | 47 +++++++++---- crates/render/src/three_d/adv/tests.rs | 91 ++++++++++++++++++++++++++ docs/agents.md | 13 ++-- docs/architecture.md | 9 ++- 4 files changed, 141 insertions(+), 19 deletions(-) diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index 8a120f0d9..ce572aaef 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -174,8 +174,8 @@ pub struct EnvInfo { 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 the light casts no - /// shadow (nothing reads it then): no shadow. + /// `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 @@ -821,7 +821,8 @@ pub fn irradiance_map(sw: u32, sh: u32, src: &[[f32; 4]], w: u32, h: u32) -> Vec /// The environment image's dominant direction and key strength: the radiance-weighted mean /// texel direction (solid-angle weighted, rotation applied), or `([0; 3], 0.0)` when the image -/// is directionless (a uniform environment cancels to zero). The mean of a single bright spot +/// is directionless (a uniform environment cancels to zero, and opposing bright regions that +/// cancel leave no mean either). 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 @@ -915,13 +916,30 @@ pub(crate) fn add_environment( }); } -/// The environment layer's pixels as linear straight RGBA. -fn env_pixels(r: &Renderer, ctx: &EvalCtx, src: Option, lin: bool) -> Option<(u32, u32, Vec<[f32; 4]>)> { +/// 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, +} + +fn env_pixels(r: &Renderer, ctx: &EvalCtx, src: Option, 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 @@ -935,7 +953,7 @@ fn env_pixels(r: &Renderer, ctx: &EvalCtx, src: Option, 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 @@ -1065,15 +1083,22 @@ pub(crate) fn build_at(r: &Renderer, ctx: &EvalCtx, sub: Option<&EvalCtx>, run: b.s.ambient = amb; if let Some(env) = env { match env_pixels(r, ctx, env.src, lin) { - Some((w, h, d)) => add_environment(&mut b, w, h, d, env.color, env.rot, env.casts_shadows, env.shadow_darkness, env.shadow_diffusion), + 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). - None => { + 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() { diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs index 5a0e35ac8..fd3c615ea 100644 --- a/crates/render/src/three_d/adv/tests.rs +++ b/crates/render/src/three_d/adv/tests.rs @@ -95,6 +95,33 @@ fn environment_key_strength_tracks_directionality() { 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, 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, 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:?}"); +} + #[test] fn equirect_round_trip_and_env_brdf() { for (u, v) in [(0.1f32, 0.2f32), (0.5, 0.5), (0.9, 0.7)] { @@ -404,6 +431,70 @@ fn environment_light_without_a_source_lights_the_scene() { 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 diff --git a/docs/agents.md b/docs/agents.md index a9d755616..42e16c3ca 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -215,12 +215,15 @@ Things that tripped up a long agent-driven session (a multi-scene 3D piece built 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. It has `castsShadows`/`shadowDarkness`/`shadowDiffusion` + 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; only a uniform image has - none, so it casts no shadow) — occluding only that key's share of the light, so the shadow - stays partial like After Effects' area-light shadows, and soft by default (a penumbra of at - least 8 pixels or four map texels, plus Shadow Diffusion). It only lights + (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) — occluding only that key's share of the light, so the + shadow stays partial like After Effects' area-light shadows, and soft by default (a penumbra + of at least 8 pixels or four map texels, plus Shadow Diffusion). It only lights Advanced 3D comps, so creating one (or changing a light's type to Environment) switches a Classic 3D comp to Advanced 3D ([#261](https://github.com/storytold/effectcraft/issues/261), diff --git a/docs/architecture.md b/docs/architecture.md index 93645f9cf..76f4c6a84 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -163,10 +163,13 @@ camera, dark below and behind, at a dim 0.3 mean level — of the light's colour when there is neither) is an equirectangular image (cosine irradiance map; roughness-indexed mip chain with the split-sum environment BRDF fit), and percentage-closer-filtered shadow maps (spot: perspective, parallel: orthographic over the scene, point: six cube faces, environment: -one orthographic from the image's dominant bright region — none only when the image is -directionless, as a uniform one is — occluding only the image's directional key fraction of +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) — occluding only the image's directional key fraction of the light, so the shadow stays partial like After Effects' area-light shadows, with a soft -default penumbra of at least eight pixels or four map texels). An Environment light only lights Advanced 3D comps, +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); From a8cba23edc11a192c6a708f5e0a54a0caae8841e Mon Sep 17 00:00:00 2001 From: Z User Date: Tue, 27 Oct 2026 14:11:55 +0000 Subject: [PATCH 8/9] Review fixes: the environment shadow follows the tinted radiance (#2436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dominant_direction weighted texels by their raw channel sum before the light's colour tinted the radiance, so equally bright red and blue regions on opposite sides cancelled to no sun and no key — while a red Environment light's tint removed the blue contribution and left the scene lit directionally red, with no shadow for its Cast Shadows to show. The tint now weights the direction sum (a white light weights [1; 3], a coloured one its normalised colour), so the sun and key are derived from the radiance the light actually emits; add_environment computes the tint once, before the direction pass, and reuses it for the texel tinting. An achromatic image is unaffected (its weights scale uniformly), so the default studio and white-lit scenes keep their shadows exactly. Tests: opposed red and blue texels cancel to a white light but a red tint points the sun straight at the red one (a blue tint, at the blue one); end to end, a red Environment light over an image with equally bright red and blue regions on exactly antipodal positions casts the shadow a white light over the same image does not — and the shadow falls opposite the red region, the side the tinted radiance comes from. --- crates/render/src/three_d/adv/scene.rs | 34 ++++--- crates/render/src/three_d/adv/tests.rs | 122 +++++++++++++++++++++++-- docs/agents.md | 5 +- docs/architecture.md | 4 +- 4 files changed, 142 insertions(+), 23 deletions(-) diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index ce572aaef..dfd35b048 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -819,23 +819,27 @@ 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 radiance-weighted mean -/// texel direction (solid-angle weighted, rotation applied), or `([0; 3], 0.0)` when the image -/// is directionless (a uniform environment cancels to zero, and opposing bright regions that -/// cancel leave no mean either). The mean of a single bright spot +/// 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]], rot: f32) -> ([f32; 3], f32) { +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); - // Luminance × solid angle (∝ sin θ). - let lum = (p[0] + p[1] + p[2]) * (v * std::f32::consts::PI).sin(); + // 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; @@ -863,9 +867,16 @@ pub(crate) fn add_environment( 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. - let (sun, key) = if casts_shadows { dominant_direction(w, h, &data, rotation) } else { ([0.0; 3], 0.0) }; + // 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 { @@ -888,10 +899,7 @@ pub(crate) fn add_environment( 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] { diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs index fd3c615ea..3f97615f9 100644 --- a/crates/render/src/three_d/adv/tests.rs +++ b/crates/render/src/three_d/adv/tests.rs @@ -74,9 +74,9 @@ fn constant_environment_has_constant_irradiance() { #[test] fn a_non_finite_environment_has_no_sun_direction() { let nan = vec![[f32::NAN; 4]; 8 * 4]; - assert_eq!(dominant_direction(8, 4, &nan, 0.0), ([0.0; 3], 0.0)); + 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, 0.0), ([0.0; 3], 0.0)); + 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 @@ -85,13 +85,13 @@ fn a_non_finite_environment_has_no_sun_direction() { #[test] fn environment_key_strength_tracks_directionality() { let uniform = vec![[0.5f32, 0.5, 0.5, 1.0]; 8 * 4]; - assert_eq!(dominant_direction(8, 4, &uniform, 0.0).1, 0.0); + 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, 0.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, 0.0); + 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}"); } @@ -110,18 +110,47 @@ fn opposing_bright_regions_cancel_to_no_shadow() { 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, 0.0), ([0.0; 3], 0.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, 0.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)] { @@ -596,6 +625,83 @@ fn a_uniform_environment_casts_no_shadow() { 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 @@ -613,7 +719,7 @@ fn default_environment_keeps_the_level_and_points_up() { weight += sa; } assert!((mean / weight - 0.3).abs() < 0.01, "dim studio mean: {}", mean / weight); - let (sun, key) = dominant_direction(w, h, &d, 0.0); + 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:?}"); diff --git a/docs/agents.md b/docs/agents.md index 42e16c3ca..9effdf18a 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -221,7 +221,10 @@ Things that tripped up a long agent-driven session (a multi-scene 3D piece built 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) — occluding only that key's share of the light, so the + 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 diff --git a/docs/architecture.md b/docs/architecture.md index 76f4c6a84..105eb8736 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -165,7 +165,9 @@ mip chain with the split-sum environment BRDF fit), and percentage-closer-filter (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) — occluding only the image's directional key fraction of +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 From 6969bd4da8b6b040410c543d9c0fce29801b76f5 Mon Sep 17 00:00:00 2001 From: Minher0 Date: Thu, 29 Oct 2026 10:11:06 +0000 Subject: [PATCH 9/9] Review fixes: pass &env to add_environment, extract env_shadow_map, drop the repro test (#2436) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add_environment takes the evaluated light (&EnvLight) instead of nine loose parameters — strict clippy (#2496) denies too_many_arguments. - The Environment light's shadow map moves into env_shadow_map so shadow_maps stays under the cognitive-complexity limit. - tests/it/ui_env_light_repro.rs deleted: it duplicated ui_env_light_gpu and the engine tests, and polled with sleeps of up to 12 s. Signed-off-by: Minher0 --- crates/render/src/three_d/adv/raster.rs | 37 +++++---- crates/render/src/three_d/adv/scene.rs | 34 +++------ crates/ui-egui/tests/it/main.rs | 1 - crates/ui-egui/tests/it/ui_env_light_repro.rs | 75 ------------------- 4 files changed, 33 insertions(+), 114 deletions(-) delete mode 100644 crates/ui-egui/tests/it/ui_env_light_repro.rs diff --git a/crates/render/src/three_d/adv/raster.rs b/crates/render/src/three_d/adv/raster.rs index 91bf45307..1c37a6267 100644 --- a/crates/render/src/three_d/adv/raster.rs +++ b/crates/render/src/three_d/adv/raster.rs @@ -13,7 +13,7 @@ use std::collections::BinaryHeap; use rayon::prelude::*; -use super::scene::{Scene, ShadowMap, Vertex}; +use super::scene::{EnvInfo, Scene, ShadowMap, Vertex}; use super::shade::{Frag, sample, shade}; /// A rendered target at raster size. @@ -500,6 +500,26 @@ pub(crate) fn shadow_sizes(width: u32, height: u32, draft: bool, kinds: &[u32]) sizes.into_iter().map(|z| (z > 0).then_some(z)).collect() } +/// The Environment light's shadow map: one orthographic map over the scene's bounding sphere +/// from its dominant direction (the light travels from the bright region into the scene), like +/// a parallel light's. +fn env_shadow_map(e: EnvInfo, centre: [f64; 3], radius: f64, size: u32) -> ShadowMap { + // 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; + ShadowMap { view: basis(eye, dir, hint), focal, ortho: true, size, offset: 0, bias: 1.5, radius: soft as f32 } +} + /// 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. @@ -599,20 +619,7 @@ pub(crate) fn shadow_maps(s: &mut Scene, draft: bool) { match sizes.get(s.lights.len()).copied().flatten() { Some(size) => { let first = maps.len() as i32; - // 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 }); + maps.push(env_shadow_map(e, centre, radius, size)); if let Some(e) = &mut s.env { e.shadow = first; } diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index dfd35b048..1197e5916 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -691,7 +691,7 @@ fn card_layer(r: &Renderer, ctx: &EvalCtx, layer: &Layer, b: &mut Builder) { /// 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 { +pub(crate) struct EnvLight { src: Option, color: [f32; 3], rot: f32, @@ -856,27 +856,17 @@ pub(crate) fn dominant_direction(w: u32, h: u32, d: &[[f32; 4]], tint: [f32; 3], } /// 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, - casts_shadows: bool, - shadow_darkness: f64, - shadow_diffusion: f64, -) { +pub(crate) fn add_environment(b: &mut Builder, env: &EnvLight, w: u32, h: u32, data: Vec<[f32; 4]>) { // 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] }; + let k = (env.color[0] + env.color[1] + env.color[2]) / 3.0; + let tint = if k > 0.0 && (env.color[0] != env.color[1] || env.color[1] != env.color[2]) { env.color.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) }; + let (sun, key) = if env.casts_shadows { dominant_direction(w, h, &data, tint, env.rot) } 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 { @@ -914,13 +904,13 @@ pub(crate) fn add_environment( mips: levels, irradiance: ii, intensity: k, - rotation, + rotation: env.rot, 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, + shadow: if env.casts_shadows { 0 } else { -1 }, + shadow_darkness: env.shadow_darkness as f32, + shadow_diffusion: env.shadow_diffusion as f32, }); } @@ -1091,16 +1081,14 @@ pub(crate) fn build_at(r: &Renderer, ctx: &EvalCtx, sub: Option<&EvalCtx>, run: b.s.ambient = amb; 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) - } + EnvPixels::Image((w, h, d)) => add_environment(&mut b, &env, w, h, d), // 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) + add_environment(&mut b, &env, w, h, d) } // A selected or found source that produced no pixels (deleted layer, missing // footage, failed decode): no environment at all — the light contributes diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs index 724b64b08..a99094baf 100644 --- a/crates/ui-egui/tests/it/main.rs +++ b/crates/ui-egui/tests/it/main.rs @@ -21,7 +21,6 @@ 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_repro.rs b/crates/ui-egui/tests/it/ui_env_light_repro.rs deleted file mode 100644 index 78a26160f..000000000 --- a/crates/ui-egui/tests/it/ui_env_light_repro.rs +++ /dev/null @@ -1,75 +0,0 @@ -//! 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"); -}