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:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-03 09:38:11 -04:00
1 parent 0f591e0856
commit bb24b931a6
12 files changed
+1286 -75

No files matched your search

+120 -4
View File
@@ -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
View File
@@ -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: &params(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
}
+1 -1
View File
@@ -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
View File
@@ -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)
}
+257
View File
@@ -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));
}
+6 -1
View File
@@ -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);
}
+276 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+96 -22
View File
@@ -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
+7 -2
View File
@@ -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,
});
}
+44 -10
View File
@@ -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)| {