mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 05:27:08 -06:00
Merge pull request #2118 from ChristopherLinnett/fix/issue-2092-camera-focus-crash
fix: bound Classic 3D depth-of-field blur and reject non-finite focus distances (#2092)
This commit is contained in:
@@ -312,14 +312,15 @@ impl Buf {
|
||||
self.offset[0] += l as f64;
|
||||
self.offset[1] += t as f64;
|
||||
}
|
||||
/// Grow by `pad` transparent pixels on every side.
|
||||
/// Grow by `pad` transparent pixels on every side (as clamped by [`Image::effective_pad`]).
|
||||
pub fn pad(&mut self, pad: u32) {
|
||||
if pad == 0 {
|
||||
let eff = self.img.effective_pad(pad);
|
||||
if eff == 0 {
|
||||
return;
|
||||
}
|
||||
self.img = self.img.padded(pad);
|
||||
self.offset[0] += pad as f64;
|
||||
self.offset[1] += pad as f64;
|
||||
self.img = self.img.padded(eff);
|
||||
self.offset[0] += eff as f64;
|
||||
self.offset[1] += eff as f64;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1351,6 +1352,20 @@ mod tests {
|
||||
assert_eq!(out.img.data, img.data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pad_offset_follows_the_clamped_pad() {
|
||||
// Image::padded clamps a hostile pad to keep each side within MAX_PADDED_SIDE; the offset
|
||||
// must move by the pad actually applied, or the content drifts from its layer position.
|
||||
let mut img = Image::new(29_990, 2);
|
||||
img.set(0, 0, [1.0, 0.0, 0.0, 1.0]);
|
||||
let mut buf = Buf { img, offset: [3.0, 4.0], scale: 1.0 };
|
||||
buf.pad(u32::MAX);
|
||||
// Width binds: (MAX_PADDED_SIDE - 29 990) / 2 = 5.
|
||||
assert_eq!(buf.offset, [8.0, 9.0]);
|
||||
let (x, y) = buf.to_px([-3.0, -4.0]);
|
||||
assert_eq!(buf.img.get(x as i64, y as i64), [1.0, 0.0, 0.0, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn dump_registry() {
|
||||
|
||||
@@ -78,6 +78,12 @@ fn set_to_layer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = "camera.setFocusToLayer";
|
||||
let (cid, cam, target) = refs(s, p, c, true)?;
|
||||
let d = focus_depth(s, cid, cam, target.ok_or_else(|| bad(c, "no layer"))?).ok_or_else(|| bad(c, "can't evaluate the layers"))?;
|
||||
// Only a finite depth can be stored: JSON has no infinity or NaN, so the project could no
|
||||
// longer be saved. Extreme but finite positions can still produce one, and `d <= 0.0` is
|
||||
// false for NaN.
|
||||
if !d.is_finite() {
|
||||
return Err(bad(c, "can't measure the distance to the layer (the camera or layer position is out of range)"));
|
||||
}
|
||||
if d <= 0.0 {
|
||||
return Err(bad(c, "the layer is behind the camera"));
|
||||
}
|
||||
|
||||
@@ -421,6 +421,70 @@ fn camera_focus_commands_undo_and_serde() {
|
||||
assert!(s.execute("camera.linkFocusToPoi", json!({})).is_ok());
|
||||
}
|
||||
|
||||
/// Set Focus Distance to Layer: degenerate targets (behind the camera, exactly at it) are clean
|
||||
/// errors, and a depth-of-field frame renders right after the command.
|
||||
#[test]
|
||||
fn camera_set_focus_to_layer_degenerate_targets_and_dof_frame() {
|
||||
let mut s = comp(400, 300);
|
||||
let t = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [t], "switch": "threeD", "value": true})).unwrap();
|
||||
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": cam, "path": "transform/position", "value": [200, 150, -500]})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": cam, "path": "cameraOptions/dof", "value": true})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [cam, t]})).unwrap();
|
||||
// Behind the camera (which now sits at z = −500 looking towards +z).
|
||||
s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200, 150, -1000]})).unwrap();
|
||||
let e = s.execute("camera.setFocusToLayer", json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("behind the camera"), "{e}");
|
||||
// Exactly at the camera: depth 0, still a clean error.
|
||||
s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200, 150, -500]})).unwrap();
|
||||
assert!(s.execute("camera.setFocusToLayer", json!({})).is_err());
|
||||
// In front: the command works, and the depth-of-field frame renders with finite pixels.
|
||||
s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200, 150, 400]})).unwrap();
|
||||
let d = s.execute("camera.setFocusToLayer", json!({})).unwrap()["focusDistance"].as_f64().unwrap();
|
||||
assert!((d - 900.0).abs() < 1e-6, "{d}");
|
||||
// An out-of-focus 3D layer, which must visibly blur, and a 0%-scaled one, whose pixels cover
|
||||
// ~0 screen pixels and so turn an ordinary defocus into a radius of ~1e9 buffer pixels.
|
||||
s.execute("prop.set", json!({"layer": cam, "path": "cameraOptions/aperture", "value": 120})).unwrap();
|
||||
let blur = s.execute("layer.newSolid", json!({"color": "#00ff00", "width": 60, "height": 60})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [blur], "switch": "threeD", "value": true})).unwrap();
|
||||
s.execute("layer.setTransform", json!({"layer": blur, "prop": "position", "value": [200, 150, 1200]})).unwrap();
|
||||
let invis = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 40, "height": 40})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [invis], "switch": "threeD", "value": true})).unwrap();
|
||||
s.execute("layer.setTransform", json!({"layer": invis, "prop": "position", "value": [200, 150, 1600]})).unwrap();
|
||||
s.execute("layer.setTransform", json!({"layer": invis, "prop": "scale", "value": [0.0, 0.0, 0.0]})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let img = s.render(cid, effectcraft_time::Tick::ZERO, Default::default());
|
||||
assert!(img.data.iter().all(|p| p.iter().all(|c| c.is_finite())), "non-finite pixels after setFocusToLayer");
|
||||
// The DoF frame actually blurred (not a no-op blur): the same frame with DoF off differs.
|
||||
s.execute("prop.set", json!({"layer": cam, "path": "cameraOptions/dof", "value": false})).unwrap();
|
||||
let plain = s.render(cid, effectcraft_time::Tick::ZERO, Default::default());
|
||||
assert_ne!(img.data, plain.data, "the out-of-focus layer should have blurred");
|
||||
}
|
||||
|
||||
/// Set Focus Distance to Layer fails cleanly on a depth that isn't finite. Every stored number
|
||||
/// here is finite, but their difference overflows f64 to infinity; JSON has no infinity, so
|
||||
/// storing it would leave a project that can't be saved.
|
||||
#[test]
|
||||
fn camera_set_focus_to_layer_rejects_non_finite_depth() {
|
||||
let mut s = comp(400, 300);
|
||||
let t = s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 50, "height": 50})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [t], "switch": "threeD", "value": true})).unwrap();
|
||||
s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200.0, 150.0, 1e308]})).unwrap();
|
||||
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.setTransform", json!({"layer": cam, "prop": "position", "value": [200.0, 150.0, -1e308]})).unwrap();
|
||||
s.execute("layer.select", json!({"layers": [cam, t]})).unwrap();
|
||||
let before = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap()["value"].as_f64().unwrap();
|
||||
let e = s.execute("camera.setFocusToLayer", json!({})).unwrap_err().to_string();
|
||||
assert!(e.contains("out of range"), "{e}");
|
||||
let after = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap()["value"].as_f64().unwrap();
|
||||
assert!((after - before).abs() < 1e-9, "a non-finite focus distance was written: {after}");
|
||||
// The project still round-trips through serde (a stored infinity would come back as null).
|
||||
let back = roundtrip(&s);
|
||||
let f = back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(cam)).unwrap().props.prop("cameraOptions/focusDistance").unwrap().value.as_f64();
|
||||
assert!((f - before).abs() < 1e-9, "serde focus distance: {f}");
|
||||
}
|
||||
|
||||
/// An id sent as a string (`"98"`, as MCP clients often do) resolves as an id when no name matches.
|
||||
#[test]
|
||||
fn numeric_string_resolves_as_id() {
|
||||
|
||||
@@ -10,8 +10,9 @@ use crate::context::{Enc, GpuImage, Params};
|
||||
|
||||
/// Blur `src` (an unpadded plane buffer) as `d` says. Returns the padded, blurred image.
|
||||
pub(crate) fn apply(e: &mut Enc, src: &GpuImage, d: &PlaneDof) -> Option<GpuImage> {
|
||||
let (w, h) = (src.width + 2 * d.pad, src.height + 2 * d.pad);
|
||||
if !e.g.fits(w + 1, h) || d.radius.len() != w as usize * h as usize || d.levels.is_empty() {
|
||||
// Saturating, so an oversized padding is refused by `fits` below instead of wrapping around.
|
||||
let (w, h) = (src.width.saturating_add(d.pad.saturating_mul(2)), src.height.saturating_add(d.pad.saturating_mul(2)));
|
||||
if !e.g.fits(w.saturating_add(1), h) || d.radius.len() != w as usize * h as usize || d.levels.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let padded = e.image(w, h);
|
||||
|
||||
@@ -23,6 +23,11 @@ pub use warp::{Sampling, WarpOpts, accumulate_warp, composite_warp, composite_wa
|
||||
|
||||
pub type Px = [f32; 4];
|
||||
|
||||
/// Maximum padded side in pixels — mirrors the project crate's `limits::MAX_SIZE` (30 000;
|
||||
/// raster sits below it in the layering and may not depend on it, so the two must stay equal).
|
||||
/// A padded side beyond the largest supported comp side has no render use.
|
||||
pub const MAX_PADDED_SIDE: u32 = 30_000;
|
||||
|
||||
/// Clamp (colour ≤ alpha ≤ 1, NaN → 0) and round half up to `levels` steps (`inv` = 1 / levels):
|
||||
/// one pixel of an 8/16 bpc buffer. Values are non-negative after the clamp, so the integer
|
||||
/// conversion rounds like `round()` and vectorises.
|
||||
@@ -161,13 +166,28 @@ impl Image {
|
||||
}
|
||||
o
|
||||
}
|
||||
/// Copy with `pad` transparent pixels on every side.
|
||||
/// The pad [`Image::padded`] applies for a requested `pad`: clamped so neither output side
|
||||
/// exceeds [`MAX_PADDED_SIDE`]. At most `(MAX_PADDED_SIDE - side) / 2`, so
|
||||
/// `side + 2 · pad ≤ MAX_PADDED_SIDE` and overflow is unreachable by construction (a side
|
||||
/// already past the cap gets pad 0). Callers that track where the pixels went (a buffer
|
||||
/// offset) must move by this, not by the requested pad.
|
||||
pub fn effective_pad(&self, pad: u32) -> u32 {
|
||||
pad.min(MAX_PADDED_SIDE.saturating_sub(self.width) / 2).min(MAX_PADDED_SIDE.saturating_sub(self.height) / 2)
|
||||
}
|
||||
/// Copy with [`Image::effective_pad`] transparent pixels on every side.
|
||||
///
|
||||
/// The clamp keeps the output within the largest image the engine supports (a max-size comp
|
||||
/// frame), so an unbounded pad cannot overflow or size a buffer past that. It is not a memory
|
||||
/// budget: callers that pad by a derived, possibly large amount bound the area themselves
|
||||
/// (`DOF_PAD_BUDGET` in render's `three_d/compose.rs`, `fits()` on the GPU path) or reject an
|
||||
/// oversized pad first (`padded_size`); legitimate bounded pads are byte-identical.
|
||||
pub fn padded(&self, pad: u32) -> Image {
|
||||
let mut out = Image::new(self.width + 2 * pad, self.height + 2 * pad);
|
||||
let eff = self.effective_pad(pad);
|
||||
let mut out = Image::new(self.width + 2 * eff, self.height + 2 * eff);
|
||||
let ow = out.width as usize;
|
||||
for y in 0..self.height as usize {
|
||||
let src = &self.data[y * self.width as usize..(y + 1) * self.width as usize];
|
||||
let o = (y + pad as usize) * ow + pad as usize;
|
||||
let o = (y + eff as usize) * ow + eff as usize;
|
||||
out.data[o..o + self.width as usize].copy_from_slice(src);
|
||||
}
|
||||
out
|
||||
@@ -384,4 +404,29 @@ mod tests {
|
||||
assert!((0.0..1.0).contains(&n));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn padded_bounds_hostile_pad_at_the_api() {
|
||||
// Fail-before (#2092 class): before the clamp, a caller passing a hostile pad sized an
|
||||
// oversized buffer here — the very OOM this guard exists to stop, now caught in padded().
|
||||
// (Wide-flat source so the clamped-to-cap output stays a test-sized allocation.)
|
||||
let mut img = Image::new(29_990, 2);
|
||||
img.set(29_989, 1, [1.0, 0.0, 0.0, 1.0]);
|
||||
let out = img.padded(u32::MAX);
|
||||
// Width binds first: eff = (MAX_PADDED_SIDE - 29 990) / 2 = 5.
|
||||
assert_eq!((out.width, out.height), (MAX_PADDED_SIDE, 12));
|
||||
// The source lands intact at the clamped offset, not the requested one.
|
||||
assert_eq!(out.get(29_994, 6), [1.0, 0.0, 0.0, 1.0]);
|
||||
assert_eq!(out.get(0, 0), [0.0; 4]); // clamped pad region stays transparent
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn padded_leaves_legit_pads_untouched() {
|
||||
// Anti-over-clamp: an ordinary pad must round exactly, byte for byte.
|
||||
let mut img = Image::new(8, 6);
|
||||
img.set(3, 2, [1.0, 0.0, 0.0, 1.0]);
|
||||
let out = img.padded(4);
|
||||
assert_eq!((out.width, out.height), (16, 14));
|
||||
assert_eq!(out.get(7, 6), [1.0, 0.0, 0.0, 1.0]); // (3+4, 2+4)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,16 +289,24 @@ pub fn progressive_blur(img: &Image, iris: &Iris, radius: &[f32]) -> Image {
|
||||
return bokeh_blur(img, iris, levels[0] as f64);
|
||||
}
|
||||
let (lo, hi, n) = (levels[0], levels[levels.len() - 1], levels.len());
|
||||
let blurred: Vec<Image> = levels.par_iter().map(|&r| bokeh_blur(img, iris, r as f64)).collect();
|
||||
// A pixel blends only the two levels around its radius, so the levels are made in order and
|
||||
// only that pair is held: peak memory stays a few images whatever the level count, rather
|
||||
// than one image per level. Each level's blur is parallel over its rows.
|
||||
let mut out = Image::new(img.width, img.height);
|
||||
out.data.par_iter_mut().enumerate().for_each(|(i, o)| {
|
||||
let r = radius[i];
|
||||
let f = ((r - lo) / (hi - lo) * (n - 1) as f32).clamp(0.0, (n - 1) as f32);
|
||||
let k = (f.floor() as usize).min(n - 2);
|
||||
let t = f - k as f32;
|
||||
let (a, b) = (blurred[k].data[i], blurred[k + 1].data[i]);
|
||||
*o = [0, 1, 2, 3].map(|c| a[c] + (b[c] - a[c]) * t);
|
||||
});
|
||||
let mut below = bokeh_blur(img, iris, lo as f64);
|
||||
for (k, &r) in levels.iter().enumerate().skip(1) {
|
||||
let above = bokeh_blur(img, iris, r as f64);
|
||||
out.data.par_iter_mut().enumerate().for_each(|(i, o)| {
|
||||
let f = ((radius[i] - lo) / (hi - lo) * (n - 1) as f32).clamp(0.0, (n - 1) as f32);
|
||||
let kk = (f.floor() as usize).min(n - 2);
|
||||
if kk + 1 == k {
|
||||
let t = f - kk as f32;
|
||||
let (a, b) = (below.data[i], above.data[i]);
|
||||
*o = [0, 1, 2, 3].map(|c| a[c] + (b[c] - a[c]) * t);
|
||||
}
|
||||
});
|
||||
below = above;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,8 @@ pub struct Dof {
|
||||
impl Dof {
|
||||
/// Circle-of-confusion diameter (comp pixels) for a point at camera depth `z`.
|
||||
pub fn coc(&self, z: f64) -> f64 {
|
||||
if z <= NEAR || self.focus <= 0.0 {
|
||||
// No defocus can be measured against a non-finite focus or depth, so it counts as in focus.
|
||||
if !z.is_finite() || z <= NEAR || !self.focus.is_finite() || self.focus <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
self.aperture * (z - self.focus).abs() / z * self.blur_level
|
||||
@@ -360,22 +361,27 @@ pub fn layer_camera(ctx: &EvalCtx, cam: &Layer) -> CameraState {
|
||||
let (eye, fwd, down) = layer_frame(ctx, cam);
|
||||
let opts = cam.props.sub("cameraOptions");
|
||||
let zoom = opts.map(|g| ctx.f(cam, g, "zoom", 1000.0)).unwrap_or(1000.0).max(1e-3);
|
||||
let dof = opts.filter(|g| ctx.b(cam, g, "dof")).map(|g| Dof {
|
||||
focus: ctx.f(cam, g, "focusDistance", zoom),
|
||||
aperture: ctx.f(cam, g, "aperture", default_aperture(w)).max(0.0),
|
||||
blur_level: ctx.f(cam, g, "blurLevel", 100.0).max(0.0) / 100.0,
|
||||
iris: super::bokeh::Iris {
|
||||
sides: super::bokeh::Iris::sides_of_shape(ctx.e(cam, g, "irisShape")),
|
||||
rotation: ctx.f(cam, g, "irisRotation", 0.0),
|
||||
roundness: ctx.f(cam, g, "irisRoundness", 0.0) / 100.0,
|
||||
aspect: ctx.f(cam, g, "irisAspectRatio", 1.0).max(0.01),
|
||||
fringe: ctx.f(cam, g, "irisDiffractionFringe", 0.0).max(0.0) / 100.0,
|
||||
},
|
||||
highlight: super::bokeh::Highlight {
|
||||
gain: ctx.f(cam, g, "highlightGain", 0.0).clamp(0.0, 100.0) / 100.0,
|
||||
threshold: ctx.f(cam, g, "highlightThreshold", 255.0).clamp(0.0, 255.0) / 255.0,
|
||||
saturation: ctx.f(cam, g, "highlightSaturation", 0.0).clamp(0.0, 100.0) / 100.0,
|
||||
},
|
||||
let dof = opts.filter(|g| ctx.b(cam, g, "dof")).map(|g| {
|
||||
// A non-finite Focus Distance would make every blur radius non-finite; use the value the
|
||||
// property defaults to (the zoom plane) instead.
|
||||
let focus = ctx.f(cam, g, "focusDistance", zoom);
|
||||
Dof {
|
||||
focus: if focus.is_finite() { focus } else { zoom },
|
||||
aperture: ctx.f(cam, g, "aperture", default_aperture(w)).max(0.0),
|
||||
blur_level: ctx.f(cam, g, "blurLevel", 100.0).max(0.0) / 100.0,
|
||||
iris: super::bokeh::Iris {
|
||||
sides: super::bokeh::Iris::sides_of_shape(ctx.e(cam, g, "irisShape")),
|
||||
rotation: ctx.f(cam, g, "irisRotation", 0.0),
|
||||
roundness: ctx.f(cam, g, "irisRoundness", 0.0) / 100.0,
|
||||
aspect: ctx.f(cam, g, "irisAspectRatio", 1.0).max(0.01),
|
||||
fringe: ctx.f(cam, g, "irisDiffractionFringe", 0.0).max(0.0) / 100.0,
|
||||
},
|
||||
highlight: super::bokeh::Highlight {
|
||||
gain: ctx.f(cam, g, "highlightGain", 0.0).clamp(0.0, 100.0) / 100.0,
|
||||
threshold: ctx.f(cam, g, "highlightThreshold", 255.0).clamp(0.0, 255.0) / 255.0,
|
||||
saturation: ctx.f(cam, g, "highlightSaturation", 0.0).clamp(0.0, 100.0) / 100.0,
|
||||
},
|
||||
}
|
||||
});
|
||||
CameraState { view: basis_view(eye, fwd, down), eye, zoom, ortho: false, dof }
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ use std::sync::Arc;
|
||||
use effectcraft_color::{BlendMode, blend_pixel};
|
||||
use effectcraft_effects::Buf;
|
||||
use effectcraft_geom::{Mat3, Mat4, Vec3, vec3};
|
||||
use effectcraft_project::{Layer, LightKind, MatteKind};
|
||||
use effectcraft_project::{Layer, LightKind, MatteKind, limits};
|
||||
use effectcraft_raster::{Image, Px, hash_noise};
|
||||
use effectcraft_time::Tick;
|
||||
use rayon::prelude::*;
|
||||
@@ -287,12 +287,45 @@ fn prepare_with<'a>(r: &Renderer, ctx: &EvalCtx<'_>, layer: &'a Layer, order: us
|
||||
}
|
||||
|
||||
/// Blur radius (buffer pixels) of every buffer pixel of a plane at `world` (layer → world), from
|
||||
/// its camera depth: the circle of confusion over the on-screen size of one layer pixel there.
|
||||
fn dof_radii(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf) -> Vec<f32> {
|
||||
dof_radii_at(cam, dof, world, (buf.img.width as usize, buf.img.height as usize), buf.offset, buf.scale)
|
||||
/// its camera depth: the circle of confusion over the on-screen size of one layer pixel there, at
|
||||
/// most `cap` ([`dof_radius_cap`]).
|
||||
fn dof_radii(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf, cap: f64) -> Vec<f32> {
|
||||
dof_radii_at(cam, dof, world, (buf.img.width as usize, buf.img.height as usize), buf.offset, buf.scale, cap)
|
||||
}
|
||||
|
||||
fn dof_radii_at(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, (w, h): (usize, usize), offset: [f64; 2], scale: f64) -> Vec<f32> {
|
||||
/// Pixels a padded blur buffer may have: the ~16 million layer buffers are held to
|
||||
/// ([`crate::Renderer::raster_scale`]), or twice the buffer when that is larger, so a big buffer
|
||||
/// can still be blurred. The blur holds a few images of this size at once whatever its level
|
||||
/// count ([`super::bokeh::progressive_blur`]), so blurring a small plane costs no more memory than
|
||||
/// blurring a large layer.
|
||||
const DOF_PAD_BUDGET: f64 = 16.0e6;
|
||||
|
||||
/// Comp pixels per buffer pixel below which a plane isn't blurred. Such a plane covers almost no
|
||||
/// screen area (a 0%-scaled layer, one at an extreme distance), while its radius in buffer
|
||||
/// pixels grows without bound as it shrinks: blurring it would cost the most for the least.
|
||||
const DOF_MIN_SCREEN_SCALE: f64 = 1e-3;
|
||||
|
||||
/// The largest blur radius (buffer pixels) of a `w`×`h` buffer: the one whose padded copy just
|
||||
/// fits [`DOF_PAD_BUDGET`]. The circle of confusion has no bound of its own (a focus distance far
|
||||
/// beyond the scene, a near-zero layer scale), so memory sets the limit; ordinary bokeh stays far
|
||||
/// below it and is never clamped.
|
||||
pub(super) fn dof_radius_cap(dof: &super::camera::Dof, w: u32, h: u32) -> f64 {
|
||||
let (w, h) = (w as f64, h as f64);
|
||||
let budget = DOF_PAD_BUDGET.max(2.0 * w * h);
|
||||
// (w + 2p)·(h + 2p) = budget, solved for the padding p.
|
||||
let pad = (((w - h) * (w - h) + 4.0 * budget).sqrt() - (w + h)) / 4.0;
|
||||
((pad - 2.0) / iris_stretch(dof)).max(0.0)
|
||||
}
|
||||
|
||||
pub(super) fn dof_radii_at(
|
||||
cam: &CameraState,
|
||||
dof: &super::camera::Dof,
|
||||
world: Mat4,
|
||||
(w, h): (usize, usize),
|
||||
offset: [f64; 2],
|
||||
scale: f64,
|
||||
cap: f64,
|
||||
) -> Vec<f32> {
|
||||
let s = scale.max(1e-9);
|
||||
let sx = world.apply_vec(vec3(1.0, 0.0, 0.0)).length().max(1e-9);
|
||||
let at = |x: f64, y: f64| cam.depth(world.apply(vec3((x - offset[0]) / s, (y - offset[1]) / s, 0.0)));
|
||||
@@ -306,31 +339,48 @@ fn dof_radii_at(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, (w, h)
|
||||
if z <= super::camera::NEAR {
|
||||
continue;
|
||||
}
|
||||
// coc · ½ / (zoom / z · sx) · scale
|
||||
*o = (dof.coc(z) * 0.5 * z / (cam.zoom * sx) * s) as f32;
|
||||
// coc · ½ / (zoom / z · sx) · scale. Infinity clamps to the cap like any large radius,
|
||||
// so the blur keeps growing with defocus; only NaN, which has no defocus, stays sharp.
|
||||
let r = dof.coc(z) * 0.5 * z / (cam.zoom * sx) * s;
|
||||
*o = if r.is_nan() || cam.zoom * sx / (z * s) < DOF_MIN_SCREEN_SCALE { 0.0 } else { r.min(cap) as f32 };
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
/// How much the iris' aspect ratio stretches a blur radius along its longer axis.
|
||||
fn iris_stretch(dof: &super::camera::Dof) -> f64 {
|
||||
let a = dof.iris.aspect.clamp(0.01, 100.0).sqrt();
|
||||
a.max(1.0 / a)
|
||||
}
|
||||
|
||||
/// The padding [`dof_blur`] gives a buffer whose largest blur radius is `rmax`.
|
||||
fn dof_pad(dof: &super::camera::Dof, rmax: f64) -> u32 {
|
||||
let a = dof.iris.aspect.clamp(0.01, 100.0).sqrt().max(1.0 / dof.iris.aspect.clamp(0.01, 100.0).sqrt());
|
||||
(rmax * a).ceil() as u32 + 2
|
||||
((rmax * iris_stretch(dof)).ceil() as u32).saturating_add(2)
|
||||
}
|
||||
|
||||
/// The size of `img` padded by `pad` on every side, or `None` past [`limits::MAX_SIZE`] on a side:
|
||||
/// [`DOF_PAD_BUDGET`] bounds the area, so a long thin buffer could still outgrow a side.
|
||||
fn padded_size(img: &Image, pad: u32) -> Option<(u32, u32)> {
|
||||
let grow = |n: u32| n.saturating_add(pad.saturating_mul(2));
|
||||
let (w, h) = (grow(img.width), grow(img.height));
|
||||
(w <= limits::MAX_SIZE && h <= limits::MAX_SIZE).then_some((w, h))
|
||||
}
|
||||
|
||||
/// [`dof_blur`] planned for an accelerator: the padding, radius map and levels, and the padded
|
||||
/// buffer's layer-space bounds. `None` when nothing blurs.
|
||||
fn dof_plan(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf) -> Option<(PlaneDof, [f64; 4])> {
|
||||
let radii = dof_radii(cam, dof, world, buf);
|
||||
let cap = dof_radius_cap(dof, buf.img.width, buf.img.height);
|
||||
let radii = dof_radii(cam, dof, world, buf, cap);
|
||||
let rmax = radii.iter().copied().fold(0.0f32, f32::max) as f64;
|
||||
if rmax <= 0.3 {
|
||||
return None;
|
||||
}
|
||||
let pad = dof_pad(dof, rmax);
|
||||
let (w, h) = (buf.img.width as usize + 2 * pad as usize, buf.img.height as usize + 2 * pad as usize);
|
||||
let (wp, hp) = padded_size(&buf.img, pad)?;
|
||||
let (w, h) = (wp as usize, hp as usize);
|
||||
let offset = [buf.offset[0] + pad as f64, buf.offset[1] + pad as f64];
|
||||
let radius = dof_radii_at(cam, dof, world, (w, h), offset, buf.scale);
|
||||
let radius = dof_radii_at(cam, dof, world, (w, h), offset, buf.scale, cap);
|
||||
let levels = super::bokeh::blur_levels(&radius)?;
|
||||
let s = buf.scale.max(1e-9);
|
||||
let bounds = [-offset[0] / s, -offset[1] / s, (w as f64 - offset[0]) / s, (h as f64 - offset[1]) / s];
|
||||
@@ -339,16 +389,20 @@ fn dof_plan(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf)
|
||||
|
||||
/// Depth of field for one plane: highlights boosted, then a bokeh-shaped blur by depth.
|
||||
fn dof_blur(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: Arc<Buf>) -> Arc<Buf> {
|
||||
let radii = dof_radii(cam, dof, world, &buf);
|
||||
let cap = dof_radius_cap(dof, buf.img.width, buf.img.height);
|
||||
let radii = dof_radii(cam, dof, world, &buf, cap);
|
||||
let rmax = radii.iter().copied().fold(0.0f32, f32::max) as f64;
|
||||
if rmax <= 0.3 {
|
||||
return buf;
|
||||
}
|
||||
let pad = dof_pad(dof, rmax);
|
||||
if padded_size(&buf.img, pad).is_none() {
|
||||
return buf;
|
||||
}
|
||||
let mut padded = buf.img.padded(pad);
|
||||
super::bokeh::boost_highlights(&mut padded, &dof.highlight);
|
||||
let pbuf = Buf { img: padded, offset: [buf.offset[0] + pad as f64, buf.offset[1] + pad as f64], scale: buf.scale };
|
||||
let pr = dof_radii(cam, dof, world, &pbuf);
|
||||
let pr = dof_radii(cam, dof, world, &pbuf, cap);
|
||||
// The (cached, shared) layer buffer stays untouched; the blurred copy is per frame.
|
||||
Arc::new(Buf { img: super::bokeh::progressive_blur(&pbuf.img, &dof.iris, &pr), offset: pbuf.offset, scale: pbuf.scale })
|
||||
}
|
||||
|
||||
@@ -13,10 +13,15 @@ const W: u32 = 200;
|
||||
const H: u32 = 100;
|
||||
|
||||
fn setup() -> (Project, ItemId, Comp) {
|
||||
setup_sized(W, H)
|
||||
}
|
||||
|
||||
/// [`setup`] with a `w`×`h` comp.
|
||||
fn setup_sized(w: u32, h: u32) -> (Project, ItemId, Comp) {
|
||||
let mut p = Project::default();
|
||||
// Exact float maths; 8/16 bpc quantisation has its own tests (tests_color).
|
||||
p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32;
|
||||
let comp = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
|
||||
let comp = Comp::new(w, h, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
|
||||
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
|
||||
(p, cid, comp)
|
||||
}
|
||||
@@ -374,7 +379,7 @@ fn shadow_diffusion_is_a_soft_ramp_not_copies() {
|
||||
// ---------------------------------------------------------------- cameras
|
||||
|
||||
fn camera(p: &mut Project, comp: &Comp) -> Layer {
|
||||
build::layer(p, comp, "Camera", LayerSource::Camera, (W, H), None)
|
||||
build::layer(p, comp, "Camera", LayerSource::Camera, (comp.width, comp.height), None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -450,9 +455,155 @@ fn depth_of_field_blurs_out_of_focus_layers() {
|
||||
assert!(b.get(66, 50)[3] > 0.01);
|
||||
}
|
||||
|
||||
/// A non-finite Focus Distance doesn't reach the blur: the camera focuses on the property's
|
||||
/// default plane (`zoom`) instead.
|
||||
#[test]
|
||||
fn dof_falls_back_when_focus_distance_is_not_finite() {
|
||||
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
let (mut p, cid, comp) = setup();
|
||||
let mut cam = camera(&mut p, &comp);
|
||||
set(&mut cam, "cameraOptions/dof", Value::Bool(true));
|
||||
set(&mut cam, "cameraOptions/aperture", Value::Scalar(60.0));
|
||||
set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(bad));
|
||||
let s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 60, 60, [100.0, 50.0, 0.0]);
|
||||
push(&mut p, cid, cam);
|
||||
push(&mut p, cid, s);
|
||||
let cp = p.comp(cid).unwrap();
|
||||
let ctx = crate::EvalCtx::new(&p, cid, cp, Tick::ZERO);
|
||||
let cs = crate::three_d::camera::layer_camera(&ctx, &cp.layers[0]);
|
||||
let f = cs.dof.unwrap().focus;
|
||||
assert!((f - zoom()).abs() < 1e-9, "{bad}: evaluated to {f}, expected the fallback to zoom {}", zoom());
|
||||
// The z = 0 plane is in focus: hard edge, finite pixels.
|
||||
let img = render(&p, cid);
|
||||
assert!(img.get(71, 50)[3] > 0.99 && img.get(69, 50)[3] < 0.01, "{bad}: the edge should be hard");
|
||||
assert!(img.data.iter().all(|px| px.iter().all(|c| c.is_finite())), "{bad}: non-finite pixels");
|
||||
}
|
||||
}
|
||||
|
||||
/// The Classic 3D blur stays bounded for degenerate inputs: a 0%-scaled 3D layer off the focal
|
||||
/// plane (e.g. the first frame of a scale-in) or a huge but finite Focus Distance asks for a
|
||||
/// radius of billions of buffer pixels, whose padded buffer can't be allocated.
|
||||
#[test]
|
||||
fn dof_blur_radius_is_bounded_for_degenerate_inputs() {
|
||||
let scene = |focus: f64, scale: f64| {
|
||||
let (mut p, cid, comp) = setup();
|
||||
let mut cam = camera(&mut p, &comp);
|
||||
set(&mut cam, "cameraOptions/dof", Value::Bool(true));
|
||||
set(&mut cam, "cameraOptions/aperture", Value::Scalar(60.0));
|
||||
set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(focus));
|
||||
let mut s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 60, 60, [100.0, 50.0, 0.0]);
|
||||
set(&mut s, "transform/scale", Value::Vec3([scale, scale, 100.0]));
|
||||
push(&mut p, cid, cam);
|
||||
push(&mut p, cid, s);
|
||||
render(&p, cid)
|
||||
};
|
||||
let finite = |img: &effectcraft_raster::Image| img.data.iter().all(|px| px.iter().all(|c| c.is_finite()));
|
||||
// A 0%-scaled layer 10 px off the focal plane: an ordinary defocus times 1/sx (sx = 1e-9).
|
||||
assert!(finite(&scene(zoom() + 10.0, 0.0)), "zero-scaled: non-finite pixels");
|
||||
// Huge but finite Focus Distances (typed in, or a large expression result): the blur is as
|
||||
// large as the buffer budget allows, never refused (which would render the layer sharp).
|
||||
// 1e307 overflows the circle of confusion itself to infinity: still the largest blur.
|
||||
for focus in [1.0e12, 1.0e307] {
|
||||
let img = scene(focus, 100.0);
|
||||
assert!(finite(&img), "focus {focus:e}: non-finite pixels");
|
||||
assert!(img.get(100, 50)[3] < 0.5, "focus {focus:e}: the layer should be spread out, not sharp ({})", img.get(100, 50)[3]);
|
||||
}
|
||||
}
|
||||
|
||||
/// The blur radius map of a plane: a 0%-scaled plane is degenerate on screen and isn't blurred;
|
||||
/// any other radius stays within the buffer budget's cap.
|
||||
#[test]
|
||||
fn dof_radii_skip_degenerate_planes_and_respect_the_cap() {
|
||||
let radii = |focus: f64, scale: f64| {
|
||||
let (mut p, cid, comp) = setup();
|
||||
let mut cam = camera(&mut p, &comp);
|
||||
set(&mut cam, "cameraOptions/dof", Value::Bool(true));
|
||||
set(&mut cam, "cameraOptions/aperture", Value::Scalar(60.0));
|
||||
set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(focus));
|
||||
let mut s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 60, 60, [100.0, 50.0, 0.0]);
|
||||
set(&mut s, "transform/scale", Value::Vec3([scale, scale, 100.0]));
|
||||
push(&mut p, cid, cam);
|
||||
push(&mut p, cid, s);
|
||||
let cp = p.comp(cid).unwrap();
|
||||
let ctx = crate::EvalCtx::new(&p, cid, cp, Tick::ZERO);
|
||||
let cs = super::camera::layer_camera(&ctx, &cp.layers[0]);
|
||||
let dof = cs.dof.unwrap();
|
||||
let cap = super::compose::dof_radius_cap(&dof, 60, 60);
|
||||
(super::compose::dof_radii_at(&cs, &dof, ctx.world_matrix(&cp.layers[1]), (60, 60), [0.0; 2], 1.0, cap), cap)
|
||||
};
|
||||
let (r, _) = radii(zoom() + 10.0, 0.0);
|
||||
assert!(r.iter().all(|&r| r == 0.0), "a 0%-scaled plane is not blurred");
|
||||
let (r, cap) = radii(1.0e12, 100.0);
|
||||
assert!(cap > 1000.0, "a 60×60 buffer may blur by well over 1000 px: {cap}");
|
||||
assert!(r.iter().all(|&r| r as f64 <= cap && r as f64 > cap - 1.0), "huge focus: every radius at the cap {cap}");
|
||||
}
|
||||
|
||||
/// Two layers that look the same on screen (400×100 at 100%, 1600×400 at 25%) at the same depth
|
||||
/// blur by the same amount on screen: the radius bound must not depend on the layer's scale.
|
||||
#[test]
|
||||
fn dof_blur_is_independent_of_layer_scale() {
|
||||
let ramp = |w: u32, h: u32, scale: f64| {
|
||||
let (mut p, cid, comp) = setup_sized(800, 400);
|
||||
let zoom = 800.0 * 50.0 / 36.0;
|
||||
let mut cam = camera(&mut p, &comp);
|
||||
set(&mut cam, "cameraOptions/dof", Value::Bool(true));
|
||||
set(&mut cam, "cameraOptions/aperture", Value::Scalar(200.0));
|
||||
// coc = aperture·|z − f| / z: a 40 px comp radius on the z = 0 plane (depth = zoom).
|
||||
set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(zoom + 2.0 * 40.0 * zoom / 200.0));
|
||||
let mut s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], w, h, [400.0, 200.0, 0.0]);
|
||||
set(&mut s, "transform/scale", Value::Vec3([scale, scale, 100.0]));
|
||||
push(&mut p, cid, cam);
|
||||
push(&mut p, cid, s);
|
||||
let img = render(&p, cid);
|
||||
soft_count(&img, (0..400).map(|x| (x, 200)))
|
||||
};
|
||||
let (full, quarter) = (ramp(400, 100, 100.0), ramp(1600, 400, 25.0));
|
||||
assert!(full > 60, "the out-of-focus edge should be soft: {full}");
|
||||
assert!(full.abs_diff(quarter) <= 2, "same on-screen layer, same depth: {full} px at 100% vs {quarter} px at 25%");
|
||||
}
|
||||
|
||||
/// The same frame at Full and Half resolution shows the same blur (in comp pixels). The radius
|
||||
/// (~100 px) is large so that any bound that depends on the resolution would show.
|
||||
#[test]
|
||||
fn dof_blur_matches_across_render_resolutions() {
|
||||
let (mut p, cid, comp) = setup_sized(800, 400);
|
||||
let zoom = 800.0 * 50.0 / 36.0;
|
||||
let mut cam = camera(&mut p, &comp);
|
||||
set(&mut cam, "cameraOptions/dof", Value::Bool(true));
|
||||
set(&mut cam, "cameraOptions/aperture", Value::Scalar(200.0));
|
||||
set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(2.0 * zoom));
|
||||
let s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 400, 100, [400.0, 200.0, 0.0]);
|
||||
push(&mut p, cid, cam);
|
||||
push(&mut p, cid, s);
|
||||
let ramp = |scale: f64| {
|
||||
let img = render_frame(&p, cid, Tick::ZERO, scale);
|
||||
let (w, h) = (img.width as i64, img.height as i64);
|
||||
soft_count(&img, (0..w / 2).map(|x| (x, h / 2))) as f64 / scale
|
||||
};
|
||||
let (full, half) = (ramp(1.0), ramp(0.5));
|
||||
assert!(full > 150.0, "a ~100 px radius must not be clamped: {full} comp px");
|
||||
assert!((full - half).abs() <= 4.0, "Full {full} vs Half {half} comp px");
|
||||
}
|
||||
|
||||
/// `Dof::coc` treats a non-finite focus or depth as in focus.
|
||||
#[test]
|
||||
fn dof_coc_is_zero_for_non_finite_inputs() {
|
||||
let d = |focus: f64| super::camera::Dof { focus, aperture: 50.0, blur_level: 1.0, iris: Default::default(), highlight: Default::default() };
|
||||
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
|
||||
assert_eq!(d(bad).coc(500.0), 0.0, "focus {bad}");
|
||||
assert_eq!(d(400.0).coc(bad), 0.0, "depth {bad}");
|
||||
}
|
||||
assert!(d(400.0).coc(500.0) > 0.0);
|
||||
}
|
||||
|
||||
/// Pixels among `pts` whose alpha is strictly between 0.05 and 0.95.
|
||||
fn soft_count(img: &effectcraft_raster::Image, pts: impl Iterator<Item = (i64, i64)>) -> usize {
|
||||
pts.filter(|&(x, y)| (0.05..0.95).contains(&img.get(x, y)[3])).count()
|
||||
}
|
||||
|
||||
/// Pixels in column `x` whose alpha is strictly between 0.05 and 0.95.
|
||||
fn soft_px(img: &effectcraft_raster::Image, x: i64) -> usize {
|
||||
(0..img.height as i64).filter(|&y| (0.05..0.95).contains(&img.get(x, y)[3])).count()
|
||||
soft_count(img, (0..img.height as i64).map(|y| (x, y)))
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user