Merge branch 'worktree-agent-a0bd45f05d0d6b082'

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