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
+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);