mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-01 14:08:38 -06:00
The Casts Shadows option Environment lights got is only half a fix if nothing shades for it: an image lights the scene from every direction, and what occludes that light is the image itself — its dominant bright region, the sun in a sky map. - dominant_direction finds it: the radiance-weighted mean texel direction (solid-angle weighted, rotation applied). A uniform image cancels to zero — a flat default environment shines from everywhere at once and casts no shadow, which a uniform environment test pins. - The shadow is one orthographic map over the scene from that direction, after the lights' maps, like a parallel light's: PCF widened by Shadow Diffusion, darkened by Shadow Darkness, cleared when nothing casts, the image is directionless, or the texel budget drops it. - It darkens the environment's own diffuse and specular light on Accepts Shadows surfaces and counts in Cast Shadows Only. CPU and GPU (WGSL) do the same; Globals carries the environment's sun direction and shadow settings (40 → 44 floats, preflight updated). A bright-disc environment turned to shine from behind the camera now shadows a sphere onto the floor behind it, Cast Shadows off lights it evenly again, and the GPU scene's sky gradient takes a shadow-casting Environment light. Also: the renderer-switch toasts read "...renderer, which draws 3D models", the dialog's Casts Shadows checkbox gets an auto id (dialog.light.casts) the tests click, and the docs' Environment notes cover the shadow.
222 lines
9.3 KiB
Rust
222 lines
9.3 KiB
Rust
//! Light Settings dialog (Layer ▸ New ▸ Light, Layer Settings on a light): every light type the
|
|
//! engine has, Environment included, and an existing light's type kept when it's reopened (#1588).
|
|
|
|
use effectcraft_engine::Session;
|
|
use effectcraft_engine::project::{Layer, LayerId, LayerSource, LightKind};
|
|
use effectcraft_ui_egui::{Dialog, EffectcraftApp};
|
|
use egui_kittest::{Harness, kittest::Queryable as _};
|
|
use serde_json::{Value, json};
|
|
|
|
fn harness() -> Harness<'static, EffectcraftApp> {
|
|
let mut s = Session::default();
|
|
s.execute("comp.new", json!({"name": "Lights", "width": 640, "height": 360, "duration": 2})).unwrap();
|
|
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
|
|
h.step();
|
|
h
|
|
}
|
|
|
|
/// An automation element's rect in the last frame drawn (a dialog settles its place in its
|
|
/// first frames, so the frame before can be stale).
|
|
fn element(h: &Harness<'_, EffectcraftApp>, id: &str) -> Option<effectcraft_ui_egui::automation::Element> {
|
|
let auto = &h.state().auto;
|
|
auto.elements.iter().chain(auto.previous.iter()).find(|e| e.id == id).cloned()
|
|
}
|
|
|
|
fn click_id(h: &mut Harness<'_, EffectcraftApp>, id: &str) {
|
|
let [x, y, w, hh] = element(h, id).unwrap_or_else(|| panic!("{id} is registered")).rect;
|
|
let p = egui::pos2(x + w / 2.0, y + hh / 2.0);
|
|
h.input_mut().events.push(egui::Event::PointerMoved(p));
|
|
h.step();
|
|
h.input_mut().events.push(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE });
|
|
h.step();
|
|
h.input_mut().events.push(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE });
|
|
h.run_steps(3);
|
|
}
|
|
|
|
fn invoke(h: &mut Harness<'_, EffectcraftApp>, id: &str, params: Value) {
|
|
let ctx = h.ctx.clone();
|
|
effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, id, params).unwrap();
|
|
h.run_steps(2);
|
|
}
|
|
|
|
/// Picks the dialog's Light Type `kind` from its menu.
|
|
fn choose(h: &mut Harness<'_, EffectcraftApp>, kind: LightKind) {
|
|
click_id(h, "dialog.light.type");
|
|
let i = LightKind::ALL.iter().position(|k| *k == kind).unwrap();
|
|
click_id(h, &format!("dialog.light.typeItem.{i}"));
|
|
}
|
|
|
|
fn ok(h: &mut Harness<'_, EffectcraftApp>) {
|
|
click_id(h, "dialog.light.ok");
|
|
assert_eq!(h.state().dialog, None);
|
|
}
|
|
|
|
fn layer(h: &Harness<'_, EffectcraftApp>, id: u64) -> Layer {
|
|
h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().clone()
|
|
}
|
|
|
|
fn kind(h: &Harness<'_, EffectcraftApp>, id: u64) -> LightKind {
|
|
match layer(h, id).source {
|
|
LayerSource::Light { kind } => kind,
|
|
other => panic!("not a light: {other:?}"),
|
|
}
|
|
}
|
|
|
|
fn last_layer(h: &Harness<'_, EffectcraftApp>) -> u64 {
|
|
h.state().session.active_comp().unwrap().layers.iter().map(|l| l.id.0).max().unwrap()
|
|
}
|
|
|
|
/// Opens Light Settings for an existing light, as Layer Settings does.
|
|
fn reopen(h: &mut Harness<'_, EffectcraftApp>, id: u64) {
|
|
invoke(h, "layer.settings", json!({"layer": id}));
|
|
assert_eq!(h.state().dialog, Some(Dialog::LightSettings));
|
|
}
|
|
|
|
/// An Environment light lit from a solid, its image turned 30°.
|
|
fn environment_light(h: &mut Harness<'_, EffectcraftApp>) -> (u64, u64) {
|
|
let s = &mut h.state_mut().session;
|
|
let sky = s.execute("layer.newSolid", json!({"width": 256, "height": 128, "name": "Sky"})).unwrap()["layer"].as_u64().unwrap();
|
|
let env = s.execute("layer.newLight", json!({"kind": "Environment", "name": "Env", "intensity": 80})).unwrap()["layer"].as_u64().unwrap();
|
|
s.execute("prop.set", json!({"layer": env, "path": "lightOptions/source", "value": sky})).unwrap();
|
|
s.execute("prop.set", json!({"layer": env, "path": "lightOptions/rotation", "value": 30})).unwrap();
|
|
h.run_steps(2);
|
|
(env, sky)
|
|
}
|
|
|
|
#[test]
|
|
fn the_light_type_menu_lists_every_light_type() {
|
|
let mut h = harness();
|
|
invoke(&mut h, "layer.newLight", json!({}));
|
|
click_id(&mut h, "dialog.light.type");
|
|
for (i, k) in LightKind::ALL.iter().enumerate() {
|
|
let item = element(&h, &format!("dialog.light.typeItem.{i}")).map(|e| e.label);
|
|
assert_eq!(item.as_deref(), Some(k.label()));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn new_light_creates_an_environment_light() {
|
|
let mut h = harness();
|
|
invoke(&mut h, "layer.newLight", json!({}));
|
|
choose(&mut h, LightKind::Environment);
|
|
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/source").is_some() && l.props.prop("lightOptions/falloff").is_none());
|
|
// One undoable step, redone as Environment.
|
|
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
|
|
assert!(h.state().session.active_comp().unwrap().layer(LayerId(id)).is_none());
|
|
h.state_mut().session.execute("edit.redo", json!({})).unwrap();
|
|
assert_eq!(kind(&h, id), LightKind::Environment);
|
|
}
|
|
|
|
#[test]
|
|
fn reopening_an_environment_light_and_pressing_ok_keeps_it() {
|
|
let mut h = harness();
|
|
let (env, sky) = environment_light(&mut h);
|
|
let before = layer(&h, env);
|
|
reopen(&mut h, env);
|
|
ok(&mut h);
|
|
let after = layer(&h, env);
|
|
assert_eq!(kind(&h, env), LightKind::Environment, "not turned into a Spot light");
|
|
assert_eq!(after.props.prop("lightOptions/source").unwrap().value.as_layer(), Some(sky));
|
|
for path in ["lightOptions/rotation", "lightOptions/intensity"] {
|
|
assert_eq!(after.props.prop(path).unwrap().value, before.props.prop(path).unwrap().value, "{path}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn cancelling_light_settings_changes_nothing() {
|
|
let mut h = harness();
|
|
let (env, _) = environment_light(&mut h);
|
|
let before = serde_json::to_value(layer(&h, env)).unwrap();
|
|
let steps = h.state().session.history.undo.len();
|
|
reopen(&mut h, env);
|
|
choose(&mut h, LightKind::Spot);
|
|
click_id(&mut h, "dialog.light.cancel");
|
|
assert_eq!(h.state().dialog, None);
|
|
assert_eq!(serde_json::to_value(layer(&h, env)).unwrap(), before);
|
|
assert_eq!(h.state().session.history.undo.len(), steps);
|
|
}
|
|
|
|
#[test]
|
|
fn changing_a_light_to_environment_undoes_and_redoes() {
|
|
let mut h = harness();
|
|
let id = h.state_mut().session.execute("layer.newLight", json!({"kind": "Spot", "coneAngle": 45})).unwrap()["layer"].as_u64().unwrap();
|
|
h.run_steps(2);
|
|
reopen(&mut h, id);
|
|
choose(&mut h, LightKind::Environment);
|
|
ok(&mut h);
|
|
assert_eq!(kind(&h, id), LightKind::Environment);
|
|
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
|
|
assert_eq!(kind(&h, id), LightKind::Spot);
|
|
assert_eq!(layer(&h, id).props.prop("lightOptions/coneAngle").unwrap().value.as_f64(), 45.0);
|
|
h.state_mut().session.execute("edit.redo", json!({})).unwrap();
|
|
assert_eq!(kind(&h, id), LightKind::Environment);
|
|
}
|
|
|
|
#[test]
|
|
fn the_other_light_types_are_created_and_kept() {
|
|
let mut h = harness();
|
|
for k in [LightKind::Parallel, LightKind::Spot, LightKind::Point, LightKind::Ambient] {
|
|
invoke(&mut h, "layer.newLight", json!({}));
|
|
choose(&mut h, k);
|
|
ok(&mut h);
|
|
let id = last_layer(&h);
|
|
assert_eq!(kind(&h, id), k);
|
|
reopen(&mut h, id);
|
|
ok(&mut h);
|
|
assert_eq!(kind(&h, id), k, "{} kept on reopening", k.label());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn falloff_and_shadow_options_show_only_for_lights_that_have_them() {
|
|
let mut h = harness();
|
|
invoke(&mut h, "layer.newLight", json!({}));
|
|
for k in LightKind::ALL {
|
|
choose(&mut h, k);
|
|
// Falloff is distance attenuation: image-based Environment lights and Ambient have none.
|
|
let falloff = !matches!(k, LightKind::Ambient | LightKind::Environment);
|
|
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();
|
|
let (env, sky) = environment_light(&mut h);
|
|
reopen(&mut h, env);
|
|
choose(&mut h, LightKind::Spot);
|
|
ok(&mut h);
|
|
assert_eq!(kind(&h, env), LightKind::Spot);
|
|
assert!(layer(&h, env).props.prop("lightOptions/source").is_none());
|
|
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
|
|
let l = layer(&h, env);
|
|
assert_eq!(kind(&h, env), LightKind::Environment);
|
|
assert_eq!(l.props.prop("lightOptions/source").unwrap().value.as_layer(), Some(sky));
|
|
assert_eq!(l.props.prop("lightOptions/rotation").unwrap().value.as_f64(), 30.0);
|
|
}
|