collective

Browse / github.com/storytold/effectcraft / patch

Classic 3D Shadow Diffusion blurs instead of stamping copies (#2444)

[Bug Report] [EffectCraft] 3D Shadow diffusion ### Description fix 3D layer shadow diffusion of light sources when casting shadows ### Operating system and version All versions <!-- ticket-relay:files --> ### Attachments ![Captura\_de\_pantalla\_20261025\_201447.png](https://tickets.devmesh.xyz/f/tQCGmp1djFH17h8or_QZVw/Captura_de_pantalla_20261025_201447.png) <!-- /ticket-relay:files --> ### Ticket details | | | |---|---| | **App** | EffectCraft | | **Type** | Bug Report | | **Reported by** | ๐–๐ซ๐ž๐ง๐œ๐ก (Discord username: wrenchpc) โ€” Discord user ID `408379633654956032` | | **Submitted** | 2026-10-25 23:15:59 UTC | | **Discord server**

Status
open
Score
0.00
Adopters
0 other operators
Author
HEOJUNFO (imported from pull request #2463, open)
Base
01c904b041064ca8ed03e62bffd07f974dee07f6
Size
+74 โˆ’7 in 4 files
Review
pending
Shared
2026-10-28
Id
eeb25be204441fbc5d249579

How it went

No other operator has reported using it yet.

Diff

raw ยท crates/gpu/src/shaders/classic3d.wgsl crates/gpu/src/tests/three_d.rs crates/render/src/three_d/compose.rs crates/render/src/three_d/tests.rs

diff --git a/crates/gpu/src/shaders/classic3d.wgsl b/crates/gpu/src/shaders/classic3d.wgsl--- a/crates/gpu/src/shaders/classic3d.wgsl+++ b/crates/gpu/src/shaders/classic3d.wgsl@@ -199,6 +199,12 @@ fn attenuation(l: u32, p: vec3<f32>) -> f32 {     return k; } +// compose::shadow_taps: samples per side of the Shadow Diffusion kernel.+fn shadow_taps(r_px: f32) -> u32 {+    let n = clamp(u32(ceil(2.0 * r_px / 1.5)), 3u, 15u);+    return n | 1u;+}+ // compose::occlusion: RGB transmittance of light `l` reaching `p` past caster `c`. fn occlusion(c: u32, p: vec3<f32>, l: u32) -> vec3<f32> {     let pl = planes[c];@@ -243,9 +249,14 @@ fn occlusion(c: u32, p: vec3<f32>, l: u32) -> vec3<f32> {     if (r * pl.tex.z < 0.5) {         px = texel(c, hit.x, hit.y);     } else {-        for (var j = -1; j <= 1; j++) {-            for (var i = -1; i <= 1; i++) {-                px += texel(c, hit.x + f32(i) * r * 0.66, hit.y + f32(j) * r * 0.66) / 9.0;+        // compose::shadow_taps: n ร— n box filter over the penumbra square.+        let n = shadow_taps(r * pl.tex.z);+        let step = 2.0 * r / f32(n);+        let h = i32(n / 2u);+        let w = 1.0 / f32(n * n);+        for (var j = -h; j <= h; j++) {+            for (var i = -h; i <= h; i++) {+                px += texel(c, hit.x + f32(i) * step, hit.y + f32(j) * step) * w;             }         }     }diff --git a/crates/gpu/src/tests/three_d.rs b/crates/gpu/src/tests/three_d.rs--- a/crates/gpu/src/tests/three_d.rs+++ b/crates/gpu/src/tests/three_d.rs@@ -133,6 +133,22 @@ fn lights_specular_transmission_and_shadows() {     } } +#[test]+fn wide_shadow_diffusion() {+    // #2444: a wide penumbra takes more than the 3 ร— 3 kernel's samples; both sides pick the+    // same count.+    for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {+        let mut s = crossing(depth, BlendMode::Normal);+        for l in &mut s.p.comp_mut(s.cid).unwrap().layers {+            if l.switches.three_d {+                set(l, "materialOptions/castsShadows", Value::Enum(1));+            }+        }+        light(&mut s, LightKind::Point, [150.0, 20.0, -80.0], &[("castsShadows", Value::Bool(true)), ("shadowDiffusion", n(60.0))]);+        check(&format!("wide shadow diffusion {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005);+    }+}+ #[test] fn depth_of_field_and_motion_blur() {     for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs--- a/crates/render/src/three_d/compose.rs+++ b/crates/render/src/three_d/compose.rs@@ -648,6 +648,13 @@ fn shade_texel(it: &Item, g: &Geo, u: f64, v: f64, t: Px, lights: &[LightState],     [lit[0] * a, lit[1] * a, lit[2] * a, a] } +/// Samples per side of the Shadow Diffusion kernel for a penumbra radius of `r_px` caster pixels:+/// odd, at least 3, and enough that neighbouring samples are at most 1.5 pixels apart, up to 15.+pub(crate) fn shadow_taps(r_px: f64) -> u32 {+    let n = ((2.0 * r_px / 1.5).ceil() as u32).clamp(3, 15);+    n | 1+}+ /// RGB transmittance of light `l` reaching `p` past caster `c` (1 = unoccluded). fn occlusion(c: &Item, p: Vec3, l: &LightState) -> [f32; 3] {     const FAR: f64 = 1.0e6;@@ -675,11 +682,17 @@ fn occlusion(c: &Item, p: Vec3, l: &LightState) -> [f32; 3] {     if r * c.buf.scale < 0.5 {         px = c.texel(hit.x, hit.y);     } else {-        for j in -1..=1 {-            for i in -1..=1 {-                let q = c.texel(hit.x + i as f64 * r * 0.66, hit.y + j as f64 * r * 0.66);+        // Box filter over the penumbra square: n ร— n cell centres, dense enough that a wide+        // diffusion blurs the caster instead of stamping copies of it (#2444).+        let n = shadow_taps(r * c.buf.scale);+        let step = 2.0 * r / n as f64;+        let h = (n / 2) as i32;+        let w = 1.0 / (n * n) as f32;+        for j in -h..=h {+            for i in -h..=h {+                let q = c.texel(hit.x + f64::from(i) * step, hit.y + f64::from(j) * step);                 for k in 0..4 {-                    px[k] += q[k] / 9.0;+                    px[k] += q[k] * w;                 }             }         }diff --git a/crates/render/src/three_d/tests.rs b/crates/render/src/three_d/tests.rs--- a/crates/render/src/three_d/tests.rs+++ b/crates/render/src/three_d/tests.rs@@ -344,6 +344,33 @@ fn shadow_darkness_and_accepts_off() {     assert!(close(v, 0.5, 1e-3), "no shadow accepted: {v}"); } +#[test]+fn shadow_diffusion_is_a_soft_ramp_not_copies() {+    // #2444: a wide Shadow Diffusion stamped 3 ร— 3 copies of the caster, so the penumbra stepped+    // through three flat levels instead of ramping smoothly from lit to shadowed.+    let (mut p, cid, comp) = setup();+    let mut recv = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 400, 200, [100.0, 50.0, 100.0]);+    no_specular(&mut recv);+    let mut caster = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 100, 200, [40.0, 50.0, 0.0]);+    set(&mut caster, "materialOptions/castsShadows", Value::Enum(2)); // Only: invisible caster+    let mut l = light(&mut p, &comp, LightKind::Parallel, [100.0, 50.0, -500.0]);+    set(&mut l, "transform/poi", Value::Vec3([100.0, 50.0, 0.0]));+    set(&mut l, "lightOptions/castsShadows", Value::Bool(true));+    // 100 px to the caster over the parallel light's 1000: a 15 px penumbra radius.+    set(&mut l, "lightOptions/shadowDiffusion", Value::Scalar(150.0));+    push(&mut p, cid, l);+    push(&mut p, cid, caster);+    push(&mut p, cid, recv);+    let img = render(&p, cid);+    let row: Vec<f32> = (0..i64::from(W)).map(|x| img.get(x, 50)[0]).collect();+    // The row crosses both vertical edges of the caster's shadow: lit, ramp, shadowed, ramp, lit.+    assert!(row.iter().any(|v| *v < 0.01) && close(row[W as usize - 1], 0.5, 1e-3), "shadow and light: {row:?}");+    let ramp = row.iter().filter(|v| **v > 0.02 && **v < 0.48).count();+    assert!(ramp >= 16, "two penumbras several pixels wide: {row:?}");+    let step = row.windows(2).map(|w| (w[1] - w[0]).abs()).fold(0.0f32, f32::max);+    assert!(step < 0.08, "no jumps between copies of the caster (largest step {step}): {row:?}");+}+ // ---------------------------------------------------------------- cameras  fn camera(p: &mut Project, comp: &Comp) -> Layer {