From bb24b931a6250c227322f797c9d22a4f9534695b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 09:38:11 -0400 Subject: [PATCH 1/4] 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 8aa1381b..f7b715cb 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 6e428c84..93352fe3 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 d6f38883..eb32115a 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 bf47e3c5..b1c21b7c 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 00000000..35e5a1bc --- /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 12100cc3..f7c94ff5 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 f0e0f08b..596c41a4 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 edef7ef1..24bc6a28 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 6204b763..bfbd2405 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 4bd3fd04..0fae1be7 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 5ec3e8c2..1570c2c6 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 0252ecb0..66eea411 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)| { From d774d11231bfde6505bd16df91b36609a979061b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 09:38:19 -0400 Subject: [PATCH 2/4] M13.11: essential graphics mirrored and linked properties Adding a property already in the Essential Graphics panel adds a mirror (EgKind::Mirror: own name and place, one value in the comp and in every instance; editing, reverting or pushing either acts on both). A property control can also drive further properties of the comp (links follow the main property both ways and take instance values). essential.addMirror, essential.linkProperty, essential.unlinkProperty; panel badges and menu; ExtendScript addToMotionGraphicsTemplate(As), canAddToMotionGraphicsTemplate, motionGraphicsTemplateName, controller count/name and exportAsMotionGraphicsTemplate. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/essential.rs | 208 +++++++++++++++++--- crates/engine/src/tests_essential.rs | 81 +++++++- crates/project/src/essential.rs | 251 ++++++++++++++++++++++-- crates/script/src/prelude.js | 52 ++++- crates/script/src/tests.rs | 38 ++++ crates/ui-egui/src/panels/essential.rs | 53 ++++- 6 files changed, 628 insertions(+), 55 deletions(-) diff --git a/crates/engine/src/commands/essential.rs b/crates/engine/src/commands/essential.rs index a24fcdc0..305c3f7e 100644 --- a/crates/engine/src/commands/essential.rs +++ b/crates/engine/src/commands/essential.rs @@ -88,6 +88,8 @@ fn add_property(s: &mut Session, p: &Value) -> Result { let group = p.get("group").and_then(Value::as_u64); let index = p.get("index").and_then(Value::as_u64).map(|i| i as usize); let rename = str_p(p, "name").map(str::to_string); + // A property already in the panel is added again as a mirror (`mirror: false`: skipped). + let mirror = b_p(p, "mirror").unwrap_or(true); let mut added = vec![]; for (cid, lid, uid) in &targets { let l = s.project.comp(*cid).and_then(|c| c.layer(*lid)).ok_or(EngineError::NoComp)?; @@ -95,25 +97,113 @@ fn add_property(s: &mut Session, p: &Value) -> Result { if control_type(pr).is_none() { return Err(bad(C, format!("`{}` can't be added to Essential Graphics (unsupported property type)", pr.name))); } - if s.project.comp(*cid).and_then(|c| c.essential.as_ref()).is_some_and(|e| e.control_for(*lid, *uid).is_some()) { - continue; - } - added.push((*cid, *lid, *uid, rename.clone().unwrap_or_else(|| pr.name.clone()))); + let existing = s.project.comp(*cid).and_then(|c| c.essential.as_ref()).and_then(|e| e.driver_of(*lid, *uid)).map(|c| (c.id, c.name.clone())); + let kind = match existing { + Some(_) if !mirror => continue, + Some((of, _)) => EgKind::Mirror { of }, + None => EgKind::Property { layer: *lid, prop: *uid, links: vec![] }, + }; + let name = rename.clone().or(existing.map(|e| e.1)).unwrap_or_else(|| pr.name.clone()); + added.push((*cid, kind, name)); } let ids = s.edit("Add Property to Essential Graphics", None, |proj, st| { if let Some((cid, ..)) = added.first() { st.essential_primary.get_or_insert(*cid); } let mut ids = vec![]; - for (k, (cid, lid, uid, name)) in added.iter().enumerate() { + for (k, (cid, kind, name)) in added.iter().enumerate() { let id = proj.alloc(); let eg = eg_of(proj, *cid)?; - insert(eg, EgControl { id, name: name.clone(), kind: EgKind::Property { layer: *lid, prop: *uid } }, group, index.map(|i| i + k), C)?; + insert(eg, EgControl { id, name: name.clone(), kind: kind.clone() }, group, index.map(|i| i + k), C)?; ids.push(id); } Ok(ids) })?; - Ok(json!({"controls": ids})) + let mirrors: Vec = ids.iter().zip(&added).filter(|(_, a)| matches!(a.1, EgKind::Mirror { .. })).map(|(i, _)| *i).collect(); + Ok(json!({"controls": ids, "mirrors": mirrors})) +} + +/// Add a mirror of a property control (the same property shown again, e.g. in another group). +fn add_mirror(s: &mut Session, p: &Value) -> Result { + const C: &str = "essential.addMirror"; + let cid = eg_comp(s, p)?; + let of = control_p(p, C)?; + let eg = s.project.comp(cid).and_then(|c| c.essential.clone()).unwrap_or_default(); + let master = + eg.resolve(of).filter(|c| matches!(c.kind, EgKind::Property { .. })).ok_or_else(|| bad(C, format!("control {of} is not a property control")))?; + let (of, master_name) = (master.id, master.name.clone()); + let name = str_p(p, "name").map(str::to_string).unwrap_or(master_name); + let group = p.get("group").and_then(Value::as_u64); + let index = p.get("index").and_then(Value::as_u64).map(|i| i as usize); + let id = s.edit("Add Mirror to Essential Graphics", None, |proj, _| { + let id = proj.alloc(); + insert(eg_of(proj, cid)?, EgControl { id, name, kind: EgKind::Mirror { of } }, group, index, C)?; + Ok(id) + })?; + Ok(json!({"control": id, "of": of})) +} + +/// The property a link command names: `{layer, path|prop}` or the first selected property. +fn link_target(s: &Session, p: &Value, cmd: &str) -> Result<(ItemId, LayerId, Uid)> { + if p.get("path").is_some() || p.get("prop").is_some() { + return super::prop::prop_ref(s, p, cmd); + } + let cid = comp_id(s, p)?; + selected_leaf_props(s) + .into_iter() + .next() + .map(|(l, u)| (cid, l, u)) + .ok_or_else(|| bad(cmd, "select a property in the timeline (or give `layer` and `path`)")) +} + +/// Make a property control drive another property too (same kind of value). +fn link_property(s: &mut Session, p: &Value) -> Result { + const C: &str = "essential.linkProperty"; + let cid = eg_comp(s, p)?; + let control = control_p(p, C)?; + let (pc, lid, uid) = link_target(s, p, C)?; + if pc != cid { + return Err(bad(C, "the property must be in the Essential Graphics composition")); + } + let eg = s.project.comp(cid).and_then(|c| c.essential.clone()).unwrap_or_default(); + let ctl = eg.resolve(control).ok_or_else(|| bad(C, format!("no control {control}")))?.clone(); + let (main, _) = essential::source_prop(&s.project, cid, &ctl).ok_or_else(|| bad(C, format!("control {control} is not a property control")))?; + let main = main.props.find(match ctl.kind { + EgKind::Property { prop, .. } => prop, + _ => 0, + }); + let target = s.project.comp(cid).and_then(|c| c.layer(lid)).and_then(|l| l.props.find(uid)).ok_or_else(|| bad(C, "no such property"))?; + if !main.is_some_and(|m| essential::linkable(m, target)) { + return Err(bad(C, format!("`{}` is not the same kind of property as the control", target.name))); + } + if let Some(d) = eg.driver_of(lid, uid) { + return Err(bad(C, format!("`{}` is already driven by control {}", target.name, d.id))); + } + let id = ctl.id; + s.edit("Link Property to Essential Graphics Control", None, |proj, _| { + if let Some(EgControl { kind: EgKind::Property { links, .. }, .. }) = eg_of(proj, cid)?.find_mut(id) { + links.push(essential::EgLink { layer: lid, prop: uid }); + } + Ok(()) + })?; + Ok(json!({"control": id, "layer": lid.0, "prop": uid})) +} + +fn unlink_property(s: &mut Session, p: &Value) -> Result { + const C: &str = "essential.unlinkProperty"; + let cid = eg_comp(s, p)?; + let control = control_p(p, C)?; + let (_, lid, uid) = link_target(s, p, C)?; + let eg = s.project.comp(cid).and_then(|c| c.essential.clone()).unwrap_or_default(); + let id = eg.resolve(control).map(|c| c.id).ok_or_else(|| bad(C, format!("no control {control}")))?; + s.edit("Unlink Property from Essential Graphics Control", None, |proj, _| match eg_of(proj, cid)?.find_mut(id) { + Some(EgControl { kind: EgKind::Property { links, .. }, .. }) if links.iter().any(|k| k.layer == lid && k.prop == uid) => { + links.retain(|k| !(k.layer == lid && k.prop == uid)); + Ok(()) + } + _ => Err(bad(C, "the property is not linked to this control")), + })?; + Ok(json!({"control": id, "layer": lid.0, "prop": uid})) } fn control_type(pr: &effectcraft_project::Property) -> Option { @@ -190,11 +280,17 @@ fn remove(s: &mut Session, p: &Value) -> Result { const C: &str = "essential.remove"; let cid = eg_comp(s, p)?; let id = control_p(p, C)?; + // A property control's mirrors go with it. + let mirrors = s.project.comp(cid).and_then(|c| c.essential.as_ref()).map(|e| e.mirrors_of(id)).unwrap_or_default(); s.edit("Remove Essential Graphics Control", None, |proj, _| { - eg_of(proj, cid)?.remove(id).ok_or_else(|| bad(C, format!("no control {id}")))?; + let eg = eg_of(proj, cid)?; + eg.remove(id).ok_or_else(|| bad(C, format!("no control {id}")))?; + for m in &mirrors { + eg.remove(*m); + } Ok(()) })?; - Ok(json!({"control": id})) + Ok(json!({"control": id, "mirrors": mirrors})) } fn move_control(s: &mut Session, p: &Value) -> Result { @@ -293,8 +389,12 @@ pub fn supported_properties(project: &Project, cid: ItemId) -> Vec { fn control_json(project: &Project, cid: ItemId, c: &EgControl) -> Value { match &c.kind { - EgKind::Property { layer, prop } => { + EgKind::Property { layer, prop, .. } => { let src = essential::source_prop(project, cid, c); + let links: Vec = essential::linked_props(project, cid, c) + .into_iter() + .map(|(l, pr)| json!({"layer": l.id.0, "layerName": l.name, "prop": pr.uid, "path": super::match_path_of(&l.props, pr.uid)})) + .collect(); json!({ "id": c.id, "name": c.name, "kind": "property", "layer": layer.0, "prop": prop, "layerName": src.map(|(l, _)| l.name.clone()), @@ -302,6 +402,21 @@ fn control_json(project: &Project, cid: ItemId, c: &EgControl) -> Value { "type": src.and_then(|(_, pr)| essential::control_type(pr)), "value": src.map(|(_, pr)| pr.value_at(Tick::ZERO).to_json()), "missing": src.is_none(), + "links": links, + "mirrors": project.comp(cid).and_then(|c| c.essential.as_ref()).map(|e| e.mirrors_of(c.id)).unwrap_or_default(), + }) + } + EgKind::Mirror { of } => { + let src = essential::source_prop(project, cid, c); + json!({ + "id": c.id, "name": c.name, "kind": "mirror", "of": of, + "layer": src.map(|(l, _)| l.id.0), + "prop": src.map(|(_, pr)| pr.uid), + "layerName": src.map(|(l, _)| l.name.clone()), + "path": src.and_then(|(l, pr)| super::match_path_of(&l.props, pr.uid)), + "type": src.and_then(|(_, pr)| essential::control_type(pr)), + "value": src.map(|(_, pr)| pr.value_at(Tick::ZERO).to_json()), + "missing": src.is_none(), }) } EgKind::Media { layer } => json!({ @@ -376,6 +491,7 @@ fn instance_json(s: &mut Session, p: &Value) -> Result { } let kind = match c.kind { EgKind::Property { .. } => "property", + EgKind::Mirror { .. } => "mirror", EgKind::Media { .. } => "media", EgKind::Comment { .. } => "comment", EgKind::Group { .. } => "group", @@ -426,11 +542,21 @@ fn targets(s: &Session, cid: ItemId, lid: LayerId, p: &Value) -> Vec<(u64, Uid)> let Some(l) = s.project.comp(cid).and_then(|c| c.layer(lid)) else { return vec![] }; let over = essential::overridden(l); let Some(g) = essential::group(l) else { return vec![] }; - let want = p.get("control").and_then(Value::as_u64); + // A control, its master and the master's mirrors share one value: act on all of them. + let want: Option> = p.get("control").and_then(Value::as_u64).map(|w| { + let src = match l.source { + LayerSource::Comp { item } => s.project.comp(item).and_then(|c| c.essential.as_ref()), + _ => None, + }; + match src.and_then(|e| e.resolve(w).map(|m| (e, m.id))) { + Some((e, m)) => std::iter::once(m).chain(e.mirrors_of(m)).chain(std::iter::once(w)).collect(), + None => vec![w], + } + }); let mut out = vec![]; g.walk("", &mut |_, pr| { if let Some(c) = essential::control_of(&pr.match_id) - && want.is_none_or(|w| w == c) + && want.as_ref().is_none_or(|w| w.contains(&c)) && (want.is_some() || over.contains(&pr.uid)) { out.push((c, pr.uid)); @@ -476,29 +602,32 @@ fn push_to_comp(s: &mut Session, p: &Value) -> Result { let l = s.project.comp(cid).and_then(|c| c.layer(lid)).cloned().ok_or(EngineError::NoComp)?; let mut writes = vec![]; for (control, uid) in &ts { - let (Some(ctl), Some(pr)) = (eg.find(*control), l.props.find(*uid)) else { continue }; + // A mirror pushes into its master's properties. + let (Some(ctl), Some(pr)) = (eg.resolve(*control), l.props.find(*uid)) else { continue }; writes.push((ctl.kind.clone(), pr.clone())); } s.edit("Push Override Values to Source", None, |proj, _| { let sc = proj.comp_mut(src).ok_or(EngineError::NoComp)?; for (kind, pr) in &writes { match kind { - EgKind::Property { layer, prop } => { - let Some(inner) = sc.layer_mut(*layer) else { continue }; - // Instance keys are in the precomp layer's time (= the source comp's time); - // the source property's keys are in the inner layer's time. - let keys: Vec<_> = pr - .keys - .iter() - .map(|k| { - let mut k = k.clone(); - k.time = inner.layer_time(k.time); - k - }) - .collect(); - if let Some(target) = inner.props.find_mut(*prop) { - target.value = pr.value.clone(); - target.keys = keys; + EgKind::Property { .. } => { + for (layer, prop) in kind.targets() { + let Some(inner) = sc.layer_mut(layer) else { continue }; + // Instance keys are in the precomp layer's time (= the source comp's + // time); the source property's keys are in the inner layer's time. + let keys: Vec<_> = pr + .keys + .iter() + .map(|k| { + let mut k = k.clone(); + k.time = inner.layer_time(k.time); + k + }) + .collect(); + if let Some(target) = inner.props.find_mut(prop) { + target.value = pr.value.clone(); + target.keys = keys; + } } } EgKind::Media { layer } => { @@ -740,10 +869,29 @@ pub fn specs() -> Vec { "Add Property to Essential Graphics", ["Animation"], None, - "{comp?, layer?, path?|prop?: uid (default: the selected properties), name?, group?: control id, index?}", + "{comp?, layer?, path?|prop?: uid (default: the selected properties), name?, group?: control id, index?, mirror?: bool (a property already present is added as a mirror; default true)}", has_selected_props, add_property ), + cmd!("essential.addMirror", "Add Mirror", [], None, "{comp?, control, name?, group?, index?}", has_eg, add_mirror), + cmd!( + "essential.linkProperty", + "Link Property to Control", + [], + None, + "{comp?, control, layer?, path?|prop? (default: the selected property)}", + has_eg, + link_property + ), + cmd!( + "essential.unlinkProperty", + "Unlink Property", + [], + None, + "{comp?, control, layer?, path?|prop? (default: the selected property)}", + has_eg, + unlink_property + ), cmd!("essential.addMedia", "Add Media Replacement", [], None, "{layer, name?, group?}", has_comp, add_media), cmd!("essential.addGroup", "Add Group", [], None, "{comp?, name?, index?}", has_comp, add_group), cmd!("essential.addComment", "Add Comment", [], None, "{comp?, text?, group?}", has_comp, add_comment), diff --git a/crates/engine/src/tests_essential.rs b/crates/engine/src/tests_essential.rs index 33473857..0732020a 100644 --- a/crates/engine/src/tests_essential.rs +++ b/crates/engine/src/tests_essential.rs @@ -67,11 +67,15 @@ fn controls_are_listed_typed_and_editable() { assert_eq!(l["controls"][1]["name"], "Opacity"); s.execute("essential.remove", json!({"control": opacity})).unwrap(); assert_eq!(s.execute("essential.list", json!({})).unwrap()["controls"].as_array().unwrap().len(), 3); - // Unsupported properties are refused; adding the same property twice adds nothing. + // Unsupported properties are refused; adding the same property twice adds a mirror (or + // nothing with `mirror: false`). s.execute("layer.addMask", json!({"layer": solid})).unwrap(); assert!(s.execute("essential.addProperty", json!({"layer": solid, "path": "masks/#1/path"})).is_err()); - let again = s.execute("essential.addProperty", json!({"layer": solid, "path": "effects/#1/color"})).unwrap(); + let again = s.execute("essential.addProperty", json!({"layer": solid, "path": "effects/#1/color", "mirror": false})).unwrap(); assert_eq!(again["controls"], json!([])); + let again = s.execute("essential.addProperty", json!({"layer": solid, "path": "effects/#1/color"})).unwrap(); + assert_eq!(again["mirrors"], again["controls"]); + s.execute("edit.undo", json!({})).unwrap(); // Dropdown Menu Control: its items are editable and it becomes a dropdown control. s.execute("effect.apply", json!({"layer": solid, "effect": "Dropdown Menu Control"})).unwrap(); s.execute("effect.editDropdown", json!({"layer": solid, "items": ["Small", "Medium", "Large", "Huge"]})).unwrap(); @@ -141,6 +145,79 @@ fn master_properties_override_per_instance_push_and_revert() { assert!(essential::group(&l).unwrap().children.iter().all(|n| n.name() != "Card Color")); } +#[test] +fn mirrored_and_linked_properties() { + let (mut s, card, solid, ids) = card(); + let opacity = ids["opacity"].as_u64().unwrap(); + // A second solid whose opacity the Opacity control drives too. + let bg2 = s.execute("layer.newSolid", json!({"name": "BG2", "color": "#00ff00"})).unwrap()["layer"].as_u64().unwrap(); + s.execute("essential.linkProperty", json!({"control": opacity, "layer": bg2, "path": "transform/opacity"})).unwrap(); + // Not the same kind of property; already driven. + assert!(s.execute("essential.linkProperty", json!({"control": opacity, "layer": bg2, "path": "transform/position"})).is_err()); + assert!(s.execute("essential.linkProperty", json!({"control": ids["color"], "layer": bg2, "path": "transform/opacity"})).is_err()); + let opa = |s: &Session, l: u64| s.project.comp(card).unwrap().layer(LayerId(l)).unwrap().props.prop("transform/opacity").unwrap().value.as_f64(); + // The link follows the main property, and the main property follows the link. + s.execute("prop.set", json!({"comp": card.0, "layer": solid, "path": "transform/opacity", "value": 40})).unwrap(); + assert_eq!(opa(&s, bg2), 40.0); + s.execute("prop.set", json!({"comp": card.0, "layer": bg2, "path": "transform/opacity", "value": 70})).unwrap(); + assert_eq!(opa(&s, solid), 70.0); + let l = s.execute("essential.list", json!({})).unwrap(); + let c = l["controls"].as_array().unwrap().iter().find(|c| c["id"] == opacity).unwrap().clone(); + assert_eq!(c["links"][0]["layer"], bg2); + assert_eq!(c["links"][0]["path"], "transform/opacity"); + // Adding the Opacity again (into the Words group) mirrors it. + let m = s.execute("essential.addProperty", json!({"layer": solid, "path": "transform/opacity", "group": ids["group"], "name": "Fade"})).unwrap(); + let mirror = m["mirrors"][0].as_u64().unwrap(); + let l = s.execute("essential.list", json!({})).unwrap(); + let mc = &l["controls"][2]["children"][1]; + assert_eq!((mc["id"].as_u64(), mc["kind"].as_str(), mc["of"].as_u64(), mc["name"].as_str()), (Some(mirror), Some("mirror"), Some(opacity), Some("Fade"))); + assert_eq!(mc["value"], json!(70.0)); + // A mirror of the mirror shows the same master; the linked solid also counts as present. + let m2 = s.execute("essential.addMirror", json!({"control": mirror})).unwrap(); + assert_eq!(m2["of"], opacity); + let again = s.execute("essential.addProperty", json!({"layer": bg2, "path": "transform/opacity"})).unwrap(); + assert_eq!(again["mirrors"].as_array().unwrap().len(), 1); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + // Instances: setting the mirror sets the master (and both links) for that instance only. + s.execute("comp.new", json!({"name": "Main", "width": 64, "height": 64, "frameRate": 30, "duration": 2})).unwrap(); + let main = s.active_comp_id().unwrap(); + let a = s.execute("layer.addItem", json!({"item": card.0})).unwrap()["layer"].as_u64().unwrap(); + let inst = s.execute("essential.instance", json!({"layer": a})).unwrap(); + let find = |inst: &Value, id: u64| inst["controls"].as_array().unwrap().iter().find(|c| c["control"] == id).unwrap().clone(); + assert_eq!(find(&inst, mirror)["kind"], "mirror"); + s.execute("essential.set", json!({"layer": a, "control": mirror, "value": 20})).unwrap(); + let inst = s.execute("essential.instance", json!({"layer": a})).unwrap(); + assert_eq!((find(&inst, opacity)["value"].clone(), find(&inst, opacity)["overridden"].clone()), (json!(20.0), json!(true))); + assert_eq!((find(&inst, mirror)["value"].clone(), find(&inst, mirror)["overridden"].clone()), (json!(20.0), json!(true))); + // Rendered: both solids at 20 % (green BG2 on top of the red-filled solid). + let px = center(&s, main); + assert!((px[3] - (1.0 - 0.8 * 0.8)).abs() < 0.02, "{px:?}"); + assert_eq!(opa(&s, solid), 70.0, "the source comp keeps its value"); + // Setting the master updates the mirror. + s.execute("essential.set", json!({"layer": a, "control": opacity, "value": 55})).unwrap(); + assert_eq!(find(&s.execute("essential.instance", json!({"layer": a})).unwrap(), mirror)["value"], json!(55.0)); + // Reverting the mirror reverts the master too. + s.execute("essential.revert", json!({"layer": a, "control": mirror})).unwrap(); + let inst = s.execute("essential.instance", json!({"layer": a})).unwrap(); + assert_eq!((find(&inst, opacity)["overridden"].clone(), find(&inst, mirror)["value"].clone()), (json!(false), json!(70.0))); + // Push to Comp from a mirror writes the master and its links. + s.execute("essential.set", json!({"layer": a, "control": mirror, "value": 35})).unwrap(); + s.execute("essential.pushToComp", json!({"layer": a, "control": mirror})).unwrap(); + assert_eq!((opa(&s, solid), opa(&s, bg2)), (35.0, 35.0)); + // Saved and loaded with the project. + let back = effectcraft_project::Project::from_json(&s.project.to_json()).unwrap(); + assert_eq!(back.comp(card).unwrap().essential, s.project.comp(card).unwrap().essential); + // Unlink; removing the master removes its mirrors. + s.execute("essential.unlinkProperty", json!({"comp": card.0, "control": opacity, "layer": bg2, "path": "transform/opacity"})).unwrap(); + s.execute("prop.set", json!({"comp": card.0, "layer": solid, "path": "transform/opacity", "value": 10})).unwrap(); + assert_eq!(opa(&s, bg2), 35.0); + let r = s.execute("essential.remove", json!({"comp": card.0, "control": opacity})).unwrap(); + assert_eq!(r["mirrors"], json!([mirror])); + let eg = s.project.comp(card).unwrap().essential.clone().unwrap(); + assert!(eg.find(mirror).is_none() && essential::group(s.project.comp(main).unwrap().layer(LayerId(a)).unwrap()).is_some()); +} + #[test] fn template_export_import_round_trip_with_overrides() { let dir = out_dir("ectemplate"); diff --git a/crates/project/src/essential.rs b/crates/project/src/essential.rs index 885c5062..bbf3b62c 100644 --- a/crates/project/src/essential.rs +++ b/crates/project/src/essential.rs @@ -41,8 +41,18 @@ pub struct EgControl { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(tag = "type")] pub enum EgKind { - /// A property of a layer of the comp. - Property { layer: LayerId, prop: Uid }, + /// A property of a layer of the comp. `links`: further properties the control drives + /// (they follow the main property in the comp and take the instance's value in every + /// instance). + Property { + layer: LayerId, + prop: Uid, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + links: Vec, + }, + /// A mirror: the property of control `of` shown again (its own name and place in the + /// panel; one value, editing either edits both, in the comp and in every instance). + Mirror { of: u64 }, /// Media Replacement: the footage of a footage layer can be swapped per instance. Media { layer: LayerId }, /// A text note shown in the panel. @@ -54,6 +64,13 @@ pub enum EgKind { }, } +/// A further property driven by a property control. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EgLink { + pub layer: LayerId, + pub prop: Uid, +} + /// The kind of control a property becomes. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] @@ -155,13 +172,46 @@ impl EssentialGraphics { }, } } - /// The control exposing property `prop` of `layer`, if any. + /// The control exposing property `prop` of `layer` (its main property), if any. pub fn control_for(&self, layer: LayerId, prop: Uid) -> Option<&EgControl> { - self.flat().into_iter().find(|c| matches!(c.kind, EgKind::Property { layer: l, prop: p } if l == layer && p == prop)) + self.flat().into_iter().find(|c| matches!(c.kind, EgKind::Property { layer: l, prop: p, .. } if l == layer && p == prop)) } - /// Controls an instance can override (properties and media; not comments or groups). + /// The property control a property drives: its main property or one of its links. + pub fn driver_of(&self, layer: LayerId, prop: Uid) -> Option<&EgControl> { + self.flat().into_iter().find(|c| match &c.kind { + EgKind::Property { layer: l, prop: p, links } => (*l == layer && *p == prop) || links.iter().any(|k| k.layer == layer && k.prop == prop), + _ => false, + }) + } + /// The control a mirror shows (following mirrors of mirrors); the control itself otherwise. + pub fn resolve(&self, id: u64) -> Option<&EgControl> { + let mut c = self.find(id)?; + for _ in 0..8 { + match c.kind { + EgKind::Mirror { of } => c = self.find(of)?, + _ => return Some(c), + } + } + None + } + /// Mirrors of control `id`. + pub fn mirrors_of(&self, id: u64) -> Vec { + self.flat().into_iter().filter(|c| matches!(c.kind, EgKind::Mirror { .. }) && self.resolve(c.id).is_some_and(|m| m.id == id)).map(|c| c.id).collect() + } + /// Controls an instance can override (properties, mirrors and media; not comments or + /// groups). pub fn has_instance_controls(&self) -> bool { - self.flat().iter().any(|c| matches!(c.kind, EgKind::Property { .. } | EgKind::Media { .. })) + self.flat().iter().any(|c| matches!(c.kind, EgKind::Property { .. } | EgKind::Mirror { .. } | EgKind::Media { .. })) + } +} + +impl EgKind { + /// Every property a property control drives: the main one, then the links. + pub fn targets(&self) -> Vec<(LayerId, Uid)> { + match self { + EgKind::Property { layer, prop, links } => std::iter::once((*layer, *prop)).chain(links.iter().map(|k| (k.layer, k.prop))).collect(), + _ => vec![], + } } } @@ -188,13 +238,92 @@ pub fn overridden(layer: &Layer) -> Vec { } } -/// The source property a control exposes, in `project`. +/// The source property a control exposes, in `project` (a mirror's: its master's main +/// property). pub fn source_prop<'a>(project: &'a Project, comp: ItemId, c: &EgControl) -> Option<(&'a Layer, &'a Property)> { - let EgKind::Property { layer, prop } = c.kind else { return None }; + let (layer, prop) = match c.kind { + EgKind::Property { layer, prop, .. } => (layer, prop), + EgKind::Mirror { .. } => match project.comp(comp)?.essential.as_ref()?.resolve(c.id)?.kind { + EgKind::Property { layer, prop, .. } => (layer, prop), + _ => return None, + }, + _ => return None, + }; let l = project.comp(comp)?.layer(layer)?; Some((l, l.props.find(prop)?)) } +/// The linked properties of a property control that exist in `project`: (layer, property). +pub fn linked_props<'a>(project: &'a Project, comp: ItemId, c: &EgControl) -> Vec<(&'a Layer, &'a Property)> { + let Some(cm) = project.comp(comp) else { return vec![] }; + match &c.kind { + EgKind::Property { links, .. } => links.iter().filter_map(|k| cm.layer(k.layer).and_then(|l| l.props.find(k.prop).map(|p| (l, p)))).collect(), + _ => vec![], + } +} + +/// Whether property `b` can be driven by a control showing `a` (the same kind of value). +pub fn linkable(a: &Property, b: &Property) -> bool { + control_type(a).is_some() && control_type(a) == control_type(b) && std::mem::discriminant(&a.value) == std::mem::discriminant(&b.value) +} + +/// The properties a control drives stay equal: when this edit (`before` → `p`) changed one of +/// them, its value and keyframes (moved to each layer's time) go to all the others; otherwise +/// the main property's do. +fn sync_links(before: &Project, p: &mut Project) { + let ids: Vec = p.comps().map(|(i, _)| *i).collect(); + for cid in ids { + let Some(comp) = p.comp(cid) else { continue }; + let Some(eg) = &comp.essential else { continue }; + let old = before.comp(cid); + let mut writes: Vec<(LayerId, Uid, Value, Vec)> = vec![]; + for c in eg.flat() { + let targets = c.kind.targets(); + if targets.len() < 2 { + continue; + } + let get = |cm: &crate::Comp, (l, u): (LayerId, Uid)| cm.layer(l).and_then(|l| l.props.find(u).cloned()); + let changed = |t: (LayerId, Uid)| match (get(comp, t), old.and_then(|o| get(o, t))) { + (Some(a), Some(b)) => a.value != b.value || a.keys != b.keys, + _ => false, + }; + // The edited property leads (the main one when several changed). + let lead = targets.iter().copied().find(|t| changed(*t)).filter(|t| !changed(targets[0]) || *t == targets[0]).unwrap_or(targets[0]); + let Some(src_l) = comp.layer(lead.0) else { continue }; + let Some(src) = src_l.props.find(lead.1) else { continue }; + for k in targets.iter().filter(|t| **t != lead) { + let Some(l) = comp.layer(k.0) else { continue }; + let Some(dst) = l.props.find(k.1) else { continue }; + if !linkable(src, dst) { + continue; + } + let keys: Vec<_> = src + .keys + .iter() + .map(|kf| { + let mut kf = kf.clone(); + kf.time = l.layer_time(src_l.comp_time(kf.time)); + kf + }) + .collect(); + if dst.value != src.value || dst.keys != keys { + writes.push((k.0, k.1, src.value.clone(), keys)); + } + } + } + if writes.is_empty() { + continue; + } + let Some(c) = p.comp_mut(cid) else { continue }; + for (lid, uid, v, keys) in writes { + if let Some(pr) = c.layer_mut(lid).and_then(|l| l.props.find_mut(uid)) { + pr.value = v; + pr.keys = keys; + } + } + } +} + /// The footage item a media control shows by default (the layer's own source). pub fn source_media(project: &Project, comp: ItemId, c: &EgControl) -> Option { let EgKind::Media { layer } = c.kind else { return None }; @@ -241,7 +370,7 @@ fn build_group(project: &Project, comp: ItemId, eg: &EssentialGraphics, old: Opt g.children = build(project, comp, children, old, over, alloc, keep); out.push(Node::Group(g)); } - EgKind::Property { .. } => { + EgKind::Property { .. } | EgKind::Mirror { .. } => { let Some((_, src)) = source_prop(project, comp, c) else { continue }; let prev = prev.and_then(Node::as_prop); let uid = prev.map(|p| p.uid).unwrap_or_else(&mut *alloc); @@ -276,10 +405,84 @@ fn build_group(project: &Project, comp: ItemId, eg: &EssentialGraphics, old: Opt } let uid = old.map(|g| g.uid).unwrap_or_else(&mut alloc); let mut keep = vec![]; - let children = build(project, comp, &eg.controls, old, &over, &mut alloc, &mut keep); + let mut children = build(project, comp, &eg.controls, old, &over, &mut alloc, &mut keep); + // Mirrors show their master's instance value (and override state). + for c in eg.flat() { + if !matches!(c.kind, EgKind::Mirror { .. }) { + continue; + } + let Some(master) = eg.resolve(c.id) else { continue }; + let Some(src) = find_prop(&children, &match_id(master.id)).cloned() else { continue }; + if let Some(dst) = find_prop_mut(&mut children, &match_id(c.id)) { + dst.value = src.value.clone(); + dst.keys = src.keys.clone(); + dst.expr = src.expr.clone(); + keep.retain(|u| *u != dst.uid); + if keep.contains(&src.uid) { + keep.push(dst.uid); + } + } + } PropGroup { uid, match_id: GROUP.into(), name: "Essential Properties".into(), kind: GroupKind::Essential { overridden: keep }, enabled: true, children } } +fn find_prop<'a>(nodes: &'a [Node], m: &str) -> Option<&'a Property> { + nodes.iter().find_map(|n| match n { + Node::Prop(p) if p.match_id == m => Some(p), + Node::Group(g) => find_prop(&g.children, m), + _ => None, + }) +} + +fn find_prop_mut<'a>(nodes: &'a mut [Node], m: &str) -> Option<&'a mut Property> { + nodes.iter_mut().find_map(|n| match n { + Node::Prop(p) if p.match_id == m => Some(p), + Node::Group(g) => find_prop_mut(&mut g.children, m), + _ => None, + }) +} + +/// An instance's mirror property edited in this edit passes its value to its master (the +/// group build then copies the master back to every mirror). +fn propagate_mirrors(before: &Project, after: &mut Project) { + let ids: Vec = after.comps().map(|(i, _)| *i).collect(); + for cid in ids { + let (Some(comp), Some(old_comp)) = (after.comp(cid), before.comp(cid)) else { continue }; + let mut writes: Vec<(usize, String, Property)> = vec![]; + for (i, l) in comp.layers.iter().enumerate() { + let LayerSource::Comp { item } = l.source else { continue }; + let Some(eg) = after.comp(item).and_then(|c| c.essential.as_ref()) else { continue }; + let (Some(g), Some(og)) = (group(l), old_comp.layer(l.id).and_then(group)) else { continue }; + for c in eg.flat() { + if !matches!(c.kind, EgKind::Mirror { .. }) { + continue; + } + let Some(master) = eg.resolve(c.id) else { continue }; + let (mm, cm) = (match_id(master.id), match_id(c.id)); + let (Some(mp), Some(cp)) = (find_prop(&g.children, &mm), find_prop(&g.children, &cm)) else { continue }; + let (Some(omp), Some(ocp)) = (find_prop(&og.children, &mm), find_prop(&og.children, &cm)) else { continue }; + let changed = |a: &Property, b: &Property| a.value != b.value || a.keys != b.keys || a.expr != b.expr; + if changed(cp, ocp) && !changed(mp, omp) { + writes.push((i, mm, cp.clone())); + } + } + } + if writes.is_empty() { + continue; + } + let Some(c) = after.comp_mut(cid) else { continue }; + for (i, mm, src) in writes { + if let Some(g) = c.layers[i].props.sub_mut(GROUP) + && let Some(dst) = find_prop_mut(&mut g.children, &mm) + { + dst.value = src.value; + dst.keys = src.keys; + dst.expr = src.expr; + } + } + } +} + /// Master properties the user changed in this edit (`before` → `after`) become overridden. fn mark_overrides(before: &Project, after: &mut Project) { let ids: Vec = after.comps().map(|(i, _)| *i).collect(); @@ -327,6 +530,8 @@ fn mark_overrides(before: &Project, after: &mut Project) { /// After an edit: mark changed master properties as overridden, then add, update or remove the /// Essential Properties groups of every precomp layer so they match their comp's controls. pub fn sync_project(before: &Project, after: &mut Project) { + sync_links(before, after); + propagate_mirrors(before, after); mark_overrides(before, after); let ids: Vec = after.comps().map(|(i, _)| *i).collect(); let mut next = after.next_id; @@ -386,13 +591,16 @@ pub fn with_overrides(project: &Project, comp: ItemId, overrides: &[Override]) - let c = p.comp_mut(comp)?; let mut any = false; for o in overrides { - let Some(ctl) = eg.find(o.control) else { continue }; - match ctl.kind { - EgKind::Property { layer, prop } => { - if let Some(pr) = c.layer_mut(layer).and_then(|l| l.props.find_mut(prop)) { - pr.keys.clear(); - pr.value = o.value.clone(); - any = true; + // A mirror's value is its master's. + let Some(ctl) = eg.resolve(o.control) else { continue }; + match &ctl.kind { + EgKind::Property { .. } => { + for (layer, prop) in ctl.kind.targets() { + if let Some(pr) = c.layer_mut(layer).and_then(|l| l.props.find_mut(prop)) { + pr.keys.clear(); + pr.value = o.value.clone(); + any = true; + } } } EgKind::Media { layer } => { @@ -401,7 +609,7 @@ pub fn with_overrides(project: &Project, comp: ItemId, overrides: &[Override]) - if !matches!(project.item(item).map(|i| &i.kind), Some(ItemKind::Footage(_))) { continue; } - if let Some(l) = c.layer_mut(layer) { + if let Some(l) = c.layer_mut(*layer) { l.source = LayerSource::Footage { item }; any = true; } @@ -432,10 +640,15 @@ pub fn offset_ids(p: &mut Project, offset: u64) { for c in v { c.id += o; match &mut c.kind { - EgKind::Property { layer, prop } => { + EgKind::Property { layer, prop, links } => { *layer = LayerId(layer.0 + o); *prop += o; + for k in links { + k.layer = LayerId(k.layer.0 + o); + k.prop += o; + } } + EgKind::Mirror { of } => *of += o, EgKind::Media { layer } => *layer = LayerId(layer.0 + o), EgKind::Group { children } => go(children, o), EgKind::Comment { .. } => {} diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index fec9d9e3..9fde823d 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -805,8 +805,22 @@ Property.prototype.property = function () { return null; }; }; P.keyLabel = function () { return 0; }; P.setLabelAtKey = function () {}; - P.addToMotionGraphicsTemplate = function () { return false; }; - P.canAddToMotionGraphicsTemplate = function () { return false; }; + // Essential Graphics: `comp` must be the comp holding the property. A property already in + // the panel is added again as a mirror (both controls edit one value). + P.canAddToMotionGraphicsTemplate = function (comp) { + var c = comp && comp.__id !== undefined ? comp.__id : this.__layer.__comp; + if (c !== this.__layer.__comp) return false; + var sup = __call("essential.list", { comp: c, supported: true }).supported || [], uid = this.__uid; + for (var i = 0; i < sup.length; i++) if (sup[i].uid === uid) return true; + return false; + }; + P.addToMotionGraphicsTemplateAs = function (comp, name) { + if (!this.canAddToMotionGraphicsTemplate(comp)) return false; + var p = this.__ref(); + if (name !== undefined) p.name = String(name); + return __call("essential.addProperty", p).controls.length > 0; + }; + P.addToMotionGraphicsTemplate = function (comp) { return this.addToMotionGraphicsTemplateAs(comp); }; })(); // Layer / composition markers (`layer.marker`, `comp.markerProperty`): keyframes are markers. @@ -1352,7 +1366,39 @@ CompItem.prototype.constructor = CompItem; }; P.openInViewer = function () { __call("comp.open", { comp: this.__id }); return null; }; P.duplicate = function () { return __item(__call("project.duplicate", { items: [this.__id] }).items[0]); }; - P.exportAsMotionGraphicsTemplate = function () { return false; }; + // Essential Graphics template: name, controllers (property, mirror and media controls in + // panel order, 1-based) and export. + P.__egControllers = function () { + var out = []; + (function go(v) { + for (var i = 0; i < v.length; i++) { + if (v[i].kind === "group") go(v[i].children || []); + else if (v[i].kind !== "comment") out.push(v[i]); + } + })(__call("essential.list", { comp: this.__id }).controls || []); + return out; + }; + def("motionGraphicsTemplateName", function () { return __call("essential.list", { comp: this.__id }).name; }, function (v) { + __call("essential.setName", { comp: this.__id, name: String(v) }); + }); + def("motionGraphicsTemplateControllerCount", function () { return this.__egControllers().length; }); + P.getMotionGraphicsTemplateControllerName = function (i) { + var c = this.__egControllers()[__num(i, "index") - 1]; + if (!c) throw __err("controller index out of range"); + return c.name; + }; + P.setMotionGraphicsControllerName = function (i, name) { + var c = this.__egControllers()[__num(i, "index") - 1]; + if (!c) throw __err("controller index out of range"); + __call("essential.rename", { comp: this.__id, control: c.id, name: String(name) }); + return String(name); + }; + P.exportAsMotionGraphicsTemplate = function (overwrite, path) { + if (path === undefined) return false; + var p = path && path.fsName !== undefined ? path.fsName : String(path); + if (!overwrite && new File(p).exists) return false; + try { __call("essential.exportTemplate", { comp: this.__id, path: p }); return true; } catch (e) { return false; } + }; })(); function FootageItem(id) { diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 458f2b41..c9a42692 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -446,3 +446,41 @@ fn layer_switches_timing_and_comp_settings() { let o = ok(&mut s, "var n = app.project.numItems; app.project.item(1).remove(); n - app.project.numItems"); assert_eq!(o.result, json!(1)); } + +#[test] +fn motion_graphics_template_hooks_add_mirrors_and_controllers() { + let mut s = session(); + let dir = std::env::temp_dir().join(format!("effectcraft-eg-script-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + let path = dir.join("card.ectemplate"); + let o = ok( + &mut s, + &format!( + r#" + var comp = app.project.items.addComp("Card", 64, 64, 1, 2, 30); + var bg = comp.layers.addSolid([1, 1, 1], "BG", 64, 64, 1); + var op = bg.transform.opacity; + var r = [op.canAddToMotionGraphicsTemplate(comp), op.addToMotionGraphicsTemplateAs(comp, "Fade")]; + // Again: a mirror of the same property. + r.push(op.addToMotionGraphicsTemplate(comp)); + r.push(comp.motionGraphicsTemplateControllerCount); + r.push(comp.getMotionGraphicsTemplateControllerName(1), comp.getMotionGraphicsTemplateControllerName(2)); + comp.setMotionGraphicsControllerName(2, "Fade Again"); + comp.motionGraphicsTemplateName = "Lower Card"; + r.push(comp.getMotionGraphicsTemplateControllerName(2), comp.motionGraphicsTemplateName); + // A property of another comp can't be added. + var other = app.project.items.addComp("Other", 64, 64, 1, 2, 30); + r.push(op.canAddToMotionGraphicsTemplate(other)); + r.push(comp.exportAsMotionGraphicsTemplate(true, new File("{}"))); + r + "#, + path.to_string_lossy() + ), + ); + assert_eq!(o.result, json!([true, true, true, 2, "Fade", "Fade", "Fade Again", "Lower Card", false, true])); + let (_, c) = comp_named(&s, "Card"); + let eg = c.essential.as_ref().unwrap(); + assert!(matches!(eg.controls[1].kind, effectcraft_engine::project::essential::EgKind::Mirror { of } if of == eg.controls[0].id)); + assert!(path.exists()); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/panels/essential.rs b/crates/ui-egui/src/panels/essential.rs index f4c565c5..3f8f1985 100644 --- a/crates/ui-egui/src/panels/essential.rs +++ b/crates/ui-egui/src/panels/essential.rs @@ -7,6 +7,10 @@ //! angle / dropdown control editing the source property; rename by double-clicking the name; //! reorder with ▲▼, remove with ✕; groups and comments from Add Formatting; Media //! Replacement for the selected footage layer. +//! - **Mirrors and links**: adding a property that is already in the panel adds a *mirror* +//! (same value, its own name and place); a control's ⋯ menu adds a mirror, links the selected +//! timeline property (the control then drives both) or unlinks one. Mirrors and linked +//! controls are badged (`essential.control..badge`, `.menu`). //! - **Export Template…** writes an open `.ectemplate`. //! - With a precomp layer that has Essential Properties selected, its instance values: edit them //! (overrides), Revert or Push to Comp. @@ -178,8 +182,25 @@ fn controls(app: &mut EffectcraftApp, ui: &mut egui::Ui, cid: ItemId, list: &[Eg let shown = EgControl { name: text.clone(), ..c.clone() }; name_label(app, ui, cid, &shown, RichText::new(text).italics().color(t.text_dim), actions); } - EgKind::Property { .. } => { + EgKind::Property { .. } | EgKind::Mirror { .. } => { name_label(app, ui, cid, c, RichText::new(&c.name), actions); + // Mirrors and linked properties are marked (hover: what they share). + let eg = app.session.project.comp(cid).and_then(|x| x.essential.clone()).unwrap_or_default(); + let badge = match &c.kind { + EgKind::Mirror { of } => Some(("mirror".to_string(), format!("Mirror of {}", eg.find(*of).map(|m| m.name.as_str()).unwrap_or("?")))), + EgKind::Property { links, .. } if !links.is_empty() => { + let names: Vec = essential::linked_props(&app.session.project, cid, c) + .into_iter() + .map(|(l, p)| format!("{} › {}", l.name, l.props.name_path_of(p.uid).unwrap_or_else(|| p.name.clone()))) + .collect(); + Some((format!("+{} linked", links.len()), format!("Also drives:\n{}", names.join("\n")))) + } + _ => None, + }; + if let Some((text, tip)) = badge { + let r = ui.label(RichText::new(text).color(t.accent).size(10.0)).on_hover_text(&tip); + app.auto.add(&format!("essential.control.{}.badge", c.id), r.rect, &tip); + } let src = essential::source_prop(&app.session.project, cid, c).map(|(l, p)| (l.id.0, l.layer_time(app.session.time()), p.clone())); match src { Some((layer, lt, p)) => { @@ -195,6 +216,7 @@ fn controls(app: &mut EffectcraftApp, ui: &mut egui::Ui, cid: ItemId, list: &[Eg ui.label(RichText::new("(missing)").color(t.danger)); } } + link_menu(app, ui, cid, c, group, actions); } EgKind::Media { .. } => { name_label(app, ui, cid, c, RichText::new(&c.name), actions); @@ -216,6 +238,35 @@ fn controls(app: &mut EffectcraftApp, ui: &mut egui::Ui, cid: ItemId, list: &[Eg } } +/// A property control's "⋯" menu: Add Mirror, Link Selected Property, Unlink. +fn link_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui, cid: ItemId, c: &EgControl, group: Option, actions: &mut Actions) { + let links: Vec<(u64, u64, String)> = match &c.kind { + EgKind::Property { .. } => essential::linked_props(&app.session.project, cid, c) + .into_iter() + .map(|(l, p)| (l.id.0, p.uid, format!("{} › {}", l.name, l.props.name_path_of(p.uid).unwrap_or_else(|| p.name.clone())))) + .collect(), + _ => vec![], + }; + let has_sel = !app.session.state.selected_props.is_empty(); + let r = ui.menu_button(RichText::new("⋯").size(10.0), |ui| { + if ui.button("Add Mirror").clicked() { + actions.push(("essential.addMirror".into(), json!({"comp": cid.0, "control": c.id, "group": group}))); + ui.close(); + } + if ui.add_enabled(has_sel, egui::Button::new("Link Selected Property")).clicked() { + actions.push(("essential.linkProperty".into(), json!({"comp": cid.0, "control": c.id}))); + ui.close(); + } + for (layer, prop, name) in &links { + if ui.button(format!("Unlink {name}")).clicked() { + actions.push(("essential.unlinkProperty".into(), json!({"comp": cid.0, "control": c.id, "layer": layer, "prop": prop}))); + ui.close(); + } + } + }); + app.auto.add(&format!("essential.control.{}.menu", c.id), r.response.rect, "Mirror and link"); +} + /// A control's name; double-click to rename (comments: edit the text). fn name_label(app: &mut EffectcraftApp, ui: &mut egui::Ui, cid: ItemId, c: &EgControl, text: RichText, actions: &mut Actions) { let edit_id = egui::Id::new(("eg-rename", c.id)); From cb516c91c31ce3c88465af76e3df15f97a92b39e Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 10:28:56 -0400 Subject: [PATCH 3/4] M13.11: scriptui concave fills, real images, wasm cfg gating onDraw fillPath tessellates any path (non-zero winding trapezoid decomposition: concave, self-intersecting, holes) instead of egui's convex-only polygon. ScriptUIImage holds a file path or embedded PNG/JPEG bytes; drawImage, image controls and icon buttons draw the decoded image. Script-host thread code (host_main, run_threaded, ToHost::Start) is gated off wasm32. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/scriptui.rs | 147 ++++++++++++++++++++- crates/script/src/scriptui.js | 40 +++++- crates/script/src/ui.rs | 27 +++- crates/ui-egui/Cargo.toml | 2 +- crates/ui-egui/src/panels/scriptui_view.rs | 80 +++++++++-- crates/ui-egui/tests/ui_scriptui.rs | 55 ++++++++ 6 files changed, 326 insertions(+), 25 deletions(-) diff --git a/crates/engine/src/scriptui.rs b/crates/engine/src/scriptui.rs index 1b88bce7..6ef64920 100644 --- a/crates/engine/src/scriptui.rs +++ b/crates/engine/src/scriptui.rs @@ -116,6 +116,8 @@ pub struct Widget { /// Frontends paint it instead of the control's default look, unless it contains /// [`DrawOp::Os`] (`drawOSControl()`), which draws the default look first. pub draw: Vec, + /// The image of an image control or icon button. + pub image: Option, pub children: Vec, } @@ -156,8 +158,37 @@ pub enum DrawOp { }, /// `drawOSControl()`: the control's default look. Os, - /// `drawImage(image, x, y, w?, h?)` (drawn as a placeholder frame). - Image { x: f64, y: f64, w: f64, h: f64 }, + /// `drawImage(image, x, y, w?, h?)`: `w` / `h` 0 = the image's own size. + Image { + #[serde(default)] + image: Option, + x: f64, + y: f64, + w: f64, + h: f64, + }, +} + +/// A ScriptUIImage (`ScriptUI.newImage`, an image control's or icon button's image, a +/// `drawImage` argument): an image file, or embedded PNG / JPEG bytes. +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(default)] +pub struct ImageRef { + /// File path (or a resource name we can't resolve: drawn as a placeholder). + pub src: Option, + /// Embedded bytes as a binary string (one character per byte, as in resource strings). + pub data: Option, + pub name: String, +} + +impl ImageRef { + /// The encoded image bytes (the embedded data, else the file through `services`). + pub fn bytes(&self, services: &dyn crate::Services) -> Option> { + if let Some(d) = &self.data { + return d.chars().map(|c| u8::try_from(c as u32).ok()).collect(); + } + services.read_file(self.src.as_deref()?).ok() + } } impl PathSeg { @@ -207,6 +238,67 @@ impl PathSeg { flush(&mut cur, &mut out, false); out } + + /// The area `fillPath` paints, as triangles: every subpath closed, overlapping subpaths + /// combined by the non-zero winding rule (`even_odd`: the even-odd rule), so concave, + /// self-intersecting and holed paths fill correctly (frontends' polygon fills are often + /// convex-only). Exact trapezoid decomposition: the plane is cut into horizontal bands at + /// every vertex and edge crossing; inside a band no edges cross, so each inside span is a + /// trapezoid (two triangles). + pub fn fill_triangles(path: &[PathSeg], n: usize, even_odd: bool) -> Vec<[[f64; 2]; 3]> { + // Non-horizontal edges, top to bottom, with their winding direction. + let mut edges: Vec<([f64; 2], [f64; 2], i32)> = vec![]; + for (pts, _) in PathSeg::polylines(path, n) { + for i in 0..pts.len() { + let (a, b) = (pts[i], pts[(i + 1) % pts.len()]); + if !(a[0].is_finite() && a[1].is_finite() && b[0].is_finite() && b[1].is_finite()) || a[1] == b[1] { + continue; + } + edges.push(if a[1] < b[1] { (a, b, 1) } else { (b, a, -1) }); + } + } + if edges.len() < 2 { + return vec![]; + } + let x_at = |e: &([f64; 2], [f64; 2], i32), y: f64| e.0[0] + (e.1[0] - e.0[0]) * (y - e.0[1]) / (e.1[1] - e.0[1]); + let mut ys: Vec = edges.iter().flat_map(|e| [e.0[1], e.1[1]]).collect(); + // Where two edges cross, a band boundary. + for i in 0..edges.len() { + for j in i + 1..edges.len() { + let (a, b) = (&edges[i], &edges[j]); + let (lo, hi) = (a.0[1].max(b.0[1]), a.1[1].min(b.1[1])); + if hi <= lo { + continue; + } + let (d0, d1) = (x_at(a, lo) - x_at(b, lo), x_at(a, hi) - x_at(b, hi)); + if d0 * d1 < 0.0 { + ys.push(lo + (hi - lo) * d0 / (d0 - d1)); + } + } + } + ys.sort_by(f64::total_cmp); + ys.dedup_by(|a, b| (*a - *b).abs() < 1e-9); + let mut out = vec![]; + let mut row: Vec<(f64, f64, f64, i32)> = vec![]; + for w in ys.windows(2) { + let (ya, yb) = (w[0], w[1]); + let ym = (ya + yb) * 0.5; + row.clear(); + row.extend(edges.iter().filter(|e| e.0[1] < ym && ym < e.1[1]).map(|e| (x_at(e, ym), x_at(e, ya), x_at(e, yb), e.2))); + row.sort_by(|a, b| a.0.total_cmp(&b.0)); + let mut wind = 0; + for k in 0..row.len().saturating_sub(1) { + wind += row[k].3; + let inside = if even_odd { wind % 2 != 0 } else { wind != 0 }; + if inside { + let (l, r) = (row[k], row[k + 1]); + out.push([[l.1, ya], [r.1, ya], [r.2, yb]]); + out.push([[l.1, ya], [r.2, yb], [l.2, yb]]); + } + } + } + out + } } impl Widget { @@ -689,4 +781,55 @@ mod tests { let j = serde_json::to_value(&ui).unwrap(); assert_eq!(j["windows"][0]["root"]["type"], "window"); } + + fn area(tris: &[[[f64; 2]; 3]]) -> f64 { + tris.iter().map(|t| ((t[1][0] - t[0][0]) * (t[2][1] - t[0][1]) - (t[2][0] - t[0][0]) * (t[1][1] - t[0][1])).abs() * 0.5).sum() + } + + fn poly(pts: &[[f64; 2]]) -> Vec { + let mut v = vec![PathSeg::M { x: pts[0][0], y: pts[0][1] }]; + v.extend(pts[1..].iter().map(|p| PathSeg::L { x: p[0], y: p[1] })); + v.push(PathSeg::Z); + v + } + + fn covers(tris: &[[[f64; 2]; 3]], p: [f64; 2]) -> bool { + let s = |a: [f64; 2], b: [f64; 2]| (b[0] - a[0]) * (p[1] - a[1]) - (b[1] - a[1]) * (p[0] - a[0]); + tris.iter().any(|t| { + let (d0, d1, d2) = (s(t[0], t[1]), s(t[1], t[2]), s(t[2], t[0])); + (d0 >= 0.0 && d1 >= 0.0 && d2 >= 0.0) || (d0 <= 0.0 && d1 <= 0.0 && d2 <= 0.0) + }) + } + + #[test] + fn concave_self_intersecting_and_holed_fills() { + // An L (concave): area 3, the notch stays empty. + let l = poly(&[[0.0, 0.0], [2.0, 0.0], [2.0, 1.0], [1.0, 1.0], [1.0, 2.0], [0.0, 2.0]]); + let t = PathSeg::fill_triangles(&l, 48, false); + assert!((area(&t) - 3.0).abs() < 1e-9, "{}", area(&t)); + assert!(covers(&t, [0.5, 1.5]) && !covers(&t, [1.5, 1.5])); + // A five-pointed star drawn in one stroke: non-zero fills the centre, even-odd doesn't. + let star: Vec<[f64; 2]> = (0..5) + .map(|i| { + let a = (i * 2 % 5) as f64 * std::f64::consts::TAU / 5.0 - std::f64::consts::FRAC_PI_2; + [50.0 + 40.0 * a.cos(), 50.0 + 40.0 * a.sin()] + }) + .collect(); + let nz = PathSeg::fill_triangles(&poly(&star), 48, false); + let eo = PathSeg::fill_triangles(&poly(&star), 48, true); + assert!(covers(&nz, [50.0, 50.0]) && !covers(&eo, [50.0, 50.0])); + assert!(covers(&eo, [50.0, 15.0]), "a point of the star"); + assert!(area(&nz) > area(&eo)); + // A rectangle with a hole (an inner square the other way round). + let mut holed = poly(&[[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0]]); + holed.extend(poly(&[[3.0, 3.0], [3.0, 7.0], [7.0, 7.0], [7.0, 3.0]])); + let h = PathSeg::fill_triangles(&holed, 48, false); + assert!((area(&h) - 84.0).abs() < 1e-9 && !covers(&h, [5.0, 5.0])); + // rectPath + ellipsePath in one path; an unclosed subpath fills as if closed. + let shapes = vec![PathSeg::R { x: 0.0, y: 0.0, w: 4.0, h: 2.0 }, PathSeg::E { x: 10.0, y: 0.0, w: 20.0, h: 20.0 }]; + let a = area(&PathSeg::fill_triangles(&shapes, 256, false)); + assert!((a - 8.0 - std::f64::consts::PI * 100.0).abs() < 1.0, "{a}"); + let open = vec![PathSeg::M { x: 0.0, y: 0.0 }, PathSeg::L { x: 4.0, y: 0.0 }, PathSeg::L { x: 0.0, y: 4.0 }]; + assert!((area(&PathSeg::fill_triangles(&open, 48, false)) - 8.0).abs() < 1e-9); + } } diff --git a/crates/script/src/scriptui.js b/crates/script/src/scriptui.js index 57e0f445..2f406741 100644 --- a/crates/script/src/scriptui.js +++ b/crates/script/src/scriptui.js @@ -281,9 +281,34 @@ function DropDownList() {} function ListBox() {} function TabbedPanel() {} function Tab() {} -function ScriptUIImage() {} +// ScriptUIImage: an image file (`src`) or embedded image bytes (`data`, a binary string as in +// resource-string images: one character per byte). +function ScriptUIImage(src, data, name) { + this.__img = true; + this.src = src || null; + this.data = data || null; + this.name = name || (src ? String(src).replace(/^.*[\\/]/, "") : ""); + this.pathname = src || ""; + this.format = data ? (data.charCodeAt(0) === 0x89 ? "png" : "jpeg") : String(src || "").replace(/^.*\./, "").toLowerCase(); +} +ScriptUIImage.prototype.toString = function () { return "[object ScriptUIImage]"; }; function ListItem() {} +// Anything ScriptUI accepts as an image: a ScriptUIImage, a File, a path (or resource name), or +// a binary string holding PNG / JPEG bytes. +function __uiImage(v) { + if (v === undefined || v === null || typeof v === "number" || typeof v === "boolean") return null; + if (v.__img) return v; + if (v.fsName !== undefined) return new ScriptUIImage(String(v.fsName), null, String(v.name || "")); + var s = String(v); + if ((s.charCodeAt(0) === 0x89 && s.substr(1, 3) === "PNG") || (s.charCodeAt(0) === 0xff && s.charCodeAt(1) === 0xd8)) return new ScriptUIImage(null, s, ""); + return s ? new ScriptUIImage(s, null, "") : null; +} +function __uiImageJson(v) { + var img = __uiImage(v); + return img ? { src: img.src, data: img.data, name: img.name } : null; +} + var ScriptUI = { version: "6.2.2", frameworkName: "EffectCraft", @@ -293,7 +318,7 @@ var ScriptUI = { PenType: { SOLID_COLOR: 0, THEME_COLOR: 1 }, Alignment: { LEFT: "left", RIGHT: "right", TOP: "top", BOTTOM: "bottom", CENTER: "center", FILL: "fill" }, newFont: function (name, style, size) { return { name: name, style: style, size: size }; }, - newImage: function () { return {}; }, + newImage: function (normal) { return __uiImage(normal); }, getResourceText: function (t) { return t; }, events: { createEvent: function (t) { return { type: t }; } } }; @@ -347,7 +372,7 @@ function __uiGraphics() { this.__push({ op: "text", text: String(text), color: pen ? pen.color : null, x: __num(x || 0), y: __num(y || 0), size: __uiFontSize(f, this), style: String(f && f.style !== undefined ? f.style : "") }); }, drawOSControl: function () { this.__push({ op: "os" }); }, - drawImage: function (img, x, y, w, h) { this.__push({ op: "image", x: __num(x || 0), y: __num(y || 0), w: w === undefined ? 0 : __num(w), h: h === undefined ? 0 : __num(h) }); }, + drawImage: function (img, x, y, w, h) { this.__push({ op: "image", image: __uiImageJson(img), x: __num(x || 0), y: __num(y || 0), w: w === undefined ? 0 : __num(w), h: h === undefined ? 0 : __num(h) }); }, drawFocusRing: function () {}, measureString: function (s, font, boundaryWidth) { var size = __uiFontSize(font, this); @@ -397,6 +422,11 @@ function __uiInit(o, type, win, parent, bounds, text, props) { o.__wid = __uiNewId(); } o.properties = props || {}; + // Image controls and icon buttons take an image where other controls take their text. + if ((type === "image" || type === "iconbutton") && __uiImage(text)) { + o.image = __uiImage(text); + text = ""; + } o.text = text === undefined || text === null ? "" : String(text); o.enabled = true; o.visible = true; @@ -459,7 +489,8 @@ function __uiProto() { if (!ctor || t === "window") throw __err("add(): unknown control type " + type); var c = Object.create(__ecGlobal[ctor].prototype); // Value arguments after `text`: slider (value, min, max), progressbar (value, max), lists (items). - __uiInit(c, t, this.window, this, bounds, typeof text === "object" && text !== null && !(text instanceof Array) ? "" : text, props); + var arg = t === "image" || t === "iconbutton" ? text : typeof text === "object" && text !== null && !(text instanceof Array) ? "" : text; + __uiInit(c, t, this.window, this, bounds, arg, props); if ((t === "dropdownlist" || t === "listbox") && text instanceof Array) { for (var i = 0; i < text.length; i++) c.add("item", text[i]); c.text = ""; @@ -718,6 +749,7 @@ function __uiJson(c) { } if (c.type === "tabbedpanel") o.activeTab = c.__sel.length ? c.__sel[0] : 0; if (c.__draw && typeof c.onDraw === "function") o.draw = c.__draw; + if ((c.type === "image" || c.type === "iconbutton") && c.image) o.image = __uiImageJson(c.image); if (c.type === "edittext") { o.multiline = !!c.__multiline; o.readOnly = !!c.__readonly; diff --git a/crates/script/src/ui.rs b/crates/script/src/ui.rs index 66574ce0..609b767b 100644 --- a/crates/script/src/ui.rs +++ b/crates/script/src/ui.rs @@ -127,10 +127,24 @@ pub(crate) fn native_modal(_: &JsValue, args: &[JsValue], ctx: &mut Context) -> // ---------------------------------------------------------------- script-host threads pub(crate) enum ToHost { + /// Run the script (script-host threads exist only off the web: the browser runs scripts + /// inline). + #[cfg(not(target_arch = "wasm32"))] Start(Session), Event(Session, ScriptUiEvent), } +impl ToHost { + /// The session a message carries (given back when the host has gone). + fn into_session(self) -> Session { + match self { + #[cfg(not(target_arch = "wasm32"))] + ToHost::Start(s) => s, + ToHost::Event(s, _) => s, + } + } +} + pub(crate) struct FromHost { session: Session, outcome: crate::Outcome, @@ -191,6 +205,7 @@ fn yield_wait(outcome: crate::Outcome, windows: Vec, done: bool) - } } +#[cfg(not(target_arch = "wasm32"))] fn host_main(name: String, code: String, link: Link) { let Ok(ToHost::Start(session)) = link.rx.recv() else { return }; let mut a = Active::new(session, &name); @@ -248,7 +263,9 @@ fn receive(session: &mut Session, host: u32, end: HostEnd) -> crate::Outcome { } } -/// Run a script file on a new script-host thread (see the module docs). +/// Run a script file on a new script-host thread (see the module docs). Not on the web, where +/// scripts run inline. +#[cfg(not(target_arch = "wasm32"))] pub(crate) fn run_threaded(session: &mut Session, req: &effectcraft_engine::ScriptRequest) -> crate::Outcome { let host = new_host_id(); let (to_tx, to_rx) = std::sync::mpsc::channel(); @@ -259,8 +276,8 @@ pub(crate) fn run_threaded(session: &mut Session, req: &effectcraft_engine::Scri if spawned.is_err() { return crate::runtime::run_inline(session, req); } - if let Err(std::sync::mpsc::SendError(ToHost::Start(s) | ToHost::Event(s, _))) = to_tx.send(ToHost::Start(std::mem::take(session))) { - *session = s; + if let Err(std::sync::mpsc::SendError(msg)) = to_tx.send(ToHost::Start(std::mem::take(session))) { + *session = msg.into_session(); return crate::Outcome { error: Some(ScriptError { message: "the script host did not start".into(), ..Default::default() }), ..Default::default() }; } receive(session, host, HostEnd { tx: to_tx, rx: from_rx }) @@ -302,8 +319,8 @@ pub fn dispatch_ui(session: &mut Session, ev: &ScriptUiEvent) -> Result) { let t = app.tokens; + let services = app.session.services.clone(); let rect = rect_of(origin, w.bounds); register(app, win, w, rect); let id = egui::Id::new(("sui", win, w.id)); @@ -87,20 +89,20 @@ fn draw_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2, let os = w.draw.iter().any(|d| matches!(d, DrawOp::Os)); if custom && !os { if w.kind.is_container() { - paint_draw(ui, rect, &w.draw, &t); + paint_draw(ui, rect, &w.draw, &t, &*services); draw_children(app, ui, origin, win, w, acts); return; } let clickable = matches!(w.kind, WidgetKind::Button | WidgetKind::IconButton | WidgetKind::Checkbox | WidgetKind::RadioButton); let r = ui.interact(rect, id, if clickable && w.enabled { Sense::click() } else { Sense::hover() }); - paint_draw(ui, rect, &w.draw, &t); + paint_draw(ui, rect, &w.draw, &t, &*services); if tip(r).clicked() && clickable { acts.push(Act::Click(win, w.id)); } return; } if custom && w.kind.is_container() { - paint_draw(ui, rect, &w.draw, &t); + paint_draw(ui, rect, &w.draw, &t, &*services); } match w.kind { WidgetKind::Group | WidgetKind::Tab | WidgetKind::Window | WidgetKind::TabbedPanel => draw_children(app, ui, origin, win, w, acts), @@ -117,6 +119,9 @@ fn draw_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2, } WidgetKind::Button | WidgetKind::IconButton => { let r = ui.add_enabled_ui(w.enabled, |ui| ui.put(rect, egui::Button::new(&w.text))).inner; + if let Some(img) = &w.image { + paint_image(ui, rect.shrink(3.0), img, &*services); + } if tip(r).clicked() { acts.push(Act::Click(win, w.id)); } @@ -236,16 +241,18 @@ fn draw_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2, } } WidgetKind::Image | WidgetKind::Unknown => { - ui.painter().rect_stroke(rect, 2.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + if !w.image.as_ref().is_some_and(|img| paint_image(ui, rect, img, &*services)) { + ui.painter().rect_stroke(rect, 2.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + } } } if custom && !w.kind.is_container() { - paint_draw(ui, rect, &w.draw, &t); + paint_draw(ui, rect, &w.draw, &t, &*services); } } /// Paint an onDraw draw list in `rect` (control coordinates → screen). -fn paint_draw(ui: &egui::Ui, rect: Rect, ops: &[DrawOp], t: &Tokens) { +fn paint_draw(ui: &egui::Ui, rect: Rect, ops: &[DrawOp], t: &Tokens, services: &dyn Services) { let p = ui.painter().with_clip_rect(rect.intersect(ui.clip_rect())); let col = |c: &Option<[f32; 4]>, theme: Color32| match c { Some(c) => egui::Rgba::from_rgba_unmultiplied(c[0], c[1], c[2], c[3]).into(), @@ -255,10 +262,19 @@ fn paint_draw(ui: &egui::Ui, rect: Rect, ops: &[DrawOp], t: &Tokens) { for op in ops { match op { DrawOp::Fill { color, path } => { + // Any path shape (concave, self-intersecting, with holes): non-zero winding + // triangles, not egui's convex-only polygon fill. let c = col(color, t.field_bg); - for (pts, _) in PathSeg::polylines(path, 48) { - let pts: Vec = pts.iter().map(|q| at(q[0], q[1])).collect(); - p.add(egui::Shape::convex_polygon(pts, c, Stroke::NONE)); + let mut mesh = egui::Mesh::default(); + for tri in PathSeg::fill_triangles(path, 48, false) { + let base = mesh.vertices.len() as u32; + for q in tri { + mesh.colored_vertex(at(q[0], q[1]), c); + } + mesh.add_triangle(base, base + 1, base + 2); + } + if !mesh.is_empty() { + p.add(egui::Shape::mesh(mesh)); } } DrawOp::Stroke { color, width, path } => { @@ -271,15 +287,53 @@ fn paint_draw(ui: &egui::Ui, rect: Rect, ops: &[DrawOp], t: &Tokens) { DrawOp::Text { text, color, x, y, size, .. } => { p.text(at(*x, *y), Align2::LEFT_TOP, text, Tokens::ui(*size as f32), col(color, t.text)); } - DrawOp::Image { x, y, w, h } => { - let r = Rect::from_min_size(at(*x, *y), vec2(*w as f32, *h as f32)); - p.rect_stroke(r, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + DrawOp::Image { image, x, y, w, h } => { + let tex = image.as_ref().and_then(|i| image_texture(ui.ctx(), i, services)); + let size = match &tex { + Some(tx) if *w <= 0.0 || *h <= 0.0 => tx.size_vec2(), + _ => vec2(*w as f32, *h as f32), + }; + let r = Rect::from_min_size(at(*x, *y), size); + match tex { + Some(tx) => { + p.image(tx.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + None => { + p.rect_stroke(r, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + } + } } DrawOp::Os => {} } } } +/// A ScriptUIImage as a texture (decoded once and kept; `None` when it can't be read or +/// decoded). +fn image_texture(ctx: &egui::Context, img: &ImageRef, services: &dyn Services) -> Option { + let id = egui::Id::new(("scriptui-image", img)); + if let Some(cached) = ctx.data(|d| d.get_temp::>(id)) { + return cached; + } + let tex = img.bytes(services).and_then(|b| image::load_from_memory(&b).ok()).map(|d| { + let rgba = d.to_rgba8(); + let ci = egui::ColorImage::from_rgba_unmultiplied([rgba.width() as usize, rgba.height() as usize], rgba.as_raw()); + ctx.load_texture(format!("scriptui-image-{}", img.name), ci, egui::TextureOptions::LINEAR) + }); + ctx.data_mut(|d| d.insert_temp(id, tex.clone())); + tex +} + +/// An image control's or icon button's image, fitted (aspect kept) and centred in `r`. +fn paint_image(ui: &egui::Ui, r: Rect, img: &ImageRef, services: &dyn Services) -> bool { + let Some(tex) = image_texture(ui.ctx(), img, services) else { return false }; + let s = tex.size_vec2(); + let k = (r.width() / s.x.max(1.0)).min(r.height() / s.y.max(1.0)).min(1.0); + let dst = Rect::from_center_size(r.center(), s * k); + ui.painter().with_clip_rect(r.intersect(ui.clip_rect())).image(tex.id(), dst, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + true +} + /// Checkboxes and radio buttons sit at the left of their (possibly filled) bounds. fn snug(r: Rect, text: &str) -> Rect { let w = (text.chars().count() as f32 * 7.0 + 26.0).min(r.width()); diff --git a/crates/ui-egui/tests/ui_scriptui.rs b/crates/ui-egui/tests/ui_scriptui.rs index bbfd9e56..9ec9c5ee 100644 --- a/crates/ui-egui/tests/ui_scriptui.rs +++ b/crates/ui-egui/tests/ui_scriptui.rs @@ -111,3 +111,58 @@ fn history_panel_jumps_between_branches() { click(&mut h, &format!("history.state.{c}")); assert_eq!(layer_names(&h), ["C", "A"]); } + +/// onDraw fills of concave paths and real images (drawImage, image controls), rendered. +#[test] +fn on_draw_concave_fills_and_images_render() { + let dir = std::env::temp_dir().join(format!("effectcraft-scriptui-img-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let png = dir.join("green.png"); + image::RgbaImage::from_pixel(8, 8, image::Rgba([0, 255, 0, 255])).save(&png).unwrap(); + let png = png.to_string_lossy().replace('\\', "/"); + let mut s = effectcraft_host::session(); + s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "duration": 4})).unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_pixels_per_point(1.0).wgpu().build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + let code = format!( + r#" + var w = new Window("palette", "Paint"); + var c = w.add("group"); + c.preferredSize = [120, 80]; + c.onDraw = function () {{ + var g = this.graphics; + g.newPath(); + // An L: the top-right quarter is a notch (a convex-only fill would cover it). + g.moveTo(0, 0); g.lineTo(60, 0); g.lineTo(60, 40); g.lineTo(120, 40); g.lineTo(120, 80); g.lineTo(0, 80); g.closePath(); + g.fillPath(g.newBrush(g.BrushType.SOLID_COLOR, [1, 0, 0, 1])); + g.drawImage(ScriptUI.newImage(File("{png}")), 90, 5, 20, 20); + }}; + var im = w.add("image", undefined, File("{png}")); + im.preferredSize = [40, 40]; + w.show(); + "# + ); + let r = h.state_mut().session.execute("script.run", json!({"code": code, "name": "paint.jsx"})).unwrap(); + assert_eq!(r["ok"], true, "{r}"); + h.run_steps(4); + let win = h.state().session.script_ui.windows[0].clone(); + let g = &win.root.children[0]; + assert!(matches!(&g.draw[1], effectcraft_engine::scriptui::DrawOp::Image { image: Some(i), .. } if i.src.as_deref() == Some(png.as_str()))); + assert_eq!(win.root.children[1].image.as_ref().and_then(|i| i.src.clone()).as_deref(), Some(png.as_str())); + let img = h.render().expect("render"); + if let Ok(d) = std::env::var("EC_SNAPSHOT_DIR") { + img.save(format!("{d}/scriptui_paint.png")).unwrap(); + } + let px = |p: egui::Pos2| img.get_pixel(p.x as u32, p.y as u32).0; + let gr = rect(&h, &format!("scriptui.{}.{}", win.id, g.id)); + let red = |c: [u8; 4]| c[0] > 200 && c[1] < 60 && c[2] < 60; + let green = |c: [u8; 4]| c[1] > 200 && c[0] < 60 && c[2] < 60; + assert!(red(px(gr.min + vec2(20.0, 20.0))), "inside the L: {:?}", px(gr.min + vec2(20.0, 20.0))); + assert!(red(px(gr.min + vec2(100.0, 60.0))), "the L's foot"); + assert!(!red(px(gr.min + vec2(75.0, 30.0))), "the notch stays empty: {:?}", px(gr.min + vec2(75.0, 30.0))); + assert!(green(px(gr.min + vec2(100.0, 15.0))), "drawImage: {:?}", px(gr.min + vec2(100.0, 15.0))); + let ir = rect(&h, &format!("scriptui.{}.{}", win.id, win.root.children[1].id)); + assert!(green(px(ir.center())), "image control: {:?}", px(ir.center())); + h.state_mut().session.execute("scriptui.close", json!({})).unwrap(); + let _ = std::fs::remove_dir_all(&dir); +} From 07278305be41ec9ea1f68455f913e66d19dbe20a Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 10:48:14 -0400 Subject: [PATCH 4/4] M13.11: docs (parity PRV-3, 3D-3, CMP-7, AUT-2; architecture GPU Advanced 3D) Co-Authored-By: Claude Opus 5.5 --- docs/architecture.md | 10 +++++++++- docs/parity.md | 43 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index f45aaa3c..ff210e5c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -175,7 +175,15 @@ fragment sort (intersecting planes, coplanar stack order), Blinn-Phong lighting, against the caster planes (Shadow Diffusion, Light Transmission) and blending. Adjustment layers run their effect stacks on the GPU-resident comp (`Renderer::run_effects_on` with an `FxTarget`): runs of GPU effects stay on the device and only non-GPU effects read back and upload. Advanced 3D -compositing and wireframes still run on the CPU between GPU steps (read back, draw, upload). GPU +runs render on the GPU end to end (`gpu::adv3d`, `Renderer::prepare_adv_run` / +`Accelerator::render_3d`): each motion-blur sub-sample's scene is rasterised (`advanced3d.wgsl`), +then compute kernels (`adv3d.wgsl`) resolve the 2×2 supersampling, average the sub-samples +(nearest depth), apply the depth-based iris depth of field with the Classic 3D bokeh spans +(`three_d::bokeh::kernel_spans`, highlight boost, progressive blur levels) and composite over the +GPU canvas; only the depth of field reads back its 8-byte radius range. Advanced 3D layers with +blend modes, track mattes or Preserve Transparency and environment backgrounds take the CPU's +2D compositing path (their scenes still render through `render_3d`). Wireframe outlines draw on +the GPU from the CPU's pixel list (`Renderer::wireframe_pixels`). GPU effects (`effects::GPU_EFFECTS`, 152 of them: blurs, colour correction, keying incl. Key Light, mattes, channel, stylize, distortion, transitions, generators, noise, grain and time; see [effects.md](effects.md)) repeat the CPU effect's steps (padding, box radii, parameters, hashes) diff --git a/docs/parity.md b/docs/parity.md index 8e08e002..4eac5a3c 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -129,18 +129,18 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.) | Output | 97% | 0.8 | Render Settings complete (field render + 3:2 pulldown, effects/solo/guide/depth/blending/blur overrides, time sampling, storage overflow), Output Module crop/ROI/resize, alpha modes, post-render actions, PCM formats, templates with defaults, render logs, Notify (M10.2); WebM VP9 key + inter frames with motion search, loop filter and rate control; M13.4: HEVC export (MP4 `hvc1`, `effectcraft-hevcenc`: Main / Main 10, IDR + P slices, quarter-pel motion, deblocking, bit-exact with ffmpeg) and AV1 export (MP4 `av01` and WebM, `effectcraft-av1enc`: 8/10-bit key + inter frames, quarter-pel motion, loop filter, bit-exact with libdav1d) with profile, level, bitrate / constant-quality and key-frame interval options; Opus SILK (NB/MB/WB, mid/side stereo) and hybrid (SWB/FB) modes chosen by bitrate and Audio/Voice tuning, with the Opus bitrate in the Output Module. HEVC/AV1 import decodes through FilmCraft (MP4/MOV/MKV/WebM). Left: HEVC/AV1 have no B-frames, multi-reference, SAO/CDEF/restoration or alpha (compression below mature encoders); Opus has no FEC/DTX and only 20 ms frames; Photoshop sequence output, overflow for movies only checks at file creation | | Audio | 85% | 0.5 | audio to keyframes | | Import | 97% | 0.5 | JPX / JBIG2 images and predefined non-Identity CJK CMaps inside PDF/AI files, Illustrator EPS relying on Adobe procsets, PSD 3D layers (M13.12: mesh shadings — free-form / lattice Gouraud triangles, Coons and tensor patches — and function-based shadings, CCITT G3 / G4 images, knockout / isolated transparency groups with group alpha and soft masks on the group's result, `/W2` vertical metrics, EPS text with embedded Type 1 / CFF or bundled fonts, and Create Shapes from Vector Layer keeping images as parented footage layers in paint order; M13.6: PDF/AI text with embedded TrueType / CFF / Type 1 / Type 3 fonts and standard-14 fallback to the bundled fonts, Flate / DCT / inline / stencil images with soft masks, Indexed and ICC alternates, luminosity / alpha soft masks, the 16 blend modes, tiling patterns, calculator functions, any page via `file.import page` and the Import dialog, clip groups kept by Create Shapes from Vector Layer as layer masks / Merge Paths; PSD smart-object perspective quads and placed-layer warps — named styles and custom quilt meshes — baked as placed; PDF / PDF-compatible AI / EPS vector footage with Continuously Rasterize, layered composition import and Create Shapes from Vector Layer, and PSD smart objects with embedded files landed in M13.2; PSD as footage/composition/retain layer sizes, SVG footage earlier) | -| Automation | ≈ 99% | 0.1 | `.jsxbin` (AUT-2: ScriptUI resource strings, `onDraw`/ScriptUIGraphics, live `onChanging`, `Socket`, Essential Graphics hooks — `addToMotionGraphicsTemplate(As)`, `canAddToMotionGraphicsTemplate`, `exportAsMotionGraphicsTemplate`, `motionGraphicsTemplateName`, controller count/names — and Watch Folder landed in M13.7; the core object model landed in M14.4; ScriptUI windows/dialogs/dockable panels with `scriptui.*` agent commands, File ▸ Scripts install + sample scripts, and the effect plug-in API (EFF-6, WebAssembly) landed in M13.1) | +| Automation | ≈ 99% | 0.1 | `.jsxbin` (AUT-2: concave / self-intersecting `onDraw` fills, real images in `drawImage`, image controls and icon buttons landed in M13.11; ScriptUI resource strings, `onDraw`/ScriptUIGraphics, live `onChanging`, `Socket`, Essential Graphics hooks — `addToMotionGraphicsTemplate(As)`, `canAddToMotionGraphicsTemplate`, `exportAsMotionGraphicsTemplate`, `motionGraphicsTemplateName`, controller count/names — and Watch Folder landed in M13.7; the core object model landed in M14.4; ScriptUI windows/dialogs/dockable panels with `scriptui.*` agent commands, File ▸ Scripts install + sample scripts, and the effect plug-in API (EFF-6, WebAssembly) landed in M13.1) | | Shapes | ≈ 80% | 1.5 | Lottie can't carry stroke taper/wave (stroke Taper and Wave, Dash 2/Gap 2/Dash 3/Gap 3 and radial-gradient Highlight Length/Angle landed in M13.5; pen tool for shape paths and vertex editing in M6.5) | -| Compositions | ≈ 80% | 3.5 | Mocha-style planar tracks for templates, Essential Graphics' mirrored properties (CMP-7: Font and uniform Scale controls, Composition ▸ Open in Essential Graphics, Save Frame As ▸ Photoshop Layers / ProEXR and the VR Comp Editor landed in M13.7; the marker dialog, Composition Flowchart, Essential Graphics with master properties, `.ectemplate` templates and Responsive Design — Time landed: CMP-6, CMP-7) | +| Compositions | ≈ 81% | 3.3 | Mocha-style planar tracks for templates (CMP-7: Essential Graphics mirrored and linked properties landed in M13.11; Font and uniform Scale controls, Composition ▸ Open in Essential Graphics, Save Frame As ▸ Photoshop Layers / ProEXR and the VR Comp Editor landed in M13.7; the marker dialog, Composition Flowchart, Essential Graphics with master properties, `.ectemplate` templates and Responsive Design — Time landed: CMP-6, CMP-7) | | Animation | 70% | 9.0 | puppet depth beyond pins and recording (puppet pin recording with Record Options landed in M13.1), Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8; keyframe colour labels and Select Keyframe Label Group, Graph Editor snapping to markers / layer ends in M13.5) | | Text | ≈ 96% | 0.3 | no extruded strokes (M13.12: the Variable Font Axes animator re-spaces the text — advances follow the animated axes; M13.6: variable font axes in the character style — `layer.setText variations`, the Character panel's Variable Font Axes fields — shape with HVAR / gvar advances and draw at that design-space position; OpenType features — stylistic sets, discretionary ligatures, contextual / stylistic alternates, swash, titling, ordinals, fractions, figure styles, true small caps / all small caps and superior / inferior glyphs with faux fallback — per character with the Character panel's OpenType popup and `text.fontFeatures` landed in M13.2; vertical Roman / Tate-Chu-Yoko, forced LTR paragraphs, caret on animated and path text, Variable Font Axes and Lottie style runs landed in M13.5; extruded, bevelled text in M7.6; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API in M9.9–M9.10) | | Web | 93% | 0.5 | GPU effects in the browser (WebGPU compositing only on the page), no disk cache, no shared-memory threads inside one engine instance (browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2; viewer frames in frame workers fed by project diffs, Roto Brush propagation in a worker, non-blocking `wait: true` jobs and a browser Media Browser (File System Access folders, browser storage) in M13.10) | -| 3D | 88% | 5.5 | multi-view layouts, the Extended Viewer for Advanced 3D comps (Classic 3D Extended Viewer landed in M13.5 UI completion); collapsed precomps of another size seen through the parent's camera render (fixed in M13.2); stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D depth of field with the iris and highlight options, collapsed precomps as real Advanced 3D geometry and extruded text/shape strokes landed in M13.8; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6 | +| 3D | 88% | 5.5 | multi-view layouts, the Extended Viewer for Advanced 3D comps (Classic 3D Extended Viewer landed in M13.5 UI completion); collapsed precomps of another size seen through the parent's camera render (fixed in M13.2); stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D depth of field with the iris and highlight options, collapsed precomps as real Advanced 3D geometry and extruded text/shape strokes landed in M13.8; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6; Advanced 3D end to end on the GPU (motion blur, iris depth of field, compositing) in M13.11 | | Effects | ≈ 85% | 3.0 | GPU versions of the remaining effects (152 run on the GPU since M13.9, EFF-5: keying, mattes, channel, colour correction, stylize, distort, noise, blurs and time effects joined the 58 of M12.7; Warp, Bezier Warp, Reshape, Smear, Cartoon, CC Page Turn and others still render on the CPU) and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) | | Interface | 74% | 5.0 | more Learn tutorials and pixel-level fidelity of dialogs (the Home ▸ Learn tab with interactive tutorials and a UI fidelity pass landed in M13.5 UI completion; visual editors for Lumetri RGB / hue-saturation curves, Colorama's output cycle wheel, Glow's colour map and Reshape's correspondence points (viewer handles), a scrolling Preview panel and AE-style Composition / Timeline tabs (close, label swatch, viewer lock) landed in M13.10; Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) | | Project | ≈ 68% | 5.5 | auto-save, folder moves, OCIO displays beyond the built-in tone map (Color Engine with OCIO/ACES working spaces, HDR compand/tone mapping, Rec. 2100 PQ/HLG output, Feet + Frames, display colour management, Simulate Output and the locked viewer landed in M7.7; proxies and Interpret Footage fields / pixel aspect / alpha guess landed: PRJ-8, PRJ-3) | | Masks & roto | 74% | 5.0 | Roto Brush's learned (3.0) segmentation model (variable-width mask feather points with the Mask Feather tool landed in M13.5; mask tracking and Mask Interpolation landed in M6.6; Roto Brush & Refine Edge with graph-cut segmentation, flow propagation, edge matting, decontamination and Freeze in M6.7) | -| Preview | 74% | 3.2 | wireframes and Advanced 3D compositing on the GPU (Classic 3D bokeh depth of field — iris shapes, highlights, fringe, progressive blur on tilted planes — runs in WGSL since M13.6: 3D Showcase GPU warm 205 → 54 ms/frame at full size on an M4 Pro; the Preview panel's five shortcuts with their own Include / Loop / Cache Before Playback / Range / Play From / Frame Rate / Skip / Resolution / Full Screen / stop options and `playback.settings.get/set` landed in M13.8; Classic 3D runs and adjustment layers composite on the GPU since M12.7; persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) | +| Preview | 77% | 2.8 | GPU compositing of Advanced 3D layers with blend modes / track mattes and of environment backgrounds (Advanced 3D runs — raster, motion blur, iris depth of field, compositing — and wireframes run on the GPU since M13.11: CPU warm 3394 → GPU warm 290 ms/frame at 1920×1080 (11.7×; Half 968 → 85 ms), GPU ≠ CPU on 0.005 % of pixels, on an M4 Pro under load; Classic 3D bokeh depth of field — iris shapes, highlights, fringe, progressive blur on tilted planes — runs in WGSL since M13.6: 3D Showcase GPU warm 205 → 54 ms/frame at full size on an M4 Pro; the Preview panel's five shortcuts with their own Include / Loop / Cache Before Playback / Range / Play From / Frame Rate / Skip / Resolution / Full Screen / stop options and `playback.settings.get/set` landed in M13.8; Classic 3D runs and adjustment layers composite on the GPU since M12.7; persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) | | Tracking | ≈ 96% | 0.3 | face tracking is a classical (skin model + feature components + shape model) fitter, not a learned detector: profile views and occluded faces are weak; Rolling Shutter Ripple is approximated by Subspace Warp's mesh density (face tracking (Outline Only / Detailed Features with Face Track Points and Extract & Copy Face Measurements), Subspace Warp's content-preserving mesh warp on subspace-smoothed trajectories, and radial lens distortion (k1, k2) in the camera bundle adjustment with Undistort Footage landed in M13.3; Rolling Shutter Repair in M9.11; point tracker, mask tracking, Warp Stabilizer and the 3D Camera Tracker in M6.x / M12.5 / M12.6) | | Paint | 0% | 4.0 | Brush, Clone Stamp, Eraser | @@ -204,6 +204,41 @@ axes since M13.6; Advanced 3D's DOF has iris shapes since M13.8). as real geometry lit by the parent (2D collapsed precomps draw nested 3D layers with the parent's renderer); text and shape strokes extrude as bevelled meshes in paint order. +## Update: M13.11 Advanced 3D on the GPU, Essential Graphics mirrors, ScriptUI paint + +- **Advanced 3D on the GPU (PRV-3, 3D-3)**: a whole Advanced 3D run renders on the device + (`gpu::adv3d`, `Renderer::prepare_adv_run`, `Accelerator::render_3d`): every motion-blur + sub-sample's scene is rasterised (depth buffer, PBR, image-based light, shadow maps), then + compute kernels resolve the 2×2 supersampling, average the sub-samples (nearest depth), apply + the depth-based iris depth of field (the Classic 3D bokeh row spans and prefix-sum gather, + highlight boost, progressive levels) and composite over the GPU canvas without a readback. + Meshes from extruded text and shapes, cards, glTF models and collapsed precomps all go through + it. Layers with blend modes, track mattes or Preserve Transparency (and environment + backgrounds) keep the CPU's 2D compositing path, their scenes still rendered by + `render_3d`. Wireframe-quality outlines draw on the GPU (exactly the CPU's pixels). The CPU + stays the oracle: GPU-vs-CPU tests on lit scenes with shadows, environment light, DOF (hexagon + with fringe and highlights, Fast Rectangle, radii at the 48 px cap), motion blur, extrusions + and collapsed precomps agree within 1/255 on ≥ 99 % of pixels (measured: ≤ 0.01 %; the rest + are silhouette pixels the two rasterisers cover differently), and the GPU post kernels match + the CPU post-processing of the same GPU raster on ≥ 99.9 % (measured: all pixels). Fixed on + the way: NaN environment lookups for straight up/down normals on Metal (`atan2(0, 0)`). + `bench --gpu --adv3d` (1920×1080 Advanced 3D comp: PBR primitives, bevelled extruded text, a + shadow-casting spot, point and environment lights, hexagonal-iris DOF with highlights, + 8-sample motion blur): CPU warm 3394 → GPU warm 290 ms/frame at 1920×1080 (11.7×; Half 968 → 85 ms), GPU ≠ CPU on 0.005 % of pixels, on an M4 Pro under load. +- **Essential Graphics mirrors (CMP-7)**: adding a property that is already in the panel adds a + *mirror* (its own name and place; one value in the comp and in every instance — editing, + reverting or pushing either acts on both; Font / Scale `as` controls mirror per kind); + `essential.addMirror`. A property control can also drive further properties of the comp + (`essential.linkProperty` / `unlinkProperty`): links follow the main property both ways and + take each instance's value. Panel badges and a ⋯ menu (Add Mirror, Link Selected Property, + Unlink); `addToMotionGraphicsTemplate` on a property already present adds a mirror, and + mirrors count as controllers. +- **ScriptUI (AUT-2)**: `fillPath` fills any path (non-zero winding trapezoid tessellation: + concave, self-intersecting, holes) instead of egui's convex-only polygons; + `ScriptUI.newImage` / ScriptUIImage hold an image file or embedded PNG/JPEG bytes, drawn by + `drawImage`, image controls and icon buttons; script-host threads are compiled out of the + web build (no dead code warnings). + ## Update: M13.5 UI completion & fidelity **Extended Viewer** (Settings ▸ 3D, the viewer's Extended Viewer button, `view.extendedViewer`):