Merge pull request #1704

This commit is contained in:
Brandon Thomas
2026-10-18 09:35:35 -04:00
6 changed files with 184 additions and 26 deletions
+8 -2
View File
@@ -62,6 +62,8 @@ pub struct Material {
pub unlit: bool,
pub accepts_lights: bool,
pub receives_shadows: bool,
/// "Accepts Shadows: Only": draws just the shadows it receives, as black × their amount.
pub shadows_only: bool,
pub casts_shadows: bool,
/// Drawn (false for "Casts Shadows: Only").
pub visible: bool,
@@ -92,6 +94,7 @@ impl Default for Material {
unlit: false,
accepts_lights: true,
receives_shadows: true,
shadows_only: false,
casts_shadows: false,
visible: true,
diffuse_k: 1.0,
@@ -104,7 +107,7 @@ impl Default for Material {
impl Material {
/// Drawn in the transparent (sorted, blended) pass.
pub fn transparent(&self) -> bool {
self.alpha_mode == 2 || self.opacity < 0.999
self.alpha_mode == 2 || self.opacity < 0.999 || self.shadows_only
}
}
@@ -360,6 +363,7 @@ fn card_material(ctx: &EvalCtx, layer: &Layer) -> Material {
double_sided: true,
accepts_lights: c.accepts_lights,
receives_shadows: c.accepts_shadows != 0,
shadows_only: c.accepts_shadows == 2,
casts_shadows: c.casts_shadows != 0,
visible: c.casts_shadows != 2,
diffuse_k: c.diffuse,
@@ -375,9 +379,11 @@ fn model_options(ctx: &EvalCtx, layer: &Layer, lin: bool) -> (Material, Option<m
let e = |m: &str, d: u32| g.and_then(|g| g.get(m)).map_or(d, |p| ctx.value(layer, p).as_enum());
let b = |m: &str| g.and_then(|g| g.get(m)).is_none_or(|p| ctx.value(layer, p).as_bool());
let casts = e("castsShadows", 0);
let accepts = e("acceptsShadows", 1);
let opts = Material {
accepts_lights: b("acceptsLights"),
receives_shadows: e("acceptsShadows", 1) != 0,
receives_shadows: accepts != 0,
shadows_only: accepts == 2,
casts_shadows: casts != 0,
visible: casts != 2,
..Material::default()
+36 -7
View File
@@ -262,6 +262,16 @@ pub fn light_shadow(s: &Scene, l: &Light, p: [f32; 3], n_l: f32) -> f32 {
1.0 - l.shadow_darkness * (1.0 - f)
}
/// Unit direction from `p` to light `l`, and the distance (infinite for a parallel light).
fn to_light(l: &Light, p: [f32; 3]) -> ([f32; 3], f32) {
if l.kind == 0 {
return (scale(l.dir, -1.0), f32::INFINITY);
}
let d = sub(l.pos, p);
let len = dot(d, d).sqrt();
if len < 1e-6 { ([0.0, 0.0, -1.0], 0.0) } else { (scale(d, 1.0 / len), len) }
}
/// Distance attenuation × spot cone of a light at `p` (AE falloff modes).
pub fn attenuation(l: &Light, p: [f32; 3], to_l: [f32; 3], dist: f32) -> f32 {
let mut k = match l.falloff {
@@ -341,6 +351,9 @@ pub fn shade(s: &Scene, f: &Frag) -> Option<[f32; 4]> {
if let Some(t) = tex(m.tex_emissive) {
emissive = [emissive[0] * t[0], emissive[1] * t[1], emissive[2] * t[2]];
}
if m.shadows_only {
return Some(shadow_only(s, m, f, alpha));
}
if m.unlit || !m.accepts_lights || (s.lights.is_empty() && s.env.is_none() && s.ambient == [0.0; 3]) {
let c = add(rgb, emissive);
return Some([c[0] * alpha, c[1] * alpha, c[2] * alpha, alpha]);
@@ -370,13 +383,7 @@ pub fn shade(s: &Scene, f: &Frag) -> Option<[f32; 4]> {
let v = if s.ortho { scale(s.cam_fwd, -1.0) } else { norm(sub(s.eye, f.pos)) };
let mut out = [0.0f32; 3];
for l in &s.lights {
let (to_l, dist) = if l.kind == 0 {
(scale(l.dir, -1.0), f32::INFINITY)
} else {
let d = sub(l.pos, f.pos);
let len = dot(d, d).sqrt();
if len < 1e-6 { ([0.0, 0.0, -1.0], 0.0) } else { (scale(d, 1.0 / len), len) }
};
let (to_l, dist) = to_light(l, f.pos);
let k = attenuation(l, f.pos, to_l, dist);
if k <= 0.0 {
continue;
@@ -408,3 +415,25 @@ pub fn shade(s: &Scene, f: &Frag) -> Option<[f32; 4]> {
let c = add(out, emissive);
Some([c[0] * alpha, c[1] * alpha, c[2] * alpha, alpha])
}
/// "Accepts Shadows: Only" (classic `compose::shade_texel`): black at the strongest shadow any
/// light casts on the fragment, nothing where it is unshadowed or takes no lights.
fn shadow_only(s: &Scene, m: &Material, f: &Frag, alpha: f32) -> [f32; 4] {
if m.unlit || !m.accepts_lights {
return [0.0; 4];
}
let n = if f.front { norm(f.normal) } else { scale(norm(f.normal), -1.0) };
let mut amount = 0.0f32;
for l in &s.lights {
let (to_l, dist) = to_light(l, f.pos);
if attenuation(l, f.pos, to_l, dist) <= 0.0 {
continue;
}
let n_l = dot(n, to_l);
if n_l <= 0.0 {
continue;
}
amount = amount.max(1.0 - light_shadow(s, l, f.pos, n_l));
}
[0.0, 0.0, 0.0, alpha * amount]
}
+67
View File
@@ -237,6 +237,73 @@ fn shadows_darken_the_receiver() {
assert!(img.data[75 * 200 + 100][0] > side[0] * 0.8);
}
#[test]
fn accepts_shadows_only_draws_just_the_shadow() {
// #1691: a shadow catcher (card or plane primitive, "Accepts Shadows: Only") in front of a
// red backdrop card, an invisible sphere casting onto it, a point light behind the camera.
let (mut p, cid) = project(200, 150);
let red = p.add_item(
"R",
Default::default(),
None,
ItemKind::Solid(effectcraft_project::Solid { color: [1.0, 0.0, 0.0], width: 400, height: 400, pixel_aspect: 1.0 }),
);
add(&mut p, cid, LayerSource::Solid { item: red }, |l| {
l.switches.three_d = true;
set(l, "transform/position", Value::Vec3([100.0, 75.0, 300.0]));
set(l, "materialOptions/acceptsShadows", Value::Enum(0));
});
let white = p.add_item(
"W",
Default::default(),
None,
ItemKind::Solid(effectcraft_project::Solid { color: [1.0, 1.0, 1.0], width: 400, height: 400, pixel_aspect: 1.0 }),
);
add(&mut p, cid, LayerSource::Solid { item: white }, |l| {
l.switches.three_d = true;
set(l, "transform/position", Value::Vec3([100.0, 75.0, 200.0]));
set(l, "materialOptions/acceptsShadows", Value::Enum(2));
});
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));
});
add(&mut p, cid, LayerSource::Light { kind: LightKind::Point }, |l| {
set(l, "transform/position", Value::Vec3([100.0, 75.0, -300.0]));
set(l, "lightOptions/castsShadows", Value::Bool(true));
});
let check = |img: &crate::Image, what: &str| {
let (mid, side) = (img.data[75 * 200 + 100], img.data[75 * 200 + 60]);
// Unshadowed: the catcher is see-through, the lit red card behind it shows.
assert!(side[0] > 0.5 && side[1] < side[0] * 0.25 && side[3] > 0.99, "{what}: side {side:?}");
// Shadowed: black over the red card.
assert!(mid[0] < 0.02 && mid[3] > 0.99, "{what}: shadow {mid:?} vs side {side:?}");
};
check(&render(&p, cid), "card");
// Nothing behind the catcher: only the shadow is opaque.
let solo = |p: &Project| {
let mut q = p.clone();
q.comp_mut(cid).unwrap().layers.pop();
let img = render(&q, cid);
let (mid, side) = (img.data[75 * 200 + 100], img.data[75 * 200 + 60]);
assert!(side[3] < 1e-3, "unshadowed catcher is transparent: {side:?}");
assert!(mid[3] > 0.5 && mid[0] < 1e-3, "shadow is black: {mid:?}");
};
solo(&p);
// The same with a plane primitive as the catcher.
let layers = &mut p.comp_mut(cid).unwrap().layers;
layers.remove(2);
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Plane }, |l| {
set(l, "geometryOptions/width", Value::Scalar(400.0));
set(l, "geometryOptions/height", Value::Scalar(400.0));
set(l, "transform/position", Value::Vec3([100.0, 75.0, 200.0]));
set(l, "materialOptions/acceptsShadows", Value::Enum(2));
});
check(&render(&p, cid), "plane");
solo(&p);
}
#[test]
fn cards_and_environment_light() {
let (mut p, cid) = project(120, 90);