diff --git a/crates/gpu/src/bokeh.rs b/crates/gpu/src/bokeh.rs new file mode 100644 index 000000000..db7ca9f7e --- /dev/null +++ b/crates/gpu/src/bokeh.rs @@ -0,0 +1,59 @@ +//! Classic 3D depth of field on the GPU (`bokeh_*` kernels): the plane buffer padded, its +//! highlights boosted, row prefix sums, then one span gather per blur level +//! ([`effectcraft_render::three_d::bokeh::kernel_spans`]) accumulated with each pixel's level +//! weight — the CPU's `progressive_blur` step for step. + +use effectcraft_render::three_d::PlaneDof; +use effectcraft_render::three_d::bokeh::kernel_spans; + +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 { + 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() { + return None; + } + let padded = e.image(w, h); + e.copy_into(src, &padded, d.pad, d.pad); + let boosted = if d.highlight.gain > 0.0 { + let out = e.image(w, h); + let mut p = Params::default(); + p.f[0] = [d.highlight.gain as f32, d.highlight.threshold as f32, d.highlight.saturation as f32, 0.0]; + e.pixels("bokeh_boost", &p, &padded, None, &out, None); + out + } else { + padded + }; + let prefix = e.image(w + 1, h); + e.dispatch("bokeh_prefix", &Params::default(), &boosted, None, &prefix, None, (h.div_ceil(64), 1)); + // Radius map, then every level's spans. + let mut data: Vec = d.radius.as_ref().clone(); + let mut levels = vec![]; + for &r in &d.levels { + let (kernels, norm) = kernel_spans(&d.iris, r as f64).unwrap_or_else(|| (vec![(1.0, vec![(0, -0.5, 0.5)])], 1.0)); + let off = data.len() as u32; + let mut count = 0u32; + for (wt, spans) in &kernels { + for &(dy, x0, x1) in spans { + data.extend_from_slice(&[dy as f32, x0 as f32, x1 as f32, *wt]); + count += 1; + } + } + levels.push((off, count, norm)); + } + let buf = e.data(&data); + let n = d.levels.len() as u32; + let (lo, hi) = (d.levels[0], d.levels[d.levels.len() - 1]); + let mut acc = e.image(w, h); + for (k, (off, count, norm)) in levels.into_iter().enumerate() { + let out = e.image(w, h); + let mut p = Params::default(); + p.u[0] = [off, count, k as u32, n]; + p.u[1] = [u32::from(k == 0), 0, 0, 0]; + p.f[0] = [1.0 / norm, lo, hi, 0.0]; + e.pixels("bokeh_gather", &p, &prefix, Some(&acc), &out, Some(&buf)); + acc = out; + } + Some(acc) +} diff --git a/crates/gpu/src/classic3d.rs b/crates/gpu/src/classic3d.rs index 0c72dd601..99d429f95 100644 --- a/crates/gpu/src/classic3d.rs +++ b/crates/gpu/src/classic3d.rs @@ -136,11 +136,18 @@ pub(crate) fn draw_run(e: &mut Enc, run: &Run3d, canvas: &GpuImage) -> Option = Vec::with_capacity(run.planes.len()); for p in &run.planes { - texs.push((e.g.upload_buf(&p.buf)?, p.buf.offset)); + let up = e.g.upload_buf(&p.buf)?; + match &p.dof { + Some(d) => { + let pad = d.pad as f64; + texs.push((crate::bokeh::apply(e, &up, d)?, [p.buf.offset[0] + pad, p.buf.offset[1] + pad])); + } + None => texs.push((up, p.buf.offset)), + } } let sizes: Vec<(u32, u32)> = texs.iter().map(|(t, _)| (t.width, t.height)).collect(); let (aw, ah, pos) = pack(&sizes, e.g.max_dim)?; diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index d6f388831..cb90fd9be 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -27,6 +27,9 @@ const ENTRIES: &[&str] = &[ "shadow_combine", "pointwise", "adjust_mix", + "bokeh_boost", + "bokeh_prefix", + "bokeh_gather", ]; /// Entry points that also bind group 1 (four read-only storage buffers; see diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index bf47e3c58..0e3eb0e09 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -28,6 +28,7 @@ //! egui-wgpu to draw. mod adv3d; +mod bokeh; mod classic3d; mod context; mod effects; diff --git a/crates/gpu/src/shaders/kernels.wgsl b/crates/gpu/src/shaders/kernels.wgsl index 443abcf9c..f471a86b8 100644 --- a/crates/gpu/src/shaders/kernels.wgsl +++ b/crates/gpu/src/shaders/kernels.wgsl @@ -844,3 +844,118 @@ fn adjust_mix(@builtin(global_invocation_id) gid: vec3) { let k = data[(gid.y * P.u[0].x + gid.x) * 4u + 3u] * P.f[0].x; textureStore(out, p, c + (a - c) * k); } + +// ---------------------------------------------------------------- Classic 3D bokeh depth of field +// (render::three_d::bokeh: boost_highlights, row prefix sums, span gathers per blur level +// blended by each pixel's radius.) + +// Highlight boost: f[0] = (gain, threshold, saturation). +@compute @workgroup_size(16, 16) +fn bokeh_boost(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + var c = textureLoad(src, p, 0); + let gain = P.f[0].x; + let thr = clamp(P.f[0].y, 0.0, 1.0); + let satk = P.f[0].z; + if (c.w > 0.0 && gain > 0.0) { + let a = c.w; + let rgb = c.xyz / a; + let l = 0.2126 * rgb.x + 0.7152 * rgb.y + 0.0722 * rgb.z; + if (!(l < thr || (thr >= 1.0 && l < 1.0))) { + var over = 1.0; + if (thr < 1.0) { + over = clamp((l - thr) / (1.0 - thr), 0.0, 1.0); + } + let k = 1.0 + gain * 4.0 * max(over, 0.25); + let sat = 1.0 + satk; + let o = max(vec3(l) + (rgb - vec3(l)) * sat, vec3(0.0)) * k; + c = vec4(o * a, a); + } + } + textureStore(out, p, c); +} + +// Row prefix sums: out is (w + 1) × h, out[x] = sum of src[0..x]. One invocation per row. +@compute @workgroup_size(64, 1) +fn bokeh_prefix(@builtin(global_invocation_id) gid: vec3) { + let dims = vec2(textureDimensions(src)); + let y = i32(gid.x); + if (y >= dims.y) { + return; + } + var acc = vec4(0.0); + textureStore(out, vec2(0, y), acc); + for (var x = 0; x < dims.x; x = x + 1) { + acc = acc + textureLoad(src, vec2(x, y), 0); + textureStore(out, vec2(x + 1, y), acc); + } +} + +fn bokeh_prefix_at(y: i32, t: f32) -> vec4 { + let n = i32(textureDimensions(src).x) - 1; + if (t <= 0.0) { + return vec4(0.0); + } + if (t >= f32(n)) { + return textureLoad(src, vec2(n, y), 0); + } + let i = i32(floor(t)); + let f = t - f32(i); + let a = textureLoad(src, vec2(i, y), 0); + let b = textureLoad(src, vec2(i + 1, y), 0); + return a + (b - a) * f; +} + +// One blur level added into the accumulator `aux`: src = prefix sums; data = the radius map +// (w·h) then spans (dy, x0, x1, weight). u[0] = (span offset, span count, level, levels), +// u[1].x = 1 for the first level (no accumulator yet); f[0] = (1 / norm, lo, hi). +@compute @workgroup_size(16, 16) +fn bokeh_gather(@builtin(global_invocation_id) gid: vec3) { + let dims = out_dims(); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + var acc = vec4(0.0); + if (P.u[1].x == 0u) { + acc = textureLoad(aux, p, 0); + } + let r = data[u32(p.y) * u32(dims.x) + u32(p.x)]; + let k = P.u[0].z; + let n = P.u[0].w; + var wgt = 1.0; + if (n > 1u) { + let lo = P.f[0].y; + let hi = P.f[0].z; + let f = clamp((r - lo) / (hi - lo) * f32(n - 1u), 0.0, f32(n - 1u)); + let kk = min(u32(floor(f)), n - 2u); + let t = f - f32(kk); + if (k == kk) { + wgt = 1.0 - t; + } else if (k == kk + 1u) { + wgt = t; + } else { + wgt = 0.0; + } + } + if (wgt > 0.0) { + var g = vec4(0.0); + let off = P.u[0].x; + for (var i = 0u; i < P.u[0].y; i = i + 1u) { + let s = off + i * 4u; + let sy = p.y + i32(data[s]); + if (sy < 0 || sy >= dims.y) { + continue; + } + let hi = bokeh_prefix_at(sy, f32(p.x) + data[s + 2u] + 0.5); + let lo = bokeh_prefix_at(sy, f32(p.x) + data[s + 1u] + 0.5); + g = g + (hi - lo) * data[s + 3u]; + } + acc = acc + g * P.f[0].x * wgt; + } + textureStore(out, p, acc); +} diff --git a/crates/gpu/src/tests_3d.rs b/crates/gpu/src/tests_3d.rs index f10368d20..e28ce3bf6 100644 --- a/crates/gpu/src/tests_3d.rs +++ b/crates/gpu/src/tests_3d.rs @@ -218,3 +218,69 @@ fn the_gpu_draws_classic_3d_runs_itself() { let d = crate::tests::diff(&cpu, &img, 1e-3); assert!(d.over * 200 <= d.total, "{d:?}"); } + +/// Classic 3D bokeh depth of field runs on the GPU: iris shapes, highlights, fringe, Fast +/// Rectangle and a tilted plane (progressive blur) match the CPU. +#[test] +fn bokeh_depth_of_field_runs_on_the_gpu() { + let cases: [(&str, &[(&str, Value)]); 4] = [ + ("fast rectangle", &[]), + ("hexagon + highlights", &[("irisShape", Value::Enum(4)), ("irisRotation", n(15.0)), ("highlightGain", n(40.0)), ("highlightThreshold", n(150.0))]), + ("round + fringe", &[("irisShape", Value::Enum(6)), ("irisRoundness", n(60.0)), ("irisDiffractionFringe", n(80.0))]), + ("wide triangle", &[("irisShape", Value::Enum(1)), ("irisAspectRatio", n(2.0))]), + ]; + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + for (label, vals) in cases { + // The crossing planes are tilted (±35°): every plane blurs progressively. + let mut s = crossing(depth, BlendMode::Normal); + camera(&mut s, [80.0, 50.0, -220.0], Some((180.0, 40.0))); + for l in &mut s.p.comp_mut(s.cid).unwrap().layers { + if l.name == "Camera" { + for (k, v) in vals { + set(l, &format!("cameraOptions/{k}"), v.clone()); + } + } + } + check(&format!("bokeh {label} {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005); + // The blur is the GPU's: the prepared planes carry it unapplied. + if depth == BitDepth::Bpc32 + && let Some(g) = gpu() + { + let r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() }); + let ctx = r.eval_ctx(s.cid, Tick::ZERO).unwrap(); + let run: Vec<_> = r.visible_layers(&ctx).into_iter().filter(|l| l.is_3d()).collect(); + let prep = r.prepare_3d_run(&ctx, &run, (W, H)).unwrap(); + assert!(prep.planes.iter().filter(|p| p.draw).all(|p| p.dof.is_some()), "{label}: depth of field deferred to the GPU"); + let mut e = crate::context::Enc::new(g.context()); + let canvas = e.image(W, H); + assert!(crate::classic3d::draw_run(&mut e, &prep, &canvas).is_some(), "{label}: drawn on the GPU"); + } + } + } +} + +/// A 2D layer whose track matte is a 3D layer: the matte is drawn through the camera on the +/// GPU walk as on the CPU. +#[test] +fn two_d_layer_with_a_three_d_track_matte() { + for depth in [BitDepth::Bpc8, BitDepth::Bpc32] { + for kind in [MatteKind::Alpha, MatteKind::Luma] { + let mut s = scene(depth); + let bg = s.solid([0.1, 0.12, 0.2], W, H); + s.push(bg); + let fill = s.footage(W, H); + let fid = s.push(fill); + // The matte: a solid turned in 3D, seen through a camera. + let m = three_d(s.solid([0.9, 0.9, 0.9], 70, 50), [80.0, 50.0, 30.0], 40.0); + let mid = s.push(m); + camera(&mut s, [40.0, 30.0, -200.0], None); + let layers = &mut s.p.comp_mut(s.cid).unwrap().layers; + let fi = layers.iter().position(|l| l.id == fid).unwrap(); + layers[fi].track_matte = Some(TrackMatte { layer: mid, kind }); + if let Some(ml) = layers.iter_mut().find(|l| l.id == mid) { + ml.switches.video = false; + } + check(&format!("3d matte {kind:?} {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005); + } + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 6ede490bb..d590eaf97 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -363,6 +363,8 @@ pub struct Renderer<'a> { /// Switches inherited from the precomp layers this comp is rendered through (see /// [`RenderOpts::nested_switches`]): the worst quality and whether motion blur is allowed. pub(crate) inherited: Option<(Quality, bool)>, + /// Classic 3D depth of field is left to an accelerator ([`three_d::Plane3d::dof`]). + pub(crate) defer_dof: bool, } /// How a collapsed precomp's 3D layers are placed in the parent (see `Renderer::collapse_into`). @@ -407,6 +409,7 @@ impl<'a> Renderer<'a> { opacity_mul: 1.0, collapse3d: None, inherited: None, + defer_dof: false, } } @@ -441,7 +444,7 @@ impl<'a> Renderer<'a> { /// A renderer for nested work (precomps, layers read by effects): one level deeper. fn nested(&self) -> Renderer<'a> { - Renderer { depth: self.depth + 1, outer: None, opacity_mul: 1.0, collapse3d: None, ..*self } + Renderer { depth: self.depth + 1, outer: None, opacity_mul: 1.0, collapse3d: None, defer_dof: false, ..*self } } /// The nested comp of a precomp layer whose transformations collapse into this comp: the diff --git a/crates/render/src/three_d/bokeh.rs b/crates/render/src/three_d/bokeh.rs index 0252ecb05..12bcbcf37 100644 --- a/crates/render/src/three_d/bokeh.rs +++ b/crates/render/src/three_d/bokeh.rs @@ -190,21 +190,28 @@ pub fn boost_highlights(img: &mut Image, h: &Highlight) { }); } -/// Gather blur of a premultiplied image with a bokeh kernel of radius `r` (pixels). Pixels -/// beyond the image are transparent. -pub fn bokeh_blur(img: &Image, iris: &Iris, r: f64) -> Image { +/// Weighted row-span kernels and their normalisation. +pub type Kernels = (Vec<(f32, Vec<(i32, f64, f64)>)>, f32); + +/// The bokeh kernel of radius `r` as weighted row spans (see [`Iris::spans`]) and the sum of +/// weights; `None` when the blur does nothing (radius below 0.3 px). Fast Rectangle is the box +/// of the iris' width and height (one span per row covering whole pixels); the diffraction +/// fringe adds the rim as the full iris minus a shrunken one. The GPU gathers with exactly these +/// spans. +pub fn kernel_spans(iris: &Iris, r: f64) -> Option { if r < 0.3 { - return img.clone(); + return None; } if iris.sides == 0 { - // Fast Rectangle: a separable box of the iris' width and height. let a = iris.aspect.clamp(0.01, 100.0).sqrt(); - let rx = (r * a).round().max(0.0) as usize; - let ry = (r / a).round().max(0.0) as usize; - return effectcraft_raster::box_blur(img, rx, ry, 1, false); + let rx = (r * a).round().max(0.0) as i32; + let ry = (r / a).round().max(0.0) as i32; + if rx == 0 && ry == 0 { + return None; + } + let spans = (-ry..=ry).map(|dy| (dy, -rx as f64 - 0.5, rx as f64 + 0.5)).collect(); + return Some((vec![(1.0, spans)], ((2 * rx + 1) * (2 * ry + 1)) as f32)); } - // The iris as row spans (prefix-sum gather: cost ∝ kernel height, not area). The - // diffraction fringe adds the rim: the full iris minus a shrunken one. let outer = iris.spans(r); let area = |sp: &[(i32, f64, f64)]| sp.iter().map(|s| (s.2 - s.1) as f32).sum::(); let mut kernels = vec![(1.0f32, outer)]; @@ -216,25 +223,72 @@ pub fn bokeh_blur(img: &Image, iris: &Iris, r: f64) -> Image { kernels.push((-f, inner)); } let norm: f32 = kernels.iter().map(|(wt, sp)| wt * area(sp)).sum(); - if norm <= 0.0 { + (norm > 0.0).then_some((kernels, norm)) +} + +/// Gather blur of a premultiplied image with a bokeh kernel of radius `r` (pixels). Pixels +/// beyond the image are transparent. +pub fn bokeh_blur(img: &Image, iris: &Iris, r: f64) -> Image { + if r < 0.3 { return img.clone(); } - span_gather(img, &kernels, norm) + if iris.sides == 0 { + // Fast Rectangle: a separable box of the iris' width and height (the same as its spans). + let a = iris.aspect.clamp(0.01, 100.0).sqrt(); + let rx = (r * a).round().max(0.0) as usize; + let ry = (r / a).round().max(0.0) as usize; + return effectcraft_raster::box_blur(img, rx, ry, 1, false); + } + // The iris as row spans (prefix-sum gather: cost ∝ kernel height, not area). + match kernel_spans(iris, r) { + Some((kernels, norm)) => span_gather(img, &kernels, norm), + None => img.clone(), + } +} + +/// The blur levels of a depth-varying blur ([`progressive_blur`]): `None` when nothing blurs, +/// one level for a near-constant radius, else up to eight radii spanning the range. +pub fn blur_levels(radius: &[f32]) -> Option> { + let (lo, hi) = radius.iter().fold((f32::INFINITY, 0.0f32), |(a, b), &r| (a.min(r), b.max(r))); + if !lo.is_finite() || hi < 0.3 { + return None; + } + if hi - lo < 0.75 { + return Some(vec![(lo + hi) * 0.5]); + } + let n = (((hi - lo) / 2.0).ceil() as usize + 1).clamp(2, 8); + Some((0..n).map(|i| lo + (hi - lo) * i as f32 / (n - 1) as f32).collect()) +} + +/// How much level `k` of `levels` contributes at radius `r` (the two levels bracketing `r` +/// are blended linearly). +pub fn level_weight(levels: &[f32], k: usize, r: f32) -> f32 { + let n = levels.len(); + if n <= 1 { + return 1.0; + } + let (lo, hi) = (levels[0], levels[n - 1]); + let f = ((r - lo) / (hi - lo) * (n - 1) as f32).clamp(0.0, (n - 1) as f32); + let kk = (f.floor() as usize).min(n - 2); + let t = f - kk as f32; + if k == kk { + 1.0 - t + } else if k == kk + 1 { + t + } else { + 0.0 + } } /// Depth-varying blur: `radius` gives each pixel's blur radius. The image is blurred at up to /// eight radii spanning the range and each pixel interpolates between the two levels around /// its own radius. pub fn progressive_blur(img: &Image, iris: &Iris, radius: &[f32]) -> Image { - let (lo, hi) = radius.iter().fold((f32::INFINITY, 0.0f32), |(a, b), &r| (a.min(r), b.max(r))); - if !lo.is_finite() || hi < 0.3 { - return img.clone(); + let Some(levels) = blur_levels(radius) else { return img.clone() }; + if levels.len() == 1 { + return bokeh_blur(img, iris, levels[0] as f64); } - if hi - lo < 0.75 { - return bokeh_blur(img, iris, ((lo + hi) * 0.5) as f64); - } - let n = (((hi - lo) / 2.0).ceil() as usize + 1).clamp(2, 8); - let levels: Vec = (0..n).map(|i| lo + (hi - lo) * i as f32 / (n - 1) as f32).collect(); + let (lo, hi, n) = (levels[0], levels[levels.len() - 1], levels.len()); let blurred: Vec = levels.par_iter().map(|&r| bokeh_blur(img, iris, r as f64)).collect(); let mut out = Image::new(img.width, img.height); out.data.par_iter_mut().enumerate().for_each(|(i, o)| { diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 25755a474..dd28816ed 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -62,6 +62,21 @@ pub(crate) struct Item<'a> { winv: Mat4, bounds: [f64; 4], bbox: [i64; 4], + /// Depth of field left to an accelerator (`buf` is then unblurred and unpadded). + dof: Option, +} + +/// Depth of field of a plane for an accelerator to apply ([`Plane3d::dof`]): pad the buffer by +/// `pad` transparent pixels, boost its highlights, then blur it at each of `levels` with +/// [`super::bokeh::kernel_spans`] and blend the levels by each pixel's `radius` (padded buffer +/// pixels, row-major) with [`super::bokeh::level_weight`] — what the CPU's depth of field does. +#[derive(Clone, Debug)] +pub struct PlaneDof { + pub pad: u32, + pub iris: super::bokeh::Iris, + pub highlight: super::bokeh::Highlight, + pub radius: Arc>, + pub levels: Vec, } impl Item<'_> { @@ -216,10 +231,20 @@ fn prepare_with<'a>( let world = outer * ctx.world_matrix(layer) * local; // Depth of field: a bokeh blur by each buffer pixel's circle of confusion (constant for a // layer facing the camera, progressive for a tilted one). + let mut plane_dof = None; + let mut bounds = buf_bounds(&buf); if let Some(dof) = cam.dof.filter(|_| !ctx.comp.draft_3d && !r.opts.draft) { - buf = dof_blur(&cam, &dof, world, buf); + if r.defer_dof { + // An accelerator blurs: hand it the radius map of the padded buffer. + if let Some((pd, padded_bounds)) = dof_plan(&cam, &dof, world, &buf) { + plane_dof = Some(pd); + bounds = padded_bounds; + } + } else { + buf = dof_blur(&cam, &dof, world, buf); + bounds = buf_bounds(&buf); + } } - let bounds = buf_bounds(&buf); let n = if in_run { mb_samples(r, ctx, layer) } else { 1 }; let geos: Vec = if n <= 1 { geo(&cam, world, bounds, comp, s, out).into_iter().collect() @@ -258,16 +283,20 @@ fn prepare_with<'a>( winv: world.inverse().unwrap_or(Mat4::IDENTITY), bounds, bbox, + dof: plane_dof, }) } /// 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 { - let s = buf.scale.max(1e-9); + dof_radii_at(cam, dof, world, (buf.img.width as usize, buf.img.height as usize), buf.offset, buf.scale) +} + +fn dof_radii_at(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, (w, h): (usize, usize), offset: [f64; 2], scale: f64) -> Vec { + let s = scale.max(1e-9); let sx = world.apply_vec(vec3(1.0, 0.0, 0.0)).length().max(1e-9); - let (w, h) = (buf.img.width as usize, buf.img.height as usize); - let at = |x: f64, y: f64| cam.depth(world.apply(vec3((x - buf.offset[0]) / s, (y - buf.offset[1]) / s, 0.0))); + let at = |x: f64, y: f64| cam.depth(world.apply(vec3((x - offset[0]) / s, (y - offset[1]) / s, 0.0))); // Camera depth is affine across a plane. let z0 = at(0.5, 0.5); let (zx, zy) = (at(1.5, 0.5) - z0, at(0.5, 1.5) - z0); @@ -285,6 +314,30 @@ fn dof_radii(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: &Buf out } +/// 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 +} + +/// [`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 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 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 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]; + Some((PlaneDof { pad, iris: dof.iris, highlight: dof.highlight, radius: Arc::new(radius), levels }, bounds)) +} + /// 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) -> Arc { let radii = dof_radii(cam, dof, world, &buf); @@ -292,8 +345,7 @@ fn dof_blur(cam: &CameraState, dof: &super::camera::Dof, world: Mat4, buf: Arc, + /// Depth of field for the accelerator to apply to `buf` (`bounds` are then the padded + /// buffer's). + pub dof: Option, pub bicubic: bool, /// One per motion-blur sub-sample (equal weights). pub geos: Vec, @@ -695,7 +750,9 @@ pub(crate) fn gpu_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u3 if ctx.comp.renderer == effectcraft_project::Renderer::Advanced3D && r.collapse3d.is_none() { return None; } - let g = gather(r, ctx, run, out); + // Depth of field is left to the accelerator (its blur runs where the planes are drawn). + let rd = Renderer { defer_dof: true, ..*r }; + let g = gather(&rd, ctx, run, out); let planes: Vec = g .items .into_iter() @@ -704,6 +761,7 @@ pub(crate) fn gpu_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u3 layer_id: it.layer.id.0 as u32, mode: it.layer.blend_mode, buf: it.buf, + dof: it.dof, bicubic: it.bicubic, geos: it.geos, mat: it.mat, diff --git a/crates/render/src/three_d/mod.rs b/crates/render/src/three_d/mod.rs index b880cdcdc..80d5dd19d 100644 --- a/crates/render/src/three_d/mod.rs +++ b/crates/render/src/three_d/mod.rs @@ -17,7 +17,7 @@ mod tests; mod tests_orient; pub use camera::{CameraState, Dof, Rig, View3D, ViewCam, Views3D, active_camera, default_camera, default_view_cam}; -pub use compose::{Geo as PlaneGeo, Plane3d, Run3d}; +pub use compose::{Geo as PlaneGeo, Plane3d, PlaneDof, Run3d}; pub use light::{LightState, Material, lights_at}; use effectcraft_geom::Mat3;