diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index ce572aaef..dfd35b048 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -819,23 +819,27 @@ pub fn irradiance_map(sw: u32, sh: u32, src: &[[f32; 4]], w: u32, h: u32) -> Vec out } -/// The environment image's dominant direction and key strength: the radiance-weighted mean -/// texel direction (solid-angle weighted, rotation applied), or `([0; 3], 0.0)` when the image -/// is directionless (a uniform environment cancels to zero, and opposing bright regions that -/// cancel leave no mean either). The mean of a single bright spot +/// The environment image's dominant direction and key strength: the mean texel direction +/// weighted by the radiance the light actually emits — solid-angle weighted, rotation applied, +/// each texel's channels scaled by `tint` (the light's colour, `[1; 3]` for a white one) — or +/// `([0; 3], 0.0)` when that radiance is directionless (a uniform environment cancels to zero, +/// and opposing bright regions that cancel leave no mean either — though a chromatic one need +/// not stay directionless once tinted: a red light over equally bright red and blue opposite +/// regions removes the blue pull and points at the red one). The mean of a single bright spot /// on black points straight at it; a gradient points somewhere between its ends. The returned /// key fraction is the mean vector's length over the radiance weight — how directional the /// image is, 0 (uniform) to ~1 (a lone spot) — and is the most of its light an Environment /// light's shadow can remove. -pub(crate) fn dominant_direction(w: u32, h: u32, d: &[[f32; 4]], rot: f32) -> ([f32; 3], f32) { +pub(crate) fn dominant_direction(w: u32, h: u32, d: &[[f32; 4]], tint: [f32; 3], rot: f32) -> ([f32; 3], f32) { use super::shade::equirect_dir; let (mut x, mut y, mut z) = (0.0f32, 0.0f32, 0.0f32); let mut weight = 0.0f32; for (i, p) in d.iter().enumerate() { let (tx, ty) = ((i % w as usize) as u32, (i / w as usize) as u32); let (u, v) = ((tx as f32 + 0.5) / w as f32, (ty as f32 + 0.5) / h as f32); - // Luminance × solid angle (∝ sin θ). - let lum = (p[0] + p[1] + p[2]) * (v * std::f32::consts::PI).sin(); + // Tinted radiance × solid angle (∝ sin θ): the shadow must follow the light the + // environment emits, not the raw texels. + let lum = (p[0] * tint[0] + p[1] * tint[1] + p[2] * tint[2]) * (v * std::f32::consts::PI).sin(); let dir = equirect_dir(u, v, rot); x += dir[0] * lum; y += dir[1] * lum; @@ -863,9 +867,16 @@ pub(crate) fn add_environment( shadow_darkness: f64, shadow_diffusion: f64, ) { + // Intensity × colour folded into the radiance (one scalar on the GPU): tint the texels. + // A white (or off) light leaves them as they are. + let k = (intensity[0] + intensity[1] + intensity[2]) / 3.0; + let tint = if k > 0.0 && (intensity[0] != intensity[1] || intensity[1] != intensity[2]) { intensity.map(|c| c / k) } else { [1.0, 1.0, 1.0] }; // The shadow direction and key strength from the full-resolution image, before the mip - // chain averages it — only a shadow-casting light reads them, so only one pays for the pass. - let (sun, key) = if casts_shadows { dominant_direction(w, h, &data, rotation) } else { ([0.0; 3], 0.0) }; + // chain averages it — only a shadow-casting light reads them, so only one pays for the + // pass — and weighted by the tint, so the shadow follows the radiance the light emits: + // opposing chromatic regions that cancel untinted need not cancel once the colour removes + // one of them (a red light over equally bright red and blue sides shadows from the red). + let (sun, key) = if casts_shadows { dominant_direction(w, h, &data, tint, rotation) } else { ([0.0; 3], 0.0) }; // Radiance level 0 at most 1024 wide. let (mut w, mut h, mut d) = (w, h, data); while w > 1024 { @@ -888,10 +899,7 @@ pub(crate) fn add_environment( let (sw, sh, sd) = small.unwrap_or((w, h, d)); let irr = irradiance_map(sw, sh, &sd, 32, 16); let ii = b.texture_from(32, 16, [0, 1], irr.into_iter()) as u32; - // Intensity × colour folded into the radiance (one scalar on the GPU): tint the texels. - let k = (intensity[0] + intensity[1] + intensity[2]) / 3.0; - if k > 0.0 && (intensity[0] != intensity[1] || intensity[1] != intensity[2]) { - let tint = intensity.map(|c| c / k); + if tint != [1.0, 1.0, 1.0] { for t in first..=ii { let ti = b.s.textures[t as usize]; for p in &mut b.s.texels[ti.offset as usize..(ti.offset + ti.width * ti.height) as usize] { diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs index fd3c615ea..3f97615f9 100644 --- a/crates/render/src/three_d/adv/tests.rs +++ b/crates/render/src/three_d/adv/tests.rs @@ -74,9 +74,9 @@ fn constant_environment_has_constant_irradiance() { #[test] fn a_non_finite_environment_has_no_sun_direction() { let nan = vec![[f32::NAN; 4]; 8 * 4]; - assert_eq!(dominant_direction(8, 4, &nan, 0.0), ([0.0; 3], 0.0)); + assert_eq!(dominant_direction(8, 4, &nan, [1.0; 3], 0.0), ([0.0; 3], 0.0)); let inf = vec![[f32::INFINITY, 0.0, 0.0, 1.0]; 8 * 4]; - assert_eq!(dominant_direction(8, 4, &inf, 0.0), ([0.0; 3], 0.0)); + assert_eq!(dominant_direction(8, 4, &inf, [1.0; 3], 0.0), ([0.0; 3], 0.0)); } /// The key fraction: a uniform image is directionless (key 0), a lone bright spot on black is @@ -85,13 +85,13 @@ fn a_non_finite_environment_has_no_sun_direction() { #[test] fn environment_key_strength_tracks_directionality() { let uniform = vec![[0.5f32, 0.5, 0.5, 1.0]; 8 * 4]; - assert_eq!(dominant_direction(8, 4, &uniform, 0.0).1, 0.0); + assert_eq!(dominant_direction(8, 4, &uniform, [1.0; 3], 0.0).1, 0.0); let mut spot = vec![[0.0f32; 4]; 64 * 32]; spot[16 * 64 + 32] = [1.0, 1.0, 1.0, 1.0]; - let (_, key) = dominant_direction(64, 32, &spot, 0.0); + let (_, key) = dominant_direction(64, 32, &spot, [1.0; 3], 0.0); assert!(key > 0.95, "a lone spot is almost fully directional: {key}"); let (w, h, d) = scene::default_environment(); - let (_, key) = dominant_direction(w, h, &d, 0.0); + let (_, key) = dominant_direction(w, h, &d, [1.0; 3], 0.0); assert!((0.35..0.55).contains(&key), "the default studio is a broad key with fill: {key}"); } @@ -110,18 +110,47 @@ fn opposing_bright_regions_cancel_to_no_shadow() { let (a, b) = ((15 * w + 16) as usize, (16 * w + 48) as usize); opposed[a] = [1.0, 1.0, 1.0, 1.0]; opposed[b] = [1.0, 1.0, 1.0, 1.0]; - assert_eq!(dominant_direction(w, h, &opposed, 0.0), ([0.0; 3], 0.0)); + assert_eq!(dominant_direction(w, h, &opposed, [1.0; 3], 0.0), ([0.0; 3], 0.0)); // One side twice as bright: the mean points at the brighter spot with its share of the // weight (key = 1/3 — two parts against one). let mut tilted = opposed.clone(); tilted[a] = [2.0, 2.0, 2.0, 1.0]; - let (sun, key) = dominant_direction(w, h, &tilted, 0.0); + let (sun, key) = dominant_direction(w, h, &tilted, [1.0; 3], 0.0); assert!(sun != [0.0; 3] && key > 0.3, "the brighter side wins: {sun:?} key {key}"); let want = shade::equirect_dir(16.5 / 64.0, 15.5 / 32.0, 0.0); let dot = sun[0] * want[0] + sun[1] * want[1] + sun[2] * want[2]; assert!(dot > 0.999, "the sun points at the brighter spot: {sun:?} vs {want:?}"); } +/// #2436 review: the sun follows the tinted radiance, not the raw texels — equally bright red +/// and blue regions on exactly opposite texels cancel to a white light (no shadow), but the +/// red light's tint removes the blue side's pull, so the mean points straight at the red one +/// (a blue light, at the blue one): the shadow lands where the emitted light comes from. +#[test] +fn a_coloured_light_shadows_from_the_region_it_tints() { + let (w, h) = (64u32, 32u32); + let mut opposed = vec![[0.0f32; 4]; (w * h) as usize]; + // Rows 15 and 16 mirror about the equator, columns 16 and 48 half a width apart: the two + // texel-centre directions are exactly opposite — equal solid-angle weight, opposite pull. + let (a, b) = ((15 * w + 16) as usize, (16 * w + 48) as usize); + opposed[a] = [1.0, 0.0, 0.0, 1.0]; + opposed[b] = [0.0, 0.0, 1.0, 1.0]; + // To a white light the channel sums are equal: they cancel, no shadow. + assert_eq!(dominant_direction(w, h, &opposed, [1.0; 3], 0.0), ([0.0; 3], 0.0)); + // A red light's tint zeroes the blue texel's weight: the red one becomes a lone spot. + // (Any positive scale of the tint — the normalised [3, 0, 0] included — points the same + // way; only the channel ratios matter.) + let (sun, key) = dominant_direction(w, h, &opposed, [1.0, 0.0, 0.0], 0.0); + let want = shade::equirect_dir(16.5 / 64.0, 15.5 / 32.0, 0.0); + let dot = sun[0] * want[0] + sun[1] * want[1] + sun[2] * want[2]; + assert!(dot > 0.999 && key > 0.95, "the red side wins: {sun:?} key {key}"); + // A blue light points at the blue one instead. + let (sun, _) = dominant_direction(w, h, &opposed, [0.0, 0.0, 1.0], 0.0); + let want = shade::equirect_dir(48.5 / 64.0, 16.5 / 32.0, 0.0); + let dot = sun[0] * want[0] + sun[1] * want[1] + sun[2] * want[2]; + assert!(dot > 0.999, "the blue side wins: {sun:?}"); +} + #[test] fn equirect_round_trip_and_env_brdf() { for (u, v) in [(0.1f32, 0.2f32), (0.5, 0.5), (0.9, 0.7)] { @@ -596,6 +625,83 @@ fn a_uniform_environment_casts_no_shadow() { assert_eq!(casting.data, off.data, "a directionless image casts no shadow"); } +/// #2436 review: a chromatic environment under a coloured light — equally bright red and blue +/// regions on exactly opposite sides of the image cancel to a white light (it casts no +/// shadow), but the red Environment light's tint removes the blue region's pull, so the red +/// one becomes the key and casts the shadow the user asked for, from the red side. +#[test] +fn a_red_light_shadows_from_the_red_region_of_a_chromatic_environment() { + let (mut p, cid) = project(200, 150); + // The floor facing the camera, a sphere in front of it casting shadows only. + 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, 110.0])); + set(l, "materialOptions/baseColor", Value::Color([1.0, 1.0, 1.0, 1.0])); + }); + 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)); + }); + // The Environment Layer: a black background with a red square and an equally bright blue + // one at exactly antipodal image positions (the blue is the point mirror of the red through + // the antipodal map x -> x + w/2, y -> h - y: centres (14.5, 44.5) and (114.5, 105.5) in a + // 200x150 image, 20x20 each) — equal solid-angle weight, opposite pull. The red sits up, + // left and toward the camera; the blue down, right and away from it. + add(&mut p, cid, LayerSource::Shape, |l| { + l.environment = true; + let mut next = 10_000u64; + let ids = &mut build::Ids(&mut next); + // Contents stack like the AE timeline — the first group is on top — so the background + // goes in last, under the red and blue squares (and its full-comp rect keeps the + // layer buffer full-size: a shape layer's buffer otherwise crops to its content). + let red_rect = build::shape_rect(ids, [20.0, 20.0], [-85.5, -30.5], 0.0); + let red_fill = build::shape_fill(ids, [1.0, 0.0, 0.0, 1.0]); + let red = build::shape_group(ids, "Red", vec![red_rect, red_fill]); + let blue_rect = build::shape_rect(ids, [20.0, 20.0], [14.5, 30.5], 0.0); + let blue_fill = build::shape_fill(ids, [0.0, 0.0, 1.0, 1.0]); + let blue = build::shape_group(ids, "Blue", vec![blue_rect, blue_fill]); + let bg_rect = build::shape_rect(ids, [200.0, 150.0], [0.0, 0.0], 0.0); + let bg_fill = build::shape_fill(ids, [0.0, 0.0, 0.0, 1.0]); + let bg = build::shape_group(ids, "BG", vec![bg_rect, bg_fill]); + for g in [red, blue, bg] { + l.props.sub_mut("contents").unwrap().children.push(g.into()); + } + }); + // A white light over the chromatic image: the regions cancel — Cast Shadows changes + // nothing, exactly like a directionless image. + add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |l| { + set(l, "lightOptions/castsShadows", Value::Bool(true)); + }); + let white_casting = render(&p, cid); + let comp = p.comp_mut(cid).unwrap(); + set(comp.layers.iter_mut().find(|l| l.is_light()).unwrap(), "lightOptions/castsShadows", Value::Bool(false)); + let white_off = render(&p, cid); + assert_eq!(white_casting.data, white_off.data, "a white light sees no dominant region in the opposed red and blue"); + // The red light: the tint removes the blue region, the red one shadows the sphere onto the + // floor below and right of it (the shadow falls opposite the red region). + let comp = p.comp_mut(cid).unwrap(); + let light = comp.layers.iter_mut().find(|l| l.is_light()).unwrap(); + set(light, "lightOptions/color", Value::Color([1.0, 0.0, 0.0, 1.0])); + set(light, "lightOptions/castsShadows", Value::Bool(true)); + let img = render(&p, cid); + let chan = |img: &crate::Image, x: usize, y: usize| img.data[y * 200 + x][0]; + let side = chan(&img, 25, 75); + let (mut core, mut cx) = (f32::INFINITY, 0usize); + for y in 100..150 { + for x in 60..170 { + if chan(&img, x, y) < core { + core = chan(&img, x, y); + cx = x; + } + } + } + assert!(side > 0.1, "the floor is lit red: {side}"); + assert!(core < side * 0.65, "the red region shadows the sphere onto the floor: {core} vs {side} at x {cx}"); + assert!(cx > 100, "the shadow is opposite the red region (right of the sphere): {cx}"); +} + /// #2436: the default environment (no Source, no Environment Layer) is a dim studio — a mean /// of 0.3, not the 1.0 that would act like a 100 % white Ambient light and wash the scene out /// (After Effects' default does not) — and directional: its dominant region points up and @@ -613,7 +719,7 @@ fn default_environment_keeps_the_level_and_points_up() { weight += sa; } assert!((mean / weight - 0.3).abs() < 0.01, "dim studio mean: {}", mean / weight); - let (sun, key) = dominant_direction(w, h, &d, 0.0); + let (sun, key) = dominant_direction(w, h, &d, [1.0; 3], 0.0); assert!(sun[1] < -0.5, "the key is up: {sun:?}"); assert!(sun[2] < -0.3, "the key is toward the camera: {sun:?}"); assert!(sun[0].abs() < 0.1, "symmetric left-right: {sun:?}"); diff --git a/docs/agents.md b/docs/agents.md index 42e16c3ca..9effdf18a 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -221,7 +221,10 @@ Things that tripped up a long agent-driven session (a multi-scene 3D piece built like the other lights: Cast Shadows shades the scene from the image's dominant bright region (the default environment's key, or a source image's brightest area; an image without a sufficiently strong dominant direction has none — a uniform one, or opposing bright regions - that cancel — so it casts no shadow) — occluding only that key's share of the light, so the + that cancel — so it casts no shadow) — the region weighted by the light's colour, so a + coloured light shadows from the regions it actually lights (opposing red and blue regions + cancel to a white light, but a red light's shadow comes from the red one) — occluding only + that key's share of the light, so the shadow stays partial like After Effects' area-light shadows, and soft by default (a penumbra of at least 8 pixels or four map texels, plus Shadow Diffusion). It only lights Advanced 3D comps, so creating one (or changing a light's type to Environment) switches a diff --git a/docs/architecture.md b/docs/architecture.md index 76f4c6a84..105eb8736 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -165,7 +165,9 @@ mip chain with the split-sum environment BRDF fit), and percentage-closer-filter (spot: perspective, parallel: orthographic over the scene, point: six cube faces, environment: one orthographic from the image's dominant bright region — none only when the image lacks a sufficiently strong dominant direction (a uniform one, or opposing bright regions that -cancel) — occluding only the image's directional key fraction of +cancel) — the region weighted by the light's colour, so a coloured light shadows from the +regions it actually lights (opposing red and blue regions cancel to a white light, but a red +light's shadow comes from the red one) — occluding only the image's directional key fraction of the light, so the shadow stays partial like After Effects' area-light shadows, with a soft default penumbra of at least eight pixels or four map texels). A Source or Environment Layer that fails to render — a deleted layer, missing footage — lights nothing: no silent fallback