Merge pull request #2474 from Minher0/fix/environment-light-no-effect-cast-shadows

Light Environment lights: default environment, Casts Shadows, Advanced 3D switch
This commit is contained in:
BFlat
2026-10-30 07:10:52 +07:00
committed by GitHub
17 changed files with 1095 additions and 68 deletions
+11 -5
View File
@@ -64,6 +64,12 @@ fn comp_renderer(s: &mut Session, p: &Value) -> Result<Value> {
if r == Renderer::Classic3D && s.project.comp(cid).is_some_and(|c| c.layers.iter().any(|l| l.source.is_model())) {
s.toast("Classic 3D doesn't draw 3D model layers: they show with the Advanced 3D renderer");
}
if r == Renderer::Classic3D {
let env = |l: &Layer| matches!(l.source, LayerSource::Light { kind: effectcraft_project::LightKind::Environment });
if s.project.comp(cid).is_some_and(|c| c.layers.iter().any(env)) {
s.toast("Classic 3D doesn't light Environment light layers: they light with the Advanced 3D renderer");
}
}
Ok(json!({"renderer": renderer_id(r)}))
}
@@ -77,7 +83,7 @@ fn renderer_id(r: Renderer) -> &'static str {
/// Only the Advanced 3D renderer draws model and primitive layers, so adding one switches a
/// Classic 3D comp to it, like After Effects (inside the adding edit: one undo step). Whether
/// the renderer changed.
fn use_advanced_3d(proj: &mut effectcraft_project::Project, cid: ItemId) -> Result<bool> {
pub(crate) fn use_advanced_3d(proj: &mut effectcraft_project::Project, cid: ItemId) -> Result<bool> {
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let switched = c.renderer != Renderer::Advanced3D;
c.renderer = Renderer::Advanced3D;
@@ -86,10 +92,10 @@ fn use_advanced_3d(proj: &mut effectcraft_project::Project, cid: ItemId) -> Resu
}
/// Tell the user when [`use_advanced_3d`] changed the comp's renderer.
fn report_switch(s: &mut Session, cid: ItemId, switched: bool) {
pub(crate) fn report_switch(s: &mut Session, cid: ItemId, switched: bool, why: &str) {
if switched {
let name = s.project.item(cid).map_or("the composition", |i| i.name.as_str()).to_string();
s.toast(format!("\"{name}\" now uses the Advanced 3D renderer, which draws 3D models"));
s.toast(format!("\"{name}\" now uses the Advanced 3D renderer, {why}"));
}
}
@@ -156,7 +162,7 @@ pub(crate) fn new_model(s: &mut Session, p: &Value) -> Result<Value> {
let id = insert_layer_at(proj, st, cid, l, index)?;
Ok((id, use_advanced_3d(proj, cid)?))
})?;
report_switch(s, cid, switched);
report_switch(s, cid, switched, "which draws 3D models");
Ok(json!({"layer": id.0, "item": item.0, "advanced3d": true}))
}
@@ -226,7 +232,7 @@ fn new_primitive(s: &mut Session, p: &Value) -> Result<Value> {
let id = insert_layer(proj, st, cid, l)?;
Ok((id, use_advanced_3d(proj, cid)?))
})?;
report_switch(s, cid, switched);
report_switch(s, cid, switched, "which draws 3D models");
Ok(json!({"layer": id.0, "kind": kind.label(), "advanced3d": true}))
}
+18 -7
View File
@@ -12,6 +12,7 @@ use effectcraft_time::Tick;
use serde_json::{Value, json};
use super::layer::{color_p, insert_layer};
use super::model3d::{report_switch, use_advanced_3d};
use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, layer_mut, layer_p, merge_p, str_p, time_p};
use crate::{EngineError, Result, Session, cmd, query};
@@ -227,12 +228,17 @@ fn new_light(s: &mut Session, p: &Value) -> Result<Value> {
};
let name = str_p(p, "name").unwrap_or("Light 1").to_string();
let t = time_p(s, p, Some(&comp));
let id = s.edit("New Light", None, |proj, st| {
// Only the Advanced 3D renderer lights with Environment lights, so adding one switches a
// Classic 3D comp to it, like After Effects (inside the adding edit: one undo step).
let env = kind == LightKind::Environment;
let (id, switched) = s.edit("New Light", None, |proj, st| {
let mut l = build::layer(proj, &comp, &name, LayerSource::Light { kind }, (comp.width, comp.height), None);
apply_light(&mut l, p, t, "layer.newLight")?;
insert_layer(proj, st, cid, l)
let id = insert_layer(proj, st, cid, l)?;
Ok((id, if env { use_advanced_3d(proj, cid)? } else { false }))
})?;
Ok(json!({"layer": id.0}))
report_switch(s, cid, switched, "which Environment lights need to light the scene");
Ok(json!({"layer": id.0, "advanced3d": env}))
}
fn light_settings(s: &mut Session, p: &Value) -> Result<Value> {
@@ -248,7 +254,7 @@ fn light_settings(s: &mut Session, p: &Value) -> Result<Value> {
),
None => None,
};
s.edit("Light Settings", None, |proj, _| {
let switched = s.edit("Light Settings", None, |proj, _| {
let old = proj.comp(cid).and_then(|c| c.layer(lid)).cloned().ok_or(EngineError::NoComp)?;
let mut l = old.clone();
if let Some(k) = kind
@@ -288,9 +294,14 @@ fn light_settings(s: &mut Session, p: &Value) -> Result<Value> {
l.name = n.clone();
}
apply_light(&mut l, p, t, "layer.lightSettings")?;
// A light that becomes an Environment light switches a Classic 3D comp to Advanced 3D,
// like adding one does; editing one that already is leaves the renderer to the user.
let was_env = matches!(old.source, LayerSource::Light { kind: LightKind::Environment });
let is_env = matches!(l.source, LayerSource::Light { kind: LightKind::Environment });
*layer_mut(proj, cid, lid)? = l;
Ok(())
Ok(if !was_env && is_env { use_advanced_3d(proj, cid)? } else { false })
})?;
report_switch(s, cid, switched, "which Environment lights need to light the scene");
Ok(Value::Null)
}
@@ -683,7 +694,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Light...",
["Layer", "New"],
Some("Cmd+Alt+Shift+L"),
"{kind?: Parallel|Spot|Point|Ambient|Environment (default Spot; Environment lights and reflects from an equirectangular image layer: prop.set its lightOptions/source to that layer's id, or leave it empty for the comp's Environment Layer, see layer.environment), name?, color?, intensity?, coneAngle?, coneFeather?, falloff?: None|Smooth|Inverse Square Clamped, radius?, falloffDistance?, castsShadows?, shadowDarkness?, shadowDiffusion?, position? [x,y,z], poi? [x,y,z]}",
"{kind?: Parallel|Spot|Point|Ambient|Environment (default Spot; Environment lights and reflects from an equirectangular image layer: prop.set its lightOptions/source to that layer's id, or leave it empty for the comp's Environment Layer or a flat default environment of its colour and intensity, see layer.environment; an Environment light switches a Classic 3D comp to Advanced 3D), name?, color?, intensity?, coneAngle?, coneFeather?, falloff?: None|Smooth|Inverse Square Clamped, radius?, falloffDistance?, castsShadows?, shadowDarkness?, shadowDiffusion?, position? [x,y,z], poi? [x,y,z]}",
has_comp,
new_light
),
@@ -710,7 +721,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Light Settings...",
[],
None,
"{layer?, name?, kind?: Parallel|Spot|Point|Ambient|Environment, color?, intensity?, coneAngle?, coneFeather?, falloff?, radius?, falloffDistance?, castsShadows?, shadowDarkness?, shadowDiffusion?, position?, poi?}",
"{layer?, name?, kind?: Parallel|Spot|Point|Ambient|Environment (changing to Environment switches a Classic 3D comp to Advanced 3D), color?, intensity?, coneAngle?, coneFeather?, falloff?, radius?, falloffDistance?, castsShadows?, shadowDarkness?, shadowDiffusion?, position?, poi?}",
has_comp,
light_settings
),
+80 -1
View File
@@ -1,6 +1,6 @@
//! 3D commands: cameras, lights, views, camera tools (with undo/redo), and the 3D demo comp.
use effectcraft_project::{AutoOrient, LayerSource, LightKind};
use effectcraft_project::{AutoOrient, LayerSource, LightKind, Renderer};
use effectcraft_render::three_d::View3D;
use serde_json::json;
@@ -148,6 +148,85 @@ fn deleting_a_source_layer_clears_the_reference() {
assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(sky)).is_some());
}
/// #2436: only the Advanced 3D renderer lights with Environment lights, and new comps are
/// Classic 3D, so one had no visible effect. Adding one (or changing a light into one) switches
/// the comp to Advanced 3D (in the same undo step, with a toast), as After Effects does.
#[test]
fn environment_lights_switch_classic_comps_to_advanced_3d() {
let toasts = |s: &mut Session| -> Vec<String> {
s.drain_events().into_iter().filter_map(|e| if let crate::Event::Toast { message, .. } = e { Some(message) } else { None }).collect()
};
let mut s = session();
let text = s.execute("layer.newText", json!({"text": "Hi"})).unwrap()["layer"].as_u64().unwrap();
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D);
toasts(&mut s);
let r = s.execute("layer.newLight", json!({"kind": "Environment"})).unwrap();
assert_eq!(r["advanced3d"], true);
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Advanced3D);
// As with Composition Settings: the text layer gets Geometry Options.
assert!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(text)).unwrap().props.sub("geometryOptions").is_some());
assert!(toasts(&mut s).iter().any(|m| m.contains("Advanced 3D")));
// A second Environment light leaves the comp (and the user) alone.
s.execute("layer.newLight", json!({"kind": "Environment"})).unwrap();
assert!(toasts(&mut s).is_empty());
// Undoing the creations takes the lights and the switch back together.
s.undo();
s.undo();
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D);
assert_eq!(layer_count(&s), 1);
// Changing a light to Environment switches too, and undoes with the type.
let spot = s.execute("layer.newLight", json!({"kind": "Spot"})).unwrap()["layer"].as_u64().unwrap();
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D);
s.execute("layer.lightSettings", json!({"layer": spot, "kind": "Environment"})).unwrap();
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Advanced3D);
s.undo();
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D);
assert_eq!(s.active_comp().unwrap().layer(effectcraft_project::LayerId(spot)).unwrap().source, LayerSource::Light { kind: LightKind::Spot });
// Other light types never switch it.
s.execute("layer.newLight", json!({"kind": "Point"})).unwrap();
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D);
// Switching back to Classic 3D with an Environment light in the comp explains itself.
let env = s.execute("layer.newLight", json!({"kind": "Environment"})).unwrap()["layer"].as_u64().unwrap();
toasts(&mut s);
s.execute("comp.renderer", json!({"renderer": "classic3d"})).unwrap();
assert!(toasts(&mut s).iter().any(|m| m.contains("Environment light")), "the Classic 3D limit is explained");
// Editing that Environment light (no type change) leaves the renderer the user chose.
s.execute("layer.lightSettings", json!({"layer": env, "intensity": 80})).unwrap();
assert_eq!(s.active_comp().unwrap().renderer, Renderer::Classic3D, "an ordinary edit doesn't re-switch");
assert!(toasts(&mut s).is_empty(), "and doesn't toast either");
}
/// An Environment light takes Casts Shadows (with Shadow Darkness and Diffusion) like every
/// other light — the Light Settings dialog offers them for all types but Ambient — and keeps no
/// Falloff, which an environment light shining from every direction has no use for (#2436).
#[test]
fn environment_light_takes_shadow_options_but_no_falloff() {
let mut s = session();
let env = s.execute("layer.newLight", json!({"kind": "Environment", "castsShadows": true, "shadowDarkness": 60, "shadowDiffusion": 12})).unwrap()["layer"]
.as_u64()
.unwrap();
assert!(prop(&s, env, "lightOptions/castsShadows").as_bool());
assert_eq!(prop(&s, env, "lightOptions/shadowDarkness").as_f64(), 60.0);
assert_eq!(prop(&s, env, "lightOptions/shadowDiffusion").as_f64(), 12.0);
assert!(
s.active_comp().unwrap().layer(effectcraft_project::LayerId(env)).unwrap().props.prop("lightOptions/falloff").is_none(),
"an environment light has no Falloff"
);
// The shadow values carry over when the type changes, both ways.
s.execute("layer.lightSettings", json!({"layer": env, "kind": "Spot", "coneAngle": 70})).unwrap();
assert!(prop(&s, env, "lightOptions/castsShadows").as_bool());
assert_eq!(prop(&s, env, "lightOptions/shadowDarkness").as_f64(), 60.0);
assert_eq!(prop(&s, env, "lightOptions/coneAngle").as_f64(), 70.0);
s.execute("layer.lightSettings", json!({"layer": env, "kind": "Environment"})).unwrap();
assert!(prop(&s, env, "lightOptions/castsShadows").as_bool());
assert_eq!(prop(&s, env, "lightOptions/source").as_layer(), None, "back to Environment: it has a Source again");
s.undo();
assert!(
s.active_comp().unwrap().layer(effectcraft_project::LayerId(env)).unwrap().props.prop("lightOptions/coneAngle").is_some(),
"back to Spot: it has a Cone Angle again"
);
}
#[test]
fn three_d_switch_undo_redo() {
let mut s = session();
+24 -9
View File
@@ -77,7 +77,7 @@ fn buffer_bytes(count: u64, stride: u64, limit: u64) -> Option<u64> {
/// Checking these before serialization bounds host packing as well as the GPU uploads.
fn packed_bytes(counts: [u64; 6], uniform_limit: u64, storage_limit: u64, buffer_limit: u64) -> Option<[u64; 7]> {
let mut sizes = [0; 7];
sizes[0] = buffer_bytes(40, 4, uniform_limit.min(buffer_limit))?;
sizes[0] = buffer_bytes(44, 4, uniform_limit.min(buffer_limit))?;
for (index, (count, stride)) in counts.into_iter().zip([96, 16, 16, 80, 80, 4]).enumerate() {
let size = count.checked_mul(stride)?.max(16);
sizes[index + 1] = buffer_bytes(size, 1, storage_limit.min(buffer_limit))?;
@@ -164,11 +164,11 @@ mod capability_tests {
#[test]
fn packed_arrays_preflight_exact_strides_and_empty_padding() {
assert_eq!(packed_bytes([0; 6], 160, 16, 160), Some([160, 16, 16, 16, 16, 16, 16]));
assert_eq!(packed_bytes([2; 6], 160, 192, 192), Some([160, 192, 32, 32, 160, 160, 16]));
assert!(packed_bytes([0; 6], 159, 16, 160).is_none());
assert!(packed_bytes([0; 6], 160, 15, 160).is_none());
assert!(packed_bytes([2; 6], 160, 192, 191).is_none());
assert_eq!(packed_bytes([0; 6], 176, 16, 176), Some([176, 16, 16, 16, 16, 16, 16]));
assert_eq!(packed_bytes([2; 6], 176, 192, 192), Some([176, 192, 32, 32, 160, 160, 16]));
assert!(packed_bytes([0; 6], 175, 16, 176).is_none());
assert!(packed_bytes([0; 6], 176, 15, 176).is_none());
assert!(packed_bytes([2; 6], 176, 192, 191).is_none());
for index in 0..6 {
let mut counts = [0; 6];
counts[index] = u64::MAX;
@@ -203,7 +203,7 @@ mod capability_tests {
};
for (scene, counts) in [(Scene::default(), [0; 6]), (scene, [1; 6])] {
let actual = pack(&scene).map(|bytes| bytes.len() as u64);
assert_eq!(Some(actual), packed_bytes(counts, 160, 160, 160));
assert_eq!(Some(actual), packed_bytes(counts, 176, 176, 176));
}
}
}
@@ -315,10 +315,25 @@ fn pack(s: &Scene) -> [Vec<u8>; 7] {
globals.extend([s.eye[0], s.eye[1], s.eye[2], s.ortho as u32 as f32]);
globals.extend([s.cam_fwd[0], s.cam_fwd[1], s.cam_fwd[2], s.lights.len() as f32]);
globals.extend([s.ambient[0], s.ambient[1], s.ambient[2], s.env.is_some() as u32 as f32]);
let e = s.env.unwrap_or(effectcraft_render::three_d::adv::EnvInfo { radiance: 0, mips: 1, irradiance: 0, intensity: 0.0, rotation: 0.0 });
let e = s.env.unwrap_or(effectcraft_render::three_d::adv::EnvInfo {
radiance: 0,
mips: 1,
irradiance: 0,
intensity: 0.0,
rotation: 0.0,
sun: [0.0; 3],
key: 0.0,
shadow: -1,
shadow_darkness: 0.0,
shadow_diffusion: 0.0,
});
globals.extend([e.radiance as f32, e.mips as f32, e.irradiance as f32, e.intensity]);
let lit = !(s.lights.is_empty() && s.env.is_none() && s.ambient == [0.0; 3]);
globals.extend([e.rotation, lit as u32 as f32, 0.0, 0.0]);
// env2: rotation, any lighting (0/1), shadow map index (-1 none), shadow darkness.
globals.extend([e.rotation, lit as u32 as f32, e.shadow as f32, e.shadow_darkness]);
// The environment's dominant direction (its shadow's source), for the shadow bias slope,
// and its key fraction — how much of its light the shadow can remove (shade::env_shadow).
globals.extend([e.sun[0], e.sun[1], e.sun[2], e.key]);
let mats = s.materials.iter().flat_map(|m| {
let flags = (m.double_sided as u32)
| (m.unlit as u32) << 1
+24 -2
View File
@@ -14,8 +14,10 @@ struct Globals {
ambient: vec4<f32>,
// radiance first texture, mip count, irradiance texture, intensity
env: vec4<f32>,
// rotation, any lighting (0/1), -, -
// rotation, any lighting (0/1), shadow map index (-1 none), shadow darkness
env2: vec4<f32>,
// the environment's dominant direction (its shadow's source)
env_sun: vec4<f32>,
};
@group(0) @binding(0) var<uniform> g: Globals;
@@ -339,6 +341,18 @@ fn light_shadow(li: u32, kind: u32, lpos: vec3<f32>, shadow: i32, darkness: f32,
return 1.0 - darkness * (1.0 - f);
}
// The Environment light's shadow from its dominant direction (shade::env_shadow). Only the
// image's key fraction (g.env_sun.w) of the light is occluded — the fill keeps shining — so
// the shadow stays partial the way After Effects renders an area-light shadow.
fn env_shadow(p: vec3<f32>, ng: vec3<f32>, n: vec3<f32>) -> f32 {
if (g.env2.z < 0.0) {
return 1.0;
}
let n_l = clamp(dot(n, g.env_sun.xyz), 0.0, 1.0);
let f = shadow_factor(u32(g.env2.z + 0.5) * SHADOW_STRIDE, p, ng, n_l);
return 1.0 - g.env2.w * g.env_sun.w * (1.0 - f);
}
fn attenuation(kind: u32, falloff: u32, radius: f32, falloff_distance: f32, cos_inner: f32, cos_outer: f32, dir: vec3<f32>, to_l: vec3<f32>, dist: f32) -> f32 {
var k = 1.0;
if (falloff == 1u && kind != 0u) {
@@ -425,6 +439,10 @@ fn shadow_only_amount(unlit: bool, normal: vec3<f32>, front: bool, pos: vec3<f32
}
amount = max(amount, 1.0 - light_shadow(li, kind, l0.xyz, i32(l3.w), l4.x, pos, n, n_l));
}
// The Environment light's shadow counts too (it reaches every surface: no falloff).
if (g.ambient.w > 0.5) {
amount = max(amount, 1.0 - env_shadow(pos, n, n));
}
return amount;
}
@@ -564,7 +582,11 @@ fn fs(i: VOut, @builtin(front_facing) front: bool) -> FOut {
let f0 = mix(vec3<f32>(0.04 * spec_k), rgb, metallic);
let ab = env_brdf(n_v, rough);
let fs = f0 * ab.x + vec3<f32>(ab.y);
acc = acc + (irr * rgb * (vec3<f32>(1.0) - fs) * (1.0 - metallic) * diff_k + spec * fs) * ao;
var esh = 1.0;
if (receives_shadows) {
esh = env_shadow(i.pos, ng, n);
}
acc = acc + (irr * rgb * (vec3<f32>(1.0) - fs) * (1.0 - metallic) * diff_k + spec * fs) * ao * esh;
}
let c = acc + emissive;
out.color = vec4<f32>(c * alpha, alpha);
+26 -6
View File
@@ -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]));
@@ -144,6 +162,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);
}
+8 -2
View File
@@ -697,10 +697,16 @@ pub fn light_options(ids: &mut Ids, kind: LightKind) -> PropGroup {
}
if kind == LightKind::Environment {
// The environment image: a layer (footage, comp…) mapped as an equirectangular panorama;
// none = the comp's Environment Layer.
// none = the comp's Environment Layer, or a flat default environment of the light's
// colour × intensity when there is none. No Falloff: an environment light shines from
// every direction at once. Environment lights cast shadows too (After Effects' Light
// Settings shows Cast Shadows for them; not for Ambient) (#2436).
return g
.with(ids.prop("source", "Source", Value::Layer(None)).with_ui(ParamUi::Layer))
.with(ids.prop("rotation", "Environment Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle));
.with(ids.prop("rotation", "Environment Rotation", Value::Scalar(0.0)).with_ui(ParamUi::Angle))
.with(ids.prop("castsShadows", "Casts Shadows", Value::Bool(false)).with_ui(ParamUi::Checkbox))
.with(ids.prop("shadowDarkness", "Shadow Darkness", Value::Scalar(100.0)).with_ui(ParamUi::Percent))
.with(ids.prop("shadowDiffusion", "Shadow Diffusion", Value::Scalar(0.0)).with_ui(ParamUi::Pixels));
}
if kind != LightKind::Ambient {
g = g
+51 -2
View File
@@ -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,11 +500,39 @@ 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`).
/// 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.
pub(crate) fn shadow_maps(s: &mut Scene, draft: bool) {
let casters: Vec<usize> = (0..s.indices.len() / 3)
.filter(|&t| s.materials.get(s.vertices[s.indices[3 * t] as usize].material as usize).is_some_and(|m| m.casts_shadows))
.collect();
// The Environment light casts a shadow when marked and its image has a dominant direction;
// with nothing to cast one, its mark is cleared either way.
let env_shadow = !casters.is_empty() && s.env.is_some_and(|e| e.shadow == 0 && e.sun != [0.0; 3]);
if !env_shadow && let Some(e) = &mut s.env {
e.shadow = -1;
}
if casters.is_empty() {
for l in &mut s.lights {
l.shadow = -1;
@@ -536,6 +564,8 @@ pub(crate) fn shadow_maps(s: &mut Scene, draft: bool) {
1
}
})
// The Environment light's map is a single orthographic one.
.chain(std::iter::once(if env_shadow { 1 } else { 0 }))
.collect();
let sizes = shadow_sizes(s.width, s.height, draft, &kinds);
let mut maps: Vec<ShadowMap> = vec![];
@@ -583,6 +613,25 @@ 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;
maps.push(env_shadow_map(e, centre, radius, size));
if let Some(e) = &mut s.env {
e.shadow = first;
}
}
// The texel budget dropped it.
None => {
if let Some(e) = &mut s.env {
e.shadow = -1;
}
}
}
}
let texels: Vec<Vec<f32>> = maps.par_iter().map(|m| shadow_raster(s, m, &casters)).collect();
for (m, t) in maps.iter_mut().zip(texels) {
m.offset = s.shadow_texels.len() as u32;
+180 -20
View File
@@ -172,6 +172,24 @@ pub struct EnvInfo {
pub intensity: f32,
/// Radians about the vertical axis.
pub rotation: f32,
/// Unit direction from the scene towards the environment's dominant (radiance-weighted)
/// bright region — the direction its shadow is cast from, like a parallel light's
/// `to_light`. `[0; 3]` when the image is directionless (uniform, or opposing bright
/// regions that cancel) or the light casts no shadow (nothing reads it then): no shadow.
pub sun: [f32; 3],
/// The image's directional (key) fraction — `|Σ dir·radiance| / Σ radiance` over the
/// sphere, 0 for a uniform image and near 1 for a single bright spot. An Environment
/// light's shadow occludes only this much of its light: the key lobe is blocked where
/// the map says so, the remaining fill keeps shining, so the shadow stays partial the
/// way After Effects renders an area-light shadow (a broad environment like the default
/// studio shadows softly, a lone bright spot on black almost fully).
pub key: f32,
/// Shadow map index into [`Scene::shadows`]: 0 marked (assigned by `raster::shadow_maps`),
/// -1 casts no shadow. Gated by the light's Cast Shadows.
pub shadow: i32,
pub shadow_darkness: f32,
/// The light's Shadow Diffusion (the penumbra width the shadow map's PCF widens to).
pub shadow_diffusion: f32,
}
#[derive(Clone, Debug, Default)]
@@ -670,9 +688,20 @@ fn card_layer(r: &Renderer, ctx: &EvalCtx, layer: &Layer, b: &mut Builder) {
}
}
/// Evaluated lights: (scene lights, ambient sum, environment light (layer, intensity × colour,
/// rotation)).
fn lights(ctx: &EvalCtx, lin: bool) -> (Vec<Light>, [f32; 3], Option<(Option<u64>, [f32; 3], f32)>) {
/// The evaluated Environment light: its image source, intensity × colour, rotation, and
/// shadow settings (After Effects' Light Settings shows Cast Shadows for Environment lights).
#[derive(Clone, Copy)]
pub(crate) struct EnvLight {
src: Option<u64>,
color: [f32; 3],
rot: f32,
casts_shadows: bool,
shadow_darkness: f64,
shadow_diffusion: f64,
}
/// Evaluated lights: (scene lights, ambient sum, environment light).
fn lights(ctx: &EvalCtx, lin: bool) -> (Vec<Light>, [f32; 3], Option<EnvLight>) {
let mut out = vec![];
let mut ambient = [0.0f32; 3];
let mut env = None;
@@ -693,7 +722,14 @@ fn lights(ctx: &EvalCtx, lin: bool) -> (Vec<Light>, [f32; 3], Option<(Option<u64
if env.is_none() {
let src = g.get("source").and_then(|p| ctx.value(layer, p).as_layer());
let rot = ctx.f(layer, g, "rotation", 0.0).to_radians() as f32;
env = Some((src, color, rot));
env = Some(EnvLight {
src,
color,
rot,
casts_shadows: ctx.b(layer, g, "castsShadows"),
shadow_darkness: ctx.f(layer, g, "shadowDarkness", 100.0).clamp(0.0, 100.0) / 100.0,
shadow_diffusion: ctx.f(layer, g, "shadowDiffusion", 0.0).max(0.0),
});
}
}
_ => {
@@ -783,8 +819,54 @@ pub fn irradiance_map(sw: u32, sh: u32, src: &[[f32; 4]], w: u32, h: u32) -> Vec
out
}
/// The environment image's dominant direction and key strength: the mean texel direction
/// weighted by the radiance the light actually emits — solid-angle weighted, rotation applied,
/// each texel's channels scaled by `tint` (the light's colour, `[1; 3]` for a white one) — or
/// `([0; 3], 0.0)` when that radiance is directionless (a uniform environment cancels to zero,
/// and opposing bright regions that cancel leave no mean either — though a chromatic one need
/// not stay directionless once tinted: a red light over equally bright red and blue opposite
/// regions removes the blue pull and points at the red one). The mean of a single bright spot
/// on black points straight at it; a gradient points somewhere between its ends. The returned
/// key fraction is the mean vector's length over the radiance weight — how directional the
/// image is, 0 (uniform) to ~1 (a lone spot) — and is the most of its light an Environment
/// light's shadow can remove.
pub(crate) fn dominant_direction(w: u32, h: u32, d: &[[f32; 4]], tint: [f32; 3], rot: f32) -> ([f32; 3], f32) {
use super::shade::equirect_dir;
let (mut x, mut y, mut z) = (0.0f32, 0.0f32, 0.0f32);
let mut weight = 0.0f32;
for (i, p) in d.iter().enumerate() {
let (tx, ty) = ((i % w as usize) as u32, (i / w as usize) as u32);
let (u, v) = ((tx as f32 + 0.5) / w as f32, (ty as f32 + 0.5) / h as f32);
// Tinted radiance × solid angle (∝ sin θ): the shadow must follow the light the
// environment emits, not the raw texels.
let lum = (p[0] * tint[0] + p[1] * tint[1] + p[2] * tint[2]) * (v * std::f32::consts::PI).sin();
let dir = equirect_dir(u, v, rot);
x += dir[0] * lum;
y += dir[1] * lum;
z += dir[2] * lum;
weight += lum.abs();
}
// Under 1 % directional, the image is uniform to the eye: no dominant region to shadow
// from. A non-finite sum (a NaN or infinite texel) is garbage, not a direction: none then.
let len = (x * x + y * y + z * z).sqrt();
if !weight.is_finite() || !len.is_finite() || weight <= 1e-9 || len < weight * 0.01 {
return ([0.0; 3], 0.0);
}
([x / len, y / len, z / len], (len / weight).clamp(0.0, 1.0))
}
/// Prefilter an environment image into the scene's textures.
pub(crate) fn add_environment(b: &mut Builder, w: u32, h: u32, data: Vec<[f32; 4]>, intensity: [f32; 3], rotation: f32) {
pub(crate) fn add_environment(b: &mut Builder, 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 = (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 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 {
@@ -807,10 +889,7 @@ pub(crate) fn add_environment(b: &mut Builder, w: u32, h: u32, data: Vec<[f32; 4
let (sw, sh, sd) = small.unwrap_or((w, h, d));
let irr = irradiance_map(sw, sh, &sd, 32, 16);
let ii = b.texture_from(32, 16, [0, 1], irr.into_iter()) as u32;
// Intensity × colour folded into the radiance (one scalar on the GPU): tint the texels.
let k = (intensity[0] + intensity[1] + intensity[2]) / 3.0;
if k > 0.0 && (intensity[0] != intensity[1] || intensity[1] != intensity[2]) {
let tint = intensity.map(|c| c / k);
if tint != [1.0, 1.0, 1.0] {
for t in first..=ii {
let ti = b.s.textures[t as usize];
for p in &mut b.s.texels[ti.offset as usize..(ti.offset + ti.width * ti.height) as usize] {
@@ -820,16 +899,45 @@ pub(crate) fn add_environment(b: &mut Builder, w: u32, h: u32, data: Vec<[f32; 4
}
}
}
b.s.env = Some(EnvInfo { radiance: first, mips: levels, irradiance: ii, intensity: k, rotation });
b.s.env = Some(EnvInfo {
radiance: first,
mips: levels,
irradiance: ii,
intensity: k,
rotation: env.rot,
sun,
key,
// Marked; `raster::shadow_maps` assigns the map (a directionless image casts none).
shadow: if env.casts_shadows { 0 } else { -1 },
shadow_darkness: env.shadow_darkness as f32,
shadow_diffusion: env.shadow_diffusion as f32,
});
}
/// The environment layer's pixels as linear straight RGBA.
fn env_pixels(r: &Renderer, ctx: &EvalCtx, src: Option<u64>, lin: bool) -> Option<(u32, u32, Vec<[f32; 4]>)> {
/// 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<u64>, lin: bool) -> EnvPixels {
let layer = match src {
Some(id) => ctx.comp.layer(effectcraft_project::LayerId(id))?,
None => ctx.comp.layers.iter().find(|l| l.environment && l.is_active_at(ctx.time))?,
Some(id) => ctx.comp.layer(effectcraft_project::LayerId(id)),
None => ctx.comp.layers.iter().find(|l| l.environment && l.is_active_at(ctx.time)),
};
let buf = r.blend_layer_buf(ctx, layer)?;
let Some(layer) = layer else {
// A set Source that resolves to no layer (deleted) failed; an unset one with no
// Environment Layer is simply unconfigured.
return if src.is_some() { EnvPixels::Failed } else { EnvPixels::Absent };
};
let Some(buf) = r.blend_layer_buf(ctx, layer) else { return EnvPixels::Failed };
let img = &buf.img;
let data = img
.data
@@ -843,7 +951,43 @@ fn env_pixels(r: &Renderer, ctx: &EvalCtx, src: Option<u64>, lin: bool) -> Optio
if lin { [s[0], s[1], s[2], 1.0] } else { [srgb_to_linear(s[0]), srgb_to_linear(s[1]), srgb_to_linear(s[2]), 1.0] }
})
.collect();
Some((img.width, img.height, data))
EnvPixels::Image((img.width, img.height, data))
}
/// The default environment when the light has no Source and no Environment Layer: a dim studio
/// gradient, bright above and toward the initial camera, dark below and behind — After
/// Effects "uses a default environment map" for an Environment light too, and its default
/// casts shadows out of the box. The solid-angle-weighted mean is 0.3, a soft studio ambience
/// (a mean of 1 would act like a 100 % white Ambient light and wash the whole scene out, which
/// is not what After Effects' default looks like); the light's colour × intensity still scales
/// the overall level, and its Rotation turns the key around the scene (#2436).
pub(crate) fn default_environment() -> (u32, u32, Vec<[f32; 4]>) {
use super::shade::{dot, equirect_dir};
// The key region's centre: three quarters up, two thirds toward the viewer (the default
// camera looks +z from -z).
let key = [0.0f32, -0.75, -0.66];
let (w, h) = (8u32, 4u32);
let mut data = Vec::with_capacity((w * h) as usize);
let (mut mean, mut weight) = (0.0f32, 0.0f32);
for y in 0..h {
let v = (y as f32 + 0.5) / h as f32;
let sa = (v * std::f32::consts::PI).sin();
for x in 0..w {
let u = (x as f32 + 0.5) / w as f32;
let l = 0.15 + 1.2 * dot(equirect_dir(u, v, 0.0), key).max(0.0);
data.push([l, l, l, 1.0]);
mean += l * sa;
weight += sa;
}
}
// A dim studio: scale the gradient to a weighted mean of exactly 0.3.
let k = if mean.is_finite() && mean > 1e-6 { 0.3 * weight / mean } else { 1.0 };
for p in &mut data {
for c in 0..3 {
p[c] *= k;
}
}
(w, h, data)
}
/// Supersampling factor: 2×2 unless draft or very large.
@@ -935,10 +1079,23 @@ pub(crate) fn build_at(r: &Renderer, ctx: &EvalCtx, sub: Option<&EvalCtx>, run:
let (ls, amb, env) = lights(ctx, lin);
b.s.lights = ls;
b.s.ambient = amb;
if let Some((src, k, rot)) = env
&& let Some((w, h, d)) = env_pixels(r, ctx, src, lin)
{
add_environment(&mut b, w, h, d, k, rot);
if let Some(env) = env {
match env_pixels(r, ctx, env.src, lin) {
EnvPixels::Image((w, h, d)) => add_environment(&mut b, &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, &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
// nothing, exactly like a layer whose source failed renders nothing, never a
// silent swap to the default studio's lighting and shadows (#2436).
EnvPixels::Failed => {}
}
}
if r.opts.shadows() {
super::raster::shadow_maps(&mut b.s, r.opts.draft);
@@ -947,6 +1104,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.
+24 -1
View File
@@ -382,6 +382,23 @@ pub fn light_shadow(s: &Scene, l: &Light, p: [f32; 3], ng: [f32; 3], n_l: f32) -
1.0 - l.shadow_darkness * (1.0 - f)
}
/// The visibility of the Environment light at `p` (1 = unshadowed), darkened by its Shadow
/// Darkness — but only by the image's key fraction: the shadow map occludes the dominant
/// (key) lobe, while the environment's fill keeps shining on the shadowed surface, so an
/// Environment light's shadow stays partial the way After Effects renders an area-light
/// shadow (a broad environment like the default studio shadows softly, a lone bright spot
/// on black almost fully). A directionless image (or a non-casting light) has no map
/// and shades unshadowed.
pub fn env_shadow(s: &Scene, e: &EnvInfo, p: [f32; 3], ng: [f32; 3], n: [f32; 3]) -> f32 {
if e.shadow < 0 || e.sun == [0.0; 3] {
return 1.0;
}
let Some(m) = s.shadows.get(e.shadow as usize) else { return 1.0 };
let n_l = dot(n, e.sun).clamp(0.0, 1.0);
let f = shadow_factor(m, &s.shadow_texels, p, ng, n_l);
1.0 - e.shadow_darkness * e.key * (1.0 - f)
}
/// Unit direction from `p` to light `l`, and the distance (infinite for a parallel light).
fn to_light(l: &Light, p: [f32; 3]) -> ([f32; 3], f32) {
if l.kind == 0 {
@@ -529,9 +546,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);
@@ -557,5 +576,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]
}
+495 -1
View File
@@ -2,7 +2,7 @@ use effectcraft_keyframe::Value;
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, LightKind, PrimitiveKind, Project, Renderer as R3, build};
use effectcraft_time::{FrameRate, Tick};
use super::scene::{Material, Scene, TexInfo, Vertex, irradiance_map};
use super::scene::{Material, Scene, TexInfo, Vertex, dominant_direction, irradiance_map};
use super::shade::{self, PI};
use super::*;
use crate::{NoFootage, RenderOpts, Renderer};
@@ -67,6 +67,90 @@ fn constant_environment_has_constant_irradiance() {
assert!(irr[16][0] > 0.9 && irr[15 * 32 + 16][0] < 0.1, "{:?} {:?}", irr[16], irr[15 * 32 + 16]);
}
/// A NaN or infinite texel poisons the sums (every NaN comparison is false, so the thresholds
/// alone would pass it through as a NaN direction); an infinite one even where sin θ = 0 turns
/// into NaN. Either way the sum is garbage, not a direction: no sun and no key, so no shadow
/// (#2436).
#[test]
fn a_non_finite_environment_has_no_sun_direction() {
let nan = vec![[f32::NAN; 4]; 8 * 4];
assert_eq!(dominant_direction(8, 4, &nan, [1.0; 3], 0.0), ([0.0; 3], 0.0));
let inf = vec![[f32::INFINITY, 0.0, 0.0, 1.0]; 8 * 4];
assert_eq!(dominant_direction(8, 4, &inf, [1.0; 3], 0.0), ([0.0; 3], 0.0));
}
/// The key fraction: a uniform image is directionless (key 0), a lone bright spot on black is
/// almost fully directional, and a broad gradient sits in between — the soft studio default
/// shadows visibly but keeps most of its light as fill (#2436).
#[test]
fn environment_key_strength_tracks_directionality() {
let uniform = vec![[0.5f32, 0.5, 0.5, 1.0]; 8 * 4];
assert_eq!(dominant_direction(8, 4, &uniform, [1.0; 3], 0.0).1, 0.0);
let mut spot = vec![[0.0f32; 4]; 64 * 32];
spot[16 * 64 + 32] = [1.0, 1.0, 1.0, 1.0];
let (_, key) = dominant_direction(64, 32, &spot, [1.0; 3], 0.0);
assert!(key > 0.95, "a lone spot is almost fully directional: {key}");
let (w, h, d) = scene::default_environment();
let (_, key) = dominant_direction(w, h, &d, [1.0; 3], 0.0);
assert!((0.35..0.55).contains(&key), "the default studio is a broad key with fill: {key}");
}
/// #2436 review: a non-uniform image can be directionless too — two equal bright spots at
/// opposite azimuths pull the radiance-weighted mean in opposite directions and cancel,
/// leaving no dominant region to shadow from (under 1 % directional): no sun, no key, so no
/// shadow, just like a uniform image. Tilt the balance and a direction re-emerges, pointing
/// at the brighter spot.
#[test]
fn opposing_bright_regions_cancel_to_no_shadow() {
let (w, h) = (64u32, 32u32);
let mut opposed = vec![[0.0f32; 4]; (w * h) as usize];
// Rows 15 and 16 mirror about the equator (v = 0.484375 / 0.515625), and columns 16 and 48
// sit half a width apart (u = 0.2578125 / 0.7578125): the two texel-centre directions are
// exactly opposite — equal solid-angle weight, opposite pull.
let (a, b) = ((15 * w + 16) as usize, (16 * w + 48) as usize);
opposed[a] = [1.0, 1.0, 1.0, 1.0];
opposed[b] = [1.0, 1.0, 1.0, 1.0];
assert_eq!(dominant_direction(w, h, &opposed, [1.0; 3], 0.0), ([0.0; 3], 0.0));
// One side twice as bright: the mean points at the brighter spot with its share of the
// weight (key = 1/3 — two parts against one).
let mut tilted = opposed.clone();
tilted[a] = [2.0, 2.0, 2.0, 1.0];
let (sun, key) = dominant_direction(w, h, &tilted, [1.0; 3], 0.0);
assert!(sun != [0.0; 3] && key > 0.3, "the brighter side wins: {sun:?} key {key}");
let want = shade::equirect_dir(16.5 / 64.0, 15.5 / 32.0, 0.0);
let dot = sun[0] * want[0] + sun[1] * want[1] + sun[2] * want[2];
assert!(dot > 0.999, "the sun points at the brighter spot: {sun:?} vs {want:?}");
}
/// #2436 review: the sun follows the tinted radiance, not the raw texels — equally bright red
/// and blue regions on exactly opposite texels cancel to a white light (no shadow), but the
/// red light's tint removes the blue side's pull, so the mean points straight at the red one
/// (a blue light, at the blue one): the shadow lands where the emitted light comes from.
#[test]
fn a_coloured_light_shadows_from_the_region_it_tints() {
let (w, h) = (64u32, 32u32);
let mut opposed = vec![[0.0f32; 4]; (w * h) as usize];
// Rows 15 and 16 mirror about the equator, columns 16 and 48 half a width apart: the two
// texel-centre directions are exactly opposite — equal solid-angle weight, opposite pull.
let (a, b) = ((15 * w + 16) as usize, (16 * w + 48) as usize);
opposed[a] = [1.0, 0.0, 0.0, 1.0];
opposed[b] = [0.0, 0.0, 1.0, 1.0];
// To a white light the channel sums are equal: they cancel, no shadow.
assert_eq!(dominant_direction(w, h, &opposed, [1.0; 3], 0.0), ([0.0; 3], 0.0));
// A red light's tint zeroes the blue texel's weight: the red one becomes a lone spot.
// (Any positive scale of the tint — the normalised [3, 0, 0] included — points the same
// way; only the channel ratios matter.)
let (sun, key) = dominant_direction(w, h, &opposed, [1.0, 0.0, 0.0], 0.0);
let want = shade::equirect_dir(16.5 / 64.0, 15.5 / 32.0, 0.0);
let dot = sun[0] * want[0] + sun[1] * want[1] + sun[2] * want[2];
assert!(dot > 0.999 && key > 0.95, "the red side wins: {sun:?} key {key}");
// A blue light points at the blue one instead.
let (sun, _) = dominant_direction(w, h, &opposed, [0.0, 0.0, 1.0], 0.0);
let want = shade::equirect_dir(48.5 / 64.0, 16.5 / 32.0, 0.0);
let dot = sun[0] * want[0] + sun[1] * want[1] + sun[2] * want[2];
assert!(dot > 0.999, "the blue side wins: {sun:?}");
}
#[test]
fn equirect_round_trip_and_env_brdf() {
for (u, v) in [(0.1f32, 0.2f32), (0.5, 0.5), (0.9, 0.7)] {
@@ -337,6 +421,365 @@ fn cards_and_environment_light() {
assert_eq!(lit.data[80 * 120 + 110][3], 0.0);
}
/// An Environment light with no Source and no Environment Layer lights the scene with a flat
/// default environment of its colour × intensity — After Effects "uses a default environment
/// map" for an Environment light — not nothing at all (#2436).
#[test]
fn environment_light_without_a_source_lights_the_scene() {
let (mut p, cid) = project(120, 90);
let sid = p.add_item(
"S",
Default::default(),
None,
ItemKind::Solid(effectcraft_project::Solid { color: [0.5, 0.5, 0.5], width: 60, height: 40, pixel_aspect: 1.0 }),
);
add(&mut p, cid, LayerSource::Solid { item: sid }, |l| l.switches.three_d = true);
let unlit = render(&p, cid);
let c = unlit.data[20 * 120 + 30];
assert!((c[0] - 0.5).abs() < 0.01 && c[3] > 0.99, "unlit card keeps its colour: {c:?}");
// No Source, no Environment Layer: the light still lights the card (a flat white
// environment at the light's colour × intensity; a card's Material Options shade it).
add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |_| {});
let lit = render(&p, cid);
let c2 = lit.data[20 * 120 + 30];
assert!((c2[0] - c[0]).abs() > 0.05 && c2[3] > 0.99, "the light has an effect: {c2:?} vs {c:?}");
// The light's parameters drive it: half intensity darkens towards zero intensity's black.
set(p.comp_mut(cid).unwrap().layers.first_mut().unwrap(), "lightOptions/intensity", Value::Scalar(50.0));
let half = render(&p, cid);
let c3 = half.data[20 * 120 + 30];
assert!(c3[0] < c2[0] - 0.02, "half intensity lights less: {c3:?} vs {c2:?}");
set(p.comp_mut(cid).unwrap().layers.first_mut().unwrap(), "lightOptions/intensity", Value::Scalar(0.0));
let dark = render(&p, cid);
let c4 = dark.data[20 * 120 + 30];
assert!(c4[0] < 0.02 && c4[3] > 0.99, "zero intensity lights nothing: {c4:?}");
// And its colour tints the light red.
set(p.comp_mut(cid).unwrap().layers.first_mut().unwrap(), "lightOptions/intensity", Value::Scalar(100.0));
set(p.comp_mut(cid).unwrap().layers.first_mut().unwrap(), "lightOptions/color", Value::Color([1.0, 0.0, 0.0, 1.0]));
let red = render(&p, cid);
let c5 = red.data[20 * 120 + 30];
assert!(c5[0] > 0.15 && c5[1] < 0.05 && c5[2] < 0.05, "red environment: {c5:?}");
}
/// #2436 review: a selected or auto-picked environment source that fails to render (missing
/// footage, a deleted layer) must not silently swap in the default studio environment — the
/// light contributes nothing, exactly like a layer whose source failed renders nothing. Only a
/// light with no Source at all (and no Environment Layer in the comp) uses the default.
#[test]
fn a_failed_environment_source_lights_nothing() {
let (mut p, cid) = project(120, 90);
let sid = p.add_item(
"S",
Default::default(),
None,
ItemKind::Solid(effectcraft_project::Solid { color: [1.0, 1.0, 1.0], width: 60, height: 40, pixel_aspect: 1.0 }),
);
add(&mut p, cid, LayerSource::Solid { item: sid }, |l| l.switches.three_d = true);
let unlit = render(&p, cid);
// The Environment Layer's footage is unreadable (missing; NoFootage serves no frames).
let fid = p.add_item(
"F",
Default::default(),
None,
ItemKind::Footage(effectcraft_project::Footage {
path: "missing.exr".into(),
kind: effectcraft_project::FootageKind::Still,
width: 64,
height: 32,
pixel_aspect: 1.0,
duration: Tick::from_seconds_f64(1.0),
has_video: true,
missing: true,
..Default::default()
}),
);
add(&mut p, cid, LayerSource::Footage { item: fid }, |l| {
l.switches.three_d = true;
l.environment = true;
});
let env_layer = p.comp(cid).unwrap().layers.iter().find(|l| l.environment).unwrap().id;
add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| {
set(l, "lightOptions/castsShadows", Value::Bool(true));
});
fn light(p: &mut Project, cid: ItemId) -> &mut effectcraft_project::Layer {
p.comp_mut(cid).unwrap().layers.iter_mut().find(|l| l.is_light()).unwrap()
}
// Auto-picked Environment Layer, unreadable: the light is off — no studio default, no
// default-environment shadow either.
let failed = render(&p, cid);
assert_eq!(failed.data, unlit.data, "an unreadable Environment Layer lights nothing");
// The light's Source set to that unreadable layer: same.
set(light(&mut p, cid), "lightOptions/source", Value::Layer(Some(env_layer.0)));
let failed = render(&p, cid);
assert_eq!(failed.data, unlit.data, "an unreadable selected Source lights nothing");
// The Source pointing at a deleted layer: same.
set(light(&mut p, cid), "lightOptions/source", Value::Layer(Some(999_999)));
let failed = render(&p, cid);
assert_eq!(failed.data, unlit.data, "a deleted selected Source lights nothing");
// Contrast: with no Source and no Environment Layer, the default studio still lights it.
let comp = p.comp_mut(cid).unwrap();
comp.layers.retain(|l| l.is_light() || !l.environment);
set(comp.layers.iter_mut().find(|l| l.is_light()).unwrap(), "lightOptions/source", Value::Layer(None));
let lit = render(&p, cid);
let (c, c2) = (unlit.data[20 * 120 + 30], lit.data[20 * 120 + 30]);
assert!(c2[3] > 0.99 && (c2[0] - c[0]).abs() > 0.05, "the unconfigured light still lights: {c2:?} vs {c:?}");
}
/// #2436: an Environment light's Cast Shadows works like After Effects' — its image's dominant
/// (brightest) region shadows the scene. A bright disc above the image's centre, turned to
/// shine from up and behind the camera (rotation 180°), shadows a sphere onto the floor below
/// it; Cast Shadows off lights it evenly.
#[test]
fn environment_lights_cast_shadows() {
let (mut p, cid) = project(200, 150);
// The floor facing the camera, a sphere in front of it casting shadows only.
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Plane }, |l| {
set(l, "geometryOptions/width", Value::Scalar(400.0));
set(l, "geometryOptions/height", Value::Scalar(400.0));
set(l, "transform/position", Value::Vec3([100.0, 75.0, 110.0]));
set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0]));
});
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Sphere }, |l| {
set(l, "geometryOptions/radius", Value::Scalar(30.0));
set(l, "transform/position", Value::Vec3([100.0, 75.0, 50.0]));
set(l, "materialOptions/castsShadows", Value::Enum(2));
});
// The Environment Layer: a tight bright key above the image's centre, on black — a
// full-comp rect keeps the layer buffer full-size, the radial gradient is the key.
add(&mut p, cid, LayerSource::Shape, |l| {
l.switches.three_d = true;
let mut next = 10_000u64;
let ids = &mut build::Ids(&mut next);
let rect = build::shape_rect(ids, [200.0, 150.0], [0.0, 0.0], 0.0);
let key = build::shape_gradient_fill(
ids,
true,
[0.0, -30.0],
[0.0, 5.0],
effectcraft_keyframe::Gradient { colors: vec![(0.0, [1.0, 1.0, 1.0, 1.0]), (1.0, [0.0, 0.0, 0.0, 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] },
);
let g = build::shape_group(ids, "Key", vec![rect, key]);
l.props.sub_mut("contents").unwrap().children.push(g.into());
l.environment = true;
});
// Turned 180°, the key shines from up and behind the camera onto the sphere and floor.
add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| {
set(l, "lightOptions/rotation", Value::Scalar(180.0));
set(l, "lightOptions/castsShadows", Value::Bool(true));
});
// The shadow lands below the sphere on the floor: its core is the darkest floor pixel
// below the sphere; the lit floor is the sample to the left. The frame is sRGB-encoded,
// so the ratios below are gamma-compressed (a linear halving reads as ~0.73).
let luma = |img: &crate::Image, x: usize, y: usize| (img.data[y * 200 + x][0] + img.data[y * 200 + x][1] + img.data[y * 200 + x][2]) / 3.0;
let img = render(&p, cid);
let side = luma(&img, 25, 75);
let (mut core, mut cx, mut cy) = (f32::INFINITY, 0usize, 0usize);
for y in 100..150 {
for x in 60..140 {
if luma(&img, x, y) < core {
core = luma(&img, x, y);
cx = x;
cy = y;
}
}
}
assert!(side > 0.1, "the floor is lit: {side}");
assert!(core < side * 0.65, "the key shadows the sphere onto the floor: {core} vs {side} at ({cx}, {cy})");
// Cast Shadows off: the floor is lit everywhere, core included.
let comp = p.comp_mut(cid).unwrap();
set(&mut comp.layers[0], "lightOptions/castsShadows", Value::Bool(false));
let img = render(&p, cid);
let mid = luma(&img, cx, cy);
assert!(mid > side * 0.9, "no shadow without Cast Shadows: {mid} vs {side}");
}
/// #2436: a uniform environment has no dominant region, so even with Cast Shadows on it casts
/// nothing — the render is the same as with the option off.
#[test]
fn a_uniform_environment_casts_no_shadow() {
let (mut p, cid) = project(120, 90);
let card = p.add_item(
"S",
Default::default(),
None,
ItemKind::Solid(effectcraft_project::Solid { color: [1.0, 1.0, 1.0], width: 60, height: 40, pixel_aspect: 1.0 }),
);
add(&mut p, cid, LayerSource::Solid { item: card }, |l| l.switches.three_d = true);
let env = p.add_item(
"E",
Default::default(),
None,
ItemKind::Solid(effectcraft_project::Solid { color: [1.0, 1.0, 1.0], width: 64, height: 32, pixel_aspect: 1.0 }),
);
add(&mut p, cid, LayerSource::Solid { item: env }, |l| {
l.environment = true;
l.switches.three_d = true;
});
add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| {
set(l, "lightOptions/castsShadows", Value::Bool(true));
});
let casting = render(&p, cid);
let comp = p.comp_mut(cid).unwrap();
set(&mut comp.layers[0], "lightOptions/castsShadows", Value::Bool(false));
let off = render(&p, cid);
assert!(casting.data[20 * 120 + 30][0] > 0.3, "the card is lit");
assert_eq!(casting.data, off.data, "a directionless image casts no shadow");
}
/// #2436 review: a chromatic environment under a coloured light — equally bright red and blue
/// regions on exactly opposite sides of the image cancel to a white light (it casts no
/// shadow), but the red Environment light's tint removes the blue region's pull, so the red
/// one becomes the key and casts the shadow the user asked for, from the red side.
#[test]
fn a_red_light_shadows_from_the_red_region_of_a_chromatic_environment() {
let (mut p, cid) = project(200, 150);
// The floor facing the camera, a sphere in front of it casting shadows only.
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Plane }, |l| {
set(l, "geometryOptions/width", Value::Scalar(400.0));
set(l, "geometryOptions/height", Value::Scalar(400.0));
set(l, "transform/position", Value::Vec3([100.0, 75.0, 110.0]));
set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0]));
});
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Sphere }, |l| {
set(l, "geometryOptions/radius", Value::Scalar(30.0));
set(l, "transform/position", Value::Vec3([100.0, 75.0, 50.0]));
set(l, "materialOptions/castsShadows", Value::Enum(2));
});
// The Environment Layer: a black background with a red square and an equally bright blue
// one at exactly antipodal image positions (the blue is the point mirror of the red through
// the antipodal map x -> x + w/2, y -> h - y: centres (14.5, 44.5) and (114.5, 105.5) in a
// 200x150 image, 20x20 each) — equal solid-angle weight, opposite pull. The red sits up,
// left and toward the camera; the blue down, right and away from it.
add(&mut p, cid, LayerSource::Shape, |l| {
l.environment = true;
let mut next = 10_000u64;
let ids = &mut build::Ids(&mut next);
// Contents stack like the AE timeline — the first group is on top — so the background
// goes in last, under the red and blue squares (and its full-comp rect keeps the
// layer buffer full-size: a shape layer's buffer otherwise crops to its content).
let red_rect = build::shape_rect(ids, [20.0, 20.0], [-85.5, -30.5], 0.0);
let red_fill = build::shape_fill(ids, [1.0, 0.0, 0.0, 1.0]);
let red = build::shape_group(ids, "Red", vec![red_rect, red_fill]);
let blue_rect = build::shape_rect(ids, [20.0, 20.0], [14.5, 30.5], 0.0);
let blue_fill = build::shape_fill(ids, [0.0, 0.0, 1.0, 1.0]);
let blue = build::shape_group(ids, "Blue", vec![blue_rect, blue_fill]);
let bg_rect = build::shape_rect(ids, [200.0, 150.0], [0.0, 0.0], 0.0);
let bg_fill = build::shape_fill(ids, [0.0, 0.0, 0.0, 1.0]);
let bg = build::shape_group(ids, "BG", vec![bg_rect, bg_fill]);
for g in [red, blue, bg] {
l.props.sub_mut("contents").unwrap().children.push(g.into());
}
});
// A white light over the chromatic image: the regions cancel — Cast Shadows changes
// nothing, exactly like a directionless image.
add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| {
set(l, "lightOptions/castsShadows", Value::Bool(true));
});
let white_casting = render(&p, cid);
let comp = p.comp_mut(cid).unwrap();
set(comp.layers.iter_mut().find(|l| l.is_light()).unwrap(), "lightOptions/castsShadows", Value::Bool(false));
let white_off = render(&p, cid);
assert_eq!(white_casting.data, white_off.data, "a white light sees no dominant region in the opposed red and blue");
// The red light: the tint removes the blue region, the red one shadows the sphere onto the
// floor below and right of it (the shadow falls opposite the red region).
let comp = p.comp_mut(cid).unwrap();
let light = comp.layers.iter_mut().find(|l| l.is_light()).unwrap();
set(light, "lightOptions/color", Value::Color([1.0, 0.0, 0.0, 1.0]));
set(light, "lightOptions/castsShadows", Value::Bool(true));
let img = render(&p, cid);
let chan = |img: &crate::Image, x: usize, y: usize| img.data[y * 200 + x][0];
let side = chan(&img, 25, 75);
let (mut core, mut cx) = (f32::INFINITY, 0usize);
for y in 100..150 {
for x in 60..170 {
if chan(&img, x, y) < core {
core = chan(&img, x, y);
cx = x;
}
}
}
assert!(side > 0.1, "the floor is lit red: {side}");
assert!(core < side * 0.65, "the red region shadows the sphere onto the floor: {core} vs {side} at x {cx}");
assert!(cx > 100, "the shadow is opposite the red region (right of the sphere): {cx}");
}
/// #2436: the default environment (no Source, no Environment Layer) is a dim studio — a mean
/// of 0.3, not the 1.0 that would act like a 100 % white Ambient light and wash the scene out
/// (After Effects' default does not) — and directional: its dominant region points up and
/// toward the initial camera, so a shadow-casting Environment light casts soft, partial
/// shadows out of the box, like After Effects' default.
#[test]
fn default_environment_keeps_the_level_and_points_up() {
let (w, h, d) = scene::default_environment();
assert_eq!((w, h), (8, 4));
let (mut mean, mut weight) = (0.0f32, 0.0f32);
for (i, p) in d.iter().enumerate() {
let v = ((i / w as usize) as f32 + 0.5) / h as f32;
let sa = (v * PI).sin();
mean += (p[0] + p[1] + p[2]) / 3.0 * sa;
weight += sa;
}
assert!((mean / weight - 0.3).abs() < 0.01, "dim studio mean: {}", mean / weight);
let (sun, key) = dominant_direction(w, h, &d, [1.0; 3], 0.0);
assert!(sun[1] < -0.5, "the key is up: {sun:?}");
assert!(sun[2] < -0.3, "the key is toward the camera: {sun:?}");
assert!(sun[0].abs() < 0.1, "symmetric left-right: {sun:?}");
assert!((0.35..0.55).contains(&key), "a broad key with fill: {key}");
// No texel clips past white at the default intensity (the fog of a mean-1 environment).
assert!(d.iter().all(|p| p[0] <= 1.0 && p[0] > 0.0), "0 < radiance <= 1");
}
/// #2436: with no Source and no Environment Layer, the Environment light's default environment
/// casts soft, partial shadows like After Effects' default environment map — a sphere shadows
/// the plane behind it, darkening part of the light while the fill keeps the floor visible
/// (an area-light shadow, not the pitch black a point light casts); Cast Shadows off lights
/// the floor evenly.
#[test]
fn the_default_environment_casts_shadows() {
let (mut p, cid) = project(200, 150);
// The floor facing the camera, a sphere in front of it casting shadows only.
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Plane }, |l| {
set(l, "geometryOptions/width", Value::Scalar(400.0));
set(l, "geometryOptions/height", Value::Scalar(400.0));
set(l, "transform/position", Value::Vec3([100.0, 75.0, 120.0]));
set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0]));
});
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Sphere }, |l| {
set(l, "geometryOptions/radius", Value::Scalar(30.0));
set(l, "transform/position", Value::Vec3([100.0, 45.0, 50.0]));
set(l, "materialOptions/castsShadows", Value::Enum(2));
});
add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| {
set(l, "lightOptions/castsShadows", Value::Bool(true));
});
// The default key (up and toward the camera) drops the shadow below the sphere: the core
// is the darkest floor pixel below it, the lit floor the sample to the left.
let luma = |img: &crate::Image, x: usize, y: usize| (img.data[y * 200 + x][0] + img.data[y * 200 + x][1] + img.data[y * 200 + x][2]) / 3.0;
let img = render(&p, cid);
let side = luma(&img, 30, 100);
let (mut core, mut cx, mut cy) = (f32::INFINITY, 0usize, 0usize);
for y in 110..150 {
for x in 80..120 {
if luma(&img, x, y) < core {
core = luma(&img, x, y);
cx = x;
cy = y;
}
}
}
assert!(side > 0.1, "the floor is lit: {side}");
// Partial: the key lobe is occluded (a clear darkening) but the fill keeps shining —
// not the pitch black a point light's shadow would cast at the same darkness. The frame
// is sRGB-encoded, so the ratios are gamma-compressed (a linear halving reads as ~0.73).
assert!(core < side * 0.8, "the default environment shadows the sphere onto the floor: {core} vs {side} at ({cx}, {cy})");
assert!(core > side * 0.45, "an area-light shadow, partial like After Effects': {core} vs {side}");
// Cast Shadows off: the floor is lit everywhere, core included.
let comp = p.comp_mut(cid).unwrap();
set(&mut comp.layers[0], "lightOptions/castsShadows", Value::Bool(false));
let img = render(&p, cid);
let mid = luma(&img, cx, cy);
assert!(mid > side * 0.9, "no shadow without Cast Shadows: {mid} vs {side}");
}
#[test]
fn depth_of_field_blurs_out_of_focus_surfaces() {
let mut img = Image::new(64, 64);
@@ -1103,3 +1546,54 @@ fn hard_edges_ramp_over_two_texels() {
assert!((1.7..3.0).contains(&w), "v {v0}: hard edge ramps over {w} texels");
}
}
// ---------------------------------------------------------------- the After Effects look (#2436)
/// #2436: an Environment light with every option at its default renders like After Effects':
/// a dim studio that leaves the lit floor a mid gray instead of washing it out to white (the
/// fog of a mean-1 default), a partial shadow (the key lobe occluded, the fill still shining —
/// not the pitch black a point light casts), and a soft edge in both quality and draft renders
/// (the map's texel grid never shows through as stair-steps).
#[test]
fn environment_shadows_match_the_after_effects_look() {
let (mut p, cid) = project(800, 600);
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Plane }, |l| {
set(l, "geometryOptions/width", Value::Scalar(1200.0));
set(l, "geometryOptions/height", Value::Scalar(900.0));
set(l, "transform/position", Value::Vec3([400.0, 300.0, 300.0]));
set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0]));
});
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Sphere }, |l| {
set(l, "geometryOptions/radius", Value::Scalar(80.0));
set(l, "transform/position", Value::Vec3([400.0, 300.0, 120.0]));
set(l, "materialOptions/castsShadows", Value::Enum(2));
});
add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| {
set(l, "lightOptions/castsShadows", Value::Bool(true));
});
let luma = |img: &crate::Image, x: usize, y: usize| (img.data[y * 800 + x][0] + img.data[y * 800 + x][1] + img.data[y * 800 + x][2]) / 3.0;
for (name, opts) in [("quality", RenderOpts::default()), ("draft", RenderOpts { draft: true, ..Default::default() })] {
let img = Renderer::new(&p, &NoFootage, opts).comp_frame(cid, Tick::ZERO);
// The shadow core: the darkest pixel of the lower half (the key drops the shadow
// below the sphere); the lit floor is the mean of the band to its left.
let (mut core, mut cx, mut cy) = (f32::INFINITY, 0usize, 0usize);
for y in 420..580 {
for x in 300..500 {
if luma(&img, x, y) < core {
core = luma(&img, x, y);
cx = x;
cy = y;
}
}
}
let lit: f32 = (60..260).map(|x| luma(&img, x, cy)).sum::<f32>() / 200.0;
assert!(lit > 0.15 && lit < 0.85, "{name}: the lit floor is a mid gray, no white fog: {lit}");
// The frame is sRGB-encoded, so the ratio is gamma-compressed (a linear halving
// reads as ~0.73): partial means clearly darker than lit, far from black.
assert!(core > lit * 0.45 && core < lit * 0.85, "{name}: a partial area-light shadow: core {core} vs lit {lit}");
// The edge's 10-90 % ramp from the lit plateau (left) down into the shadow.
let first_below = |t: f32| (260..cx).position(|x| luma(&img, x, cy) < core + (lit - core) * t).map(|i| 260 + i);
let w = (first_below(0.1).unwrap() as f32) - (first_below(0.9).unwrap() as f32);
assert!(w >= 3.5, "{name}: the edge ramps over {w} px (After Effects' env shadows are soft)");
}
}
+16 -4
View File
@@ -44,12 +44,19 @@ pub struct LightDraft {
const FALLOFFS: [&str; 3] = ["None", "Smooth", "Inverse Square Clamped"];
/// Whether a light type has Falloff and Shadows options. Ambient and Environment lights come
/// from no one place, so they have neither.
/// Whether a light type has Falloff options (distance attenuation): image-based Environment
/// lights and Ambient come from no one place, so they have none.
fn falls_off(kind: LightKind) -> bool {
!matches!(kind, LightKind::Ambient | LightKind::Environment)
}
/// Whether a light type has Shadows options in the dialog. After Effects' Light Settings shows
/// Cast Shadows (with Shadow Darkness and Diffusion) for every light but Ambient — an
/// Environment light's shadow comes from its image, not a falloff.
fn casts_shadows(kind: LightKind) -> bool {
kind != LightKind::Ambient
}
fn comp_w(app: &EffectcraftApp) -> f64 {
app.session.active_comp().map(|c| c.width as f64).unwrap_or(1920.0)
}
@@ -306,8 +313,11 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
ui.add(crate::widgets::drag_value(egui::DragValue::new(&mut d.falloff_distance).range(0.0..=100000.0).suffix(" px")));
ui.end_row();
}
}
if casts_shadows(d.kind) {
ui.label(tr("Shadows"));
ui.checkbox(&mut d.casts_shadows, tr("Casts Shadows"));
let cb = ui.checkbox(&mut d.casts_shadows, tr("Casts Shadows"));
app.auto.add_response("dialog.light.casts", &cb, "Casts Shadows");
ui.end_row();
ui.label(tr("Shadow Darkness"));
ui.add_enabled(d.casts_shadows, crate::widgets::drag_value(egui::DragValue::new(&mut d.shadow_darkness).range(0.0..=100.0).suffix(" %")));
@@ -318,7 +328,7 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
}
});
ui.add_space(14.0);
if falls_off(d.kind) {
if casts_shadows(d.kind) {
ui.label(egui::RichText::new(tr("Note: Shadows are only cast from layers with Cast Shadows enabled.")).font(Tokens::ui(11.0)).color(t.text_dim));
ui.add_space(8.0);
}
@@ -343,6 +353,8 @@ pub fn light(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
p["falloff"] = json!(d.falloff);
p["radius"] = json!(d.radius);
p["falloffDistance"] = json!(d.falloff_distance);
}
if casts_shadows(kind) {
p["castsShadows"] = json!(d.casts_shadows);
p["shadowDarkness"] = json!(d.shadow_darkness);
p["shadowDiffusion"] = json!(d.shadow_diffusion);
+1
View File
@@ -21,6 +21,7 @@ mod ui_drag_drop;
mod ui_effect_controls;
mod ui_effect_drop_highlight;
mod ui_effect_names;
mod ui_env_light_gpu;
mod ui_expression_values;
mod ui_font_search;
mod ui_form_fit;
@@ -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");
}
+23 -3
View File
@@ -177,13 +177,33 @@ fn falloff_and_shadow_options_show_only_for_lights_that_have_them() {
invoke(&mut h, "layer.newLight", json!({}));
for k in LightKind::ALL {
choose(&mut h, k);
let shown = !matches!(k, LightKind::Ambient | LightKind::Environment);
for label in ["Falloff", "Casts Shadows", "Shadow Darkness"] {
assert_eq!(h.query_by_label(label).is_some(), shown, "{label} for {}", k.label());
// Falloff is distance attenuation: image-based Environment lights and Ambient have none.
let falloff = !matches!(k, LightKind::Ambient | LightKind::Environment);
assert_eq!(h.query_by_label("Falloff").is_some(), falloff, "Falloff for {}", k.label());
// Cast Shadows shows for every light but Ambient, Environment included (#2436).
let shadows = k != LightKind::Ambient;
for label in ["Casts Shadows", "Shadow Darkness"] {
assert_eq!(h.query_by_label(label).is_some(), shadows, "{label} for {}", k.label());
}
}
}
/// #2436: an Environment light created through the dialog keeps its Cast Shadows, which the
/// engine stores on the layer.
#[test]
fn an_environment_light_takes_cast_shadows_from_the_dialog() {
let mut h = harness();
invoke(&mut h, "layer.newLight", json!({}));
choose(&mut h, LightKind::Environment);
click_id(&mut h, "dialog.light.casts");
ok(&mut h);
let id = last_layer(&h);
assert_eq!(kind(&h, id), LightKind::Environment);
let l = layer(&h, id);
assert!(l.props.prop("lightOptions/castsShadows").unwrap().value.as_bool(), "the dialog's checkbox reached the layer");
assert!(l.props.prop("lightOptions/shadowDarkness").is_some() && l.props.prop("lightOptions/falloff").is_none());
}
#[test]
fn changing_an_environment_light_to_spot_undoes_to_its_source_and_rotation() {
let mut h = harness();
+19 -2
View File
@@ -212,8 +212,25 @@ Things that tripped up a long agent-driven session (a multi-scene 3D piece built
or call `save_project {"path": …}` after each milestone.
- **Environment light.** `layer.newLight` also accepts `"kind":"Environment"`: an equirectangular
image that metallic surfaces reflect. Set its `lightOptions/source` to a footage, comp or solid
layer by layer id; with no source it uses the comp's Environment Layer (`layer.environment`)
([#261](https://github.com/storytold/effectcraft/issues/261)).
layer by layer id; with no source it uses the comp's Environment Layer (`layer.environment`),
or a default studio environment — bright above and toward the initial camera, dark below and
behind, at a dim 0.3 mean level — of the light's colour × intensity when there is none; either
way the light brightens the scene. A selected Source or Environment Layer that fails to
render — a deleted layer, missing footage — lights nothing instead: the light never silently
falls back to the studio default. It has `castsShadows`/`shadowDarkness`/`shadowDiffusion`
like the other lights: Cast Shadows shades the scene from the image's dominant bright region
(the default environment's key, or a source image's brightest area; an image without a
sufficiently strong dominant direction has none — a uniform one, or opposing bright regions
that cancel — so it casts no shadow) — the region weighted by the light's colour, so a
coloured light shadows from the regions it actually lights (opposing red and blue regions
cancel to a white light, but a red light's shadow comes from the red one) — occluding only
that key's share of the light, so the
shadow stays partial like After Effects' area-light shadows, and soft by default (a penumbra
of at least 8 pixels or four map texels, plus Shadow Diffusion). It only lights
Advanced 3D comps, so creating one (or changing a light's type to Environment) switches a
Classic 3D comp to Advanced 3D
([#261](https://github.com/storytold/effectcraft/issues/261),
[#2436](https://github.com/storytold/effectcraft/issues/2436)).
### Cookbook (from end-to-end QA)
+16 -3
View File
@@ -158,9 +158,22 @@ comp to Advanced 3D in the same undo step, as in After Effects. Shading is physi
GGX specular, Smith–Schlick geometry, Schlick Fresnel, Lambert diffuse, after Karis 2013):
parallel, spot and point lights with After Effects' falloffs, ambient lights, image-based
light from an Environment light whose Source (or the comp's Environment Layer, Layer ▸
Environment Layer) is an equirectangular image (cosine irradiance map; roughness-indexed mip
chain with the split-sum environment BRDF fit), and percentage-closer-filtered shadow maps
(spot: perspective, parallel: orthographic over the scene, point: six cube faces). Cards keep
Environment Layer, or a default studio environment — bright above and toward the initial
camera, dark below and behind, at a dim 0.3 mean level — of the light's colour × intensity
when there is neither) is an equirectangular image (cosine irradiance map; roughness-indexed
mip chain with the split-sum environment BRDF fit), and percentage-closer-filtered shadow maps
(spot: perspective, parallel: orthographic over the scene, point: six cube faces, environment:
one orthographic from the image's dominant bright region — none only when the image lacks a
sufficiently strong dominant direction (a uniform one, or opposing bright regions that
cancel) — the region weighted by the light's colour, so a coloured light shadows from the
regions it actually lights (opposing red and blue regions cancel to a white light, but a red
light's shadow comes from the red one) — occluding only the image's directional key fraction of
the light, so the shadow stays partial like After Effects' area-light shadows, with a soft
default penumbra of at least eight pixels or four map texels). A Source or Environment Layer
that fails to render — a deleted layer, missing footage — lights nothing: no silent fallback
to the default studio environment. An Environment light only lights Advanced 3D comps,
so adding one (or changing a light's type to it) switches a Classic 3D comp to Advanced 3D,
as model layers do. Cards keep
Material Options (Accepts Lights/Shadows, Casts Shadows On/Only, Ambient, Diffuse, Specular);
models and primitives add Base Color, Metallic, Roughness, Emissive. Rasterisation runs at 2×2
supersampling with a reversed-Z depth buffer; opaque triangles resolve visibility first, the