mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 13:08:07 -06:00
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:
@@ -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}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user