M13.5: camera iris bokeh depth of field and focus-link commands
3D-1: Classic 3D depth of field renders the camera's Iris Shape (Fast Rectangle box or Triangle…Decagon polygon), Iris Rotation, Roundness, Aspect Ratio and Diffraction Fringe as a span-kernel gather blur, with Highlight Gain/Threshold/Saturation boosting highlights first; tilted layers blur progressively by per-pixel camera depth. Layer > Camera > Link Focus Distance to Point of Interest / to Layer (expressions) and Set Focus Distance to Layer replace the stubs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
bbaee1fa0f
commit
85c645f337
|
|
@ -0,0 +1,105 @@
|
||||||
|
//! Layer ▸ Camera focus commands: Link Focus Distance to Point of Interest, Link Focus Distance to
|
||||||
|
//! Layer (both add an expression to Focus Distance, as After Effects does) and Set Focus Distance
|
||||||
|
//! to Layer (a one-off value).
|
||||||
|
|
||||||
|
use effectcraft_geom::vec3;
|
||||||
|
use effectcraft_keyframe::Value as KV;
|
||||||
|
use effectcraft_project::{Comp, Expression, ItemId, LayerId};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use super::{CommandSpec, bad, comp_id, layer_mut, resolve_layer};
|
||||||
|
use crate::{EngineError, Result, Session, cmd};
|
||||||
|
|
||||||
|
/// (comp, camera, target?) from `{camera?, layer?}` or the selection (a camera plus another layer).
|
||||||
|
fn refs(s: &Session, p: &Value, cmd: &str, need_target: bool) -> Result<(ItemId, LayerId, Option<LayerId>)> {
|
||||||
|
let cid = comp_id(s, p)?;
|
||||||
|
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||||
|
let sel: Vec<LayerId> = s.state.selected_layers.iter().copied().filter(|l| comp.layer(*l).is_some()).collect();
|
||||||
|
let is_cam = |c: &Comp, l: LayerId| c.layer(l).is_some_and(|l| l.is_camera());
|
||||||
|
let cam = match p.get("camera") {
|
||||||
|
Some(v) => resolve_layer(comp, v).ok_or_else(|| bad(cmd, format!("no layer {v}")))?,
|
||||||
|
None => sel.iter().copied().find(|l| is_cam(comp, *l)).ok_or_else(|| bad(cmd, "select a camera layer"))?,
|
||||||
|
};
|
||||||
|
if !is_cam(comp, cam) {
|
||||||
|
return Err(bad(cmd, "`camera` is not a camera layer"));
|
||||||
|
}
|
||||||
|
let target = match p.get("layer") {
|
||||||
|
Some(v) => Some(resolve_layer(comp, v).ok_or_else(|| bad(cmd, format!("no layer {v}")))?),
|
||||||
|
None => sel.iter().copied().find(|l| *l != cam),
|
||||||
|
};
|
||||||
|
if need_target && target.is_none() {
|
||||||
|
return Err(bad(cmd, "select the camera and the layer to focus on"));
|
||||||
|
}
|
||||||
|
Ok((cid, cam, target))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_focus_expr(s: &mut Session, cid: ItemId, cam: LayerId, label: &str, text: String) -> Result<Value> {
|
||||||
|
s.edit(label, None, |proj, _| {
|
||||||
|
let l = layer_mut(proj, cid, cam)?;
|
||||||
|
let pr = l.props.prop_mut("cameraOptions/focusDistance").ok_or_else(|| bad("camera", "the camera has no Focus Distance"))?;
|
||||||
|
pr.expr = Some(Expression { text: text.clone(), enabled: true });
|
||||||
|
Ok(json!({"expression": text}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn link_poi(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
|
let (cid, cam, _) = refs(s, p, "camera.linkFocusToPoi", false)?;
|
||||||
|
set_focus_expr(s, cid, cam, "Link Focus Distance to Point of Interest", "length(transform.pointOfInterest, transform.position)".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The focus expression for a target layer: its anchor point's distance along the camera's view
|
||||||
|
/// axis.
|
||||||
|
fn layer_expr(name: &str) -> String {
|
||||||
|
let n = serde_json::to_string(name).unwrap_or_else(|_| "\"\"".into());
|
||||||
|
format!(
|
||||||
|
"var m = thisComp.layer({n});\nvar c = toWorld([0, 0, 0]);\nvar f = normalize(sub(toWorld([0, 0, 1]), c));\ndot(sub(m.toWorld(m.transform.anchorPoint), c), f)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn link_layer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
|
let (cid, cam, target) = refs(s, p, "camera.linkFocusToLayer", true)?;
|
||||||
|
let name = s.project.comp(cid).and_then(|c| c.layer(target?)).map(|l| l.name.clone()).ok_or(EngineError::NoComp)?;
|
||||||
|
set_focus_expr(s, cid, cam, "Link Focus Distance to Layer", layer_expr(&name))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Camera depth of `target`'s anchor point at the current time.
|
||||||
|
pub(crate) fn focus_depth(s: &Session, cid: ItemId, cam: LayerId, target: LayerId) -> Option<f64> {
|
||||||
|
let comp = s.project.comp(cid)?;
|
||||||
|
let mut ctx = effectcraft_render::EvalCtx::new(&s.project, cid, comp, s.time_of(cid));
|
||||||
|
ctx.expr = s.expr.as_deref();
|
||||||
|
let c = ctx.comp.layer(cam)?;
|
||||||
|
let t = ctx.comp.layer(target)?;
|
||||||
|
let cs = effectcraft_render::three_d::camera::layer_camera(&ctx, c);
|
||||||
|
let a = t.transform().map(|tr| ctx.v3(t, tr, "anchor", [0.0; 3])).unwrap_or([0.0; 3]);
|
||||||
|
Some(cs.depth(ctx.world_matrix(t).apply(vec3(a[0], a[1], a[2]))))
|
||||||
|
}
|
||||||
|
|
||||||
|
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"))?;
|
||||||
|
if d <= 0.0 {
|
||||||
|
return Err(bad(c, "the layer is behind the camera"));
|
||||||
|
}
|
||||||
|
let t = s.time();
|
||||||
|
s.edit("Set Focus Distance to Layer", None, |proj, _| {
|
||||||
|
let l = layer_mut(proj, cid, cam)?;
|
||||||
|
let lt = l.layer_time(t);
|
||||||
|
let pr = l.props.prop_mut("cameraOptions/focusDistance").ok_or_else(|| bad(c, "the camera has no Focus Distance"))?;
|
||||||
|
pr.set_value_at(lt, KV::Scalar(d));
|
||||||
|
Ok(json!({"focusDistance": d}))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn camera_selected(s: &Session) -> std::result::Result<(), String> {
|
||||||
|
let c = s.active_comp().ok_or("no composition is open")?;
|
||||||
|
if s.state.selected_layers.iter().any(|l| c.layer(*l).is_some_and(|l| l.is_camera())) { Ok(()) } else { Err("select a camera layer".into()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn specs() -> Vec<CommandSpec> {
|
||||||
|
vec![
|
||||||
|
cmd!("camera.linkFocusToPoi", "Link Focus Distance to Point of Interest", ["Layer", "Camera"], None, "{comp?, camera?}", camera_selected, link_poi),
|
||||||
|
cmd!("camera.linkFocusToLayer", "Link Focus Distance to Layer", ["Layer", "Camera"], None, "{comp?, camera?, layer?}", camera_selected, link_layer),
|
||||||
|
cmd!("camera.setFocusToLayer", "Set Focus Distance to Layer", ["Layer", "Camera"], None, "{comp?, camera?, layer?}", camera_selected, set_to_layer),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ mod edit;
|
||||||
mod effect;
|
mod effect;
|
||||||
mod file;
|
mod file;
|
||||||
mod file_more;
|
mod file_more;
|
||||||
|
mod focus;
|
||||||
mod frontend;
|
mod frontend;
|
||||||
mod help;
|
mod help;
|
||||||
mod key_transform;
|
mod key_transform;
|
||||||
|
|
@ -129,6 +130,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||||
v.extend(view::specs());
|
v.extend(view::specs());
|
||||||
v.extend(viewer_cmds::specs());
|
v.extend(viewer_cmds::specs());
|
||||||
v.extend(shape_stroke::specs());
|
v.extend(shape_stroke::specs());
|
||||||
|
v.extend(focus::specs());
|
||||||
v.extend(key_transform::specs());
|
v.extend(key_transform::specs());
|
||||||
v.extend(file_more::specs());
|
v.extend(file_more::specs());
|
||||||
v.extend(lottie::specs());
|
v.extend(lottie::specs());
|
||||||
|
|
|
||||||
|
|
@ -36,9 +36,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||||
stub!("camera.stereoRig", "Create Stereo 3D Rig", ["Layer", "Camera"], "{}"),
|
stub!("camera.stereoRig", "Create Stereo 3D Rig", ["Layer", "Camera"], "{}"),
|
||||||
stub!("camera.orbitNull", "Create Orbit Null", ["Layer", "Camera"], "{}"),
|
stub!("camera.orbitNull", "Create Orbit Null", ["Layer", "Camera"], "{}"),
|
||||||
stub!("camera.fromModel", "Create Cameras from 3D Model", ["Layer", "Camera"], "{}"),
|
stub!("camera.fromModel", "Create Cameras from 3D Model", ["Layer", "Camera"], "{}"),
|
||||||
stub!("camera.linkFocusToPoi", "Link Focus Distance to Point of Interest", ["Layer", "Camera"], "{}"),
|
|
||||||
stub!("camera.linkFocusToLayer", "Link Focus Distance to Layer", ["Layer", "Camera"], "{}"),
|
|
||||||
stub!("camera.setFocusToLayer", "Set Focus Distance to Layer", ["Layer", "Camera"], "{}"),
|
|
||||||
stub!("light.fromModel", "Create Lights from 3D Model", ["Layer", "Light"], "{}"),
|
stub!("light.fromModel", "Create Lights from 3D Model", ["Layer", "Light"], "{}"),
|
||||||
stub!("light.controlWithCamera", "Control Light with Camera", ["Layer", "Light"], "{}"),
|
stub!("light.controlWithCamera", "Control Light with Camera", ["Layer", "Light"], "{}"),
|
||||||
stub!("light.environmentBackground", "Create Environment Light Background Layer", ["Layer", "Light"], "{}"),
|
stub!("light.environmentBackground", "Create Environment Light Background Layer", ["Layer", "Light"], "{}"),
|
||||||
|
|
|
||||||
|
|
@ -219,3 +219,33 @@ fn adaptive_sample_limit_drives_layer_samples() {
|
||||||
assert!(!json.contains("motion_blur_adaptive_limit"));
|
assert!(!json.contains("motion_blur_adaptive_limit"));
|
||||||
assert_eq!(Project::from_json(&json).unwrap().comp(s.active_comp_id().unwrap()).unwrap().motion_blur_adaptive_limit, 128);
|
assert_eq!(Project::from_json(&json).unwrap().comp(s.active_comp_id().unwrap()).unwrap().motion_blur_adaptive_limit, 128);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn camera_focus_commands_undo_and_serde() {
|
||||||
|
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, 150, 500]})).unwrap();
|
||||||
|
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||||
|
s.execute("layer.select", json!({"layers": [cam, t]})).unwrap();
|
||||||
|
assert!(s.is_enabled("camera.setFocusToLayer"));
|
||||||
|
let before = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap()["value"].as_f64().unwrap();
|
||||||
|
let d = s.execute("camera.setFocusToLayer", json!({})).unwrap()["focusDistance"].as_f64().unwrap();
|
||||||
|
assert!((d - before - 500.0).abs() < 1e-6, "the default camera focuses on z = 0: {before} → {d}");
|
||||||
|
s.execute("camera.linkFocusToLayer", json!({})).unwrap();
|
||||||
|
let e = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap();
|
||||||
|
assert!(e["expression"].as_str().unwrap().contains("thisComp.layer("), "{e}");
|
||||||
|
let back = roundtrip(&s);
|
||||||
|
assert!(back.comp(s.active_comp_id().unwrap()).unwrap().layer(LayerId(cam)).unwrap().props.prop("cameraOptions/focusDistance").unwrap().expr.is_some());
|
||||||
|
s.execute("edit.undo", json!({})).unwrap();
|
||||||
|
let e = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap();
|
||||||
|
assert!(e["expression"].is_null());
|
||||||
|
s.execute("edit.undo", json!({})).unwrap();
|
||||||
|
let v = s.execute("prop.get", json!({"layer": cam, "path": "cameraOptions/focusDistance"})).unwrap()["value"].as_f64().unwrap();
|
||||||
|
assert!((v - before).abs() < 1e-9);
|
||||||
|
s.execute("edit.redo", json!({})).unwrap();
|
||||||
|
// A camera alone can't link to a layer, but can link to its point of interest.
|
||||||
|
s.execute("layer.select", json!({"layers": [cam]})).unwrap();
|
||||||
|
assert!(s.execute("camera.linkFocusToLayer", json!({})).is_err());
|
||||||
|
assert!(s.execute("camera.linkFocusToPoi", json!({})).is_ok());
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,50 @@ pub fn session() -> Session {
|
||||||
mod tests {
|
mod tests {
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
|
/// Layer ▸ Camera ▸ Link Focus Distance to Point of Interest / to Layer: the expressions
|
||||||
|
/// evaluate to the camera's focus on the target.
|
||||||
|
#[test]
|
||||||
|
fn focus_link_expressions_track_the_target() {
|
||||||
|
use effectcraft_engine::geom::vec3;
|
||||||
|
let mut s = super::session();
|
||||||
|
s.execute("comp.new", json!({"name": "F", "width": 400, "height": 300, "duration": 2})).unwrap();
|
||||||
|
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": [260, 120, 400]})).unwrap();
|
||||||
|
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||||
|
let cid = s.active_comp_id().unwrap();
|
||||||
|
let focus = |s: &effectcraft_engine::Session| {
|
||||||
|
let comp = s.project.comp(cid).unwrap();
|
||||||
|
let mut ctx = effectcraft_engine::render::EvalCtx::new(&s.project, cid, comp, s.time());
|
||||||
|
ctx.expr = s.expr.as_deref();
|
||||||
|
let l = comp.layer(effectcraft_engine::project::LayerId(cam)).unwrap();
|
||||||
|
ctx.value(l, l.props.prop("cameraOptions/focusDistance").unwrap()).as_f64()
|
||||||
|
};
|
||||||
|
let depth = |s: &effectcraft_engine::Session| {
|
||||||
|
let comp = s.project.comp(cid).unwrap();
|
||||||
|
let ctx = effectcraft_engine::render::EvalCtx::new(&s.project, cid, comp, s.time());
|
||||||
|
let c = comp.layer(effectcraft_engine::project::LayerId(cam)).unwrap();
|
||||||
|
let tl = comp.layer(effectcraft_engine::project::LayerId(t)).unwrap();
|
||||||
|
let cs = effectcraft_engine::render::three_d::camera::layer_camera(&ctx, c);
|
||||||
|
cs.depth(ctx.world_matrix(tl).apply(vec3(25.0, 25.0, 0.0)))
|
||||||
|
};
|
||||||
|
s.execute("layer.select", json!({"layers": [cam, t]})).unwrap();
|
||||||
|
let set = s.execute("camera.setFocusToLayer", json!({})).unwrap()["focusDistance"].as_f64().unwrap();
|
||||||
|
assert!((set - depth(&s)).abs() < 1e-6 && (focus(&s) - set).abs() < 1e-6);
|
||||||
|
s.execute("camera.linkFocusToLayer", json!({})).unwrap();
|
||||||
|
// Move the target: the linked focus follows.
|
||||||
|
s.execute("layer.setTransform", json!({"layer": t, "prop": "position", "value": [200, 150, 900]})).unwrap();
|
||||||
|
assert!((focus(&s) - depth(&s)).abs() < 1e-3, "{} vs {}", focus(&s), depth(&s));
|
||||||
|
s.execute("camera.linkFocusToPoi", json!({"camera": cam})).unwrap();
|
||||||
|
let poi = {
|
||||||
|
let l = s.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(cam)).unwrap();
|
||||||
|
let p = l.props.prop("transform/position").unwrap().value.as_vec3();
|
||||||
|
let q = l.props.prop("transform/poi").unwrap().value.as_vec3();
|
||||||
|
((p[0] - q[0]).powi(2) + (p[1] - q[1]).powi(2) + (p[2] - q[2]).powi(2)).sqrt()
|
||||||
|
};
|
||||||
|
assert!((focus(&s) - poi).abs() < 1e-6, "{} vs {poi}", focus(&s));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn wired_session_renders_demo_with_expressions() {
|
fn wired_session_renders_demo_with_expressions() {
|
||||||
let mut s = super::session();
|
let mut s = super::session();
|
||||||
|
|
|
||||||
|
|
@ -283,7 +283,7 @@ fn depth_of_field_blurs_out_of_focus_surfaces() {
|
||||||
let m: f32 = im.data.iter().map(|p| p[0]).sum::<f32>() / im.data.len() as f32;
|
let m: f32 = im.data.iter().map(|p| p[0]).sum::<f32>() / im.data.len() as f32;
|
||||||
im.data.iter().map(|p| (p[0] - m).powi(2)).sum::<f32>() / im.data.len() as f32
|
im.data.iter().map(|p| (p[0] - m).powi(2)).sum::<f32>() / im.data.len() as f32
|
||||||
};
|
};
|
||||||
let dof = crate::three_d::camera::Dof { focus: 1000.0, aperture: 40.0, blur_level: 1.0 };
|
let dof = crate::three_d::camera::Dof { focus: 1000.0, aperture: 40.0, blur_level: 1.0, iris: Default::default(), highlight: Default::default() };
|
||||||
let sharp = depth_of_field(&img, &vec![1000.0; 64 * 64], &dof, 1.0);
|
let sharp = depth_of_field(&img, &vec![1000.0; 64 * 64], &dof, 1.0);
|
||||||
assert!((var(&sharp) - var(&img)).abs() < 1e-6, "in focus: unchanged");
|
assert!((var(&sharp) - var(&img)).abs() < 1e-6, "in focus: unchanged");
|
||||||
let blurred = depth_of_field(&img, &vec![3000.0; 64 * 64], &dof, 1.0);
|
let blurred = depth_of_field(&img, &vec![3000.0; 64 * 64], &dof, 1.0);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,336 @@
|
||||||
|
//! Classic 3D depth of field: bokeh-shaped gather blur.
|
||||||
|
//!
|
||||||
|
//! The camera's Iris Shape (Fast Rectangle, or a regular polygon from Triangle to Decagon),
|
||||||
|
//! Iris Rotation, Roundness and Aspect Ratio define the blur kernel; Iris Diffraction Fringe
|
||||||
|
//! brightens its rim. Highlight Gain / Threshold / Saturation boost the bright parts of the layer
|
||||||
|
//! before blurring so they bloom into visible bokeh shapes. A layer whose depth varies across it
|
||||||
|
//! (tilted towards or away from the camera) is blurred progressively: the buffer is blurred at a
|
||||||
|
//! few kernel sizes and every pixel blends the two that bracket its own circle of confusion.
|
||||||
|
|
||||||
|
use effectcraft_raster::Image;
|
||||||
|
use rayon::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
/// Camera iris (Camera Options ▸ Iris …).
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct Iris {
|
||||||
|
/// 0 = Fast Rectangle, else the number of sides (3–10).
|
||||||
|
pub sides: u32,
|
||||||
|
/// Degrees.
|
||||||
|
pub rotation: f64,
|
||||||
|
/// −1…1: 1 = circular, negative pinches the sides inwards.
|
||||||
|
pub roundness: f64,
|
||||||
|
/// Width / height of the aperture.
|
||||||
|
pub aspect: f64,
|
||||||
|
/// Rim brightness: 0 = none, 1 = the rim is twice as bright (Iris Diffraction Fringe / 100).
|
||||||
|
pub fringe: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Iris {
|
||||||
|
fn default() -> Self {
|
||||||
|
Iris { sides: 0, rotation: 0.0, roundness: 0.0, aspect: 1.0, fringe: 0.0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Highlight boost before the blur (Camera Options ▸ Highlight …), all 0–1.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct Highlight {
|
||||||
|
pub gain: f64,
|
||||||
|
pub threshold: f64,
|
||||||
|
pub saturation: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Iris {
|
||||||
|
/// Iris Shape popup index → sides (0 Fast Rectangle, 1 Triangle … 8 Decagon).
|
||||||
|
pub fn sides_of_shape(shape: u32) -> u32 {
|
||||||
|
if shape == 0 { 0 } else { (shape + 2).min(10) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalised boundary distance (1 = circumscribed circle) of the iris at angle `a`.
|
||||||
|
fn boundary(&self, a: f64) -> f64 {
|
||||||
|
let n = self.sides.max(3) as f64;
|
||||||
|
let seg = std::f64::consts::TAU / n;
|
||||||
|
let rel = (a - self.rotation.to_radians()).rem_euclid(seg) - seg * 0.5;
|
||||||
|
let poly = (std::f64::consts::PI / n).cos() / rel.cos();
|
||||||
|
let r = self.roundness.clamp(-1.0, 1.0);
|
||||||
|
// Positive: towards the circumscribed circle; negative: the sides bow inwards (the
|
||||||
|
// vertices stay put, the edge midpoints move in).
|
||||||
|
if r >= 0.0 { poly + (1.0 - poly) * r } else { (poly + (1.0 - poly) * r * 2.0).max(0.05) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the kernel offset (dx, dy) lies inside an iris of radius `r`.
|
||||||
|
fn inside(&self, r: f64, dx: f64, dy: f64) -> bool {
|
||||||
|
let a = self.aspect.clamp(0.01, 100.0).sqrt();
|
||||||
|
let (u, v) = (dx / a, dy * a);
|
||||||
|
let d = u.hypot(v);
|
||||||
|
d < 1e-9 || d <= self.boundary(v.atan2(u)) * r
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The kernel as horizontal spans: for every row offset `dy`, the (fractional) x extent
|
||||||
|
/// `[x0, x1]` of the iris of radius `r` at that height. The iris is convex for roundness ≥ 0;
|
||||||
|
/// a pinched iris keeps its outermost extent per row.
|
||||||
|
pub fn spans(&self, r: f64) -> Vec<(i32, f64, f64)> {
|
||||||
|
let a = self.aspect.clamp(0.01, 100.0).sqrt();
|
||||||
|
let (ext_x, ext_y) = (r * a + 1.0, (r / a).ceil() as i32 + 1);
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for dy in -ext_y..=ext_y {
|
||||||
|
let y = dy as f64;
|
||||||
|
// Coarse scan, then bisection of both ends.
|
||||||
|
let step = 0.5;
|
||||||
|
let mut first = None;
|
||||||
|
let mut last = None;
|
||||||
|
let mut x = -ext_x;
|
||||||
|
while x <= ext_x {
|
||||||
|
if self.inside(r, x, y) {
|
||||||
|
first.get_or_insert(x);
|
||||||
|
last = Some(x);
|
||||||
|
}
|
||||||
|
x += step;
|
||||||
|
}
|
||||||
|
let (Some(f), Some(l)) = (first, last) else { continue };
|
||||||
|
let refine = |inside_x: f64, outside_x: f64| {
|
||||||
|
let (mut i, mut o) = (inside_x, outside_x);
|
||||||
|
for _ in 0..12 {
|
||||||
|
let m = (i + o) * 0.5;
|
||||||
|
if self.inside(r, m, y) { i = m } else { o = m }
|
||||||
|
}
|
||||||
|
(i + o) * 0.5
|
||||||
|
};
|
||||||
|
out.push((dy, refine(f, f - step), refine(l, l + step)));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prefix sums of each row (per channel): `p[x]` = sum of pixels `0..x`.
|
||||||
|
fn row_prefix(img: &Image) -> Vec<Vec<[f32; 4]>> {
|
||||||
|
let w = img.width as usize;
|
||||||
|
img.data
|
||||||
|
.par_chunks(w.max(1))
|
||||||
|
.map(|row| {
|
||||||
|
let mut acc = [0.0f32; 4];
|
||||||
|
let mut v = Vec::with_capacity(w + 1);
|
||||||
|
v.push(acc);
|
||||||
|
for p in row {
|
||||||
|
for c in 0..4 {
|
||||||
|
acc[c] += p[c];
|
||||||
|
}
|
||||||
|
v.push(acc);
|
||||||
|
}
|
||||||
|
v
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Continuous prefix: sum over [0, t) with linear interpolation inside a pixel.
|
||||||
|
#[inline]
|
||||||
|
fn prefix_at(p: &[[f32; 4]], t: f64) -> [f32; 4] {
|
||||||
|
let n = p.len() - 1;
|
||||||
|
if t <= 0.0 {
|
||||||
|
return [0.0; 4];
|
||||||
|
}
|
||||||
|
if t >= n as f64 {
|
||||||
|
return p[n];
|
||||||
|
}
|
||||||
|
let i = t.floor() as usize;
|
||||||
|
let f = (t - i as f64) as f32;
|
||||||
|
let (a, b) = (p[i], p[i + 1]);
|
||||||
|
[a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f, a[2] + (b[2] - a[2]) * f, a[3] + (b[3] - a[3]) * f]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gather with span kernels: `(weight, spans)` pairs summed, then normalised by `norm`.
|
||||||
|
fn span_gather(img: &Image, kernels: &[(f32, Vec<(i32, f64, f64)>)], norm: f32) -> Image {
|
||||||
|
let pre = row_prefix(img);
|
||||||
|
let (w, h) = (img.width as usize, img.height as i64);
|
||||||
|
let mut out = Image::new(img.width, img.height);
|
||||||
|
let inv = 1.0 / norm;
|
||||||
|
out.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| {
|
||||||
|
for (x, o) in row.iter_mut().enumerate() {
|
||||||
|
let mut acc = [0.0f32; 4];
|
||||||
|
for (wt, spans) in kernels {
|
||||||
|
for &(dy, x0, x1) in spans {
|
||||||
|
let sy = y as i64 + dy as i64;
|
||||||
|
if sy < 0 || sy >= h {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let pr = &pre[sy as usize];
|
||||||
|
let (hi, lo) = (prefix_at(pr, x as f64 + x1 + 0.5), prefix_at(pr, x as f64 + x0 + 0.5));
|
||||||
|
for c in 0..4 {
|
||||||
|
acc[c] += (hi[c] - lo[c]) * wt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*o = acc.map(|v| v * inv);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Boost the highlights of a premultiplied image in place.
|
||||||
|
pub fn boost_highlights(img: &mut Image, h: &Highlight) {
|
||||||
|
if h.gain <= 0.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let thr = h.threshold.clamp(0.0, 1.0) as f32;
|
||||||
|
img.data.par_iter_mut().for_each(|p| {
|
||||||
|
if p[3] <= 0.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let a = p[3];
|
||||||
|
let (r, g, b) = (p[0] / a, p[1] / a, p[2] / a);
|
||||||
|
let l = effectcraft_color::luminance(r, g, b);
|
||||||
|
if l < thr || (thr >= 1.0 && l < 1.0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let over = if thr < 1.0 { ((l - thr) / (1.0 - thr)).clamp(0.0, 1.0) } else { 1.0 };
|
||||||
|
let k = 1.0 + h.gain as f32 * 4.0 * over.max(0.25);
|
||||||
|
let sat = 1.0 + h.saturation as f32;
|
||||||
|
let c = [r, g, b].map(|c| (l + (c - l) * sat).max(0.0) * k);
|
||||||
|
*p = [c[0] * a, c[1] * a, c[2] * a, a];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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();
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
// 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::<f32>();
|
||||||
|
let mut kernels = vec![(1.0f32, outer)];
|
||||||
|
if iris.fringe > 0.0 {
|
||||||
|
let rim = (r * 0.15).max(1.0);
|
||||||
|
let inner = if r - rim > 0.5 { iris.spans(r - rim) } else { Vec::new() };
|
||||||
|
let f = iris.fringe as f32;
|
||||||
|
kernels[0].0 = 1.0 + f;
|
||||||
|
kernels.push((-f, inner));
|
||||||
|
}
|
||||||
|
let norm: f32 = kernels.iter().map(|(wt, sp)| wt * area(sp)).sum();
|
||||||
|
if norm <= 0.0 {
|
||||||
|
return img.clone();
|
||||||
|
}
|
||||||
|
span_gather(img, &kernels, norm)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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();
|
||||||
|
}
|
||||||
|
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<f32> = (0..n).map(|i| lo + (hi - lo) * i as f32 / (n - 1) as f32).collect();
|
||||||
|
let blurred: Vec<Image> = 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)| {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// A single bright point in the middle of a transparent image.
|
||||||
|
fn dot(size: u32) -> Image {
|
||||||
|
let mut img = Image::new(size, size);
|
||||||
|
let c = (size / 2) as usize;
|
||||||
|
img.data[c * size as usize + c] = [1.0, 1.0, 1.0, 1.0];
|
||||||
|
img
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lit(img: &Image, x: i32, y: i32) -> bool {
|
||||||
|
let c = img.width as i32 / 2;
|
||||||
|
img.data[((c + y) * img.width as i32 + c + x) as usize][3] > 1e-4
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn kernel_shapes() {
|
||||||
|
// A hexagon with a vertex on +x (rotation 0): the point at 0.97 r on +x is inside, the
|
||||||
|
// point at 0.97 r at 30° (an edge midpoint at cos 30° = 0.866 r) is outside.
|
||||||
|
let hex = Iris { sides: 6, ..Default::default() };
|
||||||
|
let b = bokeh_blur(&dot(81), &hex, 20.0);
|
||||||
|
assert!(lit(&b, 19, 0));
|
||||||
|
let (x, y) = ((19.4f64 * 30f64.to_radians().cos()).round() as i32, (19.4f64 * 30f64.to_radians().sin()).round() as i32);
|
||||||
|
assert!(!lit(&b, x, y), "edge midpoint beyond the apothem");
|
||||||
|
// Rotating by 30° swaps them.
|
||||||
|
let rot = Iris { rotation: 30.0, ..hex };
|
||||||
|
let b = bokeh_blur(&dot(81), &rot, 20.0);
|
||||||
|
assert!(!lit(&b, 19, 0) && lit(&b, x, y));
|
||||||
|
// Full roundness: a circle.
|
||||||
|
let round = Iris { roundness: 1.0, ..hex };
|
||||||
|
let b = bokeh_blur(&dot(81), &round, 20.0);
|
||||||
|
assert!(lit(&b, 19, 0) && lit(&b, x, y));
|
||||||
|
// Triangle: far less area than a decagon of the same radius.
|
||||||
|
let area = |sides| {
|
||||||
|
let b = bokeh_blur(&dot(81), &Iris { sides, ..Default::default() }, 20.0);
|
||||||
|
b.data.iter().filter(|p| p[3] > 1e-4).count()
|
||||||
|
};
|
||||||
|
assert!(area(3) * 2 < area(10), "{} vs {}", area(3), area(10));
|
||||||
|
// Aspect ratio 4 makes it twice as wide and half as tall.
|
||||||
|
let wide = Iris { sides: 8, aspect: 4.0, ..Default::default() };
|
||||||
|
let b = bokeh_blur(&dot(121), &wide, 15.0);
|
||||||
|
assert!(lit(&b, 28, 0) && !lit(&b, 0, 10));
|
||||||
|
// Energy is preserved.
|
||||||
|
let sum: f32 = b.data.iter().map(|p| p[3]).sum();
|
||||||
|
assert!((sum - 1.0).abs() < 1e-3, "{sum}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn diffraction_fringe_brightens_the_rim() {
|
||||||
|
let plain = bokeh_blur(&dot(81), &Iris { sides: 8, ..Default::default() }, 20.0);
|
||||||
|
let fringe = bokeh_blur(&dot(81), &Iris { sides: 8, fringe: 2.0, ..Default::default() }, 20.0);
|
||||||
|
let at = |img: &Image, x: i32| img.data[(40 * 81 + 40 + x) as usize][3];
|
||||||
|
assert!((at(&plain, 0) - at(&plain, 17)).abs() < 1e-6, "flat disc");
|
||||||
|
assert!(at(&fringe, 18) > at(&fringe, 0) * 1.5, "{} vs {}", at(&fringe, 18), at(&fringe, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn highlights_boost_only_above_threshold() {
|
||||||
|
let mut img = Image::new(2, 1);
|
||||||
|
img.data = vec![[0.9, 0.9, 0.9, 1.0], [0.3, 0.3, 0.3, 1.0]];
|
||||||
|
boost_highlights(&mut img, &Highlight { gain: 0.5, threshold: 0.8, saturation: 0.0 });
|
||||||
|
assert!(img.data[0][0] > 1.2 && img.data[1][0] == 0.3);
|
||||||
|
let mut c = Image::new(1, 1);
|
||||||
|
c.data = vec![[1.0, 0.8, 0.8, 1.0]];
|
||||||
|
boost_highlights(&mut c, &Highlight { gain: 0.1, threshold: 0.5, saturation: 1.0 });
|
||||||
|
assert!(c.data[0][0] - c.data[0][1] > 0.3, "more saturated: {:?}", c.data[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn progressive_blur_follows_the_radius_map() {
|
||||||
|
// A vertical line, blurred 0 px at the top rising to 8 px at the bottom.
|
||||||
|
let (w, h) = (41u32, 41u32);
|
||||||
|
let mut img = Image::new(w, h);
|
||||||
|
for y in 0..h as usize {
|
||||||
|
img.data[y * w as usize + 20] = [1.0; 4];
|
||||||
|
}
|
||||||
|
let radius: Vec<f32> = (0..(w * h) as usize).map(|i| (i / w as usize) as f32 / 40.0 * 8.0).collect();
|
||||||
|
let out = progressive_blur(&img, &Iris { sides: 6, roundness: 1.0, ..Default::default() }, &radius);
|
||||||
|
let spread = |y: usize| (0..w as usize).filter(|&x| out.data[y * w as usize + x][3] > 0.03).count();
|
||||||
|
assert!(spread(1) <= 3, "top stays sharp: {}", spread(1));
|
||||||
|
assert!(spread(39) >= 13, "bottom is wide: {}", spread(39));
|
||||||
|
assert!(spread(20) > spread(1) && spread(20) < spread(39));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,12 @@ pub struct Dof {
|
||||||
pub aperture: f64,
|
pub aperture: f64,
|
||||||
/// Blur Level (1.0 = 100%).
|
/// Blur Level (1.0 = 100%).
|
||||||
pub blur_level: f64,
|
pub blur_level: f64,
|
||||||
|
/// Iris Shape, Rotation, Roundness, Aspect Ratio and Diffraction Fringe.
|
||||||
|
#[serde(default)]
|
||||||
|
pub iris: super::bokeh::Iris,
|
||||||
|
/// Highlight Gain, Threshold and Saturation.
|
||||||
|
#[serde(default)]
|
||||||
|
pub highlight: super::bokeh::Highlight,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Dof {
|
impl Dof {
|
||||||
|
|
@ -294,6 +300,18 @@ pub fn layer_camera(ctx: &EvalCtx, cam: &Layer) -> CameraState {
|
||||||
focus: ctx.f(cam, g, "focusDistance", zoom),
|
focus: ctx.f(cam, g, "focusDistance", zoom),
|
||||||
aperture: ctx.f(cam, g, "aperture", default_aperture(w)).max(0.0),
|
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,
|
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 }
|
CameraState { view: basis_view(eye, fwd, down), eye, zoom, ortho: false, dof }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -206,26 +206,10 @@ fn prepare_with<'a>(
|
||||||
let cam = camera_for(r, ctx);
|
let cam = camera_for(r, ctx);
|
||||||
let outer = r.collapse3d.map_or(Mat4::IDENTITY, |c| c.world);
|
let outer = r.collapse3d.map_or(Mat4::IDENTITY, |c| c.world);
|
||||||
let world = outer * ctx.world_matrix(layer) * local;
|
let world = outer * ctx.world_matrix(layer) * local;
|
||||||
// Depth of field: blur the layer by its circle of confusion at its centre.
|
// 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).
|
||||||
if let Some(dof) = cam.dof.filter(|_| !ctx.comp.draft_3d && !r.opts.draft) {
|
if let Some(dof) = cam.dof.filter(|_| !ctx.comp.draft_3d && !r.opts.draft) {
|
||||||
let b = buf_bounds(&buf);
|
buf = dof_blur(&cam, &dof, world, buf);
|
||||||
let centre = world.apply(vec3((b[0] + b[2]) * 0.5, (b[1] + b[3]) * 0.5, 0.0));
|
|
||||||
let z = cam.depth(centre);
|
|
||||||
let coc = dof.coc(z);
|
|
||||||
// Comp pixels on screen per layer pixel at that depth.
|
|
||||||
let k = cam.zoom / z.max(NEAR) * world.apply_vec(vec3(1.0, 0.0, 0.0)).length().max(1e-9);
|
|
||||||
let radius = coc * 0.5 / k * buf.scale;
|
|
||||||
if radius > 0.3 {
|
|
||||||
let sigma = radius / 1.5;
|
|
||||||
let pad = (sigma * 3.0).ceil() as u32 + 1;
|
|
||||||
let padded = buf.img.padded(pad);
|
|
||||||
// The (cached, shared) layer buffer stays untouched; the blurred copy is per frame.
|
|
||||||
buf = Arc::new(Buf {
|
|
||||||
img: effectcraft_raster::gaussian_blur(&padded, sigma, sigma, false),
|
|
||||||
offset: [buf.offset[0] + pad as f64, buf.offset[1] + pad as f64],
|
|
||||||
scale: buf.scale,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
let n = if in_run { mb_samples(r, ctx, layer) } else { 1 };
|
let n = if in_run { mb_samples(r, ctx, layer) } else { 1 };
|
||||||
let geos: Vec<Geo> = if n <= 1 {
|
let geos: Vec<Geo> = if n <= 1 {
|
||||||
|
|
@ -269,6 +253,47 @@ fn prepare_with<'a>(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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> {
|
||||||
|
let s = buf.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)));
|
||||||
|
// 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);
|
||||||
|
let mut out = vec![0.0f32; w * h];
|
||||||
|
out.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| {
|
||||||
|
for (x, o) in row.iter_mut().enumerate() {
|
||||||
|
let z = z0 + zx * x as f64 + zy * y as f64;
|
||||||
|
if z <= super::camera::NEAR {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// coc · ½ / (zoom / z · sx) · scale
|
||||||
|
*o = (dof.coc(z) * 0.5 * z / (cam.zoom * sx) * s) as f32;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 rmax = radii.iter().copied().fold(0.0f32, f32::max) as f64;
|
||||||
|
if rmax <= 0.3 {
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
let a = dof.iris.aspect.clamp(0.01, 100.0).sqrt().max(1.0 / dof.iris.aspect.clamp(0.01, 100.0).sqrt());
|
||||||
|
let pad = (rmax * a).ceil() as u32 + 2;
|
||||||
|
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);
|
||||||
|
// 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 })
|
||||||
|
}
|
||||||
|
|
||||||
/// Screen-space track matte factor for a layer, or None.
|
/// Screen-space track matte factor for a layer, or None.
|
||||||
fn matte_for(r: &Renderer, ctx: &EvalCtx, layer: &Layer, out: (u32, u32)) -> Option<Arc<Vec<f32>>> {
|
fn matte_for(r: &Renderer, ctx: &EvalCtx, layer: &Layer, out: (u32, u32)) -> Option<Arc<Vec<f32>>> {
|
||||||
let tm = layer.track_matte?;
|
let tm = layer.track_matte?;
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
//! groups, as in After Effects).
|
//! groups, as in After Effects).
|
||||||
|
|
||||||
pub mod adv;
|
pub mod adv;
|
||||||
|
pub mod bokeh;
|
||||||
pub mod camera;
|
pub mod camera;
|
||||||
pub(crate) mod compose;
|
pub(crate) mod compose;
|
||||||
pub mod light;
|
pub mod light;
|
||||||
|
|
|
||||||
|
|
@ -423,6 +423,63 @@ fn depth_of_field_blurs_out_of_focus_layers() {
|
||||||
assert!(b.get(66, 50)[3] > 0.01);
|
assert!(b.get(66, 50)[3] > 0.01);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tilted_layer_blurs_progressively_with_depth() {
|
||||||
|
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(80.0));
|
||||||
|
set(&mut cam, "cameraOptions/irisShape", Value::Enum(4)); // Hexagon
|
||||||
|
let mut s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 160, 40, [100.0, 50.0, 0.0]);
|
||||||
|
set(&mut s, "transform/rotationY", Value::Scalar(60.0));
|
||||||
|
push(&mut p, cid, cam);
|
||||||
|
push(&mut p, cid, s);
|
||||||
|
let img = render(&p, cid);
|
||||||
|
// The centre column sits on the focal plane: a hard top edge. The ends are 70 px in front of
|
||||||
|
// and behind it: soft.
|
||||||
|
let xs: Vec<i64> = (0..W as i64).filter(|&x| img.get(x, 50)[3] > 0.5).collect();
|
||||||
|
let (x0, x1) = (xs[0], *xs.last().unwrap());
|
||||||
|
let at = |f: f64| x0 + ((x1 - x0) as f64 * f) as i64;
|
||||||
|
let (centre, left, right) = (soft_px(&img, 100), soft_px(&img, at(0.1)), soft_px(&img, at(0.9)));
|
||||||
|
assert!(centre <= 2, "centre {centre}");
|
||||||
|
assert!(left >= 4 && right >= 4, "ends {left} {right}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn iris_shape_and_highlights_shape_the_bokeh() {
|
||||||
|
// A small bright square far out of focus: its blur takes the iris' shape.
|
||||||
|
let spot = |shape: u32, gain: 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(120.0));
|
||||||
|
set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(zoom() * 0.5));
|
||||||
|
set(&mut cam, "cameraOptions/irisShape", Value::Enum(shape));
|
||||||
|
set(&mut cam, "cameraOptions/highlightGain", Value::Scalar(gain));
|
||||||
|
set(&mut cam, "cameraOptions/highlightThreshold", Value::Scalar(200.0));
|
||||||
|
let s = solid3(&mut p, &comp, [1.0, 1.0, 1.0], 2, 2, [100.0, 50.0, 0.0]);
|
||||||
|
push(&mut p, cid, cam);
|
||||||
|
push(&mut p, cid, s);
|
||||||
|
render(&p, cid)
|
||||||
|
};
|
||||||
|
let area = |img: &effectcraft_raster::Image| img.data.iter().filter(|q| q[3] > 1e-4).count();
|
||||||
|
let (tri, dec, rect) = (spot(1, 0.0), spot(8, 0.0), spot(0, 0.0));
|
||||||
|
assert!(area(&tri) * 3 < area(&dec) * 2, "triangle {} vs decagon {}", area(&tri), area(&dec));
|
||||||
|
// Fast Rectangle: a box.
|
||||||
|
let (x0, x1) = (0..W as i64).filter(|&x| rect.get(x, 50)[3] > 1e-4).fold((i64::MAX, 0), |(a, b), x| (a.min(x), b.max(x)));
|
||||||
|
let y_extent = (0..H as i64).filter(|&y| rect.get(100, y)[3] > 1e-4).count() as i64;
|
||||||
|
assert!(((x1 - x0 + 1) - y_extent).abs() <= 2, "square box {} vs {}", x1 - x0 + 1, y_extent);
|
||||||
|
// Highlight Gain brightens the blurred highlight.
|
||||||
|
let lit = spot(8, 100.0);
|
||||||
|
let sum = |img: &effectcraft_raster::Image| img.data.iter().map(|q| q[0]).sum::<f32>();
|
||||||
|
assert!(sum(&lit) > sum(&dec) * 1.5, "{} vs {}", sum(&lit), sum(&dec));
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- views
|
// ---------------------------------------------------------------- views
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue