mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-24 10:09:32 -06:00
Merge branch 'worktree-agent-a0bd45f05d0d6b082'
This commit is contained in:
@@ -15,7 +15,7 @@
|
||||
//! [--profile main|main10] [--level auto|4.1] [--rate-control bitrate|quality] [--video-quality 1-100]
|
||||
//! [--keyint FRAMES] [--webm-codec vp9|av1] [--audio-bitrate KBPS] [--opus-app audio|voice]
|
||||
//! 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
|
||||
@@ -51,7 +51,7 @@ const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|se
|
||||
[--keyint FRAMES] [--webm-codec vp9|av1] [--audio-bitrate KBPS] [--opus-app audio|voice] | --queue
|
||||
(formats h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff;
|
||||
--profile..--keyint: HEVC / AV1, --audio-bitrate/--opus-app: WebM Opus)
|
||||
bench [--comp C] [--time S] [--scale K] [--n N] [--play N] [--gpu] per-layer/effect render timings;
|
||||
bench [--comp C] [--time S] [--scale K] [--n N] [--play N] [--gpu [--adv3d]] per-layer/effect render timings;
|
||||
--play N renders N consecutive frames with/without the layer cache;
|
||||
--gpu compares CPU and GPU ms/frame for every comp at Full and Half
|
||||
script FILE.jsx [F.ecproj] | --eval CODE run JavaScript with the After Effects-style object model
|
||||
@@ -816,6 +816,109 @@ fn adjustment_bench_comp(p: &mut effectcraft_engine::project::Project) -> Option
|
||||
Some(p.add_item("Adjustment Layers (bench)", effectcraft_engine::color::Label::None, None, ItemKind::Comp(comp.into())))
|
||||
}
|
||||
|
||||
/// `bench --gpu`'s Advanced 3D comp (1920×1080): a ground plane, PBR primitives, extruded
|
||||
/// bevelled text, a shadow-casting spot light, a point light and an environment light, seen
|
||||
/// through a depth-of-field camera (hexagonal iris, highlights) with a motion-blurred sphere.
|
||||
fn adv3d_bench_comp(p: &mut effectcraft_engine::project::Project) -> Option<ItemId> {
|
||||
use effectcraft_engine::keyframe::{Keyframe, TextDoc, Value};
|
||||
use effectcraft_engine::project::build::{self, Ids};
|
||||
use effectcraft_engine::project::{Comp, ItemKind, Layer, LayerSource, LightKind, PrimitiveKind, Renderer as R3, Solid};
|
||||
let (w, h) = (1920u32, 1080u32);
|
||||
let mut comp = Comp::new(w, h, effectcraft_time::FrameRate::FPS_30, Tick::from_seconds_f64(10.0));
|
||||
comp.renderer = R3::Advanced3D;
|
||||
comp.enable_motion_blur = true;
|
||||
comp.motion_blur_samples = 8;
|
||||
let mut layers: Vec<Layer> = vec![];
|
||||
let mut add = |p: &mut effectcraft_engine::project::Project, comp: &Comp, src: LayerSource, edit: &dyn Fn(&mut Layer)| {
|
||||
let mut l = build::layer(p, comp, "L", src, (w, h), None);
|
||||
edit(&mut l);
|
||||
layers.insert(0, l);
|
||||
};
|
||||
fn set(l: &mut Layer, path: &str, v: Value) {
|
||||
if let Some(pr) = l.props.prop_mut(path) {
|
||||
pr.value = v;
|
||||
}
|
||||
}
|
||||
add(p, &comp, LayerSource::Primitive { kind: PrimitiveKind::Plane }, &|l| {
|
||||
set(l, "geometryOptions/width", Value::Scalar(6000.0));
|
||||
set(l, "geometryOptions/height", Value::Scalar(6000.0));
|
||||
set(l, "transform/position", Value::Vec3([960.0, 800.0, 0.0]));
|
||||
set(l, "transform/rotationX", Value::Scalar(90.0));
|
||||
});
|
||||
for (i, kind) in [PrimitiveKind::Sphere, PrimitiveKind::Torus, PrimitiveKind::Cube, PrimitiveKind::Sphere].into_iter().enumerate() {
|
||||
add(p, &comp, LayerSource::Primitive { kind }, &|l| {
|
||||
let x = 400.0 + 380.0 * i as f64;
|
||||
set(l, "geometryOptions/radius", Value::Scalar(140.0));
|
||||
set(l, "geometryOptions/tubeRadius", Value::Scalar(45.0));
|
||||
for k in ["width", "height", "depth"] {
|
||||
set(l, &format!("geometryOptions/{k}"), Value::Scalar(220.0));
|
||||
}
|
||||
set(l, "transform/position", Value::Vec3([x, 640.0, 300.0 * i as f64 - 300.0]));
|
||||
set(l, "transform/rotationX", Value::Scalar(35.0));
|
||||
set(l, "transform/rotationY", Value::Scalar(25.0));
|
||||
set(l, "materialOptions/metallic", Value::Scalar(if i % 2 == 1 { 100.0 } else { 0.0 }));
|
||||
set(l, "materialOptions/roughness", Value::Scalar(30.0));
|
||||
set(l, "materialOptions/castsShadows", Value::Enum(1));
|
||||
if i == 0 {
|
||||
l.switches.motion_blur = true;
|
||||
if let Some(pr) = l.props.prop_mut("transform/position") {
|
||||
pr.keys = vec![
|
||||
Keyframe::new(Tick::ZERO, Value::Vec3([x, 640.0, -300.0])),
|
||||
Keyframe::new(Tick::from_seconds_f64(10.0), Value::Vec3([x + 300.0 * 60.0, 640.0, -300.0])),
|
||||
];
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
add(p, &comp, LayerSource::Text, &|l| {
|
||||
l.switches.three_d = true;
|
||||
let doc = TextDoc { text: "EffectCraft".into(), size: 180.0, fill: [0.95, 0.75, 0.2, 1.0], apply_fill: true, ..Default::default() };
|
||||
set(l, "text/sourceText", Value::Text(Box::new(doc)));
|
||||
let mut next = 900_000u64;
|
||||
l.props.children.push(build::extrusion_geometry_options(&mut Ids(&mut next)).into());
|
||||
set(l, "geometryOptions/extrusionDepth", Value::Scalar(40.0));
|
||||
set(l, "geometryOptions/bevelStyle", Value::Enum(1));
|
||||
set(l, "transform/position", Value::Vec3([960.0, 260.0, 200.0]));
|
||||
set(l, "transform/rotationY", Value::Scalar(-15.0));
|
||||
});
|
||||
let env = p.add_item(
|
||||
"Environment (bench)",
|
||||
effectcraft_engine::color::Label::None,
|
||||
None,
|
||||
ItemKind::Solid(Solid { color: [0.55, 0.65, 0.9], width: 64, height: 32, pixel_aspect: 1.0 }),
|
||||
);
|
||||
add(p, &comp, LayerSource::Solid { item: env }, &|l| {
|
||||
l.environment = true;
|
||||
l.switches.three_d = true;
|
||||
});
|
||||
add(p, &comp, LayerSource::Light { kind: LightKind::Environment }, &|l| set(l, "lightOptions/intensity", Value::Scalar(40.0)));
|
||||
add(p, &comp, LayerSource::Light { kind: LightKind::Spot }, &|l| {
|
||||
set(l, "transform/position", Value::Vec3([500.0, -600.0, -900.0]));
|
||||
set(l, "transform/poi", Value::Vec3([960.0, 640.0, 0.0]));
|
||||
set(l, "lightOptions/castsShadows", Value::Bool(true));
|
||||
set(l, "lightOptions/coneAngle", Value::Scalar(100.0));
|
||||
set(l, "lightOptions/shadowDiffusion", Value::Scalar(8.0));
|
||||
});
|
||||
add(p, &comp, LayerSource::Light { kind: LightKind::Point }, &|l| {
|
||||
set(l, "transform/position", Value::Vec3([1500.0, 200.0, -500.0]));
|
||||
set(l, "lightOptions/intensity", Value::Scalar(50.0));
|
||||
});
|
||||
add(p, &comp, LayerSource::Camera, &|l| {
|
||||
let zoom = match l.props.prop_mut("cameraOptions/zoom").map(|pr| pr.value.clone()) {
|
||||
Some(Value::Scalar(z)) => z,
|
||||
_ => 2666.7,
|
||||
};
|
||||
set(l, "cameraOptions/dof", Value::Bool(true));
|
||||
set(l, "cameraOptions/focusDistance", Value::Scalar(zoom));
|
||||
set(l, "cameraOptions/aperture", Value::Scalar(120.0));
|
||||
set(l, "cameraOptions/irisShape", Value::Enum(4));
|
||||
set(l, "cameraOptions/highlightGain", Value::Scalar(20.0));
|
||||
set(l, "cameraOptions/highlightThreshold", Value::Scalar(200.0));
|
||||
});
|
||||
comp.layers = layers;
|
||||
Some(p.add_item("Advanced 3D (bench)", effectcraft_engine::color::Label::None, None, ItemKind::Comp(comp.into())))
|
||||
}
|
||||
|
||||
/// `bench --gpu`: CPU vs GPU ms/frame for every comp (or `--comp`) at Full and Half. "cold"
|
||||
/// renders everything (no layer cache); "warm" reuses the layer cache (playback / scrubbing of
|
||||
/// unchanged layers: compositing cost); "viewer" is the GPU display path without readback.
|
||||
@@ -824,13 +927,16 @@ fn bench_gpu(s: &Session, args: &Args) -> Result<(), Failure> {
|
||||
let gpu = effectcraft_gpu::Gpu::headless().ok_or_else(|| Failure::Error("--gpu: no usable GPU adapter".into()))?;
|
||||
let n = args.num("--n")?.unwrap_or(10.0).max(1.0) as usize;
|
||||
// Every comp, plus an adjustment-layer comp built for the benchmark (the main comp under a
|
||||
// full-frame adjustment layer with a GPU effect stack).
|
||||
// full-frame adjustment layer with a GPU effect stack) and an Advanced 3D comp (`--adv3d`:
|
||||
// that one only).
|
||||
let mut project = (*s.project).clone();
|
||||
let comps: Vec<ItemId> = match args.opt("--comp") {
|
||||
_ if args.flag("--adv3d") => adv3d_bench_comp(&mut project).into_iter().collect(),
|
||||
Some(_) => s.active_comp_id().into_iter().collect(),
|
||||
None => {
|
||||
let mut v: Vec<ItemId> = s.project.comps().map(|(id, _)| *id).collect();
|
||||
v.extend(adjustment_bench_comp(&mut project));
|
||||
v.extend(adv3d_bench_comp(&mut project));
|
||||
v
|
||||
}
|
||||
};
|
||||
@@ -878,7 +984,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!(
|
||||
|
||||
@@ -96,6 +96,9 @@ fn add_property(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Some(t)
|
||||
}
|
||||
};
|
||||
// A property already in the panel (as the same kind of control) 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)?;
|
||||
@@ -110,30 +113,122 @@ fn add_property(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
{
|
||||
return Err(bad(C, format!("`{}` can't be shown as a {} control", pr.name, t.label())));
|
||||
}
|
||||
if s.project.comp(*cid).and_then(|c| c.essential.as_ref()).is_some_and(|e| e.control_for_as(*lid, *uid, as_type).is_some()) {
|
||||
continue;
|
||||
}
|
||||
let name = rename.clone().unwrap_or_else(|| match as_type {
|
||||
let eg = s.project.comp(*cid).and_then(|c| c.essential.as_ref());
|
||||
let existing = eg
|
||||
.and_then(|e| e.control_for_as(*lid, *uid, as_type).or_else(|| as_type.is_none().then(|| e.driver_of(*lid, *uid)).flatten()))
|
||||
.map(|c| (c.id, c.name.clone()));
|
||||
let (kind, as_type) = match existing {
|
||||
Some(_) if !mirror => continue,
|
||||
Some((of, _)) => (EgKind::Mirror { of }, None),
|
||||
None => (EgKind::Property { layer: *lid, prop: *uid, links: vec![] }, as_type),
|
||||
};
|
||||
let name = rename.clone().or(existing.map(|e| e.1)).unwrap_or_else(|| match as_type {
|
||||
Some(ControlType::Font) => format!("{} Font", l.name),
|
||||
_ => pr.name.clone(),
|
||||
});
|
||||
added.push((*cid, *lid, *uid, name, as_type));
|
||||
added.push((*cid, kind, name, as_type));
|
||||
}
|
||||
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, as_type)) in added.iter().enumerate() {
|
||||
for (k, (cid, kind, name, as_type)) in added.iter().enumerate() {
|
||||
let id = proj.alloc();
|
||||
let eg = eg_of(proj, *cid)?;
|
||||
let ctl = EgControl { id, name: name.clone(), kind: EgKind::Property { layer: *lid, prop: *uid }, as_type: *as_type };
|
||||
let ctl = EgControl { id, name: name.clone(), kind: kind.clone(), as_type: *as_type };
|
||||
insert(eg, ctl, group, index.map(|i| i + k), C)?;
|
||||
ids.push(id);
|
||||
}
|
||||
Ok(ids)
|
||||
})?;
|
||||
Ok(json!({"controls": ids}))
|
||||
let mirrors: Vec<u64> = 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<Value> {
|
||||
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 }, as_type: None }, 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<Value> {
|
||||
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();
|
||||
if ctl.as_type == Some(ControlType::Font) {
|
||||
return Err(bad(C, "a Font control can't drive other properties"));
|
||||
}
|
||||
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<Value> {
|
||||
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<ControlType> {
|
||||
@@ -249,11 +344,17 @@ fn remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
@@ -381,8 +482,12 @@ pub fn supported_properties(project: &Project, cid: ItemId) -> Vec<Value> {
|
||||
|
||||
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<Value> = 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()),
|
||||
@@ -390,6 +495,24 @@ fn control_json(project: &Project, cid: ItemId, c: &EgControl) -> Value {
|
||||
"type": src.and_then(|(_, pr)| essential::effective_type(c, pr)),
|
||||
"value": src.map(|(_, pr)| value_json(essential::effective_type(c, pr), &pr.value_at(Tick::ZERO))),
|
||||
"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 } => {
|
||||
// Shown as its master (same kind of control).
|
||||
let master = project.comp(cid).and_then(|x| x.essential.as_ref()).and_then(|e| e.resolve(c.id)).cloned();
|
||||
let src = essential::source_prop(project, cid, c);
|
||||
let ty = |pr| master.as_ref().and_then(|m| essential::effective_type(m, pr));
|
||||
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)| ty(pr)),
|
||||
"value": src.map(|(_, pr)| value_json(ty(pr), &pr.value_at(Tick::ZERO))),
|
||||
"missing": src.is_none(),
|
||||
})
|
||||
}
|
||||
EgKind::Media { layer } => json!({
|
||||
@@ -464,6 +587,7 @@ fn instance_json(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let kind = match c.kind {
|
||||
EgKind::Property { .. } => "property",
|
||||
EgKind::Mirror { .. } => "mirror",
|
||||
EgKind::Media { .. } => "media",
|
||||
EgKind::Comment { .. } => "comment",
|
||||
EgKind::Group { .. } => "group",
|
||||
@@ -520,11 +644,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<Vec<u64>> = 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));
|
||||
@@ -570,7 +704,8 @@ fn push_to_comp(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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(), ctl.as_type));
|
||||
}
|
||||
s.edit("Push Override Values to Source", None, |proj, _| {
|
||||
@@ -578,7 +713,7 @@ fn push_to_comp(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for (kind, pr, as_type) in &writes {
|
||||
match kind {
|
||||
// A Font control writes only the font (the source keeps its text and keys).
|
||||
EgKind::Property { layer, prop } if *as_type == Some(ControlType::Font) => {
|
||||
EgKind::Property { layer, prop, .. } if *as_type == Some(ControlType::Font) => {
|
||||
if let Some(target) = sc.layer_mut(*layer).and_then(|l| l.props.find_mut(*prop)) {
|
||||
essential::merge_font(&mut target.value, &pr.value);
|
||||
for k in &mut target.keys {
|
||||
@@ -586,24 +721,27 @@ fn push_to_comp(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
}
|
||||
}
|
||||
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 } => {
|
||||
if let Some(inner) = sc.layer_mut(*layer) {
|
||||
inner.source = LayerSource::Footage { item: ItemId(pr.value.as_f64().max(0.0) as u64) };
|
||||
@@ -843,10 +981,29 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add Property to Essential Graphics",
|
||||
["Animation"],
|
||||
None,
|
||||
"{comp?, layer?, path?|prop?: uid (default: the selected properties), name?, group?: control id, index?, as?: font (Source Text font family/style/size) | scale (uniform Scale)}",
|
||||
"{comp?, layer?, path?|prop?: uid (default: the selected properties), name?, group?: control id, index?, as?: font (Source Text font family/style/size) | scale (uniform Scale), 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),
|
||||
|
||||
@@ -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<DrawOp>,
|
||||
/// The image of an image control or icon button.
|
||||
pub image: Option<ImageRef>,
|
||||
pub children: Vec<Widget>,
|
||||
}
|
||||
|
||||
@@ -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<ImageRef>,
|
||||
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<String>,
|
||||
/// Embedded bytes as a binary string (one character per byte, as in resource strings).
|
||||
pub data: Option<String>,
|
||||
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<Vec<u8>> {
|
||||
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<f64> = 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<PathSeg> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -16,10 +16,20 @@ fn font_and_scale_controls_override_per_instance() {
|
||||
let t = s.execute("essential.addProperty", json!({"layer": text, "path": "text/sourceText"})).unwrap()["controls"][0].as_u64().unwrap();
|
||||
let f = s.execute("essential.addProperty", json!({"layer": text, "path": "text/sourceText", "as": "font"})).unwrap()["controls"][0].as_u64().unwrap();
|
||||
let sc = s.execute("essential.addProperty", json!({"layer": text, "path": "transform/scale", "as": "scale"})).unwrap()["controls"][0].as_u64().unwrap();
|
||||
// Adding the same font control again is a no-op; opacity can't be a font.
|
||||
// Adding the same font control again adds nothing with `mirror: false` (else a mirror of
|
||||
// it); opacity can't be a font.
|
||||
assert!(
|
||||
s.execute("essential.addProperty", json!({"layer": text, "path": "text/sourceText", "as": "font"})).unwrap()["controls"].as_array().unwrap().is_empty()
|
||||
s.execute("essential.addProperty", json!({"layer": text, "path": "text/sourceText", "as": "font", "mirror": false})).unwrap()["controls"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
let m = s.execute("essential.addProperty", json!({"layer": text, "path": "text/sourceText", "as": "font"})).unwrap();
|
||||
assert_eq!(m["mirrors"].as_array().unwrap().len(), 1);
|
||||
let ml = s.execute("essential.list", json!({})).unwrap();
|
||||
let mirror = ml["controls"].as_array().unwrap().iter().find(|c| c["kind"] == "mirror").unwrap().clone();
|
||||
assert_eq!((mirror["of"].as_u64(), mirror["type"].as_str()), (Some(f), Some("font")));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.execute("essential.addProperty", json!({"layer": text, "path": "transform/opacity", "as": "font"})).is_err());
|
||||
assert_eq!(s.execute("essential.canAdd", json!({"layer": text, "path": "transform/opacity", "as": "font"})).unwrap()["ok"], false);
|
||||
assert_eq!(s.execute("essential.canAdd", json!({"layer": text, "path": "transform/scale", "as": "scale"})).unwrap()["type"], "scale");
|
||||
|
||||
+416
-12
@@ -2,19 +2,33 @@
|
||||
//! (`Depth32Float`, Greater), the scene's materials, textures, lights and shadow maps in
|
||||
//! storage buffers, and `advanced3d.wgsl`'s physically based fragment shader. Opaque triangles
|
||||
//! are drawn with depth writes, then the sorted transparent ones blended over (premultiplied).
|
||||
//! Colour (`Rgba16Float`) and camera depth (`R32Float`) are read back for the CPU's resolve,
|
||||
//! depth of field and encoding.
|
||||
//! Colour (`Rgba16Float`) and camera depth (`R32Float`) stay on the GPU for `adv3d.wgsl`'s
|
||||
//! compute kernels, which run the rest of `effectcraft_render::three_d::adv::render_prepared`:
|
||||
//! the 2×2 supersampling resolve, the motion-blur sub-sample average (nearest depth), the
|
||||
//! depth-based iris depth of field (the Classic 3D bokeh kernel's row spans and prefix-sum
|
||||
//! gather, highlight boost, progressive blend between blur levels), the sRGB encode and the
|
||||
//! premultiplied "over" onto the compositor's canvas. Only the depth of field reads anything
|
||||
//! back (its 8-byte radius range, to choose the blur levels as the CPU does).
|
||||
//!
|
||||
//! Wireframe-quality layers draw their outlines here too ([`wireframe`]).
|
||||
|
||||
use effectcraft_render::three_d::adv::{Scene, Target};
|
||||
use effectcraft_render::three_d::Dof;
|
||||
use effectcraft_render::three_d::adv::{Prepared, Rendered, Scene, Target};
|
||||
use effectcraft_render::three_d::bokeh;
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
use crate::context::{Enc, GpuContext};
|
||||
use crate::context::{Enc, GpuContext, GpuImage};
|
||||
|
||||
/// Pipelines (built on first use).
|
||||
pub(crate) struct Pipes {
|
||||
bgl: wgpu::BindGroupLayout,
|
||||
opaque: wgpu::RenderPipeline,
|
||||
transparent: wgpu::RenderPipeline,
|
||||
post: Post,
|
||||
}
|
||||
|
||||
fn pipes(g: &GpuContext) -> &Pipes {
|
||||
g.adv3d.get_or_init(|| Pipes::new(&g.device))
|
||||
}
|
||||
|
||||
const COLOR: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
|
||||
@@ -96,7 +110,7 @@ impl Pipes {
|
||||
cache: None,
|
||||
})
|
||||
};
|
||||
Pipes { opaque: make(false), transparent: make(true), bgl }
|
||||
Pipes { opaque: make(false), transparent: make(true), bgl, post: Post::new(device) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,9 +222,12 @@ fn half_to_f32(h: u16) -> f32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rasterise a scene. `None` when the device can't (no readback, size or buffer limits).
|
||||
pub(crate) fn render(g: &GpuContext, s: &Scene) -> Option<Target> {
|
||||
if !g.can_readback() || !g.fits(s.width, s.height) || s.indices.is_empty() {
|
||||
/// Record the render pass of one scene into `e`: (colour `Rgba16Float`, camera depth `R32Float`
|
||||
/// with −1 where nothing was drawn) at the scene's raster size. `None` when the device can't
|
||||
/// (size or buffer limits).
|
||||
fn raster_into(e: &mut Enc, s: &Scene) -> Option<(wgpu::Texture, wgpu::Texture)> {
|
||||
let g = e.g;
|
||||
if !g.fits(s.width, s.height) || s.indices.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let limits = g.device.limits();
|
||||
@@ -218,7 +235,7 @@ pub(crate) fn render(g: &GpuContext, s: &Scene) -> Option<Target> {
|
||||
if bufs.iter().any(|b| b.len() as u64 > limits.max_storage_buffer_binding_size) {
|
||||
return None;
|
||||
}
|
||||
let p = g.adv3d.get_or_init(|| Pipes::new(&g.device));
|
||||
let p = pipes(g);
|
||||
let dev = &g.device;
|
||||
let vb: Vec<u8> = s
|
||||
.vertices
|
||||
@@ -257,12 +274,12 @@ pub(crate) fn render(g: &GpuContext, s: &Scene) -> Option<Target> {
|
||||
view_formats: &[],
|
||||
})
|
||||
};
|
||||
let rt = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC;
|
||||
let rt = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::TEXTURE_BINDING;
|
||||
let color = tex(COLOR, rt);
|
||||
let zout = tex(DEPTH_OUT, rt);
|
||||
let depth = tex(DEPTH, wgpu::TextureUsages::RENDER_ATTACHMENT);
|
||||
let (cv, zv, dv) = (color.create_view(&Default::default()), zout.create_view(&Default::default()), depth.create_view(&Default::default()));
|
||||
let mut enc = dev.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("adv3d") });
|
||||
let enc = e.encoder();
|
||||
{
|
||||
let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("adv3d"),
|
||||
@@ -302,8 +319,17 @@ pub(crate) fn render(g: &GpuContext, s: &Scene) -> Option<Target> {
|
||||
pass.draw_indexed(s.opaque_count..n, 0, 0..1);
|
||||
}
|
||||
}
|
||||
g.queue.submit([enc.finish()]);
|
||||
Some((color, zout))
|
||||
}
|
||||
|
||||
/// Rasterise a scene and read it back ([`effectcraft_render::Accelerator::raster_3d`]). `None`
|
||||
/// when the device can't (no readback, size or buffer limits).
|
||||
pub(crate) fn render(g: &GpuContext, s: &Scene) -> Option<Target> {
|
||||
if !g.can_readback() {
|
||||
return None;
|
||||
}
|
||||
let mut e = Enc::new(g);
|
||||
let (color, zout) = raster_into(&mut e, s)?;
|
||||
let cb = e.read_texture(&color, s.width, s.height, 8)?;
|
||||
let zb = e.read_texture(&zout, s.width, s.height, 4)?;
|
||||
let color = cb.chunks_exact(8).map(|c| [0, 1, 2, 3].map(|k| half_to_f32(u16::from_le_bytes([c[2 * k], c[2 * k + 1]])))).collect();
|
||||
@@ -316,3 +342,381 @@ pub(crate) fn render(g: &GpuContext, s: &Scene) -> Option<Target> {
|
||||
.collect();
|
||||
Some(Target { width: s.width, height: s.height, color, depth })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- after the rasteriser
|
||||
|
||||
/// Compute kernels of `adv3d.wgsl` (resolve, motion-blur accumulation, depth of field,
|
||||
/// encoding, compositing, wireframes) with their shared bind group layout and the placeholder
|
||||
/// resources bound to the slots a kernel doesn't use.
|
||||
pub(crate) struct Post {
|
||||
bgl: wgpu::BindGroupLayout,
|
||||
kernels: Vec<(&'static str, wgpu::ComputePipeline)>,
|
||||
/// Placeholders for bindings 3–9 (distinct buffers: writable bindings must not alias).
|
||||
dummy_bufs: Vec<wgpu::Buffer>,
|
||||
dummy_color: wgpu::TextureView,
|
||||
dummy_z: wgpu::TextureView,
|
||||
dummy_out: wgpu::TextureView,
|
||||
}
|
||||
|
||||
const KERNELS: &[&str] = &["resolve", "radius_of", "boost", "prefix_rows", "gather", "finish", "wire"];
|
||||
|
||||
impl Post {
|
||||
fn new(device: &wgpu::Device) -> Post {
|
||||
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("effectcraft advanced 3d post"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("shaders/adv3d.wgsl").into()),
|
||||
});
|
||||
let tex = |binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Texture {
|
||||
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
multisampled: false,
|
||||
},
|
||||
count: None,
|
||||
};
|
||||
let buf = |binding, read_only| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { read_only }, has_dynamic_offset: false, min_binding_size: None },
|
||||
count: None,
|
||||
};
|
||||
let entries = [
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 0,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None },
|
||||
count: None,
|
||||
},
|
||||
tex(1),
|
||||
tex(2),
|
||||
buf(3, false),
|
||||
buf(4, false),
|
||||
buf(5, false),
|
||||
buf(6, true),
|
||||
buf(7, false),
|
||||
buf(8, false),
|
||||
buf(9, false),
|
||||
wgpu::BindGroupLayoutEntry {
|
||||
binding: 10,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::StorageTexture {
|
||||
access: wgpu::StorageTextureAccess::WriteOnly,
|
||||
format: crate::context::FORMAT,
|
||||
view_dimension: wgpu::TextureViewDimension::D2,
|
||||
},
|
||||
count: None,
|
||||
},
|
||||
];
|
||||
let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("advanced 3d post"), entries: &entries });
|
||||
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("advanced 3d post"),
|
||||
bind_group_layouts: &[Some(&bgl)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let kernels = KERNELS
|
||||
.iter()
|
||||
.map(|k| {
|
||||
let p = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some(k),
|
||||
layout: Some(&layout),
|
||||
module: &module,
|
||||
entry_point: Some(k),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
(*k, p)
|
||||
})
|
||||
.collect();
|
||||
let dummy_bufs = (3..=9)
|
||||
.map(|_| {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("adv3d dummy"),
|
||||
size: 16,
|
||||
usage: wgpu::BufferUsages::STORAGE,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let tex1 = |format, usage| {
|
||||
device
|
||||
.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("adv3d dummy"),
|
||||
size: wgpu::Extent3d { width: 1, height: 1, depth_or_array_layers: 1 },
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format,
|
||||
usage,
|
||||
view_formats: &[],
|
||||
})
|
||||
.create_view(&Default::default())
|
||||
};
|
||||
Post {
|
||||
bgl,
|
||||
kernels,
|
||||
dummy_bufs,
|
||||
dummy_color: tex1(COLOR, wgpu::TextureUsages::TEXTURE_BINDING),
|
||||
dummy_z: tex1(DEPTH_OUT, wgpu::TextureUsages::TEXTURE_BINDING),
|
||||
dummy_out: tex1(crate::context::FORMAT, wgpu::TextureUsages::STORAGE_BINDING),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The resources of one post kernel dispatch (unset slots get placeholders).
|
||||
#[derive(Default)]
|
||||
struct Binds<'b> {
|
||||
color: Option<&'b wgpu::TextureView>,
|
||||
z: Option<&'b wgpu::TextureView>,
|
||||
/// Bindings 3–9: acc, depth, src, table, radius, range, prefix.
|
||||
bufs: [Option<&'b wgpu::Buffer>; 7],
|
||||
out: Option<&'b wgpu::TextureView>,
|
||||
}
|
||||
|
||||
/// Uniform block of the post kernels (`Post` in `adv3d.wgsl`).
|
||||
fn params(u0: [u32; 4], f0: [f32; 4]) -> Vec<u8> {
|
||||
let mut v: Vec<u8> = u0.iter().flat_map(|x| x.to_le_bytes()).collect();
|
||||
v.extend([0u8; 16]);
|
||||
v.extend(f0.iter().flat_map(|x| x.to_le_bytes()));
|
||||
v.extend([0u8; 16]);
|
||||
v
|
||||
}
|
||||
|
||||
fn dispatch(e: &mut Enc, kernel: &str, u0: [u32; 4], f0: [f32; 4], b: Binds, groups: (u32, u32)) {
|
||||
let g = e.g;
|
||||
let post = &pipes(g).post;
|
||||
let Some((_, pipe)) = post.kernels.iter().find(|(k, _)| *k == kernel) else { return };
|
||||
let ub = g.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: None, contents: ¶ms(u0, f0), usage: wgpu::BufferUsages::UNIFORM });
|
||||
let mut entries = vec![
|
||||
wgpu::BindGroupEntry { binding: 0, resource: ub.as_entire_binding() },
|
||||
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(b.color.unwrap_or(&post.dummy_color)) },
|
||||
wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(b.z.unwrap_or(&post.dummy_z)) },
|
||||
];
|
||||
for (i, buf) in b.bufs.iter().enumerate() {
|
||||
entries.push(wgpu::BindGroupEntry { binding: 3 + i as u32, resource: buf.unwrap_or(&post.dummy_bufs[i]).as_entire_binding() });
|
||||
}
|
||||
entries.push(wgpu::BindGroupEntry { binding: 10, resource: wgpu::BindingResource::TextureView(b.out.unwrap_or(&post.dummy_out)) });
|
||||
let bg = g.device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some(kernel), layout: &post.bgl, entries: &entries });
|
||||
let enc = e.encoder();
|
||||
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some(kernel), timestamp_writes: None });
|
||||
pass.set_pipeline(pipe);
|
||||
pass.set_bind_group(0, &bg, &[]);
|
||||
pass.dispatch_workgroups(groups.0.max(1), groups.1.max(1), 1);
|
||||
}
|
||||
|
||||
fn pixel_groups(w: u32, h: u32) -> (u32, u32) {
|
||||
(w.div_ceil(16), h.div_ceil(16))
|
||||
}
|
||||
|
||||
fn storage_buf(g: &GpuContext, size: u64, label: &str) -> wgpu::Buffer {
|
||||
g.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some(label),
|
||||
size: size.max(16),
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read a storage buffer back (submits and waits).
|
||||
fn read_buffer(e: &mut Enc, buf: &wgpu::Buffer, size: u64) -> Option<Vec<u8>> {
|
||||
if !e.g.can_readback() {
|
||||
return None;
|
||||
}
|
||||
let rb = e.g.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("adv3d readback"),
|
||||
size,
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
e.encoder().copy_buffer_to_buffer(buf, 0, &rb, 0, size);
|
||||
e.submit();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
rb.map_async(wgpu::MapMode::Read, .., move |r| {
|
||||
let _ = tx.send(r);
|
||||
});
|
||||
e.g.device.poll(wgpu::PollType::wait_indefinitely()).ok()?;
|
||||
rx.recv().ok()?.ok()?;
|
||||
let out = rb.get_mapped_range(..).ok()?.to_vec();
|
||||
rb.unmap();
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// A resolved run on the GPU: premultiplied linear (or working-space) colour and camera depth
|
||||
/// (`BIG` where nothing was drawn), one entry per output pixel.
|
||||
struct Resolved {
|
||||
acc: wgpu::Buffer,
|
||||
depth: wgpu::Buffer,
|
||||
width: u32,
|
||||
height: u32,
|
||||
}
|
||||
|
||||
/// The rasteriser's "nothing drawn" depth on the GPU (∞ on the CPU).
|
||||
const BIG: f32 = 3.0e38;
|
||||
|
||||
/// Rasterise every motion-blur sub-sample of a prepared run, resolve and average them, then
|
||||
/// apply the depth of field (`render_prepared`'s steps). `None` = can't run here (sizes,
|
||||
/// limits, or a depth of field without readback); `Some(None)` = nothing drawn.
|
||||
fn resolve_run(e: &mut Enc, prep: &Prepared) -> Option<Option<Resolved>> {
|
||||
let (w, h) = prep.out;
|
||||
let g = e.g;
|
||||
if !g.fits(w, h) || (prep.dof.is_some() && !g.can_readback()) {
|
||||
return None;
|
||||
}
|
||||
let px = w as u64 * h as u64;
|
||||
let max = g.device.limits().max_storage_buffer_binding_size;
|
||||
if px * 16 > max {
|
||||
return None;
|
||||
}
|
||||
let acc = storage_buf(g, px * 16, "adv3d acc");
|
||||
let depth = storage_buf(g, px * 4, "adv3d depth");
|
||||
let n = prep.samples.max(1);
|
||||
let weight = if n <= 1 { 1.0 } else { 1.0 / n as f32 };
|
||||
let mut first = true;
|
||||
for i in 0..n {
|
||||
let s = prep.scene(i);
|
||||
if s.indices.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if s.width != w * s.ssaa.max(1) || s.height != h * s.ssaa.max(1) {
|
||||
return None;
|
||||
}
|
||||
let (color, zout) = raster_into(e, &s)?;
|
||||
let (cv, zv) = (color.create_view(&Default::default()), zout.create_view(&Default::default()));
|
||||
let b = Binds { color: Some(&cv), z: Some(&zv), bufs: [Some(&acc), Some(&depth), None, None, None, None, None], out: None };
|
||||
dispatch(e, "resolve", [w, h, s.ssaa.max(1), first as u32], [weight, 0.0, 0.0, 0.0], b, pixel_groups(w, h));
|
||||
first = false;
|
||||
// One sub-sample's targets alive at a time.
|
||||
e.submit();
|
||||
}
|
||||
if first {
|
||||
return Some(None);
|
||||
}
|
||||
if let Some(dof) = &prep.dof {
|
||||
depth_of_field(e, &acc, &depth, (w, h), dof, prep.scale)?;
|
||||
}
|
||||
Some(Some(Resolved { acc, depth, width: w, height: h }))
|
||||
}
|
||||
|
||||
/// `adv::depth_of_field` on the resolved buffers: radii, highlights, prefix sums, the iris
|
||||
/// gather at the levels around each pixel's radius. The result replaces `acc`.
|
||||
fn depth_of_field(e: &mut Enc, acc: &wgpu::Buffer, depth: &wgpu::Buffer, (w, h): (u32, u32), dof: &Dof, scale: f64) -> Option<()> {
|
||||
let g = e.g;
|
||||
let px = w as u64 * h as u64;
|
||||
if (w as u64 + 1) * h as u64 * 16 > g.device.limits().max_storage_buffer_binding_size {
|
||||
return None;
|
||||
}
|
||||
let radius = storage_buf(g, px * 4, "adv3d radius");
|
||||
let mut init = Vec::with_capacity(16);
|
||||
for v in [f32::INFINITY.to_bits(), 0, 0, 0] {
|
||||
init.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
let range = g.device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("adv3d radius range"),
|
||||
contents: &init,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
|
||||
});
|
||||
let f0 = [dof.focus as f32, dof.aperture as f32, dof.blur_level as f32, scale as f32];
|
||||
let b = Binds { bufs: [None, Some(depth), None, None, Some(&radius), Some(&range), None], ..Default::default() };
|
||||
dispatch(e, "radius_of", [w, h, 0, 0], f0, b, pixel_groups(w, h));
|
||||
let rb = read_buffer(e, &range, 16)?;
|
||||
let lo = f32::from_bits(u32::from_le_bytes([rb[0], rb[1], rb[2], rb[3]]));
|
||||
let hi = f32::from_bits(u32::from_le_bytes([rb[4], rb[5], rb[6], rb[7]]));
|
||||
if hi < 0.5 {
|
||||
return Some(());
|
||||
}
|
||||
let Some(levels) = bokeh::blur_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_spans(&dof.iris, r as f64) {
|
||||
Some((kernels, norm)) => {
|
||||
let first = levels.len() + spans.len();
|
||||
for (wt, sp) in &kernels {
|
||||
spans.extend(sp.iter().map(|&(dy, x0, x1)| [dy as f32, x0 as f32, x1 as f32, *wt]));
|
||||
}
|
||||
let count = levels.len() + spans.len() - first;
|
||||
heads.push([first as f32, count as f32, 1.0 / norm, 0.0]);
|
||||
}
|
||||
None => heads.push([0.0, 0.0, 0.0, 1.0]),
|
||||
}
|
||||
}
|
||||
let table: Vec<u8> = heads.iter().chain(&spans).flatten().flat_map(|x| x.to_le_bytes()).collect();
|
||||
if table.len() as u64 > g.device.limits().max_storage_buffer_binding_size {
|
||||
return None;
|
||||
}
|
||||
let table = e.bytes(table);
|
||||
let src = storage_buf(g, px * 16, "adv3d dof source");
|
||||
let prefix = storage_buf(g, (w as u64 + 1) * h as u64 * 16, "adv3d prefix");
|
||||
let hl = dof.highlight;
|
||||
let b = Binds { bufs: [Some(acc), None, Some(&src), None, Some(&radius), None, None], ..Default::default() };
|
||||
dispatch(e, "boost", [w, h, 0, 0], [hl.gain as f32, hl.threshold as f32, hl.saturation as f32, 0.0], b, pixel_groups(w, h));
|
||||
let b = Binds { bufs: [None, None, Some(&src), None, None, None, Some(&prefix)], ..Default::default() };
|
||||
dispatch(e, "prefix_rows", [w, h, 0, 0], [0.0; 4], b, (h.div_ceil(64), 1));
|
||||
let b = Binds { bufs: [Some(acc), None, Some(&src), Some(&table), Some(&radius), None, Some(&prefix)], ..Default::default() };
|
||||
dispatch(e, "gather", [w, h, levels.len() as u32, 0], [lo, hi, 0.0, 0.0], b, pixel_groups(w, h));
|
||||
e.submit();
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// The resolved run as an image in the canvas's encoding, composited over `canvas` when given.
|
||||
fn finish(e: &mut Enc, r: &Resolved, encode: bool, canvas: Option<&GpuImage>) -> GpuImage {
|
||||
let out = e.image(r.width, r.height);
|
||||
let ov = out.texture.create_view(&Default::default());
|
||||
let cv = canvas.map(|c| c.texture.create_view(&Default::default()));
|
||||
let b = Binds { color: cv.as_ref(), bufs: [Some(&r.acc), None, None, None, None, None, None], out: Some(&ov), ..Default::default() };
|
||||
dispatch(e, "finish", [r.width, r.height, encode as u32, canvas.is_some() as u32], [0.0; 4], b, pixel_groups(r.width, r.height));
|
||||
out
|
||||
}
|
||||
|
||||
/// [`effectcraft_render::Accelerator::render_3d`]: the whole prepared run on the GPU, read
|
||||
/// back.
|
||||
pub(crate) fn render_prepared(g: &GpuContext, prep: &Prepared) -> Option<Rendered> {
|
||||
if !g.can_readback() {
|
||||
return None;
|
||||
}
|
||||
let mut e = Enc::new(g);
|
||||
let Some(r) = resolve_run(&mut e, prep)? else { return Some(None) };
|
||||
let img = finish(&mut e, &r, !prep.linear, None);
|
||||
let img = e.download(&img)?;
|
||||
let px = r.width as u64 * r.height as u64;
|
||||
let db = read_buffer(&mut e, &r.depth, px * 4)?;
|
||||
let depth = db
|
||||
.chunks_exact(4)
|
||||
.map(|c| {
|
||||
let z = f32::from_le_bytes([c[0], c[1], c[2], c[3]]);
|
||||
if z >= BIG * 0.5 { f32::INFINITY } else { z }
|
||||
})
|
||||
.collect();
|
||||
Some(Some((img, depth)))
|
||||
}
|
||||
|
||||
/// Draw a prepared run over the GPU canvas (the compositor's walk; no readback unless the
|
||||
/// depth of field needs its radius range). `None` = render it on the CPU.
|
||||
pub(crate) fn draw_run(e: &mut Enc, prep: &Prepared, canvas: &GpuImage) -> Option<GpuImage> {
|
||||
if (canvas.width, canvas.height) != prep.out {
|
||||
return None;
|
||||
}
|
||||
match resolve_run(e, prep)? {
|
||||
None => Some(canvas.clone()),
|
||||
Some(r) => Some(finish(e, &r, !prep.linear, Some(canvas))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Wireframe-quality layer outlines: `canvas` with `pixels` set to opaque white
|
||||
/// ([`effectcraft_render::Renderer::wireframe_pixels`]).
|
||||
pub(crate) fn wireframe(e: &mut Enc, canvas: &GpuImage, pixels: &[(u32, u32)]) -> GpuImage {
|
||||
let out = e.image(canvas.width, canvas.height);
|
||||
e.copy_into(canvas, &out, 0, 0);
|
||||
if pixels.is_empty() {
|
||||
return out;
|
||||
}
|
||||
let table: Vec<u8> = pixels.iter().flat_map(|&(x, y)| [x as f32, y as f32, 0.0, 0.0]).flat_map(f32::to_le_bytes).collect();
|
||||
let table = e.bytes(table);
|
||||
let ov = out.texture.create_view(&Default::default());
|
||||
let b = Binds { bufs: [None, None, None, Some(&table), None, None, None], out: Some(&ov), ..Default::default() };
|
||||
let n = pixels.len() as u32;
|
||||
dispatch(e, "wire", [n, 0, 0, 0], [0.0; 4], b, (n.div_ceil(64), 1));
|
||||
out
|
||||
}
|
||||
|
||||
@@ -405,7 +405,7 @@ impl<'g> Enc<'g> {
|
||||
Enc { g, enc: None, pending: 0 }
|
||||
}
|
||||
|
||||
fn encoder(&mut self) -> &mut wgpu::CommandEncoder {
|
||||
pub(crate) fn encoder(&mut self) -> &mut wgpu::CommandEncoder {
|
||||
let g = self.g;
|
||||
self.enc.get_or_insert_with(|| g.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("effectcraft") }))
|
||||
}
|
||||
|
||||
+10
-3
@@ -8,11 +8,14 @@
|
||||
//! mattes, Preserve Transparency, layer styles' passes, all 38 blend modes and the 8/16 bpc
|
||||
//! clamping and quantisation, and converts colour spaces. Classic 3D runs composite here too
|
||||
//! (`classic3d`: per-pixel fragment sort, lights, ray-cast shadows), and adjustment layers run
|
||||
//! their effect stacks on the GPU-resident comp. Advanced 3D compositing and wireframes run on
|
||||
//! the CPU between GPU steps (read back, draw, upload).
|
||||
//! their effect stacks on the GPU-resident comp. Wireframe-quality outlines are drawn here too.
|
||||
//!
|
||||
//! Advanced 3D comps rasterise on a render pipeline (`advanced3d.wgsl`: depth buffer, PBR,
|
||||
//! image-based light, shadow maps), see [`Accelerator::raster_3d`].
|
||||
//! image-based light, shadow maps) and finish in compute kernels (`adv3d.wgsl`: supersampling
|
||||
//! resolve, motion-blur sub-samples, iris depth of field, encoding, compositing), see
|
||||
//! [`Accelerator::render_3d`] and `Renderer::prepare_adv_run`. Advanced 3D layers with blend
|
||||
//! modes, track mattes or Preserve Transparency still composite on the CPU (their scenes
|
||||
//! render here through [`Accelerator::render_3d`]).
|
||||
//!
|
||||
//! GPU effects ([`effectcraft_effects::GPU_EFFECTS`]) run as compute kernels with the CPU
|
||||
//! effect's exact steps (padding, box-blur radii, parameter conversions); chains of them are
|
||||
@@ -155,6 +158,10 @@ impl Accelerator for Gpu {
|
||||
adv3d::render(&self.ctx, scene)
|
||||
}
|
||||
|
||||
fn render_3d(&self, run: &effectcraft_render::three_d::adv::Prepared) -> Option<effectcraft_render::three_d::adv::Rendered> {
|
||||
adv3d::render_prepared(&self.ctx, run)
|
||||
}
|
||||
|
||||
fn particles(&self) -> Option<&dyn effectcraft_effects::psim::ParticleSim> {
|
||||
self.ctx.can_readback().then_some(self as &dyn effectcraft_effects::psim::ParticleSim)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
// Advanced 3D on the GPU after the rasteriser (`advanced3d.wgsl`): the steps of
|
||||
// `effectcraft_render::three_d::adv::render_prepared` on GPU-resident buffers, operation for
|
||||
// operation:
|
||||
//
|
||||
// resolve box-filter the 2×2 supersampled colour and camera depth of one motion-blur
|
||||
// sub-sample and add it to the accumulation (weight 1/n; depth = nearest)
|
||||
// radius each pixel's depth-of-field blur radius (`Dof::coc` · scale / 2, ≤ 48) and the
|
||||
// range over the frame (atomics on the float bits; radii are ≥ 0)
|
||||
// boost Highlight Gain / Threshold / Saturation (`bokeh::boost_highlights`), blended in
|
||||
// where the pixel is blurred
|
||||
// prefix per-row prefix sums (`bokeh::row_prefix`)
|
||||
// gather the iris-shaped gather at the two blur levels around each pixel's radius
|
||||
// (`bokeh::span_gather` on `bokeh::kernel` spans) blended (`progressive_blur`)
|
||||
// finish linear → sRGB (`adv::encode`) and premultiplied "over" the canvas (`draw_run`)
|
||||
// wire Wireframe-quality outlines: set the listed pixels to opaque white
|
||||
//
|
||||
// Depth "nothing drawn" is BIG on the GPU (∞ on the CPU).
|
||||
|
||||
struct Post {
|
||||
u0: vec4<u32>,
|
||||
u1: vec4<u32>,
|
||||
f0: vec4<f32>,
|
||||
f1: vec4<f32>,
|
||||
};
|
||||
|
||||
@group(0) @binding(0) var<uniform> P: Post;
|
||||
@group(0) @binding(1) var color_in: texture_2d<f32>;
|
||||
@group(0) @binding(2) var z_in: texture_2d<f32>;
|
||||
@group(0) @binding(3) var<storage, read_write> acc: array<vec4<f32>>;
|
||||
@group(0) @binding(4) var<storage, read_write> depth: array<f32>;
|
||||
@group(0) @binding(5) var<storage, read_write> src: array<vec4<f32>>;
|
||||
@group(0) @binding(6) var<storage, read> table: array<vec4<f32>>;
|
||||
@group(0) @binding(7) var<storage, read_write> radius: array<f32>;
|
||||
@group(0) @binding(8) var<storage, read_write> range: array<atomic<u32>>;
|
||||
@group(0) @binding(9) var<storage, read_write> prefix: array<vec4<f32>>;
|
||||
@group(0) @binding(10) var out: texture_storage_2d<rgba32float, write>;
|
||||
|
||||
const BIG: f32 = 3.0e38;
|
||||
const MAX_RADIUS: f32 = 48.0;
|
||||
const NEAR: f32 = 1.0;
|
||||
|
||||
// P.u0 = (width, height, ssaa, first), P.f0.x = weight
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn resolve(@builtin(global_invocation_id) id: vec3<u32>) {
|
||||
let w = P.u0.x;
|
||||
let h = P.u0.y;
|
||||
if (id.x >= w || id.y >= h) {
|
||||
return;
|
||||
}
|
||||
let k = P.u0.z;
|
||||
var a = vec4<f32>(0.0);
|
||||
var dz = 0.0;
|
||||
var dn = 0.0;
|
||||
for (var j = 0u; j < k; j = j + 1u) {
|
||||
for (var i = 0u; i < k; i = i + 1u) {
|
||||
let p = vec2<i32>(i32(id.x * k + i), i32(id.y * k + j));
|
||||
a = a + textureLoad(color_in, p, 0);
|
||||
let z = textureLoad(z_in, p, 0).x;
|
||||
if (z >= 0.0) {
|
||||
dz = dz + z;
|
||||
dn = dn + 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
let n = f32(k * k);
|
||||
let res = a / n;
|
||||
var d = BIG;
|
||||
if (dn > 0.0) {
|
||||
d = dz / dn;
|
||||
}
|
||||
let ix = id.y * w + id.x;
|
||||
let wt = P.f0.x;
|
||||
if (P.u0.w == 1u) {
|
||||
acc[ix] = res * wt;
|
||||
depth[ix] = d;
|
||||
} else {
|
||||
acc[ix] = acc[ix] + res * wt;
|
||||
depth[ix] = min(depth[ix], d);
|
||||
}
|
||||
}
|
||||
|
||||
// P.u0 = (width, height), P.f0 = (focus, aperture, blur level, scale)
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn radius_of(@builtin(global_invocation_id) id: vec3<u32>) {
|
||||
let w = P.u0.x;
|
||||
if (id.x >= w || id.y >= P.u0.y) {
|
||||
return;
|
||||
}
|
||||
let ix = id.y * w + id.x;
|
||||
var z = depth[ix];
|
||||
if (z >= BIG * 0.5) {
|
||||
z = 1.0e9;
|
||||
}
|
||||
var coc = 0.0;
|
||||
if (z > NEAR && P.f0.x > 0.0) {
|
||||
coc = P.f0.y * abs(z - P.f0.x) / z * P.f0.z;
|
||||
}
|
||||
let r = min(coc * P.f0.w * 0.5, MAX_RADIUS);
|
||||
radius[ix] = r;
|
||||
atomicMin(&range[0], bitcast<u32>(r));
|
||||
atomicMax(&range[1], bitcast<u32>(r));
|
||||
}
|
||||
|
||||
fn luminance(c: vec3<f32>) -> f32 {
|
||||
return 0.2126 * c.x + 0.7152 * c.y + 0.0722 * c.z;
|
||||
}
|
||||
|
||||
// P.u0 = (width, height), P.f0 = (gain, threshold, saturation)
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn boost(@builtin(global_invocation_id) id: vec3<u32>) {
|
||||
let w = P.u0.x;
|
||||
if (id.x >= w || id.y >= P.u0.y) {
|
||||
return;
|
||||
}
|
||||
let ix = id.y * w + id.x;
|
||||
let p = acc[ix];
|
||||
var b = p;
|
||||
let gain = P.f0.x;
|
||||
if (gain > 0.0 && p.w > 0.0) {
|
||||
let a = p.w;
|
||||
let c = p.xyz / a;
|
||||
let l = luminance(c);
|
||||
let thr = clamp(P.f0.y, 0.0, 1.0);
|
||||
if (!(l < thr || (thr >= 1.0 && l < 1.0))) {
|
||||
var over = 1.0;
|
||||
if (thr < 1.0) {
|
||||
over = clamp((l - thr) / (1.0 - thr), 0.0, 1.0);
|
||||
}
|
||||
let k = 1.0 + gain * 4.0 * max(over, 0.25);
|
||||
let sat = 1.0 + P.f0.z;
|
||||
let s = max(vec3<f32>(l) + (c - vec3<f32>(l)) * sat, vec3<f32>(0.0)) * k;
|
||||
b = vec4<f32>(s * a, a);
|
||||
}
|
||||
let kk = clamp(radius[ix] - 0.5, 0.0, 1.0);
|
||||
b = p + (b - p) * kk;
|
||||
}
|
||||
src[ix] = b;
|
||||
}
|
||||
|
||||
// One invocation per row. P.u0 = (width, height)
|
||||
@compute @workgroup_size(64)
|
||||
fn prefix_rows(@builtin(global_invocation_id) id: vec3<u32>) {
|
||||
let w = P.u0.x;
|
||||
let y = id.x;
|
||||
if (y >= P.u0.y) {
|
||||
return;
|
||||
}
|
||||
let base = y * (w + 1u);
|
||||
var a = vec4<f32>(0.0);
|
||||
prefix[base] = a;
|
||||
for (var x = 0u; x < w; x = x + 1u) {
|
||||
a = a + src[y * w + x];
|
||||
prefix[base + x + 1u] = a;
|
||||
}
|
||||
}
|
||||
|
||||
fn prefix_at(y: u32, t: f32) -> vec4<f32> {
|
||||
let n = P.u0.x;
|
||||
let base = y * (n + 1u);
|
||||
if (t <= 0.0) {
|
||||
return vec4<f32>(0.0);
|
||||
}
|
||||
if (t >= f32(n)) {
|
||||
return prefix[base + n];
|
||||
}
|
||||
let i = u32(floor(t));
|
||||
let f = t - f32(i);
|
||||
let a = prefix[base + i];
|
||||
let b = prefix[base + i + 1u];
|
||||
return a + (b - a) * f;
|
||||
}
|
||||
|
||||
// Level `j` of the table: (first span, span count, 1 / norm, identity).
|
||||
fn level(j: u32, x: u32, y: u32) -> vec4<f32> {
|
||||
let w = P.u0.x;
|
||||
let h = i32(P.u0.y);
|
||||
let l = table[j];
|
||||
if (l.w > 0.5) {
|
||||
return src[y * w + x];
|
||||
}
|
||||
let first = u32(l.x);
|
||||
let count = u32(l.y);
|
||||
var a = vec4<f32>(0.0);
|
||||
for (var s = first; s < first + count; s = s + 1u) {
|
||||
let sp = table[s];
|
||||
let sy = i32(y) + i32(sp.x);
|
||||
if (sy < 0 || sy >= h) {
|
||||
continue;
|
||||
}
|
||||
let hi = prefix_at(u32(sy), f32(x) + sp.z + 0.5);
|
||||
let lo = prefix_at(u32(sy), f32(x) + sp.y + 0.5);
|
||||
a = a + (hi - lo) * sp.w;
|
||||
}
|
||||
return a * l.z;
|
||||
}
|
||||
|
||||
// P.u0 = (width, height, levels), P.f0 = (lo, hi)
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn gather(@builtin(global_invocation_id) id: vec3<u32>) {
|
||||
let w = P.u0.x;
|
||||
if (id.x >= w || id.y >= P.u0.y) {
|
||||
return;
|
||||
}
|
||||
let ix = id.y * w + id.x;
|
||||
let n = P.u0.z;
|
||||
if (n <= 1u) {
|
||||
acc[ix] = level(0u, id.x, id.y);
|
||||
return;
|
||||
}
|
||||
let lo = P.f0.x;
|
||||
let hi = P.f0.y;
|
||||
let r = radius[ix];
|
||||
let f = clamp((r - lo) / (hi - lo) * f32(n - 1u), 0.0, f32(n - 1u));
|
||||
let k = min(u32(floor(f)), n - 2u);
|
||||
let t = f - f32(k);
|
||||
let a = level(k, id.x, id.y);
|
||||
let b = level(k + 1u, id.x, id.y);
|
||||
acc[ix] = a + (b - a) * t;
|
||||
}
|
||||
|
||||
fn linear_to_srgb(c: f32) -> f32 {
|
||||
if (c <= 0.0031308) {
|
||||
return c * 12.92;
|
||||
}
|
||||
return 1.055 * pow(c, 1.0 / 2.4) - 0.055;
|
||||
}
|
||||
|
||||
// P.u0 = (width, height, encode, over the canvas in color_in)
|
||||
@compute @workgroup_size(16, 16)
|
||||
fn finish(@builtin(global_invocation_id) id: vec3<u32>) {
|
||||
let w = P.u0.x;
|
||||
if (id.x >= w || id.y >= P.u0.y) {
|
||||
return;
|
||||
}
|
||||
var p = acc[id.y * w + id.x];
|
||||
if (P.u0.z == 1u && p.w > 1e-6) {
|
||||
let a = p.w;
|
||||
p = vec4<f32>(linear_to_srgb(p.x / a) * a, linear_to_srgb(p.y / a) * a, linear_to_srgb(p.z / a) * a, a);
|
||||
}
|
||||
let q = vec2<i32>(i32(id.x), i32(id.y));
|
||||
if (P.u0.w == 1u) {
|
||||
let d = textureLoad(color_in, q, 0);
|
||||
let k = 1.0 - p.w;
|
||||
p = vec4<f32>(p.x + d.x * k, p.y + d.y * k, p.z + d.z * k, p.w + d.w * k);
|
||||
}
|
||||
textureStore(out, q, p);
|
||||
}
|
||||
|
||||
// One invocation per pixel listed in `table` (x, y). P.u0.x = count
|
||||
@compute @workgroup_size(64)
|
||||
fn wire(@builtin(global_invocation_id) id: vec3<u32>) {
|
||||
if (id.x >= P.u0.x) {
|
||||
return;
|
||||
}
|
||||
let p = table[id.x];
|
||||
textureStore(out, vec2<i32>(i32(p.x), i32(p.y)), vec4<f32>(1.0));
|
||||
}
|
||||
@@ -167,7 +167,12 @@ fn env_brdf(n_v: f32, rough: f32) -> vec2<f32> {
|
||||
}
|
||||
|
||||
fn equirect_uv(d: vec3<f32>, rot: f32) -> vec2<f32> {
|
||||
let u = 0.5 + (atan2(d.x, d.z) + rot) / (2.0 * PI);
|
||||
// atan2(0, 0) is 0 on the CPU but may be NaN on GPUs (straight up / down normals).
|
||||
var a = 0.0;
|
||||
if (d.x != 0.0 || d.z != 0.0) {
|
||||
a = atan2(d.x, d.z);
|
||||
}
|
||||
let u = 0.5 + (a + rot) / (2.0 * PI);
|
||||
let v = acos(clamp(-d.y, -1.0, 1.0)) / PI;
|
||||
return vec2<f32>(u - floor(u), v);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
//! 1.5 % of pixels (silhouettes) may differ more. Skipped without an adapter.
|
||||
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, LightKind, PrimitiveKind, Project, Renderer as R3, Solid, build};
|
||||
use effectcraft_project::build;
|
||||
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, LightKind, PrimitiveKind, Project, Renderer as R3, Solid};
|
||||
use effectcraft_render::three_d::adv::{self, Scene};
|
||||
use effectcraft_render::{Accelerator, EvalCtx, NoFootage, RenderOpts, Renderer};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
@@ -165,3 +166,277 @@ fn gpu_raster_perf_smoke() {
|
||||
let gpu = t0.elapsed();
|
||||
eprintln!("adv3d 480x320 (2x SSAA of 240x160), {} triangles: cpu {:?}, gpu {:?}", s.indices.len() / 3, cpu, gpu);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- whole runs (M13.11)
|
||||
//
|
||||
// `Accelerator::render_3d` (raster, resolve, motion-blur average, iris depth of field, encode
|
||||
// on the GPU) against `adv::render_prepared` on the CPU. Tolerance: ≤ 1/255 on ≥ 99 % of the
|
||||
// pixels. Against the CPU rasteriser the remaining pixels are silhouette pixels the two
|
||||
// rasterisers cover differently (and the blur spreads); against the CPU post-processing of
|
||||
// the *same* GPU raster the agreement is ≥ 99.9 % (f32 order of operations only).
|
||||
|
||||
/// The lit scene seen through a depth-of-field camera (hexagonal iris, diffraction fringe,
|
||||
/// highlight boost) with the sphere moving under motion blur.
|
||||
fn dof_mb_project() -> (Project, ItemId) {
|
||||
let (mut p, cid) = scene_project();
|
||||
add(&mut p, cid, LayerSource::Camera, |l| {
|
||||
let zoom = match l.props.prop_mut("cameraOptions/zoom").unwrap().value {
|
||||
Value::Scalar(z) => z,
|
||||
_ => 300.0,
|
||||
};
|
||||
set(l, "cameraOptions/dof", Value::Bool(true));
|
||||
set(l, "cameraOptions/focusDistance", Value::Scalar(zoom - 60.0));
|
||||
set(l, "cameraOptions/aperture", Value::Scalar(160.0));
|
||||
set(l, "cameraOptions/irisShape", Value::Enum(4));
|
||||
set(l, "cameraOptions/irisRotation", Value::Scalar(15.0));
|
||||
set(l, "cameraOptions/irisDiffractionFringe", Value::Scalar(50.0));
|
||||
set(l, "cameraOptions/highlightGain", Value::Scalar(30.0));
|
||||
set(l, "cameraOptions/highlightThreshold", Value::Scalar(150.0));
|
||||
});
|
||||
let c = p.comp_mut(cid).unwrap();
|
||||
c.enable_motion_blur = true;
|
||||
c.motion_blur_samples = 6;
|
||||
// The sphere moves 40 px per frame.
|
||||
let sphere = c.layers.iter_mut().find(|l| matches!(l.source, LayerSource::Primitive { kind: PrimitiveKind::Sphere })).unwrap();
|
||||
sphere.switches.motion_blur = true;
|
||||
sphere.props.prop_mut("transform/position").unwrap().keys = vec![
|
||||
effectcraft_keyframe::Keyframe::new(Tick::ZERO, Value::Vec3([80.0, 95.0, 0.0])),
|
||||
effectcraft_keyframe::Keyframe::new(Tick::from_seconds_f64(1.0), Value::Vec3([80.0 + 30.0 * 40.0, 95.0, 0.0])),
|
||||
];
|
||||
(p, cid)
|
||||
}
|
||||
|
||||
/// Share of pixels (0–1) differing by more than 1/255 on any channel, and the largest
|
||||
/// difference.
|
||||
fn off_share(a: &[[f32; 4]], b: &[[f32; 4]]) -> (f64, f32) {
|
||||
let mut off = 0;
|
||||
let mut max = 0.0f32;
|
||||
for (p, q) in a.iter().zip(b) {
|
||||
// NaN counts as a difference (f32::max would drop it).
|
||||
let d = (0..4).map(|k| (p[k] - q[k]).abs()).fold(0.0f32, |a, b| if b.is_nan() || a.is_nan() { f32::NAN } else { a.max(b) });
|
||||
max = max.max(d);
|
||||
if d.is_nan() || d > 1.0 / 255.0 + 1e-6 {
|
||||
off += 1;
|
||||
}
|
||||
}
|
||||
(off as f64 / a.len().max(1) as f64, max)
|
||||
}
|
||||
|
||||
fn run_of(p: &Project, cid: ItemId) -> (EvalCtx<'_>, Vec<&effectcraft_project::Layer>) {
|
||||
let ctx = EvalCtx { project: p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None };
|
||||
let run: Vec<&effectcraft_project::Layer> = ctx.comp.layers.iter().rev().filter(|l| l.is_3d() && l.has_video()).collect();
|
||||
(ctx, run)
|
||||
}
|
||||
|
||||
fn compare_run(p: &Project, cid: ItemId, g: &Gpu, what: &str) {
|
||||
let (ctx, run) = run_of(p, cid);
|
||||
let out = (ctx.comp.width, ctx.comp.height);
|
||||
// CPU reference: CPU raster and post.
|
||||
let cpu_r = Renderer::new(p, &NoFootage, RenderOpts::default());
|
||||
let prep = adv::Prepared::new(&cpu_r, &ctx, &run, out);
|
||||
let (cimg, cdepth) = adv::render_prepared(&prep).expect("cpu drew");
|
||||
// The GPU: everything on the device.
|
||||
let (gimg, gdepth) = g.render_3d(&prep).expect("gpu handled").expect("gpu drew");
|
||||
let (share, max) = off_share(&cimg.data, &gimg.data);
|
||||
eprintln!("{what}: gpu vs cpu {:.3} % of pixels beyond 1/255 (max {max:.4})", share * 100.0);
|
||||
assert!(share <= 0.01, "{what}: {:.3} % of pixels differ", share * 100.0);
|
||||
let dz = cdepth.iter().zip(&gdepth).filter(|(a, b)| a.is_finite() != b.is_finite() || (a.is_finite() && (*a - *b).abs() > 0.01 * a.abs().max(1.0))).count();
|
||||
assert!((dz as f64) < 0.015 * cdepth.len() as f64, "{what}: {dz} depth mismatches");
|
||||
// CPU post-processing of the GPU raster: the post kernels alone.
|
||||
let mut gr = Renderer::new(p, &NoFootage, RenderOpts { backend: effectcraft_render::Backend::Gpu, ..Default::default() });
|
||||
gr.accel = Some(g);
|
||||
let prep = adv::Prepared::new(&gr, &ctx, &run, out);
|
||||
let (himg, _) = adv::render_prepared(&prep).expect("cpu post drew");
|
||||
let (share, max) = off_share(&himg.data, &gimg.data);
|
||||
eprintln!("{what}: gpu post vs cpu post on the gpu raster: {:.4} % beyond 1/255 (max {max:.4})", share * 100.0);
|
||||
assert!(share <= 0.001, "{what}: post kernels differ on {:.3} % of pixels", share * 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_runs_match_cpu_with_dof_and_motion_blur() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let (p, cid) = dof_mb_project();
|
||||
let (ctx, run) = run_of(&p, cid);
|
||||
let r = Renderer::new(&p, &NoFootage, RenderOpts::default());
|
||||
let prep = adv::Prepared::new(&r, &ctx, &run, (240, 160));
|
||||
assert_eq!(prep.samples, 6);
|
||||
let dof = prep.dof.expect("depth of field");
|
||||
assert_eq!(dof.iris.sides, 6);
|
||||
compare_run(&p, cid, &g, "dof + motion blur");
|
||||
// The depth of field really blurs (against the same frame in focus).
|
||||
let (blurred, _) = g.render_3d(&prep).unwrap().unwrap();
|
||||
let mut sharp_prep = adv::Prepared::new(&r, &ctx, &run, (240, 160));
|
||||
sharp_prep.dof = None;
|
||||
let (sharp, _) = g.render_3d(&sharp_prep).unwrap().unwrap();
|
||||
let (changed, _) = off_share(&blurred.data, &sharp.data);
|
||||
assert!(changed > 0.2, "out-of-focus pixels: {:.1} %", changed * 100.0);
|
||||
// Fast Rectangle iris, no highlights; and without depth of field.
|
||||
let (mut p, cid) = dof_mb_project();
|
||||
for l in &mut p.comp_mut(cid).unwrap().layers {
|
||||
if let Some(pr) = l.props.prop_mut("cameraOptions/irisShape") {
|
||||
pr.value = Value::Enum(0);
|
||||
}
|
||||
if let Some(pr) = l.props.prop_mut("cameraOptions/highlightGain") {
|
||||
pr.value = Value::Scalar(0.0);
|
||||
}
|
||||
}
|
||||
compare_run(&p, cid, &g, "fast rectangle");
|
||||
for l in &mut p.comp_mut(cid).unwrap().layers {
|
||||
if let Some(pr) = l.props.prop_mut("cameraOptions/dof") {
|
||||
pr.value = Value::Bool(false);
|
||||
}
|
||||
}
|
||||
compare_run(&p, cid, &g, "motion blur only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_runs_match_cpu_for_extrusions_and_collapsed_precomps() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let (mut p, cid) = scene_project();
|
||||
// Extruded text with a bevelled stroke.
|
||||
add(&mut p, cid, LayerSource::Text, |l| {
|
||||
l.switches.three_d = true;
|
||||
let doc = effectcraft_keyframe::TextDoc {
|
||||
text: "Ab".into(),
|
||||
size: 50.0,
|
||||
fill: [0.9, 0.8, 0.2, 1.0],
|
||||
apply_fill: true,
|
||||
stroke: [0.1, 0.2, 0.9, 1.0],
|
||||
apply_stroke: true,
|
||||
stroke_width: 4.0,
|
||||
..Default::default()
|
||||
};
|
||||
set(l, "text/sourceText", Value::Text(Box::new(doc)));
|
||||
let mut next = 20_000u64;
|
||||
l.props.children.push(build::extrusion_geometry_options(&mut build::Ids(&mut next)).into());
|
||||
set(l, "geometryOptions/extrusionDepth", Value::Scalar(15.0));
|
||||
set(l, "geometryOptions/bevelStyle", Value::Enum(1));
|
||||
set(l, "transform/position", Value::Vec3([120.0, 50.0, -30.0]));
|
||||
set(l, "transform/rotationY", Value::Scalar(25.0));
|
||||
});
|
||||
// A collapsed precomp holding a cube.
|
||||
let mut nc = Comp::new(240, 160, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
|
||||
nc.renderer = R3::Classic3D;
|
||||
let nid = p.add_item("N", Default::default(), None, ItemKind::Comp(nc.into()));
|
||||
add(&mut p, nid, LayerSource::Primitive { kind: PrimitiveKind::Cube }, |l| {
|
||||
for k in ["width", "height", "depth"] {
|
||||
set(l, &format!("geometryOptions/{k}"), Value::Scalar(30.0));
|
||||
}
|
||||
set(l, "transform/position", Value::Vec3([200.0, 120.0, -20.0]));
|
||||
set(l, "transform/rotationY", Value::Scalar(35.0));
|
||||
set(l, "transform/rotationX", Value::Scalar(-25.0));
|
||||
});
|
||||
add(&mut p, cid, LayerSource::Comp { item: nid }, |l| {
|
||||
l.switches.three_d = true;
|
||||
l.switches.collapse = true;
|
||||
});
|
||||
let (ctx, run) = run_of(&p, cid);
|
||||
let s = adv::scene_of(&Renderer::new(&p, &NoFootage, RenderOpts::default()), &ctx, &run, (240, 160));
|
||||
assert!(s.indices.len() / 3 > 500, "meshes: {} triangles", s.indices.len() / 3);
|
||||
compare_run(&p, cid, &g, "extrusions + collapsed precomp");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_compositor_draws_advanced_3d_runs_on_the_device() {
|
||||
let Some(g) = gpu() else { return };
|
||||
// A 2D background below the run and a 2D layer above it.
|
||||
let (mut p, cid) = dof_mb_project();
|
||||
let bg = p.add_item("B", Default::default(), None, ItemKind::Solid(Solid { color: [0.1, 0.1, 0.15], width: 240, height: 160, pixel_aspect: 1.0 }));
|
||||
let comp = p.comp(cid).unwrap().clone();
|
||||
let l = build::layer(&mut p, &comp, "B", LayerSource::Solid { item: bg }, (240, 160), None);
|
||||
p.comp_mut(cid).unwrap().layers.push(l);
|
||||
let top = p.add_item("T", Default::default(), None, ItemKind::Solid(Solid { color: [1.0, 0.5, 0.0], width: 40, height: 30, pixel_aspect: 1.0 }));
|
||||
add(&mut p, cid, LayerSource::Solid { item: top }, |l| set(l, "transform/opacity", Value::Scalar(50.0)));
|
||||
let (ctx, run) = run_of(&p, cid);
|
||||
assert!(Renderer::new(&p, &NoFootage, RenderOpts::default()).prepare_adv_run(&ctx, &run, (240, 160)).is_some());
|
||||
let cpu = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO);
|
||||
let mut r = Renderer::new(&p, &NoFootage, RenderOpts { backend: effectcraft_render::Backend::Gpu, ..Default::default() });
|
||||
r.accel = Some(&g);
|
||||
let gimg = g.render(&r, cid, Tick::ZERO).expect("gpu walk");
|
||||
let (share, max) = off_share(&cpu.data, &gimg.data);
|
||||
eprintln!("walk: {:.3} % beyond 1/255 (max {max:.4})", share * 100.0);
|
||||
assert!(share <= 0.01, "{:.3} % of pixels differ", share * 100.0);
|
||||
// A blend mode in the run takes the CPU's 2D path (its scene still renders on the GPU).
|
||||
p.comp_mut(cid).unwrap().layers.iter_mut().find(|l| l.switches.three_d && !l.is_light() && l.source.is_av()).unwrap().blend_mode =
|
||||
effectcraft_color::BlendMode::Screen;
|
||||
let (ctx, run) = run_of(&p, cid);
|
||||
assert!(Renderer::new(&p, &NoFootage, RenderOpts::default()).prepare_adv_run(&ctx, &run, (240, 160)).is_none());
|
||||
let cpu = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO);
|
||||
let mut r = Renderer::new(&p, &NoFootage, RenderOpts { backend: effectcraft_render::Backend::Gpu, ..Default::default() });
|
||||
r.accel = Some(&g);
|
||||
let gimg = r.comp_frame(cid, Tick::ZERO);
|
||||
let (share, _) = off_share(&cpu.data, &gimg.data);
|
||||
assert!(share <= 0.01, "blend-mode run: {:.3} %", share * 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_wireframes_match_cpu_exactly() {
|
||||
let Some(g) = gpu() else { return };
|
||||
let mut p = Project::default();
|
||||
let c = Comp::new(200, 120, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
|
||||
let cid = p.add_item("C", Default::default(), None, ItemKind::Comp(c.into()));
|
||||
let sid = p.add_item("S", Default::default(), None, ItemKind::Solid(Solid { color: [0.2, 0.4, 0.6], width: 200, height: 120, pixel_aspect: 1.0 }));
|
||||
add(&mut p, cid, LayerSource::Solid { item: sid }, |_| {});
|
||||
let s2 = p.add_item("W", Default::default(), None, ItemKind::Solid(Solid { color: [1.0, 0.0, 0.0], width: 70, height: 40, pixel_aspect: 1.0 }));
|
||||
add(&mut p, cid, LayerSource::Solid { item: s2 }, |l| {
|
||||
l.switches.quality = effectcraft_project::Quality::Wireframe;
|
||||
set(l, "transform/rotation", Value::Scalar(23.0));
|
||||
set(l, "transform/scale", Value::Vec3([130.0, 90.0, 100.0]));
|
||||
});
|
||||
let cpu = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO);
|
||||
let mut r = Renderer::new(&p, &NoFootage, RenderOpts { backend: effectcraft_render::Backend::Gpu, ..Default::default() });
|
||||
r.accel = Some(&g);
|
||||
let gimg = g.render(&r, cid, Tick::ZERO).expect("gpu walk");
|
||||
let white = cpu.data.iter().filter(|q| **q == [1.0; 4]).count();
|
||||
assert!(white > 100, "outline drawn: {white}");
|
||||
let (share, max) = off_share(&cpu.data, &gimg.data);
|
||||
assert!(share == 0.0, "{share} differ (max {max})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_dof_with_capped_radii() {
|
||||
// Radii at the 48 px cap over most of the frame (8 blur levels).
|
||||
let Some(g) = gpu() else { return };
|
||||
let (mut p, cid) = dof_mb_project();
|
||||
for l in &mut p.comp_mut(cid).unwrap().layers {
|
||||
if let Some(pr) = l.props.prop_mut("cameraOptions/aperture") {
|
||||
pr.value = Value::Scalar(1500.0);
|
||||
}
|
||||
l.switches.motion_blur = false;
|
||||
}
|
||||
compare_run(&p, cid, &g, "capped radii");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_environment_light_on_flat_extrusions_has_no_nan() {
|
||||
// Bevelled text faces pointing straight up or down: atan2(0, 0) in the environment lookup
|
||||
// (NaN on some GPUs) must give the CPU's 0.
|
||||
let Some(g) = gpu() else { return };
|
||||
let mut p = Project::default();
|
||||
let mut c = Comp::new(480, 270, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
|
||||
c.renderer = R3::Advanced3D;
|
||||
let cid = p.add_item("C", Default::default(), None, ItemKind::Comp(c.into()));
|
||||
add(&mut p, cid, LayerSource::Text, |l| {
|
||||
l.switches.three_d = true;
|
||||
let doc =
|
||||
effectcraft_keyframe::TextDoc { text: "EffectCraft".into(), size: 45.0, fill: [0.95, 0.75, 0.2, 1.0], apply_fill: true, ..Default::default() };
|
||||
set(l, "text/sourceText", Value::Text(Box::new(doc)));
|
||||
let mut next = 900_000u64;
|
||||
l.props.children.push(build::extrusion_geometry_options(&mut build::Ids(&mut next)).into());
|
||||
set(l, "geometryOptions/extrusionDepth", Value::Scalar(10.0));
|
||||
set(l, "geometryOptions/bevelStyle", Value::Enum(1));
|
||||
set(l, "transform/position", Value::Vec3([240.0, 65.0, 50.0]));
|
||||
set(l, "transform/rotationY", Value::Scalar(-15.0));
|
||||
});
|
||||
let env = p.add_item("E", Default::default(), None, ItemKind::Solid(Solid { color: [0.6, 0.7, 1.0], width: 32, height: 16, pixel_aspect: 1.0 }));
|
||||
add(&mut p, cid, LayerSource::Solid { item: env }, |l| {
|
||||
l.environment = true;
|
||||
l.switches.three_d = true;
|
||||
});
|
||||
add(&mut p, cid, LayerSource::Light { kind: LightKind::Environment }, |_| {});
|
||||
let s = scene(&p, cid);
|
||||
let t = g.raster_3d(&s).unwrap();
|
||||
let nan = t.color.iter().filter(|c| c.iter().any(|x| !x.is_finite())).count();
|
||||
assert_eq!(nan, 0, "non-finite pixels");
|
||||
compare(&s, &g);
|
||||
}
|
||||
|
||||
+12
-3
@@ -107,6 +107,14 @@ fn draw_3d<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, run: &[&'a Laye
|
||||
if run.is_empty() {
|
||||
return Some(());
|
||||
}
|
||||
// Advanced 3D: rendered and composited on the GPU (`adv3d`).
|
||||
if let Some(prep) = r.prepare_adv_run(ctx, run, (canvas.width, canvas.height)) {
|
||||
match crate::adv3d::draw_run(e, &prep, canvas) {
|
||||
Some(img) => *canvas = img,
|
||||
None => on_cpu(e, canvas, |img| r.draw_3d_run(ctx, run, img))?,
|
||||
}
|
||||
return Some(());
|
||||
}
|
||||
match r.prepare_3d_run(ctx, run, (canvas.width, canvas.height)).and_then(|prep| crate::classic3d::draw_run(e, &prep, canvas)) {
|
||||
Some(img) => *canvas = img,
|
||||
None => on_cpu(e, canvas, |img| r.draw_3d_run(ctx, run, img))?,
|
||||
@@ -139,9 +147,10 @@ fn draw_layer(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: &
|
||||
}
|
||||
return on_cpu(e, canvas, |img| r.draw_layer_cpu(ctx, layer, img));
|
||||
}
|
||||
if layer.switches.quality == Quality::Wireframe {
|
||||
// Wireframes draw outlines on the CPU.
|
||||
return on_cpu(e, canvas, |img| r.draw_layer_cpu(ctx, layer, img));
|
||||
if r.quality(layer) == Quality::Wireframe {
|
||||
// The layer's bounds as a one-pixel outline (the CPU's pixels).
|
||||
*canvas = crate::adv3d::wireframe(e, canvas, &r.wireframe_pixels(ctx, layer, (canvas.width, canvas.height)));
|
||||
return Some(());
|
||||
}
|
||||
let opacity = r.layer_opacity(ctx, layer);
|
||||
if opacity <= 0.0 {
|
||||
|
||||
+242
-27
@@ -47,8 +47,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<EgLink>,
|
||||
},
|
||||
/// 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.
|
||||
@@ -60,6 +70,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")]
|
||||
@@ -215,16 +232,49 @@ impl EssentialGraphics {
|
||||
}
|
||||
/// The control exposing property `prop` of `layer`, 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))
|
||||
}
|
||||
/// The control exposing property `prop` of `layer` as `as_type` (`None` = its natural
|
||||
/// control): a property can have one control of each kind (Source Text as Text and Font).
|
||||
pub fn control_for_as(&self, layer: LayerId, prop: Uid, as_type: Option<ControlType>) -> Option<&EgControl> {
|
||||
self.flat().into_iter().find(|c| c.as_type == as_type && matches!(c.kind, EgKind::Property { layer: l, prop: p } if l == layer && p == prop))
|
||||
self.flat().into_iter().find(|c| c.as_type == as_type && 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<u64> {
|
||||
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![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,13 +301,93 @@ pub fn overridden(layer: &Layer) -> Vec<Uid> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<ItemId> = 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<crate::Keyframe>)> = vec![];
|
||||
for c in eg.flat() {
|
||||
let targets = c.kind.targets();
|
||||
// Font controls take only the font: they never drive whole values.
|
||||
if targets.len() < 2 || c.as_type == Some(ControlType::Font) {
|
||||
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<ItemId> {
|
||||
let EgKind::Media { layer } = c.kind else { return None };
|
||||
@@ -304,7 +434,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);
|
||||
@@ -339,10 +469,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<ItemId> = 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<ItemId> = after.comps().map(|(i, _)| *i).collect();
|
||||
@@ -390,6 +594,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<ItemId> = after.comps().map(|(i, _)| *i).collect();
|
||||
let mut next = after.next_id;
|
||||
@@ -449,26 +655,30 @@ pub fn with_overrides(project: &Project, comp: ItemId, overrides: &[Override]) -
|
||||
let c = p.comp_mut(comp)?;
|
||||
let mut any = false;
|
||||
// Whole values first, then Font controls (which change only the font of whatever text the
|
||||
// instance shows).
|
||||
let is_font = |o: &&Override| eg.find(o.control).is_some_and(|c| c.as_type == Some(ControlType::Font));
|
||||
// instance shows). A mirror acts as its master control.
|
||||
let is_font = |o: &&Override| eg.resolve(o.control).is_some_and(|c| c.as_type == Some(ControlType::Font));
|
||||
let ordered = overrides.iter().filter(|o| !is_font(o)).chain(overrides.iter().filter(is_font));
|
||||
for o in ordered {
|
||||
let Some(ctl) = eg.find(o.control) else { continue };
|
||||
match ctl.kind {
|
||||
EgKind::Property { layer, prop } if ctl.as_type == Some(ControlType::Font) => {
|
||||
if let Some(pr) = c.layer_mut(layer).and_then(|l| l.props.find_mut(prop)) {
|
||||
merge_font(&mut pr.value, &o.value);
|
||||
for k in &mut pr.keys {
|
||||
merge_font(&mut k.value, &o.value);
|
||||
let Some(ctl) = eg.resolve(o.control) else { continue };
|
||||
match &ctl.kind {
|
||||
EgKind::Property { .. } if ctl.as_type == Some(ControlType::Font) => {
|
||||
for (layer, prop) in ctl.kind.targets() {
|
||||
if let Some(pr) = c.layer_mut(layer).and_then(|l| l.props.find_mut(prop)) {
|
||||
merge_font(&mut pr.value, &o.value);
|
||||
for k in &mut pr.keys {
|
||||
merge_font(&mut k.value, &o.value);
|
||||
}
|
||||
any = true;
|
||||
}
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
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;
|
||||
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 } => {
|
||||
@@ -477,7 +687,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;
|
||||
}
|
||||
@@ -508,10 +718,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 { .. } => {}
|
||||
|
||||
+41
-16
@@ -278,6 +278,12 @@ pub trait Accelerator: Send + Sync {
|
||||
fn raster_3d(&self, _scene: &three_d::adv::Scene) -> Option<three_d::adv::Target> {
|
||||
None
|
||||
}
|
||||
/// Render a whole prepared Advanced 3D run (every motion-blur sub-sample rasterised and
|
||||
/// resolved, averaged, depth of field, encoding) with [`three_d::adv::render_prepared`]'s
|
||||
/// semantics. `None` = not handled (the CPU path runs, using [`Self::raster_3d`]).
|
||||
fn render_3d(&self, _run: &three_d::adv::Prepared) -> Option<three_d::adv::Rendered> {
|
||||
None
|
||||
}
|
||||
/// A particle simulation backend (GPU particles) for the stepped particle effects, with
|
||||
/// the CPU simulation's semantics. `None` = they simulate on the CPU.
|
||||
fn particles(&self) -> Option<&dyn effectcraft_effects::psim::ParticleSim> {
|
||||
@@ -592,22 +598,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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1571,6 +1563,39 @@ impl<'a> Renderer<'a> {
|
||||
three_d::compose::gpu_run(self, ctx, run, out)
|
||||
}
|
||||
|
||||
/// A run of consecutive 3D layers of an Advanced 3D comp prepared for an accelerator's
|
||||
/// compositor ([`three_d::adv::Prepared`]): rendered as one scene and composited (Normal,
|
||||
/// premultiplied over) onto the canvas. `None` = not an Advanced 3D comp, or a layer needs the
|
||||
/// 2D compositing path (blend mode, track matte, Preserve Transparency): draw it with
|
||||
/// [`Self::draw_3d_run`].
|
||||
pub fn prepare_adv_run<'p>(&'p self, ctx: &'p EvalCtx<'a>, run: &'p [&'p Layer], out: (u32, u32)) -> Option<three_d::adv::Prepared<'p, 'a>> {
|
||||
three_d::adv::prepare_run(self, ctx, run, out)
|
||||
}
|
||||
|
||||
/// The pixels a Wireframe-quality layer sets (white, opaque) on a `size` canvas: its
|
||||
/// bounds' outline, one pixel wide. Empty when the layer has no bounds.
|
||||
pub fn wireframe_pixels(&self, ctx: &EvalCtx, layer: &Layer, size: (u32, u32)) -> Vec<(u32, u32)> {
|
||||
let Some(b) = content_bounds(ctx, layer) else { return vec![] };
|
||||
let s = self.opts.scale;
|
||||
let (l2c, _) = ctx.layer_to_comp(layer);
|
||||
let m = Mat3::scale(vec2(s, s)) * self.outer.map_or(l2c, |o| o * l2c);
|
||||
let c = [(b[0], b[1]), (b[2], b[1]), (b[2], b[3]), (b[0], b[3])].map(|(x, y)| m.apply(vec2(x, y)));
|
||||
let (w, h) = (size.0 as i64, size.1 as i64);
|
||||
let mut out = vec![];
|
||||
for i in 0..4 {
|
||||
let (p, q) = (c[i], c[(i + 1) % 4]);
|
||||
let n = ((q.x - p.x).abs().max((q.y - p.y).abs()) * 2.0).ceil().clamp(1.0, 1.0e5) as usize;
|
||||
for j in 0..=n {
|
||||
let t = j as f64 / n as f64;
|
||||
let (x, y) = ((p.x + (q.x - p.x) * t).floor() as i64, (p.y + (q.y - p.y) * t).floor() as i64);
|
||||
if x >= 0 && y >= 0 && x < w && y < h {
|
||||
out.push((x as u32, y as u32));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Run the layer's effect stack on `target` (an accelerator's resident image): GPU-capable
|
||||
/// runs through [`FxTarget::gpu`], the others through [`FxTarget::cpu`], with the CPU's
|
||||
/// parameters and environment. `adjustment`: the stack runs on the comp below (adjustment
|
||||
|
||||
@@ -99,32 +99,81 @@ fn mb_samples(r: &Renderer, ctx: &EvalCtx, layers: &[&Layer]) -> usize {
|
||||
if r.opts.draft { 4 } else { ctx.comp.motion_blur_samples.clamp(2, 64) as usize }
|
||||
}
|
||||
|
||||
/// Render `layers` as one Advanced 3D scene: premultiplied pixels in the canvas's encoding and
|
||||
/// the camera depth of each pixel (∞ where nothing was drawn). With motion blur the scene is
|
||||
/// rendered at sub-samples spread over the comp's shutter (angle and phase) and averaged; the
|
||||
/// depth is the nearest over the samples.
|
||||
pub(crate) fn render_layers(r: &Renderer, ctx: &EvalCtx, layers: &[&Layer], out: (u32, u32)) -> Option<(Image, Vec<f32>)> {
|
||||
let n = mb_samples(r, ctx, layers);
|
||||
let (mut img, depth, linear) = if n <= 1 {
|
||||
let s = scene::build(r, ctx, layers, out);
|
||||
/// An Advanced 3D run prepared for an accelerator ([`crate::Accelerator::render_3d`], the GPU
|
||||
/// compositor's walk): the motion-blur sub-sample scenes (built on demand, one at a time), the
|
||||
/// camera's depth of field and the output encoding. The accelerator rasterises every
|
||||
/// sub-sample, resolves the supersampling, averages the sub-samples (nearest depth), applies
|
||||
/// the depth of field and encodes, as [`render_prepared`] does on the CPU.
|
||||
pub struct Prepared<'p, 'a> {
|
||||
r: &'p Renderer<'a>,
|
||||
ctx: &'p EvalCtx<'a>,
|
||||
layers: &'p [&'p Layer],
|
||||
/// Output size (pixels).
|
||||
pub out: (u32, u32),
|
||||
/// Motion-blur sub-samples (1 = none).
|
||||
pub samples: usize,
|
||||
/// Depth of field (the camera at the frame time; `None` = off, Draft 3D or draft renders).
|
||||
pub dof: Option<super::camera::Dof>,
|
||||
/// Render scale (circles of confusion are in comp pixels).
|
||||
pub scale: f64,
|
||||
/// The scene is in the working encoding already (linear projects): no sRGB encode.
|
||||
pub linear: bool,
|
||||
}
|
||||
|
||||
impl<'p, 'a> Prepared<'p, 'a> {
|
||||
pub fn new(r: &'p Renderer<'a>, ctx: &'p EvalCtx<'a>, layers: &'p [&'p Layer], out: (u32, u32)) -> Self {
|
||||
let cam = camera_for(r, ctx);
|
||||
Prepared {
|
||||
r,
|
||||
ctx,
|
||||
layers,
|
||||
out,
|
||||
samples: mb_samples(r, ctx, layers),
|
||||
dof: cam.dof.filter(|_| !ctx.comp.draft_3d && !r.opts.draft),
|
||||
scale: r.opts.scale,
|
||||
linear: r.pipe.linear || r.pipe.linear_blend,
|
||||
}
|
||||
}
|
||||
|
||||
/// The scene of sub-sample `i` (`0..samples`), spread over the comp's shutter (angle and
|
||||
/// phase).
|
||||
pub fn scene(&self, i: usize) -> Scene {
|
||||
let n = self.samples;
|
||||
if n <= 1 {
|
||||
return scene::build(self.r, self.ctx, self.layers, self.out);
|
||||
}
|
||||
let ctx = self.ctx;
|
||||
let fd = ctx.comp.frame_duration().seconds();
|
||||
let (angle, phase) = (ctx.comp.shutter_angle / 360.0, ctx.comp.shutter_phase / 360.0);
|
||||
let f = phase + angle * i as f64 / (n - 1) as f64;
|
||||
let sub = ctx.at(ctx.time + effectcraft_time::Tick::from_seconds_f64(f * fd));
|
||||
scene::build_at(self.r, ctx, Some(&sub), self.layers, self.out)
|
||||
}
|
||||
}
|
||||
|
||||
/// What an accelerator returns for a [`Prepared`] run: `None` when nothing was drawn, else the
|
||||
/// premultiplied image in the canvas's encoding and the camera depth of each pixel (∞ where
|
||||
/// nothing was drawn; the nearest over the motion-blur sub-samples).
|
||||
pub type Rendered = Option<(Image, Vec<f32>)>;
|
||||
|
||||
/// Render a prepared run on the CPU compositor (each scene rasterised by the accelerator's
|
||||
/// [`crate::Accelerator::raster_3d`] when there is one): the reference for
|
||||
/// [`crate::Accelerator::render_3d`].
|
||||
pub fn render_prepared(p: &Prepared) -> Rendered {
|
||||
let (r, n, out) = (p.r, p.samples, p.out);
|
||||
let (mut img, depth) = if n <= 1 {
|
||||
let s = p.scene(0);
|
||||
if s.indices.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (img, depth) = raster_resolved(r, &s);
|
||||
(img, depth, s.linear_io)
|
||||
raster_resolved(r, &s)
|
||||
} else {
|
||||
let fd = ctx.comp.frame_duration().seconds();
|
||||
let (angle, phase) = (ctx.comp.shutter_angle / 360.0, ctx.comp.shutter_phase / 360.0);
|
||||
let mut acc = Image::new(out.0, out.1);
|
||||
let mut depth = vec![f32::INFINITY; (out.0 * out.1) as usize];
|
||||
let mut linear = false;
|
||||
let mut any = false;
|
||||
let k = 1.0 / n as f32;
|
||||
for i in 0..n {
|
||||
let f = phase + angle * i as f64 / (n - 1) as f64;
|
||||
let sub = ctx.at(ctx.time + effectcraft_time::Tick::from_seconds_f64(f * fd));
|
||||
let s = scene::build_at(r, ctx, Some(&sub), layers, out);
|
||||
linear = s.linear_io;
|
||||
let s = p.scene(i);
|
||||
if s.indices.is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -140,18 +189,43 @@ pub(crate) fn render_layers(r: &Renderer, ctx: &EvalCtx, layers: &[&Layer], out:
|
||||
if !any {
|
||||
return None;
|
||||
}
|
||||
(acc, depth, linear)
|
||||
(acc, depth)
|
||||
};
|
||||
let cam = camera_for(r, ctx);
|
||||
if let Some(dof) = cam.dof.filter(|_| !ctx.comp.draft_3d && !r.opts.draft) {
|
||||
img = depth_of_field(&img, &depth, &dof, r.opts.scale);
|
||||
if let Some(dof) = &p.dof {
|
||||
img = depth_of_field(&img, &depth, dof, p.scale);
|
||||
}
|
||||
if !linear {
|
||||
if !p.linear {
|
||||
encode(&mut img);
|
||||
}
|
||||
Some((img, depth))
|
||||
}
|
||||
|
||||
/// Render `layers` as one Advanced 3D scene: premultiplied pixels in the canvas's encoding and
|
||||
/// the camera depth of each pixel (∞ where nothing was drawn). With motion blur the scene is
|
||||
/// rendered at sub-samples spread over the comp's shutter (angle and phase) and averaged; the
|
||||
/// depth is the nearest over the samples. The whole run goes to the accelerator when it
|
||||
/// implements [`crate::Accelerator::render_3d`].
|
||||
pub(crate) fn render_layers(r: &Renderer, ctx: &EvalCtx, layers: &[&Layer], out: (u32, u32)) -> Option<(Image, Vec<f32>)> {
|
||||
let p = Prepared::new(r, ctx, layers, out);
|
||||
if let Some(a) = r.active_accel()
|
||||
&& let Some(res) = a.render_3d(&p)
|
||||
{
|
||||
return res;
|
||||
}
|
||||
render_prepared(&p)
|
||||
}
|
||||
|
||||
/// The run as one prepared scene for an accelerator's compositor when it can be composited
|
||||
/// directly over the canvas (Normal-mode layers without track mattes or Preserve Transparency;
|
||||
/// those go through [`draw_run`]'s 2D path; environment backgrounds draw their sky first). `None`
|
||||
/// when the comp isn't Advanced 3D.
|
||||
pub(crate) fn prepare_run<'p, 'a>(r: &'p Renderer<'a>, ctx: &'p EvalCtx<'a>, run: &'p [&'p Layer], out: (u32, u32)) -> Option<Prepared<'p, 'a>> {
|
||||
if !active(r, ctx) || run.iter().any(|l| l.environment_background || needs_2d_composite(ctx, l)) {
|
||||
return None;
|
||||
}
|
||||
Some(Prepared::new(r, ctx, run, out))
|
||||
}
|
||||
|
||||
/// Layers whose blend mode, track matte or Preserve Transparency needs the 2D compositing path.
|
||||
fn needs_2d_composite(ctx: &EvalCtx, l: &Layer) -> bool {
|
||||
l.blend_mode != effectcraft_color::BlendMode::Normal
|
||||
|
||||
@@ -311,11 +311,16 @@ impl Builder {
|
||||
let wn = n.apply_vec(vec3(nn[0] as f64, nn[1] as f64, nn[2] as f64)).normalize();
|
||||
let t = p.tangents.get(k).copied().unwrap_or([1.0, 0.0, 0.0, 1.0]);
|
||||
let wt = m.apply_vec(vec3(t[0] as f64, t[1] as f64, t[2] as f64)).normalize();
|
||||
// Degenerate normals and tangents (zero length: NaN once normalised) get the shader's
|
||||
// fallbacks here, so every rasteriser shades them alike (GPU compilers may not keep
|
||||
// NaN comparisons).
|
||||
let finite = |v: Vec3, or: [f32; 3]| if v.x.is_finite() && v.y.is_finite() && v.z.is_finite() { [v.x as f32, v.y as f32, v.z as f32] } else { or };
|
||||
let [tx, ty, tz] = finite(wt, [1.0, 0.0, 0.0]);
|
||||
self.s.vertices.push(Vertex {
|
||||
pos: [w.x as f32, w.y as f32, w.z as f32],
|
||||
normal: [wn.x as f32, wn.y as f32, wn.z as f32],
|
||||
normal: finite(wn, [0.0, 0.0, -1.0]),
|
||||
uv: p.uvs.get(k).copied().unwrap_or([0.0, 0.0]),
|
||||
tangent: [wt.x as f32, wt.y as f32, wt.z as f32, if det < 0.0 { -t[3] } else { t[3] }],
|
||||
tangent: [tx, ty, tz, if det < 0.0 { -t[3] } else { t[3] }],
|
||||
material: mat,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1439,7 +1439,7 @@ CompItem.prototype.constructor = CompItem;
|
||||
var out = [];
|
||||
(function walk(list) {
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
if (list[i].kind === "property" || list[i].kind === "media") out.push(list[i]);
|
||||
if (list[i].kind === "property" || list[i].kind === "mirror" || list[i].kind === "media") out.push(list[i]);
|
||||
if (list[i].children) walk(list[i].children);
|
||||
}
|
||||
})(this.__eg().controls);
|
||||
|
||||
@@ -287,9 +287,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",
|
||||
@@ -299,7 +324,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 }; } }
|
||||
};
|
||||
@@ -353,7 +378,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);
|
||||
@@ -403,6 +428,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;
|
||||
@@ -465,7 +495,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 = "";
|
||||
@@ -724,6 +755,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;
|
||||
|
||||
@@ -446,3 +446,28 @@ 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 o = ok(
|
||||
&mut s,
|
||||
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 (a controller of its own).
|
||||
r.push(op.addToMotionGraphicsTemplate(comp));
|
||||
r.push(comp.motionGraphicsTemplateControllerCount);
|
||||
r.push(comp.getMotionGraphicsTemplateControllerName(1), comp.getMotionGraphicsTemplateControllerName(2));
|
||||
comp.setMotionGraphicsControllerName(2, "Fade Again");
|
||||
r.push(comp.getMotionGraphicsTemplateControllerName(2));
|
||||
r
|
||||
"#,
|
||||
);
|
||||
assert_eq!(o.result, json!([true, true, true, 2, "Fade", "Fade", "Fade Again"]));
|
||||
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));
|
||||
}
|
||||
|
||||
+22
-5
@@ -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<ScriptWindow>, 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<J, Strin
|
||||
session.script_ui.revision += 1;
|
||||
return Err("the script that made this window has ended".into());
|
||||
};
|
||||
if let Err(std::sync::mpsc::SendError(ToHost::Event(s, _) | ToHost::Start(s))) = end.tx.send(ToHost::Event(std::mem::take(session), ev.clone())) {
|
||||
*session = s;
|
||||
if let Err(std::sync::mpsc::SendError(msg)) = end.tx.send(ToHost::Event(std::mem::take(session), ev.clone())) {
|
||||
*session = msg.into_session();
|
||||
session.script_ui.windows.retain(|w| w.host != host);
|
||||
session.script_ui.revision += 1;
|
||||
return Err("the script that made this window has ended".into());
|
||||
|
||||
@@ -28,9 +28,9 @@ log = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
web-time = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
image = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
egui_kittest = { workspace = true, features = ["eframe", "wgpu"] }
|
||||
image = { workspace = true }
|
||||
pollster = "0.4"
|
||||
effectcraft-host = { workspace = true }
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
//! control, Scale as one uniform slider); 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.<id>.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.
|
||||
@@ -192,13 +196,37 @@ 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<String> = 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)) => {
|
||||
let v = p.value_at(lt);
|
||||
if let Some(nv) = value_widget(app, ui, &format!("essential.control.{}.value", c.id), essential::effective_type(c, &p), &p, &v) {
|
||||
if let Some(nv) = value_widget(
|
||||
app,
|
||||
ui,
|
||||
&format!("essential.control.{}.value", c.id),
|
||||
essential::effective_type(eg.resolve(c.id).unwrap_or(c), &p),
|
||||
&p,
|
||||
&v,
|
||||
) {
|
||||
actions.push((
|
||||
"prop.set".into(),
|
||||
json!({"comp": cid.0, "layer": layer, "prop": p.uid, "value": nv, "merge": format!("eg-{}", c.id)}),
|
||||
@@ -209,6 +237,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);
|
||||
@@ -230,6 +259,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<u64>, 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));
|
||||
@@ -445,7 +503,7 @@ fn instance(app: &mut EffectcraftApp, ui: &mut egui::Ui, actions: &mut Actions)
|
||||
}
|
||||
_ => {
|
||||
let v = p.value_at(lt);
|
||||
if let Some(nv) = value_widget(app, ui, &auto, essential::effective_type(c, &p), &p, &v) {
|
||||
if let Some(nv) = value_widget(app, ui, &auto, essential::effective_type(eg.resolve(c.id).unwrap_or(c), &p), &p, &v) {
|
||||
actions.push((
|
||||
"essential.set".into(),
|
||||
json!({"layer": layer.id.0, "control": c.id, "value": nv, "merge": format!("egi-{}-{}", layer.id.0, c.id)}),
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
//! named it). Controls with an `onDraw` handler are painted from their draw list; edit texts and
|
||||
//! sliders send live `changing` updates (onChanging) while typing / dragging.
|
||||
|
||||
use effectcraft_engine::scriptui::{DrawOp, PathSeg, ScriptWindow, Widget, WidgetKind, WindowKind, layout};
|
||||
use effectcraft_engine::Services;
|
||||
use effectcraft_engine::scriptui::{DrawOp, ImageRef, PathSeg, ScriptWindow, Widget, WidgetKind, WindowKind, layout};
|
||||
use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, UiBuilder, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -77,6 +78,7 @@ fn draw_children(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2
|
||||
|
||||
fn draw_widget(app: &mut EffectcraftApp, ui: &mut egui::Ui, origin: egui::Pos2, win: u32, w: &Widget, acts: &mut Vec<Act>) {
|
||||
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<egui::Pos2> = 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<egui::TextureHandle> {
|
||||
let id = egui::Id::new(("scriptui-image", img));
|
||||
if let Some(cached) = ctx.data(|d| d.get_temp::<Option<egui::TextureHandle>>(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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
+39
-4
@@ -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`):
|
||||
|
||||
Reference in New Issue
Block a user