From bb24b931a6250c227322f797c9d22a4f9534695b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 09:38:11 -0400 Subject: [PATCH] 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 --- apps/effectcraft-cli/src/main.rs | 124 ++++++- crates/gpu/src/adv3d.rs | 428 ++++++++++++++++++++++++- crates/gpu/src/context.rs | 2 +- crates/gpu/src/lib.rs | 13 +- crates/gpu/src/shaders/adv3d.wgsl | 257 +++++++++++++++ crates/gpu/src/shaders/advanced3d.wgsl | 7 +- crates/gpu/src/tests_adv3d.rs | 277 +++++++++++++++- crates/gpu/src/walk.rs | 15 +- crates/render/src/lib.rs | 57 +++- crates/render/src/three_d/adv/mod.rs | 118 +++++-- crates/render/src/three_d/adv/scene.rs | 9 +- crates/render/src/three_d/bokeh.rs | 54 +++- 12 files changed, 1286 insertions(+), 75 deletions(-) create mode 100644 crates/gpu/src/shaders/adv3d.wgsl diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index 8aa1381b2..f7b715cb3 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -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 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 { + 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 = 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 = 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 = 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!( diff --git a/crates/gpu/src/adv3d.rs b/crates/gpu/src/adv3d.rs index 6e428c846..93352fe34 100644 --- a/crates/gpu/src/adv3d.rs +++ b/crates/gpu/src/adv3d.rs @@ -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 { - 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 { 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 = s .vertices @@ -257,12 +274,12 @@ pub(crate) fn render(g: &GpuContext, s: &Scene) -> Option { 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 { 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 { + 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 { .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, + 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 { + let mut v: Vec = 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> { + 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> { + 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 = 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 { + 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 { + 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 = 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 +} diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index d6f388831..eb32115a5 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -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") })) } diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index bf47e3c58..b1c21b7c1 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -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 { + 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) } diff --git a/crates/gpu/src/shaders/adv3d.wgsl b/crates/gpu/src/shaders/adv3d.wgsl new file mode 100644 index 000000000..35e5a1bc0 --- /dev/null +++ b/crates/gpu/src/shaders/adv3d.wgsl @@ -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, + u1: vec4, + f0: vec4, + f1: vec4, +}; + +@group(0) @binding(0) var P: Post; +@group(0) @binding(1) var color_in: texture_2d; +@group(0) @binding(2) var z_in: texture_2d; +@group(0) @binding(3) var acc: array>; +@group(0) @binding(4) var depth: array; +@group(0) @binding(5) var src: array>; +@group(0) @binding(6) var table: array>; +@group(0) @binding(7) var radius: array; +@group(0) @binding(8) var range: array>; +@group(0) @binding(9) var prefix: array>; +@group(0) @binding(10) var out: texture_storage_2d; + +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) { + 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(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(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) { + 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(r)); + atomicMax(&range[1], bitcast(r)); +} + +fn luminance(c: vec3) -> 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) { + 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(l) + (c - vec3(l)) * sat, vec3(0.0)) * k; + b = vec4(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) { + let w = P.u0.x; + let y = id.x; + if (y >= P.u0.y) { + return; + } + let base = y * (w + 1u); + var a = vec4(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 { + let n = P.u0.x; + let base = y * (n + 1u); + if (t <= 0.0) { + return vec4(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 { + 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(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) { + 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) { + 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(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(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(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) { + if (id.x >= P.u0.x) { + return; + } + let p = table[id.x]; + textureStore(out, vec2(i32(p.x), i32(p.y)), vec4(1.0)); +} diff --git a/crates/gpu/src/shaders/advanced3d.wgsl b/crates/gpu/src/shaders/advanced3d.wgsl index 12100cc38..f7c94ff54 100644 --- a/crates/gpu/src/shaders/advanced3d.wgsl +++ b/crates/gpu/src/shaders/advanced3d.wgsl @@ -167,7 +167,12 @@ fn env_brdf(n_v: f32, rough: f32) -> vec2 { } fn equirect_uv(d: vec3, rot: f32) -> vec2 { - 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(u - floor(u), v); } diff --git a/crates/gpu/src/tests_adv3d.rs b/crates/gpu/src/tests_adv3d.rs index f0e0f08bc..596c41a4f 100644 --- a/crates/gpu/src/tests_adv3d.rs +++ b/crates/gpu/src/tests_adv3d.rs @@ -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); +} diff --git a/crates/gpu/src/walk.rs b/crates/gpu/src/walk.rs index edef7ef15..24bc6a289 100644 --- a/crates/gpu/src/walk.rs +++ b/crates/gpu/src/walk.rs @@ -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 { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 6204b763c..bfbd2405d 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -278,6 +278,12 @@ pub trait Accelerator: Send + Sync { fn raster_3d(&self, _scene: &three_d::adv::Scene) -> Option { 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 { + 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::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 diff --git a/crates/render/src/three_d/adv/mod.rs b/crates/render/src/three_d/adv/mod.rs index 4bd3fd04d..0fae1be70 100644 --- a/crates/render/src/three_d/adv/mod.rs +++ b/crates/render/src/three_d/adv/mod.rs @@ -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)> { - 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, + /// 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)>; + +/// 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)> { + 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> { + 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 diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index 5ec3e8c2b..1570c2c6a 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -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, }); } diff --git a/crates/render/src/three_d/bokeh.rs b/crates/render/src/three_d/bokeh.rs index 0252ecb05..66eea4117 100644 --- a/crates/render/src/three_d/bokeh.rs +++ b/crates/render/src/three_d/bokeh.rs @@ -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> { + 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 = (0..n).map(|i| lo + (hi - lo) * i as f32 / (n - 1) as f32).collect(); let blurred: Vec = levels.par_iter().map(|&r| bokeh_blur(img, iris, r as f64)).collect(); let mut out = Image::new(img.width, img.height); out.data.par_iter_mut().enumerate().for_each(|(i, o)| {