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 94366459..b75d5af7 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);