From ee5c9ac2dd762198796e323a70c0401f8560d8a1 Mon Sep 17 00:00:00 2001 From: heojunfo Date: Sat, 17 Oct 2026 13:31:08 +0900 Subject: [PATCH] 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) --- crates/gpu/src/adv3d.rs | 7 ++- crates/gpu/src/shaders/advanced3d.wgsl | 71 ++++++++++++++++++++------ crates/gpu/src/tests_adv3d.rs | 12 +++++ crates/render/src/three_d/adv/scene.rs | 10 +++- crates/render/src/three_d/adv/shade.rs | 43 +++++++++++++--- crates/render/src/three_d/adv/tests.rs | 67 ++++++++++++++++++++++++ 6 files changed, 184 insertions(+), 26 deletions(-) diff --git a/crates/gpu/src/adv3d.rs b/crates/gpu/src/adv3d.rs index 6ae11445..24c5a3b3 100644 --- a/crates/gpu/src/adv3d.rs +++ b/crates/gpu/src/adv3d.rs @@ -320,7 +320,12 @@ fn pack(s: &Scene) -> [Vec; 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], diff --git a/crates/gpu/src/shaders/advanced3d.wgsl b/crates/gpu/src/shaders/advanced3d.wgsl index e1dbc8f5..06becba4 100644 --- a/crates/gpu/src/shaders/advanced3d.wgsl +++ b/crates/gpu/src/shaders/advanced3d.wgsl @@ -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, ldir: vec3, pos: vec3) -> vec4 { + if (kind == 0u) { + return vec4(-ldir, 1e30); + } + let d = lpos - pos; + let len = sqrt(dot(d, d)); + if (len < 1e-6) { + return vec4(0.0, 0.0, -1.0, 0.0); + } + return vec4(d / len, len); +} + +// shade::shadow_only: the strongest shadow any light casts on the fragment. +fn shadow_only_amount(unlit: bool, normal: vec3, front: bool, pos: vec3) -> 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(dot(g.view_z.xyz, i.pos) + g.view_z.w); + if (shadows_only) { + out.color = vec4(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(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; - 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(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; diff --git a/crates/gpu/src/tests_adv3d.rs b/crates/gpu/src/tests_adv3d.rs index 869c60aa..231dc976 100644 --- a/crates/gpu/src/tests_adv3d.rs +++ b/crates/gpu/src/tests_adv3d.rs @@ -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 }; diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index 34517933..a0c60b89 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -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 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] +} diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs index d276a0f0..694a5c2b 100644 --- a/crates/render/src/three_d/adv/tests.rs +++ b/crates/render/src/three_d/adv/tests.rs @@ -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);