mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
M13.11: advanced 3d gpu
Advanced 3D runs render entirely on the GPU: every motion-blur sub-sample rasterised, resolved and averaged, the depth-based iris depth of field (Classic 3D bokeh spans, highlight boost, progressive levels), the sRGB encode and the composite over the GPU canvas (Accelerator::render_3d, Renderer::prepare_adv_run, adv3d.wgsl). Wireframe outlines draw on the GPU. Fixes NaN environment lookups for straight up/down normals on Metal. bench --gpu gains an Advanced 3D comp (--adv3d). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
0f591e0856
commit
bb24b931a6
12 files changed
+1286
-75
No files matched your search
@@ -13,7 +13,7 @@
|
||||
//! [--work-area] [--fps N] [--resolution full|half|third|quarter|K] [--quality best|draft] [--channels rgb|rgba]
|
||||
//! [--jpeg-quality N] [--bitrate KBPS] [--prores proxy|lt|standard|hq|4444|4444xq] [--audio auto|on|off]
|
||||
//! effectcraft-cli render F.ecproj --queue render the project's Render Queue
|
||||
//! effectcraft-cli bench [--comp C] [--time S] [--scale K] [--n N] [--play N] [--gpu] render timings
|
||||
//! effectcraft-cli bench [--comp C] [--time S] [--scale K] [--n N] [--play N] [--gpu [--adv3d]] render timings
|
||||
//! (--gpu: CPU vs GPU ms/frame for every comp at Full and Half)
|
||||
//! effectcraft-cli script FILE.jsx [F.ecproj] | --eval CODE run an After Effects-style script
|
||||
//! effectcraft-cli mcp [--bridge PORT] MCP server on stdio
|
||||
@@ -45,7 +45,7 @@ const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|se
|
||||
render [--comp C] --out FILE [--format F] [--start S] [--end S] [--work-area] [--fps N]
|
||||
[--resolution full|half|third|quarter|K] [--quality best|draft] [--channels rgb|rgba]
|
||||
[--jpeg-quality N] [--bitrate KBPS] [--prores PROFILE] [--audio auto|on|off] | --queue
|
||||
bench [--comp C] [--time S] [--scale K] [--n N] [--play N] [--gpu] per-layer/effect render timings;
|
||||
bench [--comp C] [--time S] [--scale K] [--n N] [--play N] [--gpu [--adv3d]] per-layer/effect render timings;
|
||||
--play N renders N consecutive frames with/without the layer cache;
|
||||
--gpu compares CPU and GPU ms/frame for every comp at Full and Half
|
||||
script FILE.jsx [F.ecproj] | --eval CODE run JavaScript with the After Effects-style object model
|
||||
@@ -786,6 +786,109 @@ fn adjustment_bench_comp(p: &mut effectcraft_engine::project::Project) -> Option
|
||||
Some(p.add_item("Adjustment Layers (bench)", effectcraft_engine::color::Label::None, None, ItemKind::Comp(comp.into())))
|
||||
}
|
||||
|
||||
/// `bench --gpu`'s Advanced 3D comp (1920×1080): a ground plane, PBR primitives, extruded
|
||||
/// bevelled text, a shadow-casting spot light, a point light and an environment light, seen
|
||||
/// through a depth-of-field camera (hexagonal iris, highlights) with a motion-blurred sphere.
|
||||
fn adv3d_bench_comp(p: &mut effectcraft_engine::project::Project) -> Option<ItemId> {
|
||||
use effectcraft_engine::keyframe::{Keyframe, TextDoc, Value};
|
||||
use effectcraft_engine::project::build::{self, Ids};
|
||||
use effectcraft_engine::project::{Comp, ItemKind, Layer, LayerSource, LightKind, PrimitiveKind, Renderer as R3, Solid};
|
||||
let (w, h) = (1920u32, 1080u32);
|
||||
let mut comp = Comp::new(w, h, effectcraft_time::FrameRate::FPS_30, Tick::from_seconds_f64(10.0));
|
||||
comp.renderer = R3::Advanced3D;
|
||||
comp.enable_motion_blur = true;
|
||||
comp.motion_blur_samples = 8;
|
||||
let mut layers: Vec<Layer> = vec![];
|
||||
let mut add = |p: &mut effectcraft_engine::project::Project, comp: &Comp, src: LayerSource, edit: &dyn Fn(&mut Layer)| {
|
||||
let mut l = build::layer(p, comp, "L", src, (w, h), None);
|
||||
edit(&mut l);
|
||||
layers.insert(0, l);
|
||||
};
|
||||
fn set(l: &mut Layer, path: &str, v: Value) {
|
||||
if let Some(pr) = l.props.prop_mut(path) {
|
||||
pr.value = v;
|
||||
}
|
||||
}
|
||||
add(p, &comp, LayerSource::Primitive { kind: PrimitiveKind::Plane }, &|l| {
|
||||
set(l, "geometryOptions/width", Value::Scalar(6000.0));
|
||||
set(l, "geometryOptions/height", Value::Scalar(6000.0));
|
||||
set(l, "transform/position", Value::Vec3([960.0, 800.0, 0.0]));
|
||||
set(l, "transform/rotationX", Value::Scalar(90.0));
|
||||
});
|
||||
for (i, kind) in [PrimitiveKind::Sphere, PrimitiveKind::Torus, PrimitiveKind::Cube, PrimitiveKind::Sphere].into_iter().enumerate() {
|
||||
add(p, &comp, LayerSource::Primitive { kind }, &|l| {
|
||||
let x = 400.0 + 380.0 * i as f64;
|
||||
set(l, "geometryOptions/radius", Value::Scalar(140.0));
|
||||
set(l, "geometryOptions/tubeRadius", Value::Scalar(45.0));
|
||||
for k in ["width", "height", "depth"] {
|
||||
set(l, &format!("geometryOptions/{k}"), Value::Scalar(220.0));
|
||||
}
|
||||
set(l, "transform/position", Value::Vec3([x, 640.0, 300.0 * i as f64 - 300.0]));
|
||||
set(l, "transform/rotationX", Value::Scalar(35.0));
|
||||
set(l, "transform/rotationY", Value::Scalar(25.0));
|
||||
set(l, "materialOptions/metallic", Value::Scalar(if i % 2 == 1 { 100.0 } else { 0.0 }));
|
||||
set(l, "materialOptions/roughness", Value::Scalar(30.0));
|
||||
set(l, "materialOptions/castsShadows", Value::Enum(1));
|
||||
if i == 0 {
|
||||
l.switches.motion_blur = true;
|
||||
if let Some(pr) = l.props.prop_mut("transform/position") {
|
||||
pr.keys = vec![
|
||||
Keyframe::new(Tick::ZERO, Value::Vec3([x, 640.0, -300.0])),
|
||||
Keyframe::new(Tick::from_seconds_f64(10.0), Value::Vec3([x + 300.0 * 60.0, 640.0, -300.0])),
|
||||
];
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
add(p, &comp, LayerSource::Text, &|l| {
|
||||
l.switches.three_d = true;
|
||||
let doc = TextDoc { text: "EffectCraft".into(), size: 180.0, fill: [0.95, 0.75, 0.2, 1.0], apply_fill: true, ..Default::default() };
|
||||
set(l, "text/sourceText", Value::Text(Box::new(doc)));
|
||||
let mut next = 900_000u64;
|
||||
l.props.children.push(build::extrusion_geometry_options(&mut Ids(&mut next)).into());
|
||||
set(l, "geometryOptions/extrusionDepth", Value::Scalar(40.0));
|
||||
set(l, "geometryOptions/bevelStyle", Value::Enum(1));
|
||||
set(l, "transform/position", Value::Vec3([960.0, 260.0, 200.0]));
|
||||
set(l, "transform/rotationY", Value::Scalar(-15.0));
|
||||
});
|
||||
let env = p.add_item(
|
||||
"Environment (bench)",
|
||||
effectcraft_engine::color::Label::None,
|
||||
None,
|
||||
ItemKind::Solid(Solid { color: [0.55, 0.65, 0.9], width: 64, height: 32, pixel_aspect: 1.0 }),
|
||||
);
|
||||
add(p, &comp, LayerSource::Solid { item: env }, &|l| {
|
||||
l.environment = true;
|
||||
l.switches.three_d = true;
|
||||
});
|
||||
add(p, &comp, LayerSource::Light { kind: LightKind::Environment }, &|l| set(l, "lightOptions/intensity", Value::Scalar(40.0)));
|
||||
add(p, &comp, LayerSource::Light { kind: LightKind::Spot }, &|l| {
|
||||
set(l, "transform/position", Value::Vec3([500.0, -600.0, -900.0]));
|
||||
set(l, "transform/poi", Value::Vec3([960.0, 640.0, 0.0]));
|
||||
set(l, "lightOptions/castsShadows", Value::Bool(true));
|
||||
set(l, "lightOptions/coneAngle", Value::Scalar(100.0));
|
||||
set(l, "lightOptions/shadowDiffusion", Value::Scalar(8.0));
|
||||
});
|
||||
add(p, &comp, LayerSource::Light { kind: LightKind::Point }, &|l| {
|
||||
set(l, "transform/position", Value::Vec3([1500.0, 200.0, -500.0]));
|
||||
set(l, "lightOptions/intensity", Value::Scalar(50.0));
|
||||
});
|
||||
add(p, &comp, LayerSource::Camera, &|l| {
|
||||
let zoom = match l.props.prop_mut("cameraOptions/zoom").map(|pr| pr.value.clone()) {
|
||||
Some(Value::Scalar(z)) => z,
|
||||
_ => 2666.7,
|
||||
};
|
||||
set(l, "cameraOptions/dof", Value::Bool(true));
|
||||
set(l, "cameraOptions/focusDistance", Value::Scalar(zoom));
|
||||
set(l, "cameraOptions/aperture", Value::Scalar(120.0));
|
||||
set(l, "cameraOptions/irisShape", Value::Enum(4));
|
||||
set(l, "cameraOptions/highlightGain", Value::Scalar(20.0));
|
||||
set(l, "cameraOptions/highlightThreshold", Value::Scalar(200.0));
|
||||
});
|
||||
comp.layers = layers;
|
||||
Some(p.add_item("Advanced 3D (bench)", effectcraft_engine::color::Label::None, None, ItemKind::Comp(comp.into())))
|
||||
}
|
||||
|
||||
/// `bench --gpu`: CPU vs GPU ms/frame for every comp (or `--comp`) at Full and Half. "cold"
|
||||
/// renders everything (no layer cache); "warm" reuses the layer cache (playback / scrubbing of
|
||||
/// unchanged layers: compositing cost); "viewer" is the GPU display path without readback.
|
||||
@@ -794,13 +897,16 @@ fn bench_gpu(s: &Session, args: &Args) -> Result<(), Failure> {
|
||||
let gpu = effectcraft_gpu::Gpu::headless().ok_or_else(|| Failure::Error("--gpu: no usable GPU adapter".into()))?;
|
||||
let n = args.num("--n")?.unwrap_or(10.0).max(1.0) as usize;
|
||||
// Every comp, plus an adjustment-layer comp built for the benchmark (the main comp under a
|
||||
// full-frame adjustment layer with a GPU effect stack).
|
||||
// full-frame adjustment layer with a GPU effect stack) and an Advanced 3D comp (`--adv3d`:
|
||||
// that one only).
|
||||
let mut project = (*s.project).clone();
|
||||
let comps: Vec<ItemId> = match args.opt("--comp") {
|
||||
_ if args.flag("--adv3d") => adv3d_bench_comp(&mut project).into_iter().collect(),
|
||||
Some(_) => s.active_comp_id().into_iter().collect(),
|
||||
None => {
|
||||
let mut v: Vec<ItemId> = s.project.comps().map(|(id, _)| *id).collect();
|
||||
v.extend(adjustment_bench_comp(&mut project));
|
||||
v.extend(adv3d_bench_comp(&mut project));
|
||||
v
|
||||
}
|
||||
};
|
||||
@@ -848,7 +954,17 @@ fn bench_gpu(s: &Session, args: &Args) -> Result<(), Failure> {
|
||||
// Agreement: share of pixels where the GPU frame differs from the CPU reference by
|
||||
// more than 1/255 on any channel.
|
||||
let (a, b) = (mk(Backend::Cpu, Some(&cache)), mk(Backend::Gpu, Some(&gcache)));
|
||||
let off = a.data.iter().zip(&b.data).filter(|(p, q)| (0..4).any(|c| (p[c] - q[c]).abs() > 1.0 / 255.0 + 1e-6)).count();
|
||||
let off = a
|
||||
.data
|
||||
.iter()
|
||||
.zip(&b.data)
|
||||
.filter(|(p, q)| {
|
||||
(0..4).any(|c| {
|
||||
let d = (p[c] - q[c]).abs();
|
||||
d.is_nan() || d > 1.0 / 255.0 + 1e-6
|
||||
})
|
||||
})
|
||||
.count();
|
||||
let pct = 100.0 * off as f64 / a.data.len().max(1) as f64;
|
||||
let speedup = cpu_warm / gpu_warm.max(1e-9);
|
||||
eprintln!(
|
||||
|
||||
+416
-12
@@ -2,19 +2,33 @@
|
||||
//! (`Depth32Float`, Greater), the scene's materials, textures, lights and shadow maps in
|
||||
//! storage buffers, and `advanced3d.wgsl`'s physically based fragment shader. Opaque triangles
|
||||
//! are drawn with depth writes, then the sorted transparent ones blended over (premultiplied).
|
||||
//! Colour (`Rgba16Float`) and camera depth (`R32Float`) are read back for the CPU's resolve,
|
||||
//! depth of field and encoding.
|
||||
//! Colour (`Rgba16Float`) and camera depth (`R32Float`) stay on the GPU for `adv3d.wgsl`'s
|
||||
//! compute kernels, which run the rest of `effectcraft_render::three_d::adv::render_prepared`:
|
||||
//! the 2×2 supersampling resolve, the motion-blur sub-sample average (nearest depth), the
|
||||
//! depth-based iris depth of field (the Classic 3D bokeh kernel's row spans and prefix-sum
|
||||
//! gather, highlight boost, progressive blend between blur levels), the sRGB encode and the
|
||||
//! premultiplied "over" onto the compositor's canvas. Only the depth of field reads anything
|
||||
//! back (its 8-byte radius range, to choose the blur levels as the CPU does).
|
||||
//!
|
||||
//! Wireframe-quality layers draw their outlines here too ([`wireframe`]).
|
||||
|
||||
use effectcraft_render::three_d::adv::{Scene, Target};
|
||||
use effectcraft_render::three_d::Dof;
|
||||
use effectcraft_render::three_d::adv::{Prepared, Rendered, Scene, Target};
|
||||
use effectcraft_render::three_d::bokeh;
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
use crate::context::{Enc, GpuContext};
|
||||
use crate::context::{Enc, GpuContext, GpuImage};
|
||||
|
||||
/// Pipelines (built on first use).
|
||||
pub(crate) struct Pipes {
|
||||
bgl: wgpu::BindGroupLayout,
|
||||
opaque: wgpu::RenderPipeline,
|
||||
transparent: wgpu::RenderPipeline,
|
||||
post: Post,
|
||||
}
|
||||
|
||||
fn pipes(g: &GpuContext) -> &Pipes {
|
||||
g.adv3d.get_or_init(|| Pipes::new(&g.device))
|
||||
}
|
||||
|
||||
const COLOR: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
@@ -96,7 +110,7 @@ impl Pipes {
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
Pipes { opaque: make(false), transparent: make(true), bgl }
|
||||
Pipes { opaque: make(false), transparent: make(true), bgl, post: Post::new(device) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,9 +222,12 @@ fn half_to_f32(h: u16) -> f32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rasterise a scene. `None` when the device can't (no readback, size or buffer limits).
|
||||
pub(crate) fn render(g: &GpuContext, s: &Scene) -> Option<Target> {
|
||||
if !g.can_readback() || !g.fits(s.width, s.height) || s.indices.is_empty() {
|
||||
/// Record the render pass of one scene into `e`: (colour `Rgba16Float`, camera depth `R32Float`
|
||||
/// with −1 where nothing was drawn) at the scene's raster size. `None` when the device can't
|
||||
/// (size or buffer limits).
|
||||
fn raster_into(e: &mut Enc, s: &Scene) -> Option<(wgpu::Texture, wgpu::Texture)> {
|
||||
let g = e.g;
|
||||
if !g.fits(s.width, s.height) || s.indices.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let limits = g.device.limits();
|
||||
@@ -218,7 +235,7 @@ pub(crate) fn render(g: &GpuContext, s: &Scene) -> Option<Target> {
|
||||
if bufs.iter().any(|b| b.len() as u64 > limits.max_storage_buffer_binding_size) {
|
||||
return None;
|
||||
}
|
||||
let p = g.adv3d.get_or_init(|| Pipes::new(&g.device));
|
||||
let p = pipes(g);
|
||||
let dev = &g.device;
|
||||
let vb: Vec<u8> = s
|
||||
.vertices
|
||||
@@ -257,12 +274,12 @@ pub(crate) fn render(g: &GpuContext, s: &Scene) -> Option<Target> {
|
||||
view_formats: &[],
|
||||
})
|
||||
};
|
||||
let rt = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC;
|
||||
let rt = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::TEXTURE_BINDING;
|
||||
let color = tex(COLOR, rt);
|
||||
let zout = tex(DEPTH_OUT, rt);
|
||||
let depth = tex(DEPTH, wgpu::TextureUsages::RENDER_ATTACHMENT);
|
||||
let (cv, zv, dv) = (color.create_view(&Default::default()), zout.create_view(&Default::default()), depth.create_view(&Default::default()));
|
||||
let mut enc = dev.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("adv3d") });
|
||||
let enc = e.encoder();
|
||||
{
|
||||
let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("adv3d"),
|
||||
@@ -302,8 +319,17 @@ pub(crate) fn render(g: &GpuContext, s: &Scene) -> Option<Target> {
|
||||
pass.draw_indexed(s.opaque_count..n, 0, 0..1);
|
||||
}
|
||||
}
|
||||
g.queue.submit([enc.finish()]);
|
||||
Some((color, zout))
|
||||
}
|
||||
|
||||
/// Rasterise a scene and read it back ([`effectcraft_render::Accelerator::raster_3d`]). `None`
|
||||
/// when the device can't (no readback, size or buffer limits).
|
||||
pub(crate) fn render(g: &GpuContext, s: &Scene) -> Option<Target> {
|
||||
if !g.can_readback() {
|
||||
return None;
|
||||
}
|
||||
let mut e = Enc::new(g);
|
||||
let (color, zout) = raster_into(&mut e, s)?;
|
||||
let cb = e.read_texture(&color, s.width, s.height, 8)?;
|
||||
let zb = e.read_texture(&zout, s.width, s.height, 4)?;
|
||||
let color = cb.chunks_exact(8).map(|c| [0, 1, 2, 3].map(|k| half_to_f32(u16::from_le_bytes([c[2 * k], c[2 * k + 1]])))).collect();
|
||||
@@ -316,3 +342,381 @@ pub(crate) fn render(g: &GpuContext, s: &Scene) -> Option<Target> {
|
||||
.collect();
|
||||
Some(Target { width: s.width, height: s.height, color, depth })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- after the rasteriser
|
||||
|
||||
/// Compute kernels of `adv3d.wgsl` (resolve, motion-blur accumulation, depth of field,
|
||||
/// encoding, compositing, wireframes) with their shared bind group layout and the placeholder
|
||||
/// resources bound to the slots a kernel doesn't use.
|
||||
pub(crate) struct Post {
|
||||
bgl: wgpu::BindGroupLayout,
|
||||
kernels: Vec<(&'static str, wgpu::ComputePipeline)>,
|
||||
/// Placeholders for bindings 3–9 (distinct buffers: writable bindings must not alias).
|
||||
dummy_bufs: Vec<wgpu::Buffer>,
|
||||
dummy_color: wgpu::TextureView,
|
||||
dummy_z: wgpu::TextureView,
|
||||
dummy_out: wgpu::TextureView,
|
||||
}
|
||||
|
||||
const KERNELS: &[&str] = &["resolve", "radius_of", "boost", "prefix_rows", "gather", "finish", "wire"];
|
||||
|
||||
impl Post {
|
||||
fn new(device: &wgpu::Device) -> Post {
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("effectcraft advanced 3d post"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/adv3d.wgsl").into()),
|
||||
});
|
||||
let tex = |binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
};
|
||||
let buf = |binding, read_only| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { read_only }, has_dynamic_offset: false, min_binding_size: None },
|
||||
count: None,
|
||||
};
|
||||
let entries = [
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None },
|
||||
count: None,
|
||||
},
|
||||
tex(1),
|
||||
tex(2),
|
||||
buf(3, false),
|
||||
buf(4, false),
|
||||
buf(5, false),
|
||||
buf(6, true),
|
||||
buf(7, false),
|
||||
buf(8, false),
|
||||
buf(9, false),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 10,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::StorageTexture {
|
||||
access: wgpu::StorageTextureAccess::WriteOnly,
|
||||
format: crate::context::FORMAT,
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
];
|
||||
let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("advanced 3d post"), entries: &entries });
|
||||
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("advanced 3d post"),
|
||||
bind_group_layouts: &[Some(&bgl)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let kernels = KERNELS
|
||||
.iter()
|
||||
.map(|k| {
|
||||
let p = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some(k),
|
||||
layout: Some(&layout),
|
||||
module: &module,
|
||||
entry_point: Some(k),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
(*k, p)
|
||||
})
|
||||
.collect();
|
||||
let dummy_bufs = (3..=9)
|
||||
.map(|_| {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("adv3d dummy"),
|
||||
size: 16,
|
||||
usage: wgpu::BufferUsages::STORAGE,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let tex1 = |format, usage| {
|
||||
device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("adv3d dummy"),
|
||||
size: wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 },
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default())
|
||||
};
|
||||
Post {
|
||||
bgl,
|
||||
kernels,
|
||||
dummy_bufs,
|
||||
dummy_color: tex1(COLOR, wgpu::TextureUsages::TEXTURE_BINDING),
|
||||
dummy_z: tex1(DEPTH_OUT, wgpu::TextureUsages::TEXTURE_BINDING),
|
||||
dummy_out: tex1(crate::context::FORMAT, wgpu::TextureUsages::STORAGE_BINDING),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The resources of one post kernel dispatch (unset slots get placeholders).
|
||||
#[derive(Default)]
|
||||
struct Binds<'b> {
|
||||
color: Option<&'b wgpu::TextureView>,
|
||||
z: Option<&'b wgpu::TextureView>,
|
||||
/// Bindings 3–9: acc, depth, src, table, radius, range, prefix.
|
||||
bufs: [Option<&'b wgpu::Buffer>; 7],
|
||||
out: Option<&'b wgpu::TextureView>,
|
||||
}
|
||||
|
||||
/// Uniform block of the post kernels (`Post` in `adv3d.wgsl`).
|
||||
fn params(u0: [u32; 4], f0: [f32; 4]) -> Vec<u8> {
|
||||
let mut v: Vec<u8> = u0.iter().flat_map(|x| x.to_le_bytes()).collect();
|
||||
v.extend([0u8; 16]);
|
||||
v.extend(f0.iter().flat_map(|x| x.to_le_bytes()));
|
||||
v.extend([0u8; 16]);
|
||||
v
|
||||
}
|
||||
|
||||
fn dispatch(e: &mut Enc, kernel: &str, u0: [u32; 4], f0: [f32; 4], b: Binds, groups: (u32, u32)) {
|
||||
let g = e.g;
|
||||
let post = &pipes(g).post;
|
||||
let Some((_, pipe)) = post.kernels.iter().find(|(k, _)| *k == kernel) else { return };
|
||||
let ub = g.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: None, contents: ¶ms(u0, f0), usage: wgpu::BufferUsages::UNIFORM });
|
||||
let mut entries = vec![
|
||||
wgpu::BindGroupEntry { binding: 0, resource: ub.as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(b.color.unwrap_or(&post.dummy_color)) },
|
||||
wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(b.z.unwrap_or(&post.dummy_z)) },
|
||||
];
|
||||
for (i, buf) in b.bufs.iter().enumerate() {
|
||||
entries.push(wgpu::BindGroupEntry { binding: 3 + i as u32, resource: buf.unwrap_or(&post.dummy_bufs[i]).as_entire_binding() });
|
||||
}
|
||||
entries.push(wgpu::BindGroupEntry { binding: 10, resource: wgpu::BindingResource::TextureView(b.out.unwrap_or(&post.dummy_out)) });
|
||||
let bg = g.device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some(kernel), layout: &post.bgl, entries: &entries });
|
||||
let enc = e.encoder();
|
||||
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some(kernel), timestamp_writes: None });
|
||||
pass.set_pipeline(pipe);
|
||||
pass.set_bind_group(0, &bg, &[]);
|
||||
pass.dispatch_workgroups(groups.0.max(1), groups.1.max(1), 1);
|
||||
}
|
||||
|
||||
fn pixel_groups(w: u32, h: u32) -> (u32, u32) {
|
||||
(w.div_ceil(16), h.div_ceil(16))
|
||||
}
|
||||
|
||||
fn storage_buf(g: &GpuContext, size: u64, label: &str) -> wgpu::Buffer {
|
||||
g.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some(label),
|
||||
size: size.max(16),
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read a storage buffer back (submits and waits).
|
||||
fn read_buffer(e: &mut Enc, buf: &wgpu::Buffer, size: u64) -> Option<Vec<u8>> {
|
||||
if !e.g.can_readback() {
|
||||
return None;
|
||||
}
|
||||
let rb = e.g.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("adv3d readback"),
|
||||
size,
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
e.encoder().copy_buffer_to_buffer(buf, 0, &rb, 0, size);
|
||||
e.submit();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
rb.map_async(wgpu::MapMode::Read, .., move |r| {
|
||||
let _ = tx.send(r);
|
||||
});
|
||||
e.g.device.poll(wgpu::PollType::wait_indefinitely()).ok()?;
|
||||
rx.recv().ok()?.ok()?;
|
||||
let out = rb.get_mapped_range(..).ok()?.to_vec();
|
||||
rb.unmap();
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// A resolved run on the GPU: premultiplied linear (or working-space) colour and camera depth
|
||||
/// (`BIG` where nothing was drawn), one entry per output pixel.
|
||||
struct Resolved {
|
||||
acc: wgpu::Buffer,
|
||||
depth: wgpu::Buffer,
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
/// The rasteriser's "nothing drawn" depth on the GPU (∞ on the CPU).
|
||||
const BIG: f32 = 3.0e38;
|
||||
|
||||
/// Rasterise every motion-blur sub-sample of a prepared run, resolve and average them, then
|
||||
/// apply the depth of field (`render_prepared`'s steps). `None` = can't run here (sizes,
|
||||
/// limits, or a depth of field without readback); `Some(None)` = nothing drawn.
|
||||
fn resolve_run(e: &mut Enc, prep: &Prepared) -> Option<Option<Resolved>> {
|
||||
let (w, h) = prep.out;
|
||||
let g = e.g;
|
||||
if !g.fits(w, h) || (prep.dof.is_some() && !g.can_readback()) {
|
||||
return None;
|
||||
}
|
||||
let px = w as u64 * h as u64;
|
||||
let max = g.device.limits().max_storage_buffer_binding_size;
|
||||
if px * 16 > max {
|
||||
return None;
|
||||
}
|
||||
let acc = storage_buf(g, px * 16, "adv3d acc");
|
||||
let depth = storage_buf(g, px * 4, "adv3d depth");
|
||||
let n = prep.samples.max(1);
|
||||
let weight = if n <= 1 { 1.0 } else { 1.0 / n as f32 };
|
||||
let mut first = true;
|
||||
for i in 0..n {
|
||||
let s = prep.scene(i);
|
||||
if s.indices.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if s.width != w * s.ssaa.max(1) || s.height != h * s.ssaa.max(1) {
|
||||
return None;
|
||||
}
|
||||
let (color, zout) = raster_into(e, &s)?;
|
||||
let (cv, zv) = (color.create_view(&Default::default()), zout.create_view(&Default::default()));
|
||||
let b = Binds { color: Some(&cv), z: Some(&zv), bufs: [Some(&acc), Some(&depth), None, None, None, None, None], out: None };
|
||||
dispatch(e, "resolve", [w, h, s.ssaa.max(1), first as u32], [weight, 0.0, 0.0, 0.0], b, pixel_groups(w, h));
|
||||
first = false;
|
||||
// One sub-sample's targets alive at a time.
|
||||
e.submit();
|
||||
}
|
||||
if first {
|
||||
return Some(None);
|
||||
}
|
||||
if let Some(dof) = &prep.dof {
|
||||
depth_of_field(e, &acc, &depth, (w, h), dof, prep.scale)?;
|
||||
}
|
||||
Some(Some(Resolved { acc, depth, width: w, height: h }))
|
||||
}
|
||||
|
||||
/// `adv::depth_of_field` on the resolved buffers: radii, highlights, prefix sums, the iris
|
||||
/// gather at the levels around each pixel's radius. The result replaces `acc`.
|
||||
fn depth_of_field(e: &mut Enc, acc: &wgpu::Buffer, depth: &wgpu::Buffer, (w, h): (u32, u32), dof: &Dof, scale: f64) -> Option<()> {
|
||||
let g = e.g;
|
||||
let px = w as u64 * h as u64;
|
||||
if (w as u64 + 1) * h as u64 * 16 > g.device.limits().max_storage_buffer_binding_size {
|
||||
return None;
|
||||
}
|
||||
let radius = storage_buf(g, px * 4, "adv3d radius");
|
||||
let mut init = Vec::with_capacity(16);
|
||||
for v in [f32::INFINITY.to_bits(), 0, 0, 0] {
|
||||
init.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
let range = g.device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("adv3d radius range"),
|
||||
contents: &init,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
|
||||
});
|
||||
let f0 = [dof.focus as f32, dof.aperture as f32, dof.blur_level as f32, scale as f32];
|
||||
let b = Binds { bufs: [None, Some(depth), None, None, Some(&radius), Some(&range), None], ..Default::default() };
|
||||
dispatch(e, "radius_of", [w, h, 0, 0], f0, b, pixel_groups(w, h));
|
||||
let rb = read_buffer(e, &range, 16)?;
|
||||
let lo = f32::from_bits(u32::from_le_bytes([rb[0], rb[1], rb[2], rb[3]]));
|
||||
let hi = f32::from_bits(u32::from_le_bytes([rb[4], rb[5], rb[6], rb[7]]));
|
||||
if hi < 0.5 {
|
||||
return Some(());
|
||||
}
|
||||
let Some(levels) = bokeh::progressive_levels(lo, hi) else { return Some(()) };
|
||||
// Level headers, then every level's weighted spans.
|
||||
let mut heads: Vec<[f32; 4]> = vec![];
|
||||
let mut spans: Vec<[f32; 4]> = vec![];
|
||||
for &r in &levels {
|
||||
match bokeh::kernel(&dof.iris, r as f64) {
|
||||
Some((kernels, norm)) => {
|
||||
let first = levels.len() + spans.len();
|
||||
for (wt, sp) in &kernels {
|
||||
spans.extend(sp.iter().map(|&(dy, x0, x1)| [dy as f32, x0 as f32, x1 as f32, *wt]));
|
||||
}
|
||||
let count = levels.len() + spans.len() - first;
|
||||
heads.push([first as f32, count as f32, 1.0 / norm, 0.0]);
|
||||
}
|
||||
None => heads.push([0.0, 0.0, 0.0, 1.0]),
|
||||
}
|
||||
}
|
||||
let table: Vec<u8> = heads.iter().chain(&spans).flatten().flat_map(|x| x.to_le_bytes()).collect();
|
||||
if table.len() as u64 > g.device.limits().max_storage_buffer_binding_size {
|
||||
return None;
|
||||
}
|
||||
let table = e.bytes(table);
|
||||
let src = storage_buf(g, px * 16, "adv3d dof source");
|
||||
let prefix = storage_buf(g, (w as u64 + 1) * h as u64 * 16, "adv3d prefix");
|
||||
let hl = dof.highlight;
|
||||
let b = Binds { bufs: [Some(acc), None, Some(&src), None, Some(&radius), None, None], ..Default::default() };
|
||||
dispatch(e, "boost", [w, h, 0, 0], [hl.gain as f32, hl.threshold as f32, hl.saturation as f32, 0.0], b, pixel_groups(w, h));
|
||||
let b = Binds { bufs: [None, None, Some(&src), None, None, None, Some(&prefix)], ..Default::default() };
|
||||
dispatch(e, "prefix_rows", [w, h, 0, 0], [0.0; 4], b, (h.div_ceil(64), 1));
|
||||
let b = Binds { bufs: [Some(acc), None, Some(&src), Some(&table), Some(&radius), None, Some(&prefix)], ..Default::default() };
|
||||
dispatch(e, "gather", [w, h, levels.len() as u32, 0], [lo, hi, 0.0, 0.0], b, pixel_groups(w, h));
|
||||
e.submit();
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// The resolved run as an image in the canvas's encoding, composited over `canvas` when given.
|
||||
fn finish(e: &mut Enc, r: &Resolved, encode: bool, canvas: Option<&GpuImage>) -> GpuImage {
|
||||
let out = e.image(r.width, r.height);
|
||||
let ov = out.texture.create_view(&Default::default());
|
||||
let cv = canvas.map(|c| c.texture.create_view(&Default::default()));
|
||||
let b = Binds { color: cv.as_ref(), bufs: [Some(&r.acc), None, None, None, None, None, None], out: Some(&ov), ..Default::default() };
|
||||
dispatch(e, "finish", [r.width, r.height, encode as u32, canvas.is_some() as u32], [0.0; 4], b, pixel_groups(r.width, r.height));
|
||||
out
|
||||
}
|
||||
|
||||
/// [`effectcraft_render::Accelerator::render_3d`]: the whole prepared run on the GPU, read
|
||||
/// back.
|
||||
pub(crate) fn render_prepared(g: &GpuContext, prep: &Prepared) -> Option<Rendered> {
|
||||
if !g.can_readback() {
|
||||
return None;
|
||||
}
|
||||
let mut e = Enc::new(g);
|
||||
let Some(r) = resolve_run(&mut e, prep)? else { return Some(None) };
|
||||
let img = finish(&mut e, &r, !prep.linear, None);
|
||||
let img = e.download(&img)?;
|
||||
let px = r.width as u64 * r.height as u64;
|
||||
let db = read_buffer(&mut e, &r.depth, px * 4)?;
|
||||
let depth = db
|
||||
.chunks_exact(4)
|
||||
.map(|c| {
|
||||
let z = f32::from_le_bytes([c[0], c[1], c[2], c[3]]);
|
||||
if z >= BIG * 0.5 { f32::INFINITY } else { z }
|
||||
})
|
||||
.collect();
|
||||
Some(Some((img, depth)))
|
||||
}
|
||||
|
||||
/// Draw a prepared run over the GPU canvas (the compositor's walk; no readback unless the
|
||||
/// depth of field needs its radius range). `None` = render it on the CPU.
|
||||
pub(crate) fn draw_run(e: &mut Enc, prep: &Prepared, canvas: &GpuImage) -> Option<GpuImage> {
|
||||
if (canvas.width, canvas.height) != prep.out {
|
||||
return None;
|
||||
}
|
||||
match resolve_run(e, prep)? {
|
||||
None => Some(canvas.clone()),
|
||||
Some(r) => Some(finish(e, &r, !prep.linear, Some(canvas))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Wireframe-quality layer outlines: `canvas` with `pixels` set to opaque white
|
||||
/// ([`effectcraft_render::Renderer::wireframe_pixels`]).
|
||||
pub(crate) fn wireframe(e: &mut Enc, canvas: &GpuImage, pixels: &[(u32, u32)]) -> GpuImage {
|
||||
let out = e.image(canvas.width, canvas.height);
|
||||
e.copy_into(canvas, &out, 0, 0);
|
||||
if pixels.is_empty() {
|
||||
return out;
|
||||
}
|
||||
let table: Vec<u8> = pixels.iter().flat_map(|&(x, y)| [x as f32, y as f32, 0.0, 0.0]).flat_map(f32::to_le_bytes).collect();
|
||||
let table = e.bytes(table);
|
||||
let ov = out.texture.create_view(&Default::default());
|
||||
let b = Binds { bufs: [None, None, None, Some(&table), None, None, None], out: Some(&ov), ..Default::default() };
|
||||
let n = pixels.len() as u32;
|
||||
dispatch(e, "wire", [n, 0, 0, 0], [0.0; 4], b, (n.div_ceil(64), 1));
|
||||
out
|
||||
}
|
||||
@@ -394,7 +394,7 @@ impl<'g> Enc<'g> {
|
||||
Enc { g, enc: None, pending: 0 }
|
||||
}
|
||||
|
||||
fn encoder(&mut self) -> &mut wgpu::CommandEncoder {
|
||||
pub(crate) fn encoder(&mut self) -> &mut wgpu::CommandEncoder {
|
||||
let g = self.g;
|
||||
self.enc.get_or_insert_with(|| g.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("effectcraft") }))
|
||||
}
|
||||
|
||||
+10
-3
@@ -8,11 +8,14 @@
|
||||
//! mattes, Preserve Transparency, layer styles' passes, all 38 blend modes and the 8/16 bpc
|
||||
//! clamping and quantisation, and converts colour spaces. Classic 3D runs composite here too
|
||||
//! (`classic3d`: per-pixel fragment sort, lights, ray-cast shadows), and adjustment layers run
|
||||
//! their effect stacks on the GPU-resident comp. Advanced 3D compositing and wireframes run on
|
||||
//! the CPU between GPU steps (read back, draw, upload).
|
||||
//! their effect stacks on the GPU-resident comp. Wireframe-quality outlines are drawn here too.
|
||||
//!
|
||||
//! Advanced 3D comps rasterise on a render pipeline (`advanced3d.wgsl`: depth buffer, PBR,
|
||||
//! image-based light, shadow maps), see [`Accelerator::raster_3d`].
|
||||
//! image-based light, shadow maps) and finish in compute kernels (`adv3d.wgsl`: supersampling
|
||||
//! resolve, motion-blur sub-samples, iris depth of field, encoding, compositing), see
|
||||
//! [`Accelerator::render_3d`] and `Renderer::prepare_adv_run`. Advanced 3D layers with blend
|
||||
//! modes, track mattes or Preserve Transparency still composite on the CPU (their scenes
|
||||
//! render here through [`Accelerator::render_3d`]).
|
||||
//!
|
||||
//! GPU effects ([`effectcraft_effects::GPU_EFFECTS`]) run as compute kernels with the CPU
|
||||
//! effect's exact steps (padding, box-blur radii, parameter conversions); chains of them are
|
||||
@@ -147,6 +150,10 @@ impl Accelerator for Gpu {
|
||||
adv3d::render(&self.ctx, scene)
|
||||
}
|
||||
|
||||
fn render_3d(&self, run: &effectcraft_render::three_d::adv::Prepared) -> Option<effectcraft_render::three_d::adv::Rendered> {
|
||||
adv3d::render_prepared(&self.ctx, run)
|
||||
}
|
||||
|
||||
fn particles(&self) -> Option<&dyn effectcraft_effects::psim::ParticleSim> {
|
||||
self.ctx.can_readback().then_some(self as &dyn effectcraft_effects::psim::ParticleSim)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
// Advanced 3D on the GPU after the rasteriser (`advanced3d.wgsl`): the steps of
|
||||
// `effectcraft_render::three_d::adv::render_prepared` on GPU-resident buffers, operation for
|
||||
// operation:
|
||||
//
|
||||
// resolve box-filter the 2×2 supersampled colour and camera depth of one motion-blur
|
||||
// sub-sample and add it to the accumulation (weight 1/n; depth = nearest)
|
||||
// radius each pixel's depth-of-field blur radius (`Dof::coc` · scale / 2, ≤ 48) and the
|
||||
// range over the frame (atomics on the float bits; radii are ≥ 0)
|
||||
// boost Highlight Gain / Threshold / Saturation (`bokeh::boost_highlights`), blended in
|
||||
// where the pixel is blurred
|
||||
// prefix per-row prefix sums (`bokeh::row_prefix`)
|
||||
// gather the iris-shaped gather at the two blur levels around each pixel's radius
|
||||
// (`bokeh::span_gather` on `bokeh::kernel` spans) blended (`progressive_blur`)
|
||||
// finish linear → sRGB (`adv::encode`) and premultiplied "over" the canvas (`draw_run`)
|
||||
// wire Wireframe-quality outlines: set the listed pixels to opaque white
|
||||
//
|
||||
// Depth "nothing drawn" is BIG on the GPU (∞ on the CPU).
|
||||
|
||||
struct Post {
|
||||
u0: vec4<u32>,
|
||||
u1: vec4<u32>,
|
||||
f0: vec4<f32>,
|
||||
f1: vec4<f32>,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> P: Post;
|
||||
@group(0) @binding(1) var color_in: texture_2d<f32>;
|
||||
@group(0) @binding(2) var z_in: texture_2d<f32>;
|
||||
@group(0) @binding(3) var<storage, read_write> acc: array<vec4<f32>>;
|
||||
@group(0) @binding(4) var<storage, read_write> depth: array<f32>;
|
||||
@group(0) @binding(5) var<storage, read_write> src: array<vec4<f32>>;
|
||||
@group(0) @binding(6) var<storage, read> table: array<vec4<f32>>;
|
||||
@group(0) @binding(7) var<storage, read_write> radius: array<f32>;
|
||||
@group(0) @binding(8) var<storage, read_write> range: array<atomic<u32>>;
|
||||
@group(0) @binding(9) var<storage, read_write> prefix: array<vec4<f32>>;
|
||||
@group(0) @binding(10) var out: texture_storage_2d<rgba32float, write>;
|
||||
|
||||
const BIG: f32 = 3.0e38;
|
||||
const MAX_RADIUS: f32 = 48.0;
|
||||
const NEAR: f32 = 1.0;
|
||||
|
||||
// P.u0 = (width, height, ssaa, first), P.f0.x = weight
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn resolve(@builtin(global_invocation_id) id: vec3<u32>) {
|
||||
let w = P.u0.x;
|
||||
let h = P.u0.y;
|
||||
if (id.x >= w || id.y >= h) {
|
||||
return;
|
||||
}
|
||||
let k = P.u0.z;
|
||||
var a = vec4<f32>(0.0);
|
||||
var dz = 0.0;
|
||||
var dn = 0.0;
|
||||
for (var j = 0u; j < k; j = j + 1u) {
|
||||
for (var i = 0u; i < k; i = i + 1u) {
|
||||
let p = vec2<i32>(i32(id.x * k + i), i32(id.y * k + j));
|
||||
a = a + textureLoad(color_in, p, 0);
|
||||
let z = textureLoad(z_in, p, 0).x;
|
||||
if (z >= 0.0) {
|
||||
dz = dz + z;
|
||||
dn = dn + 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
let n = f32(k * k);
|
||||
let res = a / n;
|
||||
var d = BIG;
|
||||
if (dn > 0.0) {
|
||||
d = dz / dn;
|
||||
}
|
||||
let ix = id.y * w + id.x;
|
||||
let wt = P.f0.x;
|
||||
if (P.u0.w == 1u) {
|
||||
acc[ix] = res * wt;
|
||||
depth[ix] = d;
|
||||
} else {
|
||||
acc[ix] = acc[ix] + res * wt;
|
||||
depth[ix] = min(depth[ix], d);
|
||||
}
|
||||
}
|
||||
|
||||
// P.u0 = (width, height), P.f0 = (focus, aperture, blur level, scale)
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn radius_of(@builtin(global_invocation_id) id: vec3<u32>) {
|
||||
let w = P.u0.x;
|
||||
if (id.x >= w || id.y >= P.u0.y) {
|
||||
return;
|
||||
}
|
||||
let ix = id.y * w + id.x;
|
||||
var z = depth[ix];
|
||||
if (z >= BIG * 0.5) {
|
||||
z = 1.0e9;
|
||||
}
|
||||
var coc = 0.0;
|
||||
if (z > NEAR && P.f0.x > 0.0) {
|
||||
coc = P.f0.y * abs(z - P.f0.x) / z * P.f0.z;
|
||||
}
|
||||
let r = min(coc * P.f0.w * 0.5, MAX_RADIUS);
|
||||
radius[ix] = r;
|
||||
atomicMin(&range[0], bitcast<u32>(r));
|
||||
atomicMax(&range[1], bitcast<u32>(r));
|
||||
}
|
||||
|
||||
fn luminance(c: vec3<f32>) -> f32 {
|
||||
return 0.2126 * c.x + 0.7152 * c.y + 0.0722 * c.z;
|
||||
}
|
||||
|
||||
// P.u0 = (width, height), P.f0 = (gain, threshold, saturation)
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn boost(@builtin(global_invocation_id) id: vec3<u32>) {
|
||||
let w = P.u0.x;
|
||||
if (id.x >= w || id.y >= P.u0.y) {
|
||||
return;
|
||||
}
|
||||
let ix = id.y * w + id.x;
|
||||
let p = acc[ix];
|
||||
var b = p;
|
||||
let gain = P.f0.x;
|
||||
if (gain > 0.0 && p.w > 0.0) {
|
||||
let a = p.w;
|
||||
let c = p.xyz / a;
|
||||
let l = luminance(c);
|
||||
let thr = clamp(P.f0.y, 0.0, 1.0);
|
||||
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 + P.f0.z;
|
||||
let s = max(vec3<f32>(l) + (c - vec3<f32>(l)) * sat, vec3<f32>(0.0)) * k;
|
||||
b = vec4<f32>(s * a, a);
|
||||
}
|
||||
let kk = clamp(radius[ix] - 0.5, 0.0, 1.0);
|
||||
b = p + (b - p) * kk;
|
||||
}
|
||||
src[ix] = b;
|
||||
}
|
||||
|
||||
// One invocation per row. P.u0 = (width, height)
|
||||
@compute @workgroup_size(64)
|
||||
fn prefix_rows(@builtin(global_invocation_id) id: vec3<u32>) {
|
||||
let w = P.u0.x;
|
||||
let y = id.x;
|
||||
if (y >= P.u0.y) {
|
||||
return;
|
||||
}
|
||||
let base = y * (w + 1u);
|
||||
var a = vec4<f32>(0.0);
|
||||
prefix[base] = a;
|
||||
for (var x = 0u; x < w; x = x + 1u) {
|
||||
a = a + src[y * w + x];
|
||||
prefix[base + x + 1u] = a;
|
||||
}
|
||||
}
|
||||
|
||||
fn prefix_at(y: u32, t: f32) -> vec4<f32> {
|
||||
let n = P.u0.x;
|
||||
let base = y * (n + 1u);
|
||||
if (t <= 0.0) {
|
||||
return vec4<f32>(0.0);
|
||||
}
|
||||
if (t >= f32(n)) {
|
||||
return prefix[base + n];
|
||||
}
|
||||
let i = u32(floor(t));
|
||||
let f = t - f32(i);
|
||||
let a = prefix[base + i];
|
||||
let b = prefix[base + i + 1u];
|
||||
return a + (b - a) * f;
|
||||
}
|
||||
|
||||
// Level `j` of the table: (first span, span count, 1 / norm, identity).
|
||||
fn level(j: u32, x: u32, y: u32) -> vec4<f32> {
|
||||
let w = P.u0.x;
|
||||
let h = i32(P.u0.y);
|
||||
let l = table[j];
|
||||
if (l.w > 0.5) {
|
||||
return src[y * w + x];
|
||||
}
|
||||
let first = u32(l.x);
|
||||
let count = u32(l.y);
|
||||
var a = vec4<f32>(0.0);
|
||||
for (var s = first; s < first + count; s = s + 1u) {
|
||||
let sp = table[s];
|
||||
let sy = i32(y) + i32(sp.x);
|
||||
if (sy < 0 || sy >= h) {
|
||||
continue;
|
||||
}
|
||||
let hi = prefix_at(u32(sy), f32(x) + sp.z + 0.5);
|
||||
let lo = prefix_at(u32(sy), f32(x) + sp.y + 0.5);
|
||||
a = a + (hi - lo) * sp.w;
|
||||
}
|
||||
return a * l.z;
|
||||
}
|
||||
|
||||
// P.u0 = (width, height, levels), P.f0 = (lo, hi)
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn gather(@builtin(global_invocation_id) id: vec3<u32>) {
|
||||
let w = P.u0.x;
|
||||
if (id.x >= w || id.y >= P.u0.y) {
|
||||
return;
|
||||
}
|
||||
let ix = id.y * w + id.x;
|
||||
let n = P.u0.z;
|
||||
if (n <= 1u) {
|
||||
acc[ix] = level(0u, id.x, id.y);
|
||||
return;
|
||||
}
|
||||
let lo = P.f0.x;
|
||||
let hi = P.f0.y;
|
||||
let r = radius[ix];
|
||||
let f = clamp((r - lo) / (hi - lo) * f32(n - 1u), 0.0, f32(n - 1u));
|
||||
let k = min(u32(floor(f)), n - 2u);
|
||||
let t = f - f32(k);
|
||||
let a = level(k, id.x, id.y);
|
||||
let b = level(k + 1u, id.x, id.y);
|
||||
acc[ix] = a + (b - a) * t;
|
||||
}
|
||||
|
||||
fn linear_to_srgb(c: f32) -> f32 {
|
||||
if (c <= 0.0031308) {
|
||||
return c * 12.92;
|
||||
}
|
||||
return 1.055 * pow(c, 1.0 / 2.4) - 0.055;
|
||||
}
|
||||
|
||||
// P.u0 = (width, height, encode, over the canvas in color_in)
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn finish(@builtin(global_invocation_id) id: vec3<u32>) {
|
||||
let w = P.u0.x;
|
||||
if (id.x >= w || id.y >= P.u0.y) {
|
||||
return;
|
||||
}
|
||||
var p = acc[id.y * w + id.x];
|
||||
if (P.u0.z == 1u && p.w > 1e-6) {
|
||||
let a = p.w;
|
||||
p = vec4<f32>(linear_to_srgb(p.x / a) * a, linear_to_srgb(p.y / a) * a, linear_to_srgb(p.z / a) * a, a);
|
||||
}
|
||||
let q = vec2<i32>(i32(id.x), i32(id.y));
|
||||
if (P.u0.w == 1u) {
|
||||
let d = textureLoad(color_in, q, 0);
|
||||
let k = 1.0 - p.w;
|
||||
p = vec4<f32>(p.x + d.x * k, p.y + d.y * k, p.z + d.z * k, p.w + d.w * k);
|
||||
}
|
||||
textureStore(out, q, p);
|
||||
}
|
||||
|
||||
// One invocation per pixel listed in `table` (x, y). P.u0.x = count
|
||||
@compute @workgroup_size(64)
|
||||
fn wire(@builtin(global_invocation_id) id: vec3<u32>) {
|
||||
if (id.x >= P.u0.x) {
|
||||
return;
|
||||
}
|
||||
let p = table[id.x];
|
||||
textureStore(out, vec2<i32>(i32(p.x), i32(p.y)), vec4<f32>(1.0));
|
||||
}
|
||||
@@ -167,7 +167,12 @@ fn env_brdf(n_v: f32, rough: f32) -> vec2<f32> {
|
||||
}
|
||||
|
||||
fn equirect_uv(d: vec3<f32>, rot: f32) -> vec2<f32> {
|
||||
let u = 0.5 + (atan2(d.x, d.z) + rot) / (2.0 * PI);
|
||||
// atan2(0, 0) is 0 on the CPU but may be NaN on GPUs (straight up / down normals).
|
||||
var a = 0.0;
|
||||
if (d.x != 0.0 || d.z != 0.0) {
|
||||
a = atan2(d.x, d.z);
|
||||
}
|
||||
let u = 0.5 + (a + rot) / (2.0 * PI);
|
||||
let v = acos(clamp(-d.y, -1.0, 1.0)) / PI;
|
||||
return vec2<f32>(u - floor(u), v);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
//! 1.5 % of pixels (silhouettes) may differ more. Skipped without an adapter.
|
||||
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, LightKind, PrimitiveKind, Project, Renderer as R3, Solid, build};
|
||||
use effectcraft_project::build;
|
||||
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, LightKind, PrimitiveKind, Project, Renderer as R3, Solid};
|
||||
use effectcraft_render::three_d::adv::{self, Scene};
|
||||
use effectcraft_render::{Accelerator, EvalCtx, NoFootage, RenderOpts, Renderer};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
@@ -165,3 +166,277 @@ fn gpu_raster_perf_smoke() {
|
||||
let gpu = t0.elapsed();
|
||||
eprintln!("adv3d 480x320 (2x SSAA of 240x160), {} triangles: cpu {:?}, gpu {:?}", s.indices.len() / 3, cpu, gpu);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- whole runs (M13.11)
|
||||
//
|
||||
// `Accelerator::render_3d` (raster, resolve, motion-blur average, iris depth of field, encode
|
||||
// on the GPU) against `adv::render_prepared` on the CPU. Tolerance: ≤ 1/255 on ≥ 99 % of the
|
||||
// pixels. Against the CPU rasteriser the remaining pixels are silhouette pixels the two
|
||||
// rasterisers cover differently (and the blur spreads); against the CPU post-processing of
|
||||
// the *same* GPU raster the agreement is ≥ 99.9 % (f32 order of operations only).
|
||||
|
||||
/// The lit scene seen through a depth-of-field camera (hexagonal iris, diffraction fringe,
|
||||
/// highlight boost) with the sphere moving under motion blur.
|
||||
fn dof_mb_project() -> (Project, ItemId) {
|
||||
let (mut p, cid) = scene_project();
|
||||
add(&mut p, cid, LayerSource::Camera, |l| {
|
||||
let zoom = match l.props.prop_mut("cameraOptions/zoom").unwrap().value {
|
||||
Value::Scalar(z) => z,
|
||||
_ => 300.0,
|
||||
};
|
||||
set(l, "cameraOptions/dof", Value::Bool(true));
|
||||
set(l, "cameraOptions/focusDistance", Value::Scalar(zoom - 60.0));
|
||||
set(l, "cameraOptions/aperture", Value::Scalar(160.0));
|
||||
set(l, "cameraOptions/irisShape", Value::Enum(4));
|
||||
set(l, "cameraOptions/irisRotation", Value::Scalar(15.0));
|
||||
set(l, "cameraOptions/irisDiffractionFringe", Value::Scalar(50.0));
|
||||
set(l, "cameraOptions/highlightGain", Value::Scalar(30.0));
|
||||
set(l, "cameraOptions/highlightThreshold", Value::Scalar(150.0));
|
||||
});
|
||||
let c = p.comp_mut(cid).unwrap();
|
||||
c.enable_motion_blur = true;
|
||||
c.motion_blur_samples = 6;
|
||||
// The sphere moves 40 px per frame.
|
||||
let sphere = c.layers.iter_mut().find(|l| matches!(l.source, LayerSource::Primitive { kind: PrimitiveKind::Sphere })).unwrap();
|
||||
sphere.switches.motion_blur = true;
|
||||
sphere.props.prop_mut("transform/position").unwrap().keys = vec![
|
||||
effectcraft_keyframe::Keyframe::new(Tick::ZERO, Value::Vec3([80.0, 95.0, 0.0])),
|
||||
effectcraft_keyframe::Keyframe::new(Tick::from_seconds_f64(1.0), Value::Vec3([80.0 + 30.0 * 40.0, 95.0, 0.0])),
|
||||
];
|
||||
(p, cid)
|
||||
}
|
||||
|
||||
/// Share of pixels (0–1) differing by more than 1/255 on any channel, and the largest
|
||||
/// difference.
|
||||
fn off_share(a: &[[f32; 4]], b: &[[f32; 4]]) -> (f64, f32) {
|
||||
let mut off = 0;
|
||||
let mut max = 0.0f32;
|
||||
for (p, q) in a.iter().zip(b) {
|
||||
// NaN counts as a difference (f32::max would drop it).
|
||||
let d = (0..4).map(|k| (p[k] - q[k]).abs()).fold(0.0f32, |a, b| if b.is_nan() || a.is_nan() { f32::NAN } else { a.max(b) });
|
||||
max = max.max(d);
|
||||
if d.is_nan() || d > 1.0 / 255.0 + 1e-6 {
|
||||
off += 1;
|
||||
}
|
||||
}
|
||||
(off as f64 / a.len().max(1) as f64, max)
|
||||
}
|
||||
|
||||
fn run_of(p: &Project, cid: ItemId) -> (EvalCtx<'_>, Vec<&effectcraft_project::Layer>) {
|
||||
let ctx = EvalCtx { project: p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None };
|
||||
let run: Vec<&effectcraft_project::Layer> = ctx.comp.layers.iter().rev().filter(|l| l.is_3d() && l.has_video()).collect();
|
||||
(ctx, run)
|
||||
}
|
||||
|
||||
fn compare_run(p: &Project, cid: ItemId, g: &Gpu, what: &str) {
|
||||
let (ctx, run) = run_of(p, cid);
|
||||
let out = (ctx.comp.width, ctx.comp.height);
|
||||
// CPU reference: CPU raster and post.
|
||||
let cpu_r = Renderer::new(p, &NoFootage, RenderOpts::default());
|
||||
let prep = adv::Prepared::new(&cpu_r, &ctx, &run, out);
|
||||
let (cimg, cdepth) = adv::render_prepared(&prep).expect("cpu drew");
|
||||
// The GPU: everything on the device.
|
||||
let (gimg, gdepth) = g.render_3d(&prep).expect("gpu handled").expect("gpu drew");
|
||||
let (share, max) = off_share(&cimg.data, &gimg.data);
|
||||
eprintln!("{what}: gpu vs cpu {:.3} % of pixels beyond 1/255 (max {max:.4})", share * 100.0);
|
||||
assert!(share <= 0.01, "{what}: {:.3} % of pixels differ", share * 100.0);
|
||||
let dz = cdepth.iter().zip(&gdepth).filter(|(a, b)| a.is_finite() != b.is_finite() || (a.is_finite() && (*a - *b).abs() > 0.01 * a.abs().max(1.0))).count();
|
||||
assert!((dz as f64) < 0.015 * cdepth.len() as f64, "{what}: {dz} depth mismatches");
|
||||
// CPU post-processing of the GPU raster: the post kernels alone.
|
||||
let mut gr = Renderer::new(p, &NoFootage, RenderOpts { backend: effectcraft_render::Backend::Gpu, ..Default::default() });
|
||||
gr.accel = Some(g);
|
||||
let prep = adv::Prepared::new(&gr, &ctx, &run, out);
|
||||
let (himg, _) = adv::render_prepared(&prep).expect("cpu post drew");
|
||||
let (share, max) = off_share(&himg.data, &gimg.data);
|
||||
eprintln!("{what}: gpu post vs cpu post on the gpu raster: {:.4} % beyond 1/255 (max {max:.4})", share * 100.0);
|
||||
assert!(share <= 0.001, "{what}: post kernels differ on {:.3} % of pixels", share * 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_runs_match_cpu_with_dof_and_motion_blur() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let (p, cid) = dof_mb_project();
|
||||
let (ctx, run) = run_of(&p, cid);
|
||||
let r = Renderer::new(&p, &NoFootage, RenderOpts::default());
|
||||
let prep = adv::Prepared::new(&r, &ctx, &run, (240, 160));
|
||||
assert_eq!(prep.samples, 6);
|
||||
let dof = prep.dof.expect("depth of field");
|
||||
assert_eq!(dof.iris.sides, 6);
|
||||
compare_run(&p, cid, &g, "dof + motion blur");
|
||||
// The depth of field really blurs (against the same frame in focus).
|
||||
let (blurred, _) = g.render_3d(&prep).unwrap().unwrap();
|
||||
let mut sharp_prep = adv::Prepared::new(&r, &ctx, &run, (240, 160));
|
||||
sharp_prep.dof = None;
|
||||
let (sharp, _) = g.render_3d(&sharp_prep).unwrap().unwrap();
|
||||
let (changed, _) = off_share(&blurred.data, &sharp.data);
|
||||
assert!(changed > 0.2, "out-of-focus pixels: {:.1} %", changed * 100.0);
|
||||
// Fast Rectangle iris, no highlights; and without depth of field.
|
||||
let (mut p, cid) = dof_mb_project();
|
||||
for l in &mut p.comp_mut(cid).unwrap().layers {
|
||||
if let Some(pr) = l.props.prop_mut("cameraOptions/irisShape") {
|
||||
pr.value = Value::Enum(0);
|
||||
}
|
||||
if let Some(pr) = l.props.prop_mut("cameraOptions/highlightGain") {
|
||||
pr.value = Value::Scalar(0.0);
|
||||
}
|
||||
}
|
||||
compare_run(&p, cid, &g, "fast rectangle");
|
||||
for l in &mut p.comp_mut(cid).unwrap().layers {
|
||||
if let Some(pr) = l.props.prop_mut("cameraOptions/dof") {
|
||||
pr.value = Value::Bool(false);
|
||||
}
|
||||
}
|
||||
compare_run(&p, cid, &g, "motion blur only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_runs_match_cpu_for_extrusions_and_collapsed_precomps() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let (mut p, cid) = scene_project();
|
||||
// Extruded text with a bevelled stroke.
|
||||
add(&mut p, cid, LayerSource::Text, |l| {
|
||||
l.switches.three_d = true;
|
||||
let doc = effectcraft_keyframe::TextDoc {
|
||||
text: "Ab".into(),
|
||||
size: 50.0,
|
||||
fill: [0.9, 0.8, 0.2, 1.0],
|
||||
apply_fill: true,
|
||||
stroke: [0.1, 0.2, 0.9, 1.0],
|
||||
apply_stroke: true,
|
||||
stroke_width: 4.0,
|
||||
..Default::default()
|
||||
};
|
||||
set(l, "text/sourceText", Value::Text(Box::new(doc)));
|
||||
let mut next = 20_000u64;
|
||||
l.props.children.push(build::extrusion_geometry_options(&mut build::Ids(&mut next)).into());
|
||||
set(l, "geometryOptions/extrusionDepth", Value::Scalar(15.0));
|
||||
set(l, "geometryOptions/bevelStyle", Value::Enum(1));
|
||||
set(l, "transform/position", Value::Vec3([120.0, 50.0, -30.0]));
|
||||
set(l, "transform/rotationY", Value::Scalar(25.0));
|
||||
});
|
||||
// A collapsed precomp holding a cube.
|
||||
let mut nc = Comp::new(240, 160, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
|
||||
nc.renderer = R3::Classic3D;
|
||||
let nid = p.add_item("N", Default::default(), None, ItemKind::Comp(nc.into()));
|
||||
add(&mut p, nid, LayerSource::Primitive { kind: PrimitiveKind::Cube }, |l| {
|
||||
for k in ["width", "height", "depth"] {
|
||||
set(l, &format!("geometryOptions/{k}"), Value::Scalar(30.0));
|
||||
}
|
||||
set(l, "transform/position", Value::Vec3([200.0, 120.0, -20.0]));
|
||||
set(l, "transform/rotationY", Value::Scalar(35.0));
|
||||
set(l, "transform/rotationX", Value::Scalar(-25.0));
|
||||
});
|
||||
add(&mut p, cid, LayerSource::Comp { item: nid }, |l| {
|
||||
l.switches.three_d = true;
|
||||
l.switches.collapse = true;
|
||||
});
|
||||
let (ctx, run) = run_of(&p, cid);
|
||||
let s = adv::scene_of(&Renderer::new(&p, &NoFootage, RenderOpts::default()), &ctx, &run, (240, 160));
|
||||
assert!(s.indices.len() / 3 > 500, "meshes: {} triangles", s.indices.len() / 3);
|
||||
compare_run(&p, cid, &g, "extrusions + collapsed precomp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_compositor_draws_advanced_3d_runs_on_the_device() {
|
||||
let Some(g) = gpu() else { return };
|
||||
// A 2D background below the run and a 2D layer above it.
|
||||
let (mut p, cid) = dof_mb_project();
|
||||
let bg = p.add_item("B", Default::default(), None, ItemKind::Solid(Solid { color: [0.1, 0.1, 0.15], width: 240, height: 160, pixel_aspect: 1.0 }));
|
||||
let comp = p.comp(cid).unwrap().clone();
|
||||
let l = build::layer(&mut p, &comp, "B", LayerSource::Solid { item: bg }, (240, 160), None);
|
||||
p.comp_mut(cid).unwrap().layers.push(l);
|
||||
let top = p.add_item("T", Default::default(), None, ItemKind::Solid(Solid { color: [1.0, 0.5, 0.0], width: 40, height: 30, pixel_aspect: 1.0 }));
|
||||
add(&mut p, cid, LayerSource::Solid { item: top }, |l| set(l, "transform/opacity", Value::Scalar(50.0)));
|
||||
let (ctx, run) = run_of(&p, cid);
|
||||
assert!(Renderer::new(&p, &NoFootage, RenderOpts::default()).prepare_adv_run(&ctx, &run, (240, 160)).is_some());
|
||||
let cpu = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO);
|
||||
let mut r = Renderer::new(&p, &NoFootage, RenderOpts { backend: effectcraft_render::Backend::Gpu, ..Default::default() });
|
||||
r.accel = Some(&g);
|
||||
let gimg = g.render(&r, cid, Tick::ZERO).expect("gpu walk");
|
||||
let (share, max) = off_share(&cpu.data, &gimg.data);
|
||||
eprintln!("walk: {:.3} % beyond 1/255 (max {max:.4})", share * 100.0);
|
||||
assert!(share <= 0.01, "{:.3} % of pixels differ", share * 100.0);
|
||||
// A blend mode in the run takes the CPU's 2D path (its scene still renders on the GPU).
|
||||
p.comp_mut(cid).unwrap().layers.iter_mut().find(|l| l.switches.three_d && !l.is_light() && l.source.is_av()).unwrap().blend_mode =
|
||||
effectcraft_color::BlendMode::Screen;
|
||||
let (ctx, run) = run_of(&p, cid);
|
||||
assert!(Renderer::new(&p, &NoFootage, RenderOpts::default()).prepare_adv_run(&ctx, &run, (240, 160)).is_none());
|
||||
let cpu = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO);
|
||||
let mut r = Renderer::new(&p, &NoFootage, RenderOpts { backend: effectcraft_render::Backend::Gpu, ..Default::default() });
|
||||
r.accel = Some(&g);
|
||||
let gimg = r.comp_frame(cid, Tick::ZERO);
|
||||
let (share, _) = off_share(&cpu.data, &gimg.data);
|
||||
assert!(share <= 0.01, "blend-mode run: {:.3} %", share * 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_wireframes_match_cpu_exactly() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let mut p = Project::default();
|
||||
let c = Comp::new(200, 120, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
|
||||
let cid = p.add_item("C", Default::default(), None, ItemKind::Comp(c.into()));
|
||||
let sid = p.add_item("S", Default::default(), None, ItemKind::Solid(Solid { color: [0.2, 0.4, 0.6], width: 200, height: 120, pixel_aspect: 1.0 }));
|
||||
add(&mut p, cid, LayerSource::Solid { item: sid }, |_| {});
|
||||
let s2 = p.add_item("W", Default::default(), None, ItemKind::Solid(Solid { color: [1.0, 0.0, 0.0], width: 70, height: 40, pixel_aspect: 1.0 }));
|
||||
add(&mut p, cid, LayerSource::Solid { item: s2 }, |l| {
|
||||
l.switches.quality = effectcraft_project::Quality::Wireframe;
|
||||
set(l, "transform/rotation", Value::Scalar(23.0));
|
||||
set(l, "transform/scale", Value::Vec3([130.0, 90.0, 100.0]));
|
||||
});
|
||||
let cpu = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO);
|
||||
let mut r = Renderer::new(&p, &NoFootage, RenderOpts { backend: effectcraft_render::Backend::Gpu, ..Default::default() });
|
||||
r.accel = Some(&g);
|
||||
let gimg = g.render(&r, cid, Tick::ZERO).expect("gpu walk");
|
||||
let white = cpu.data.iter().filter(|q| **q == [1.0; 4]).count();
|
||||
assert!(white > 100, "outline drawn: {white}");
|
||||
let (share, max) = off_share(&cpu.data, &gimg.data);
|
||||
assert!(share == 0.0, "{share} differ (max {max})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_dof_with_capped_radii() {
|
||||
// Radii at the 48 px cap over most of the frame (8 blur levels).
|
||||
let Some(g) = gpu() else { return };
|
||||
let (mut p, cid) = dof_mb_project();
|
||||
for l in &mut p.comp_mut(cid).unwrap().layers {
|
||||
if let Some(pr) = l.props.prop_mut("cameraOptions/aperture") {
|
||||
pr.value = Value::Scalar(1500.0);
|
||||
}
|
||||
l.switches.motion_blur = false;
|
||||
}
|
||||
compare_run(&p, cid, &g, "capped radii");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_environment_light_on_flat_extrusions_has_no_nan() {
|
||||
// Bevelled text faces pointing straight up or down: atan2(0, 0) in the environment lookup
|
||||
// (NaN on some GPUs) must give the CPU's 0.
|
||||
let Some(g) = gpu() else { return };
|
||||
let mut p = Project::default();
|
||||
let mut c = Comp::new(480, 270, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
|
||||
c.renderer = R3::Advanced3D;
|
||||
let cid = p.add_item("C", Default::default(), None, ItemKind::Comp(c.into()));
|
||||
add(&mut p, cid, LayerSource::Text, |l| {
|
||||
l.switches.three_d = true;
|
||||
let doc =
|
||||
effectcraft_keyframe::TextDoc { text: "EffectCraft".into(), size: 45.0, fill: [0.95, 0.75, 0.2, 1.0], apply_fill: true, ..Default::default() };
|
||||
set(l, "text/sourceText", Value::Text(Box::new(doc)));
|
||||
let mut next = 900_000u64;
|
||||
l.props.children.push(build::extrusion_geometry_options(&mut build::Ids(&mut next)).into());
|
||||
set(l, "geometryOptions/extrusionDepth", Value::Scalar(10.0));
|
||||
set(l, "geometryOptions/bevelStyle", Value::Enum(1));
|
||||
set(l, "transform/position", Value::Vec3([240.0, 65.0, 50.0]));
|
||||
set(l, "transform/rotationY", Value::Scalar(-15.0));
|
||||
});
|
||||
let env = p.add_item("E", Default::default(), None, ItemKind::Solid(Solid { color: [0.6, 0.7, 1.0], width: 32, height: 16, pixel_aspect: 1.0 }));
|
||||
add(&mut p, cid, LayerSource::Solid { item: env }, |l| {
|
||||
l.environment = true;
|
||||
l.switches.three_d = true;
|
||||
});
|
||||
add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |_| {});
|
||||
let s = scene(&p, cid);
|
||||
let t = g.raster_3d(&s).unwrap();
|
||||
let nan = t.color.iter().filter(|c| c.iter().any(|x| !x.is_finite())).count();
|
||||
assert_eq!(nan, 0, "non-finite pixels");
|
||||
compare(&s, &g);
|
||||
}
|
||||
+12
-3
@@ -107,6 +107,14 @@ fn draw_3d<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, run: &[&'a Laye
|
||||
if run.is_empty() {
|
||||
return Some(());
|
||||
}
|
||||
// Advanced 3D: rendered and composited on the GPU (`adv3d`).
|
||||
if let Some(prep) = r.prepare_adv_run(ctx, run, (canvas.width, canvas.height)) {
|
||||
match crate::adv3d::draw_run(e, &prep, canvas) {
|
||||
Some(img) => *canvas = img,
|
||||
None => on_cpu(e, canvas, |img| r.draw_3d_run(ctx, run, img))?,
|
||||
}
|
||||
return Some(());
|
||||
}
|
||||
match r.prepare_3d_run(ctx, run, (canvas.width, canvas.height)).and_then(|prep| crate::classic3d::draw_run(e, &prep, canvas)) {
|
||||
Some(img) => *canvas = img,
|
||||
None => on_cpu(e, canvas, |img| r.draw_3d_run(ctx, run, img))?,
|
||||
@@ -139,9 +147,10 @@ fn draw_layer(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: &
|
||||
}
|
||||
return on_cpu(e, canvas, |img| r.draw_layer_cpu(ctx, layer, img));
|
||||
}
|
||||
if layer.switches.quality == Quality::Wireframe {
|
||||
// Wireframes draw outlines on the CPU.
|
||||
return on_cpu(e, canvas, |img| r.draw_layer_cpu(ctx, layer, img));
|
||||
if r.quality(layer) == Quality::Wireframe {
|
||||
// The layer's bounds as a one-pixel outline (the CPU's pixels).
|
||||
*canvas = crate::adv3d::wireframe(e, canvas, &r.wireframe_pixels(ctx, layer, (canvas.width, canvas.height)));
|
||||
return Some(());
|
||||
}
|
||||
let opacity = r.layer_opacity(ctx, layer);
|
||||
if opacity <= 0.0 {
|
||||
|
||||
+41
-16
@@ -278,6 +278,12 @@ pub trait Accelerator: Send + Sync {
|
||||
fn raster_3d(&self, _scene: &three_d::adv::Scene) -> Option<three_d::adv::Target> {
|
||||
None
|
||||
}
|
||||
/// Render a whole prepared Advanced 3D run (every motion-blur sub-sample rasterised and
|
||||
/// resolved, averaged, depth of field, encoding) with [`three_d::adv::render_prepared`]'s
|
||||
/// semantics. `None` = not handled (the CPU path runs, using [`Self::raster_3d`]).
|
||||
fn render_3d(&self, _run: &three_d::adv::Prepared) -> Option<three_d::adv::Rendered> {
|
||||
None
|
||||
}
|
||||
/// A particle simulation backend (GPU particles) for the stepped particle effects, with
|
||||
/// the CPU simulation's semantics. `None` = they simulate on the CPU.
|
||||
fn particles(&self) -> Option<&dyn effectcraft_effects::psim::ParticleSim> {
|
||||
@@ -588,22 +594,8 @@ impl<'a> Renderer<'a> {
|
||||
|
||||
/// Draw a Wireframe-quality layer: its bounds as a one-pixel outline.
|
||||
fn draw_wireframe(&self, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image) {
|
||||
let Some(b) = content_bounds(ctx, layer) else { return };
|
||||
let s = self.opts.scale;
|
||||
let (l2c, _) = ctx.layer_to_comp(layer);
|
||||
let m = Mat3::scale(vec2(s, s)) * self.outer.map_or(l2c, |o| o * l2c);
|
||||
let c = [(b[0], b[1]), (b[2], b[1]), (b[2], b[3]), (b[0], b[3])].map(|(x, y)| m.apply(vec2(x, y)));
|
||||
let (w, h) = (canvas.width as i64, canvas.height as i64);
|
||||
for i in 0..4 {
|
||||
let (p, q) = (c[i], c[(i + 1) % 4]);
|
||||
let n = ((q.x - p.x).abs().max((q.y - p.y).abs()) * 2.0).ceil().clamp(1.0, 1.0e5) as usize;
|
||||
for j in 0..=n {
|
||||
let t = j as f64 / n as f64;
|
||||
let (x, y) = ((p.x + (q.x - p.x) * t).floor() as i64, (p.y + (q.y - p.y) * t).floor() as i64);
|
||||
if x >= 0 && y >= 0 && x < w && y < h {
|
||||
canvas.set(x as u32, y as u32, [1.0, 1.0, 1.0, 1.0]);
|
||||
}
|
||||
}
|
||||
for (x, y) in self.wireframe_pixels(ctx, layer, (canvas.width, canvas.height)) {
|
||||
canvas.set(x, y, [1.0, 1.0, 1.0, 1.0]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1567,6 +1559,39 @@ impl<'a> Renderer<'a> {
|
||||
three_d::compose::gpu_run(self, ctx, run, out)
|
||||
}
|
||||
|
||||
/// A run of consecutive 3D layers of an Advanced 3D comp prepared for an accelerator's
|
||||
/// compositor ([`three_d::adv::Prepared`]): rendered as one scene and composited (Normal,
|
||||
/// premultiplied over) onto the canvas. `None` = not an Advanced 3D comp, or a layer needs the
|
||||
/// 2D compositing path (blend mode, track matte, Preserve Transparency): draw it with
|
||||
/// [`Self::draw_3d_run`].
|
||||
pub fn prepare_adv_run<'p>(&'p self, ctx: &'p EvalCtx<'a>, run: &'p [&'p Layer], out: (u32, u32)) -> Option<three_d::adv::Prepared<'p, 'a>> {
|
||||
three_d::adv::prepare_run(self, ctx, run, out)
|
||||
}
|
||||
|
||||
/// The pixels a Wireframe-quality layer sets (white, opaque) on a `size` canvas: its
|
||||
/// bounds' outline, one pixel wide. Empty when the layer has no bounds.
|
||||
pub fn wireframe_pixels(&self, ctx: &EvalCtx, layer: &Layer, size: (u32, u32)) -> Vec<(u32, u32)> {
|
||||
let Some(b) = content_bounds(ctx, layer) else { return vec![] };
|
||||
let s = self.opts.scale;
|
||||
let (l2c, _) = ctx.layer_to_comp(layer);
|
||||
let m = Mat3::scale(vec2(s, s)) * self.outer.map_or(l2c, |o| o * l2c);
|
||||
let c = [(b[0], b[1]), (b[2], b[1]), (b[2], b[3]), (b[0], b[3])].map(|(x, y)| m.apply(vec2(x, y)));
|
||||
let (w, h) = (size.0 as i64, size.1 as i64);
|
||||
let mut out = vec![];
|
||||
for i in 0..4 {
|
||||
let (p, q) = (c[i], c[(i + 1) % 4]);
|
||||
let n = ((q.x - p.x).abs().max((q.y - p.y).abs()) * 2.0).ceil().clamp(1.0, 1.0e5) as usize;
|
||||
for j in 0..=n {
|
||||
let t = j as f64 / n as f64;
|
||||
let (x, y) = ((p.x + (q.x - p.x) * t).floor() as i64, (p.y + (q.y - p.y) * t).floor() as i64);
|
||||
if x >= 0 && y >= 0 && x < w && y < h {
|
||||
out.push((x as u32, y as u32));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Run the layer's effect stack on `target` (an accelerator's resident image): GPU-capable
|
||||
/// runs through [`FxTarget::gpu`], the others through [`FxTarget::cpu`], with the CPU's
|
||||
/// parameters and environment. `adjustment`: the stack runs on the comp below (adjustment
|
||||
|
||||
@@ -99,32 +99,81 @@ fn mb_samples(r: &Renderer, ctx: &EvalCtx, layers: &[&Layer]) -> usize {
|
||||
if r.opts.draft { 4 } else { ctx.comp.motion_blur_samples.clamp(2, 64) as usize }
|
||||
}
|
||||
|
||||
/// Render `layers` as one Advanced 3D scene: premultiplied pixels in the canvas's encoding and
|
||||
/// the camera depth of each pixel (∞ where nothing was drawn). With motion blur the scene is
|
||||
/// rendered at sub-samples spread over the comp's shutter (angle and phase) and averaged; the
|
||||
/// depth is the nearest over the samples.
|
||||
pub(crate) fn render_layers(r: &Renderer, ctx: &EvalCtx, layers: &[&Layer], out: (u32, u32)) -> Option<(Image, Vec<f32>)> {
|
||||
let n = mb_samples(r, ctx, layers);
|
||||
let (mut img, depth, linear) = if n <= 1 {
|
||||
let s = scene::build(r, ctx, layers, out);
|
||||
/// An Advanced 3D run prepared for an accelerator ([`crate::Accelerator::render_3d`], the GPU
|
||||
/// compositor's walk): the motion-blur sub-sample scenes (built on demand, one at a time), the
|
||||
/// camera's depth of field and the output encoding. The accelerator rasterises every
|
||||
/// sub-sample, resolves the supersampling, averages the sub-samples (nearest depth), applies
|
||||
/// the depth of field and encodes, as [`render_prepared`] does on the CPU.
|
||||
pub struct Prepared<'p, 'a> {
|
||||
r: &'p Renderer<'a>,
|
||||
ctx: &'p EvalCtx<'a>,
|
||||
layers: &'p [&'p Layer],
|
||||
/// Output size (pixels).
|
||||
pub out: (u32, u32),
|
||||
/// Motion-blur sub-samples (1 = none).
|
||||
pub samples: usize,
|
||||
/// Depth of field (the camera at the frame time; `None` = off, Draft 3D or draft renders).
|
||||
pub dof: Option<super::camera::Dof>,
|
||||
/// Render scale (circles of confusion are in comp pixels).
|
||||
pub scale: f64,
|
||||
/// The scene is in the working encoding already (linear projects): no sRGB encode.
|
||||
pub linear: bool,
|
||||
}
|
||||
|
||||
impl<'p, 'a> Prepared<'p, 'a> {
|
||||
pub fn new(r: &'p Renderer<'a>, ctx: &'p EvalCtx<'a>, layers: &'p [&'p Layer], out: (u32, u32)) -> Self {
|
||||
let cam = camera_for(r, ctx);
|
||||
Prepared {
|
||||
r,
|
||||
ctx,
|
||||
layers,
|
||||
out,
|
||||
samples: mb_samples(r, ctx, layers),
|
||||
dof: cam.dof.filter(|_| !ctx.comp.draft_3d && !r.opts.draft),
|
||||
scale: r.opts.scale,
|
||||
linear: r.pipe.linear || r.pipe.linear_blend,
|
||||
}
|
||||
}
|
||||
|
||||
/// The scene of sub-sample `i` (`0..samples`), spread over the comp's shutter (angle and
|
||||
/// phase).
|
||||
pub fn scene(&self, i: usize) -> Scene {
|
||||
let n = self.samples;
|
||||
if n <= 1 {
|
||||
return scene::build(self.r, self.ctx, self.layers, self.out);
|
||||
}
|
||||
let ctx = self.ctx;
|
||||
let fd = ctx.comp.frame_duration().seconds();
|
||||
let (angle, phase) = (ctx.comp.shutter_angle / 360.0, ctx.comp.shutter_phase / 360.0);
|
||||
let f = phase + angle * i as f64 / (n - 1) as f64;
|
||||
let sub = ctx.at(ctx.time + effectcraft_time::Tick::from_seconds_f64(f * fd));
|
||||
scene::build_at(self.r, ctx, Some(&sub), self.layers, self.out)
|
||||
}
|
||||
}
|
||||
|
||||
/// What an accelerator returns for a [`Prepared`] run: `None` when nothing was drawn, else the
|
||||
/// premultiplied image in the canvas's encoding and the camera depth of each pixel (∞ where
|
||||
/// nothing was drawn; the nearest over the motion-blur sub-samples).
|
||||
pub type Rendered = Option<(Image, Vec<f32>)>;
|
||||
|
||||
/// Render a prepared run on the CPU compositor (each scene rasterised by the accelerator's
|
||||
/// [`crate::Accelerator::raster_3d`] when there is one): the reference for
|
||||
/// [`crate::Accelerator::render_3d`].
|
||||
pub fn render_prepared(p: &Prepared) -> Rendered {
|
||||
let (r, n, out) = (p.r, p.samples, p.out);
|
||||
let (mut img, depth) = if n <= 1 {
|
||||
let s = p.scene(0);
|
||||
if s.indices.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (img, depth) = raster_resolved(r, &s);
|
||||
(img, depth, s.linear_io)
|
||||
raster_resolved(r, &s)
|
||||
} else {
|
||||
let fd = ctx.comp.frame_duration().seconds();
|
||||
let (angle, phase) = (ctx.comp.shutter_angle / 360.0, ctx.comp.shutter_phase / 360.0);
|
||||
let mut acc = Image::new(out.0, out.1);
|
||||
let mut depth = vec![f32::INFINITY; (out.0 * out.1) as usize];
|
||||
let mut linear = false;
|
||||
let mut any = false;
|
||||
let k = 1.0 / n as f32;
|
||||
for i in 0..n {
|
||||
let f = phase + angle * i as f64 / (n - 1) as f64;
|
||||
let sub = ctx.at(ctx.time + effectcraft_time::Tick::from_seconds_f64(f * fd));
|
||||
let s = scene::build_at(r, ctx, Some(&sub), layers, out);
|
||||
linear = s.linear_io;
|
||||
let s = p.scene(i);
|
||||
if s.indices.is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -140,18 +189,43 @@ pub(crate) fn render_layers(r: &Renderer, ctx: &EvalCtx, layers: &[&Layer], out:
|
||||
if !any {
|
||||
return None;
|
||||
}
|
||||
(acc, depth, linear)
|
||||
(acc, depth)
|
||||
};
|
||||
let cam = camera_for(r, ctx);
|
||||
if let Some(dof) = cam.dof.filter(|_| !ctx.comp.draft_3d && !r.opts.draft) {
|
||||
img = depth_of_field(&img, &depth, &dof, r.opts.scale);
|
||||
if let Some(dof) = &p.dof {
|
||||
img = depth_of_field(&img, &depth, dof, p.scale);
|
||||
}
|
||||
if !linear {
|
||||
if !p.linear {
|
||||
encode(&mut img);
|
||||
}
|
||||
Some((img, depth))
|
||||
}
|
||||
|
||||
/// Render `layers` as one Advanced 3D scene: premultiplied pixels in the canvas's encoding and
|
||||
/// the camera depth of each pixel (∞ where nothing was drawn). With motion blur the scene is
|
||||
/// rendered at sub-samples spread over the comp's shutter (angle and phase) and averaged; the
|
||||
/// depth is the nearest over the samples. The whole run goes to the accelerator when it
|
||||
/// implements [`crate::Accelerator::render_3d`].
|
||||
pub(crate) fn render_layers(r: &Renderer, ctx: &EvalCtx, layers: &[&Layer], out: (u32, u32)) -> Option<(Image, Vec<f32>)> {
|
||||
let p = Prepared::new(r, ctx, layers, out);
|
||||
if let Some(a) = r.active_accel()
|
||||
&& let Some(res) = a.render_3d(&p)
|
||||
{
|
||||
return res;
|
||||
}
|
||||
render_prepared(&p)
|
||||
}
|
||||
|
||||
/// The run as one prepared scene for an accelerator's compositor when it can be composited
|
||||
/// directly over the canvas (Normal-mode layers without track mattes or Preserve Transparency;
|
||||
/// those go through [`draw_run`]'s 2D path; environment backgrounds draw their sky first). `None`
|
||||
/// when the comp isn't Advanced 3D.
|
||||
pub(crate) fn prepare_run<'p, 'a>(r: &'p Renderer<'a>, ctx: &'p EvalCtx<'a>, run: &'p [&'p Layer], out: (u32, u32)) -> Option<Prepared<'p, 'a>> {
|
||||
if !active(r, ctx) || run.iter().any(|l| l.environment_background || needs_2d_composite(ctx, l)) {
|
||||
return None;
|
||||
}
|
||||
Some(Prepared::new(r, ctx, run, out))
|
||||
}
|
||||
|
||||
/// Layers whose blend mode, track matte or Preserve Transparency needs the 2D compositing path.
|
||||
fn needs_2d_composite(ctx: &EvalCtx, l: &Layer) -> bool {
|
||||
l.blend_mode != effectcraft_color::BlendMode::Normal
|
||||
|
||||
@@ -311,11 +311,16 @@ impl Builder {
|
||||
let wn = n.apply_vec(vec3(nn[0] as f64, nn[1] as f64, nn[2] as f64)).normalize();
|
||||
let t = p.tangents.get(k).copied().unwrap_or([1.0, 0.0, 0.0, 1.0]);
|
||||
let wt = m.apply_vec(vec3(t[0] as f64, t[1] as f64, t[2] as f64)).normalize();
|
||||
// Degenerate normals and tangents (zero length: NaN once normalised) get the shader's
|
||||
// fallbacks here, so every rasteriser shades them alike (GPU compilers may not keep
|
||||
// NaN comparisons).
|
||||
let finite = |v: Vec3, or: [f32; 3]| if v.x.is_finite() && v.y.is_finite() && v.z.is_finite() { [v.x as f32, v.y as f32, v.z as f32] } else { or };
|
||||
let [tx, ty, tz] = finite(wt, [1.0, 0.0, 0.0]);
|
||||
self.s.vertices.push(Vertex {
|
||||
pos: [w.x as f32, w.y as f32, w.z as f32],
|
||||
normal: [wn.x as f32, wn.y as f32, wn.z as f32],
|
||||
normal: finite(wn, [0.0, 0.0, -1.0]),
|
||||
uv: p.uvs.get(k).copied().unwrap_or([0.0, 0.0]),
|
||||
tangent: [wt.x as f32, wt.y as f32, wt.z as f32, if det < 0.0 { -t[3] } else { t[3] }],
|
||||
tangent: [tx, ty, tz, if det < 0.0 { -t[3] } else { t[3] }],
|
||||
material: mat,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -203,6 +203,32 @@ pub fn bokeh_blur(img: &Image, iris: &Iris, r: f64) -> Image {
|
||||
let ry = (r / a).round().max(0.0) as usize;
|
||||
return effectcraft_raster::box_blur(img, rx, ry, 1, false);
|
||||
}
|
||||
match kernel(iris, r) {
|
||||
Some((kernels, norm)) => span_gather(img, &kernels, norm),
|
||||
None => img.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Weighted row-span kernels (see [`Iris::spans`]).
|
||||
pub type SpanKernels = Vec<(f32, Vec<(i32, f64, f64)>)>;
|
||||
|
||||
/// [`bokeh_blur`]'s gather kernel at radius `r` as weighted row spans and their normaliser
|
||||
/// (accelerators run the same gather). Fast Rectangle is the box of the iris' width and height
|
||||
/// (what [`bokeh_blur`]'s separable box computes). `None` = the identity (no blur).
|
||||
pub fn kernel(iris: &Iris, r: f64) -> Option<(SpanKernels, f32)> {
|
||||
if r < 0.3 {
|
||||
return None;
|
||||
}
|
||||
if iris.sides == 0 {
|
||||
let a = iris.aspect.clamp(0.01, 100.0).sqrt();
|
||||
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);
|
||||
@@ -216,10 +242,21 @@ 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 {
|
||||
return img.clone();
|
||||
(norm > 0.0).then_some((kernels, norm))
|
||||
}
|
||||
|
||||
/// [`progressive_blur`]'s blur levels for a radius range: `None` = no blur at all; one level
|
||||
/// when the range is narrow (every pixel takes it); else up to eight spanning `lo..=hi`
|
||||
/// (each pixel blends the two around its own radius).
|
||||
pub fn progressive_levels(lo: f32, hi: f32) -> Option<Vec<f32>> {
|
||||
if !lo.is_finite() || hi < 0.3 {
|
||||
return None;
|
||||
}
|
||||
span_gather(img, &kernels, norm)
|
||||
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())
|
||||
}
|
||||
|
||||
/// Depth-varying blur: `radius` gives each pixel's blur radius. The image is blurred at up to
|
||||
@@ -227,14 +264,11 @@ pub fn bokeh_blur(img: &Image, iris: &Iris, r: f64) -> Image {
|
||||
/// 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) = progressive_levels(lo, hi) else { return img.clone() };
|
||||
let n = levels.len();
|
||||
if n == 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<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)| {
|
||||
|
||||
Reference in new issue
Block a user