Advanced 3D: Accepts Shadows Only draws just the shadow (#1691)

The Advanced 3D renderer folded Accepts Shadows (Off/On/Only) into a
bool, so a shadow catcher set to Only drew as an ordinary lit surface.
The material now carries shadows_only. Such a fragment draws black at
the strongest shadow a light casts on it, as Classic 3D does, and it
goes in the blended pass so what is behind it shows through. The CPU
shader and advanced3d.wgsl get the same branch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
heojunfo
2026-10-17 13:31:08 +09:00
co-authored by Claude Opus 5.5
parent 8f02f52a44
commit ee5c9ac2dd
6 changed files with 184 additions and 26 deletions
+6 -1
View File
@@ -320,7 +320,12 @@ fn pack(s: &Scene) -> [Vec<u8>; 7] {
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]);
let mats = s.materials.iter().flat_map(|m| {
let flags = (m.double_sided as u32) | (m.unlit as u32) << 1 | (m.accepts_lights as u32) << 2 | (m.receives_shadows as u32) << 3 | (m.alpha_mode << 8);
let flags = (m.double_sided as u32)
| (m.unlit as u32) << 1
| (m.accepts_lights as u32) << 2
| (m.receives_shadows as u32) << 3
| (m.shadows_only as u32) << 4
| (m.alpha_mode << 8);
[
m.base[0],
m.base[1],
+55 -16
View File
@@ -288,6 +288,53 @@ struct FOut {
@location(1) depth: u32,
};
// shade::to_light: unit direction to the light (xyz) and its distance (w).
fn to_light(kind: u32, lpos: vec3<f32>, ldir: vec3<f32>, pos: vec3<f32>) -> vec4<f32> {
if (kind == 0u) {
return vec4<f32>(-ldir, 1e30);
}
let d = lpos - pos;
let len = sqrt(dot(d, d));
if (len < 1e-6) {
return vec4<f32>(0.0, 0.0, -1.0, 0.0);
}
return vec4<f32>(d / len, len);
}
// shade::shadow_only: the strongest shadow any light casts on the fragment.
fn shadow_only_amount(unlit: bool, normal: vec3<f32>, front: bool, pos: vec3<f32>) -> f32 {
var amount = 0.0;
if (unlit) {
return amount;
}
var n = norm3(normal);
if (!front) {
n = -n;
}
let nl_count = u32(g.fwd.w);
for (var li = 0u; li < nl_count; li = li + 1u) {
let lb = li * LIGHT_STRIDE;
let l0 = lights[lb];
let l1 = lights[lb + 1u];
let l2 = lights[lb + 2u];
let l3 = lights[lb + 3u];
let l4 = lights[lb + 4u];
let kind = u32(l0.w);
let tl = to_light(kind, l0.xyz, l1.xyz, pos);
let to_l = tl.xyz;
let dist = tl.w;
if (attenuation(kind, u32(l3.x), l3.y, l3.z, l2.w, l1.w, l1.xyz, to_l, dist) <= 0.0) {
continue;
}
let n_l = dot(n, to_l);
if (n_l <= 0.0) {
continue;
}
amount = max(amount, 1.0 - light_shadow(li, kind, l0.xyz, i32(l3.w), l4.x, pos, n_l));
}
return amount;
}
@fragment
fn fs(i: VOut, @builtin(front_facing) front: bool) -> FOut {
let mb = i.material * MAT_STRIDE;
@@ -302,6 +349,7 @@ fn fs(i: VOut, @builtin(front_facing) front: bool) -> FOut {
let unlit = (flags & 2u) != 0u;
let accepts_lights = (flags & 4u) != 0u;
let receives_shadows = (flags & 8u) != 0u;
let shadows_only = (flags & 16u) != 0u;
let alpha_mode = (flags >> 8u) & 3u;
if (!front && !double_sided) {
discard;
@@ -330,6 +378,10 @@ fn fs(i: VOut, @builtin(front_facing) front: bool) -> FOut {
}
var out: FOut;
out.depth = bitcast<u32>(dot(g.view_z.xyz, i.pos) + g.view_z.w);
if (shadows_only) {
out.color = vec4<f32>(0.0, 0.0, 0.0, alpha * shadow_only_amount(unlit || !accepts_lights, i.normal, front, i.pos));
return out;
}
if (unlit || !accepts_lights || g.env2.y < 0.5) {
let c = rgb + emissive;
out.color = vec4<f32>(c * alpha, alpha);
@@ -381,22 +433,9 @@ fn fs(i: VOut, @builtin(front_facing) front: bool) -> FOut {
let l3 = lights[lb + 3u];
let l4 = lights[lb + 4u];
let kind = u32(l0.w);
var to_l: vec3<f32>;
var dist: f32;
if (kind == 0u) {
to_l = -l1.xyz;
dist = 1e30;
} else {
let d = l0.xyz - i.pos;
let len = sqrt(dot(d, d));
if (len < 1e-6) {
to_l = vec3<f32>(0.0, 0.0, -1.0);
dist = 0.0;
} else {
to_l = d / len;
dist = len;
}
}
let tl = to_light(kind, l0.xyz, l1.xyz, i.pos);
let to_l = tl.xyz;
let dist = tl.w;
let k = attenuation(kind, u32(l3.x), l3.y, l3.z, l2.w, l1.w, l1.xyz, to_l, dist);
if (k <= 0.0) {
continue;
+12
View File
@@ -155,6 +155,18 @@ fn gpu_matches_cpu_unlit_and_full_frames() {
assert!((diff as f64) < 0.02 * cpu.data.len() as f64, "{diff} pixels differ");
}
#[test]
fn gpu_matches_cpu_shadow_catcher() {
// #1691: the floor plane as a shadow catcher ("Accepts Shadows: Only").
let Some(g) = gpu() else { return };
let (mut p, cid) = scene_project();
let floor = p.comp_mut(cid).unwrap().layers.last_mut().unwrap();
set(floor, "materialOptions/acceptsShadows", Value::Enum(2));
let s = scene(&p, cid);
assert!(s.materials.iter().any(|m| m.shadows_only) && !s.shadows.is_empty());
compare(&s, &g);
}
#[test]
fn gpu_raster_perf_smoke() {
let Some(g) = gpu() else { return };
+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);