Merge main into M13.6 panels and content tools

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 03:23:13 -04:00
commit a5be68499e
79 changed files with 10110 additions and 292 deletions

View File

@ -749,6 +749,43 @@ fn time_ms(n: usize, mut f: impl FnMut()) -> f64 {
median(&mut v)
}
/// `bench --gpu`'s adjustment-layer comp: a copy of the first comp with a full-frame adjustment
/// layer on top running Gaussian Blur, Levels, Vibrance and Glow (all GPU effects).
fn adjustment_bench_comp(p: &mut effectcraft_engine::project::Project) -> Option<ItemId> {
use effectcraft_engine::effects;
use effectcraft_engine::keyframe::Value;
use effectcraft_engine::project::build::{self, Ids};
use effectcraft_engine::project::{ItemKind, LayerSource, Solid};
let (_, base) = p.comps().next().map(|(id, c)| (*id, c.clone()))?;
let mut comp = base.clone();
let (w, h) = (comp.width, comp.height);
let sid = p.add_item(
"Adjustment (bench)",
effectcraft_engine::color::Label::None,
None,
ItemKind::Solid(Solid { color: [1.0; 3], width: w, height: h, pixel_aspect: 1.0 }),
);
let mut l = build::layer(p, &comp, "Adjustment (bench)", LayerSource::Solid { item: sid }, (w, h), None);
l.switches.adjustment = true;
for (id, vals) in [
("ec.blur.gaussian", vec![("blurriness", Value::Scalar(12.0))]),
("ec.color.levels", vec![("gamma", Value::Scalar(1.3))]),
("ec.color.vibrance", vec![("vibrance", Value::Scalar(40.0))]),
("ec.stylize.glow", vec![("threshold", Value::Scalar(55.0))]),
] {
let spec = effects::find(id)?;
let mut next = p.next_id;
let mut g = effects::instantiate(spec, &mut Ids(&mut next), spec.name, [w as f64, h as f64]);
p.next_id = next;
for (k, v) in vals {
g.prop_mut(k)?.value = v;
}
l.props.sub_mut("effects")?.children.push(g.into());
}
comp.layers.insert(0, l);
Some(p.add_item("Adjustment Layers (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.
@ -756,19 +793,30 @@ fn bench_gpu(s: &Session, args: &Args) -> Result<(), Failure> {
use effectcraft_render::Backend;
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).
let mut project = (*s.project).clone();
let comps: Vec<ItemId> = match args.opt("--comp") {
Some(_) => s.active_comp_id().into_iter().collect(),
None => s.project.comps().map(|(id, _)| *id).collect(),
None => {
let mut v: Vec<ItemId> = s.project.comps().map(|(id, _)| *id).collect();
v.extend(adjustment_bench_comp(&mut project));
v
}
};
let project = &project;
eprintln!("GPU: {} — median of {n} runs (ms/frame)", effectcraft_render::Accelerator::name(&gpu));
eprintln!("{:<28} {:>5} {:>10} {:>9} {:>9} {:>9} {:>9} {:>9}", "comp", "res", "size", "cpu cold", "gpu cold", "cpu warm", "gpu warm", "gpu view");
eprintln!(
"{:<28} {:>5} {:>10} {:>9} {:>9} {:>9} {:>9} {:>9} {:>8} {:>9}",
"comp", "res", "size", "cpu cold", "gpu cold", "cpu warm", "gpu warm", "gpu view", "speedup", "gpu≠cpu"
);
for cid in comps {
let Some(comp) = s.project.comp(cid) else { continue };
let name = s.project.item(cid).map(|i| i.name.clone()).unwrap_or_default();
let Some(comp) = project.comp(cid) else { continue };
let name = project.item(cid).map(|i| i.name.clone()).unwrap_or_default();
let t = Tick::from_seconds_f64(args.num("--time")?.unwrap_or(3.0).min(comp.duration.seconds() * 0.5));
for (label, scale) in [("Full", 1.0), ("Half", 0.5)] {
let mk = |backend: Backend, cache: Option<&LayerCache>| {
let mut r = Renderer::new(&s.project, s.footage.as_ref(), RenderOpts { scale, backend, ..Default::default() });
let mut r = Renderer::new(project, s.footage.as_ref(), RenderOpts { scale, backend, ..Default::default() });
r.expr = s.expr.as_deref();
r.cache = cache;
r.accel = Some(&gpu);
@ -789,7 +837,7 @@ fn bench_gpu(s: &Session, args: &Args) -> Result<(), Failure> {
std::hint::black_box(mk(Backend::Gpu, Some(&gcache)));
});
let view = time_ms(n, || {
let mut r = Renderer::new(&s.project, s.footage.as_ref(), RenderOpts { scale, backend: Backend::Gpu, ..Default::default() });
let mut r = Renderer::new(project, s.footage.as_ref(), RenderOpts { scale, backend: Backend::Gpu, ..Default::default() });
r.expr = s.expr.as_deref();
r.cache = Some(&gcache);
r.accel = Some(&gpu);
@ -797,7 +845,15 @@ fn bench_gpu(s: &Session, args: &Args) -> Result<(), Failure> {
gpu.wait();
});
let size = format!("{}x{}", (comp.width as f64 * scale).round(), (comp.height as f64 * scale).round());
eprintln!("{name:<28} {label:>5} {size:>10} {cpu_cold:>9.2} {gpu_cold:>9.2} {cpu_warm:>9.2} {gpu_warm:>9.2} {view:>9.2}");
// 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 pct = 100.0 * off as f64 / a.data.len().max(1) as f64;
let speedup = cpu_warm / gpu_warm.max(1e-9);
eprintln!(
"{name:<28} {label:>5} {size:>10} {cpu_cold:>9.2} {gpu_cold:>9.2} {cpu_warm:>9.2} {gpu_warm:>9.2} {view:>9.2} {speedup:>7.2}x {pct:>8.3}%"
);
}
}
Ok(())

View File

@ -1,5 +1,5 @@
//! RGB colour spaces for colour management: the project working space, footage interpretation
//! and the display.
//! RGB colour spaces for colour management: the project working space, footage interpretation,
//! output and the display.
//!
//! Each space is defined by its published primaries and white point and its transfer function:
//!
@ -7,15 +7,23 @@
//! * **Rec. 709** — ITU-R BT.709-6 primaries, D65, the BT.1886 display EOTF (gamma 2.4).
//! * **Rec. 2020** — ITU-R BT.2020-2 primaries, D65, BT.1886 (gamma 2.4).
//! * **Display P3** — SMPTE EG 432-1 (DCI-P3) primaries with D65 and the sRGB curve.
//! * **ACEScg** — AP1 primaries (Academy S-2014-004), the ACES white (x 0.32168, y 0.33767),
//! linear.
//! * **ACES2065-1** — AP0 primaries (SMPTE ST 2065-1), the ACES white, linear.
//! * **Rec. 2100 PQ** — BT.2020 primaries with the SMPTE ST 2084 / ITU-R BT.2100 perceptual
//! quantizer; linear 1.0 is the HDR reference white of ITU-R BT.2408 (203 cd/m²).
//! * **Rec. 2100 HLG** — BT.2020 primaries with the ITU-R BT.2100 hybrid log-gamma OETF;
//! linear 1.0 is the BT.2408 reference white (75% HLG signal).
//!
//! RGB → XYZ matrices are derived from the primaries (the standard construction from chromaticity
//! coordinates: columns of xyY → XYZ scaled so that RGB (1, 1, 1) maps to the white point), so
//! coordinates: columns of xyY → XYZ scaled so that RGB (1, 1, 1) maps to the white point), and
//! spaces whose white is not D65 (ACES) are adapted to D65 with the Bradford transform, so
//! conversions between any two spaces are exact up to `f64` rounding. Transfer functions extend
//! to over-range and negative values (32 bpc) by mirroring around zero.
use serde::{Deserialize, Serialize};
/// An RGB colour space (all D65).
/// An RGB colour space.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ColorSpace {
/// sRGB IEC 61966-2-1.
@ -26,13 +34,117 @@ pub enum ColorSpace {
Rec2020,
/// Display P3.
DisplayP3,
/// ACEScg (AP1, linear).
AcesCg,
/// ACES2065-1 (AP0, linear).
Aces2065,
/// Rec. 2100 PQ (BT.2020 primaries, SMPTE ST 2084).
Rec2100Pq,
/// Rec. 2100 HLG (BT.2020 primaries, hybrid log-gamma).
Rec2100Hlg,
}
/// CIE xy chromaticity of D65.
const D65: [f64; 2] = [0.3127, 0.3290];
/// CIE xy chromaticity of the ACES white point.
pub const ACES_WHITE: [f64; 2] = [0.32168, 0.33767];
const AP0: [[f64; 2]; 3] = [[0.7347, 0.2653], [0.0, 1.0], [0.0001, -0.0770]];
const AP1: [[f64; 2]; 3] = [[0.713, 0.293], [0.165, 0.830], [0.128, 0.044]];
const BT2020: [[f64; 2]; 3] = [[0.708, 0.292], [0.170, 0.797], [0.131, 0.046]];
/// SMPTE ST 2084 constants.
const PQ_M1: f64 = 2610.0 / 16384.0;
const PQ_M2: f64 = 2523.0 / 4096.0 * 128.0;
const PQ_C1: f64 = 3424.0 / 4096.0;
const PQ_C2: f64 = 2413.0 / 4096.0 * 32.0;
const PQ_C3: f64 = 2392.0 / 4096.0 * 32.0;
/// Linear 1.0 in cd/m² (ITU-R BT.2408 HDR reference white).
pub const PQ_REFERENCE_WHITE: f64 = 203.0;
/// ITU-R BT.2100 HLG constants.
const HLG_A: f64 = 0.178_832_77;
const HLG_B: f64 = 0.284_668_92;
const HLG_C: f64 = 0.559_910_73;
/// Scene light (E, 0..1) of linear 1.0: the BT.2408 reference white at a 75% HLG signal.
pub const HLG_REFERENCE: f64 = 0.264_962_56;
/// SMPTE ST 2084 inverse EOTF: absolute luminance / 10 000 cd/m² → signal.
pub fn pq_encode(y: f64) -> f64 {
let y = y.max(0.0).powf(PQ_M1);
((PQ_C1 + PQ_C2 * y) / (1.0 + PQ_C3 * y)).powf(PQ_M2)
}
/// SMPTE ST 2084 EOTF: signal → absolute luminance / 10 000 cd/m².
pub fn pq_decode(e: f64) -> f64 {
let p = e.clamp(0.0, 1.0).powf(1.0 / PQ_M2);
((p - PQ_C1).max(0.0) / (PQ_C2 - PQ_C3 * p)).powf(1.0 / PQ_M1)
}
/// ITU-R BT.2100 HLG OETF: scene light E (0..1) → signal.
pub fn hlg_encode(e: f64) -> f64 {
let e = e.max(0.0);
if e <= 1.0 / 12.0 { (3.0 * e).sqrt() } else { HLG_A * (12.0 * e - HLG_B).ln() + HLG_C }
}
/// ITU-R BT.2100 HLG inverse OETF: signal → scene light E.
pub fn hlg_decode(v: f64) -> f64 {
let v = v.max(0.0);
if v <= 0.5 { v * v / 3.0 } else { (((v - HLG_C) / HLG_A).exp() + HLG_B) / 12.0 }
}
/// Transfer curves (see [`ColorSpace::curve`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Curve {
Linear,
Srgb,
/// BT.1886 gamma 2.4.
Gamma24,
Pq,
Hlg,
}
impl Curve {
/// Encoded value → linear light.
pub fn decode(self, v: f32) -> f32 {
let a = v.abs();
let l = match self {
Curve::Linear => a,
Curve::Srgb => crate::srgb_to_linear(a),
Curve::Gamma24 => a.powf(2.4),
Curve::Pq => (pq_decode(a as f64) * 10_000.0 / PQ_REFERENCE_WHITE) as f32,
Curve::Hlg => (hlg_decode(a as f64) / HLG_REFERENCE) as f32,
};
l.copysign(v)
}
/// Linear light → encoded value.
pub fn encode(self, v: f32) -> f32 {
let a = v.abs();
let e = match self {
Curve::Linear => a,
Curve::Srgb => crate::linear_to_srgb(a),
Curve::Gamma24 => a.powf(1.0 / 2.4),
Curve::Pq => pq_encode(a as f64 * PQ_REFERENCE_WHITE / 10_000.0) as f32,
Curve::Hlg => hlg_encode(a as f64 * HLG_REFERENCE) as f32,
};
e.copysign(v)
}
}
impl ColorSpace {
pub const ALL: [ColorSpace; 4] = [ColorSpace::Srgb, ColorSpace::Rec709, ColorSpace::Rec2020, ColorSpace::DisplayP3];
/// Every space (footage interpretation, output).
pub const ALL: [ColorSpace; 8] = [
ColorSpace::Srgb,
ColorSpace::Rec709,
ColorSpace::Rec2020,
ColorSpace::DisplayP3,
ColorSpace::AcesCg,
ColorSpace::Aces2065,
ColorSpace::Rec2100Pq,
ColorSpace::Rec2100Hlg,
];
/// Spaces a project can work in (Project Settings ▸ Color ▸ Working Space).
pub const WORKING: [ColorSpace; 6] =
[ColorSpace::Srgb, ColorSpace::Rec709, ColorSpace::Rec2020, ColorSpace::DisplayP3, ColorSpace::AcesCg, ColorSpace::Aces2065];
/// Display name (as in Project Settings ▸ Color ▸ Working Space).
pub fn label(self) -> &'static str {
@ -41,16 +153,25 @@ impl ColorSpace {
ColorSpace::Rec709 => "HDTV (Rec. 709)",
ColorSpace::Rec2020 => "Rec. 2020",
ColorSpace::DisplayP3 => "Display P3",
ColorSpace::AcesCg => "ACEScg",
ColorSpace::Aces2065 => "ACES2065-1",
ColorSpace::Rec2100Pq => "Rec. 2100 PQ",
ColorSpace::Rec2100Hlg => "Rec. 2100 HLG",
}
}
/// Short identifier used by commands and files (`srgb`, `rec709`, `rec2020`, `p3`).
/// Short identifier used by commands and files (`srgb`, `rec709`, `rec2020`, `p3`, `acescg`,
/// `aces2065`, `rec2100pq`, `rec2100hlg`).
pub fn id(self) -> &'static str {
match self {
ColorSpace::Srgb => "srgb",
ColorSpace::Rec709 => "rec709",
ColorSpace::Rec2020 => "rec2020",
ColorSpace::DisplayP3 => "p3",
ColorSpace::AcesCg => "acescg",
ColorSpace::Aces2065 => "aces2065",
ColorSpace::Rec2100Pq => "rec2100pq",
ColorSpace::Rec2100Hlg => "rec2100hlg",
}
}
@ -62,6 +183,10 @@ impl ColorSpace {
"rec709" | "bt709" | "hdtvrec709" | "hdtv" | "709" => Some(ColorSpace::Rec709),
"rec2020" | "bt2020" | "2020" | "uhdtv" => Some(ColorSpace::Rec2020),
"p3" | "displayp3" | "p3d65" | "dcip3d65" => Some(ColorSpace::DisplayP3),
"acescg" | "ap1" => Some(ColorSpace::AcesCg),
"aces2065" | "aces20651" | "aces" | "ap0" => Some(ColorSpace::Aces2065),
"rec2100pq" | "bt2100pq" | "pq" | "st2084" | "hdr10" => Some(ColorSpace::Rec2100Pq),
"rec2100hlg" | "bt2100hlg" | "hlg" => Some(ColorSpace::Rec2100Hlg),
_ => None,
}
}
@ -70,37 +195,70 @@ impl ColorSpace {
pub fn primaries(self) -> [[f64; 2]; 3] {
match self {
ColorSpace::Srgb | ColorSpace::Rec709 => [[0.640, 0.330], [0.300, 0.600], [0.150, 0.060]],
ColorSpace::Rec2020 => [[0.708, 0.292], [0.170, 0.797], [0.131, 0.046]],
ColorSpace::Rec2020 | ColorSpace::Rec2100Pq | ColorSpace::Rec2100Hlg => BT2020,
ColorSpace::DisplayP3 => [[0.680, 0.320], [0.265, 0.690], [0.150, 0.060]],
ColorSpace::AcesCg => AP1,
ColorSpace::Aces2065 => AP0,
}
}
/// Linear RGB → CIE XYZ (Y of white = 1).
/// White point (CIE xy).
pub fn white(self) -> [f64; 2] {
match self {
ColorSpace::AcesCg | ColorSpace::Aces2065 => ACES_WHITE,
_ => D65,
}
}
/// The transfer curve.
pub fn curve(self) -> Curve {
match self {
ColorSpace::Srgb | ColorSpace::DisplayP3 => Curve::Srgb,
ColorSpace::Rec709 | ColorSpace::Rec2020 => Curve::Gamma24,
ColorSpace::AcesCg | ColorSpace::Aces2065 => Curve::Linear,
ColorSpace::Rec2100Pq => Curve::Pq,
ColorSpace::Rec2100Hlg => Curve::Hlg,
}
}
/// The space is linear light by definition (ACES).
pub fn is_linear(self) -> bool {
self.curve() == Curve::Linear
}
/// A high-dynamic-range encoding (Rec. 2100 PQ / HLG).
pub fn is_hdr(self) -> bool {
matches!(self.curve(), Curve::Pq | Curve::Hlg)
}
/// Linear RGB → CIE XYZ D65 (Y of white = 1; non-D65 spaces Bradford-adapted).
pub fn to_xyz(self) -> [[f64; 3]; 3] {
rgb_to_xyz(self.primaries(), D65)
let w = self.white();
let m = rgb_to_xyz(self.primaries(), w);
if w == D65 { m } else { mul(&bradford(w, D65), &m) }
}
/// Encoded value → linear light.
pub fn decode(self, v: f32) -> f32 {
let a = v.abs();
let l = match self {
ColorSpace::Srgb | ColorSpace::DisplayP3 => crate::srgb_to_linear(a),
ColorSpace::Rec709 | ColorSpace::Rec2020 => a.powf(2.4),
};
l.copysign(v)
self.curve().decode(v)
}
/// Linear light → encoded value.
pub fn encode(self, v: f32) -> f32 {
let a = v.abs();
let e = match self {
ColorSpace::Srgb | ColorSpace::DisplayP3 => crate::linear_to_srgb(a),
ColorSpace::Rec709 | ColorSpace::Rec2020 => a.powf(1.0 / 2.4),
};
e.copysign(v)
self.curve().encode(v)
}
}
/// Bradford chromatic adaptation from white `src` to white `dst` (CIE xy).
pub fn bradford(src: [f64; 2], dst: [f64; 2]) -> [[f64; 3]; 3] {
const MB: [[f64; 3]; 3] = [[0.8951, 0.2664, -0.1614], [-0.7502, 1.7135, 0.0367], [0.0389, -0.0685, 1.0296]];
let xyz = |c: [f64; 2]| [c[0] / c[1], 1.0, (1.0 - c[0] - c[1]) / c[1]];
let s = mul_vec(&MB, xyz(src));
let d = mul_vec(&MB, xyz(dst));
let diag = [[d[0] / s[0], 0.0, 0.0], [0.0, d[1] / s[1], 0.0], [0.0, 0.0, d[2] / s[2]]];
mul(&invert(&MB), &mul(&diag, &MB))
}
/// The standard RGB → XYZ matrix from primaries and white point.
pub fn rgb_to_xyz(p: [[f64; 2]; 3], white: [f64; 2]) -> [[f64; 3]; 3] {
let xyz = |c: [f64; 2]| [c[0] / c[1], 1.0, (1.0 - c[0] - c[1]) / c[1]];
@ -160,7 +318,7 @@ impl Conversion {
/// the conversion is the identity.
pub fn new(from: ColorSpace, from_linear: bool, to: ColorSpace, to_linear: bool) -> Option<Conversion> {
let same_primaries = from.primaries() == to.primaries();
let same_curve = from_linear == to_linear && (from_linear || curve_eq(from, to));
let same_curve = from_linear == to_linear && (from_linear || from.curve() == to.curve());
if same_primaries && same_curve {
return None;
}
@ -201,11 +359,6 @@ impl Conversion {
}
}
fn curve_eq(a: ColorSpace, b: ColorSpace) -> bool {
let srgb = |s: ColorSpace| matches!(s, ColorSpace::Srgb | ColorSpace::DisplayP3);
srgb(a) == srgb(b)
}
#[cfg(test)]
mod tests {
use super::*;
@ -235,11 +388,17 @@ mod tests {
let back = Conversion::new(b, bl, a, al);
assert_eq!(fwd.is_none(), back.is_none());
for c in samples {
// HDR signals are 0..1.
if a.is_hdr() && !al && c.iter().any(|v| *v > 1.0) {
continue;
}
let mid = fwd.map(|f| f.apply(c)).unwrap_or(c);
let o = back.map(|f| f.apply(mid)).unwrap_or(mid);
for i in 0..3 {
// Pure power curves amplify f32 rounding near zero.
assert!((o[i] - c[i]).abs() < 2e-3, "{a:?}{al}→{b:?}{bl}: {c:?} → {mid:?} → {o:?}");
// Pure power curves amplify f32 rounding near zero; PQ signals near 1.0
// stand for ~50× reference white.
let tol = if a.is_hdr() || b.is_hdr() { 1e-2 } else { 2e-3 };
assert!((o[i] - c[i]).abs() < tol, "{a:?}{al}→{b:?}{bl}: {c:?} → {mid:?} → {o:?}");
}
}
}
@ -270,4 +429,42 @@ mod tests {
assert_eq!(ColorSpace::parse("sRGB IEC61966-2.1"), Some(ColorSpace::Srgb));
assert_eq!(ColorSpace::parse("Display P3"), Some(ColorSpace::DisplayP3));
}
#[test]
fn aces_and_rec2100_spaces() {
// SMPTE ST 2065-1 AP0 → XYZ (ACES white, unadapted).
let m = rgb_to_xyz(ColorSpace::Aces2065.primaries(), ACES_WHITE);
let want = [[0.952_552_4, 0.0, 0.000_093_68], [0.343_966_4, 0.728_166_1, -0.072_132_5], [0.0, 0.0, 1.008_825_2]];
for i in 0..3 {
for j in 0..3 {
assert!((m[i][j] - want[i][j]).abs() < 1e-5, "{i}{j}: {}", m[i][j]);
}
}
// ACEScg white → sRGB white; ACEScg red sits outside sRGB (negative green/blue).
let c = Conversion::new(ColorSpace::AcesCg, true, ColorSpace::Srgb, true).unwrap();
let w = c.apply([1.0, 1.0, 1.0]);
assert!(w.iter().all(|v| (v - 1.0).abs() < 1e-3), "{w:?}");
let r = c.apply([1.0, 0.0, 0.0]);
assert!((r[0] - 1.705).abs() < 3e-3 && r[1] < 0.0 && r[2] < 0.0, "{r:?}");
assert!(ColorSpace::AcesCg.is_linear() && !ColorSpace::Srgb.is_linear());
// PQ: the BT.2408 reference white (1.0 = 203 cd/m²) encodes to ≈ 0.58; 10 000 cd/m² to 1.
let pq = ColorSpace::Rec2100Pq;
assert!((pq.encode(1.0) - 0.5807).abs() < 1e-3, "{}", pq.encode(1.0));
assert!((pq.encode(10_000.0 / 203.0) - 1.0).abs() < 1e-4);
assert!((pq_encode(100.0 / 10_000.0) - 0.508).abs() < 1e-3);
// HLG: reference white at 75%; the OETF is continuous at 1/12.
let hlg = ColorSpace::Rec2100Hlg;
assert!((hlg.encode(1.0) - 0.75).abs() < 1e-5, "{}", hlg.encode(1.0));
assert!((hlg_encode(1.0 / 12.0) - 0.5).abs() < 1e-9 && (hlg_encode(1.0) - 1.0).abs() < 1e-6);
for v in [0.0f32, 0.01, 0.18, 1.0, 3.0] {
assert!((pq.decode(pq.encode(v)) - v).abs() < 1e-3 * v.max(1.0), "pq {v}");
assert!((hlg.decode(hlg.encode(v)) - v).abs() < 1e-3 * v.max(1.0), "hlg {v}");
}
assert!(pq.is_hdr() && hlg.is_hdr() && !ColorSpace::Rec2020.is_hdr());
// Same primaries as Rec. 2020: only the curve changes.
assert!(Conversion::new(ColorSpace::Rec2020, false, ColorSpace::Rec2100Pq, false).unwrap().matrix.is_none());
for cs in ColorSpace::ALL {
assert_eq!(ColorSpace::parse(cs.id()), Some(cs));
assert_eq!(ColorSpace::parse(cs.label()), Some(cs), "{}", cs.label());
}
}
}

View File

@ -277,11 +277,9 @@ fn iris_spans(r: f64, sides: u32, rotation_deg: f64, roundness: f64) -> Vec<(i64
spans
}
fn camera_lens_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
let r = ctx.params.f("blurRadius").max(0.0) * b.scale;
if r < 0.5 {
return b;
}
/// Camera Lens Blur's iris spans at blur radius `r` pixels (shape, rotation, roundness and
/// aspect ratio applied) and the iris' horizontal reach (shared with the GPU kernel).
pub fn camera_lens_spans(ctx: &EffectCtx, r: f64) -> (Vec<(i64, i64, i64)>, f64) {
let shape = ctx.params.e("irisProperties/irisShape");
let sides = if shape >= 8 { 0 } else { shape + 3 };
let mut spans = iris_spans(r, sides, ctx.params.f("irisProperties/irisRotation"), ctx.params.f("irisProperties/irisRoundness").clamp(0.0, 100.0) / 100.0);
@ -293,7 +291,15 @@ fn camera_lens_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
s.2 = (s.2 as f64 * aspect).round() as i64;
}
}
let reach = r * aspect.max(1.0);
(spans, r * aspect.max(1.0))
}
fn camera_lens_blur(ctx: &EffectCtx, mut b: Buf) -> Buf {
let r = ctx.params.f("blurRadius").max(0.0) * b.scale;
if r < 0.5 {
return b;
}
let (spans, reach) = camera_lens_spans(ctx, r);
let repeat = ctx.params.b("repeatEdge") || ctx.adjustment;
if !repeat {
b.pad(reach.ceil() as u32 + 1);

View File

@ -6,6 +6,13 @@ use effectcraft_project::ParamUi;
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
/// Id of the Stereo 3D Controls effect (Layer ▸ Camera ▸ Create Stereo 3D Rig).
pub const STEREO_CONTROLS: &str = "ec.control.stereo3d";
/// Stereo 3D Controls ▸ Configuration: which eyes are offset from the master camera.
pub const STEREO_CONFIGURATIONS: [&str; 3] = ["Stereo Pair (Left & Right)", "Center & Right", "Center & Left"];
/// Stereo 3D Controls ▸ Convergence Of: the zero-parallax distance.
pub const STEREO_CONVERGENCE: [&str; 2] = ["Camera Point of Interest", "Camera Zoom Plane"];
fn passthrough(_: &EffectCtx, b: Buf) -> Buf {
b
}
@ -24,5 +31,17 @@ pub fn specs() -> Vec<EffectSpec> {
spec("ec.control.point3d", "3D Point Control", vec![p("point", "3D Point", Value::Vec3([0.0, 0.0, 0.0]), ParamUi::Point3)]),
spec("ec.control.layer", "Layer Control", vec![p("layer", "Layer", Value::Layer(None), ParamUi::Layer)]),
spec("ec.control.dropdown", "Dropdown Menu Control", vec![p("menu", "Menu", Value::Enum(0), popup(&["Item 1", "Item 2", "Item 3"]))]),
// Layer ▸ Camera ▸ Create Stereo 3D Rig: the rig's eye cameras read these (expressions).
spec(
STEREO_CONTROLS,
"Stereo 3D Controls",
vec![
p("configuration", "Configuration", Value::Enum(0), popup(&STEREO_CONFIGURATIONS)),
p("sceneDepth", "Stereo Scene Depth", num(3.0), slider(0.0, 100.0, 0.0, 10.0, 2)),
p("convergence", "Enable Convergence", Value::Bool(false), ParamUi::Checkbox),
p("convergenceOf", "Convergence Of", Value::Enum(0), popup(&STEREO_CONVERGENCE)),
p("zOffset", "Convergence Z Offset", num(0.0), slider(-100_000.0, 100_000.0, -2000.0, 2000.0, 1)),
],
),
]
}

View File

@ -414,7 +414,8 @@ fn grid_warp(src: &Image, w: u32, h: u32, nx: usize, ny: usize, dest: &[Pt], src
out
}
fn parse_mesh(s: &str) -> Vec<Pt> {
/// Mesh Warp's `mesh` parameter: the vertex offsets (layer pixels), row-major.
pub fn parse_mesh(s: &str) -> Vec<(f64, f64)> {
s.split(|c: char| c.is_whitespace() || c == ';')
.filter(|t| !t.is_empty())
.filter_map(|t| {

View File

@ -75,7 +75,7 @@ pub(crate) const GEN_BLEND_MODES: [&str; 20] = [
"Silhouette Alpha",
];
fn gen_mode(i: u32) -> Option<BlendMode> {
pub fn gen_mode(i: u32) -> Option<BlendMode> {
Some(match i {
0 => return None,
2 => BlendMode::Add,

View File

@ -61,7 +61,7 @@ const CELL_PATTERNS: [&str; 12] = [
];
/// Shaping used for Cell Pattern option `i` (see [`cell_value`]).
fn pattern_kind(i: u32) -> u32 {
pub fn pattern_kind(i: u32) -> u32 {
match i {
0..=5 => i,
6 => 1,

View File

@ -17,7 +17,7 @@ mod channel3d;
mod color2;
mod color3;
mod color_fx;
mod controls;
pub mod controls;
mod distort;
mod distort2;
mod distort3;
@ -37,9 +37,11 @@ mod noise2;
mod noise3;
mod obsolete;
mod ocio;
pub mod ocio_config;
pub mod paint;
mod perspective;
mod perspective2;
pub mod psim;
pub mod puppet;
pub mod roto;
mod sim;
@ -62,11 +64,20 @@ use std::sync::OnceLock;
pub use color_fx::{exposure_settings, levels_clip};
pub use color2::Curve;
pub use distort2::parse_mesh;
use effectcraft_keyframe::Value;
use effectcraft_project::build::Ids;
use effectcraft_project::{GroupKind, ParamUi, PropGroup, Property};
pub use effectcraft_raster::{AuxChannels, Image};
pub use misc::{INVERT_ALPHA, INVERT_CHANNELS};
pub use sim::particle_state;
pub use sim3::playground_state;
// CPU helpers the GPU kernels share (effectcraft-gpu).
pub use blur2::camera_lens_spans;
pub use generate::gen_mode;
pub use generate2::pattern_kind as cell_pattern_kind;
pub use noise::GrainLook;
pub use transition::place_layer;
/// Effect categories in Effects & Presets order.
pub const CATEGORIES: &[&str] = &[
@ -260,6 +271,11 @@ pub trait EffectHost: Sync {
fn aux(&self) -> Option<std::sync::Arc<AuxChannels>> {
None
}
/// A particle simulation backend (GPU particles; see [`psim`]). `None` = simulate on the
/// CPU.
fn particles(&self) -> Option<&dyn psim::ParticleSim> {
None
}
}
/// Extra context the renderer may supply (all optional; `Default` is "nothing known").
@ -641,6 +657,42 @@ pub const GPU_EFFECTS: &[&str] = &[
"ec.color.exposure",
"ec.channel.invert",
"ec.distort.transform",
"ec.distort.turbulentdisplace",
"ec.distort.displacementmap",
"ec.distort.wavewarp",
"ec.distort.ripple",
"ec.distort.twirl",
"ec.distort.bulge",
"ec.distort.cclens",
"ec.distort.meshwarp",
"ec.stylize.mosaic",
"ec.stylize.findedges",
"ec.stylize.emboss",
// Blur, transition and generate family (effectcraft-gpu `fx_generate`).
"ec.blur.radial",
"ec.blur.cameralens",
"ec.blur.ccradialfast",
"ec.transition.venetian",
"ec.transition.linearwipe",
"ec.transition.radialwipe",
"ec.transition.gradientwipe",
"ec.generate.cellpattern",
"ec.generate.checkerboard",
"ec.generate.grid",
"ec.generate.fourcolor",
"ec.noise.noise",
"ec.noise.addgrain",
// effectcraft-gpu fx_color
"ec.color.colorbalance",
"ec.color.vibrance",
"ec.color.lumetri",
"ec.color.blackwhite",
"ec.color.tritone",
"ec.color.colorama",
"ec.color.channelmixer",
"ec.color.selectivecolor",
"ec.key.linearcolor",
"ec.keying.keylight",
];
/// Effects whose output depends on [`EffectCtx::time`] directly (not only through animated

View File

@ -41,7 +41,7 @@ pub(crate) const GRAIN_BLEND_MODES: [&str; 5] = ["Film", "Multiply", "Add", "Scr
/// The Tweaking / Color / Application / Animation controls Add Grain and Match Grain share
/// (twirl-down groups with the same ids in both effects).
pub(crate) struct GrainLook {
pub struct GrainLook {
pub intensity: f32,
pub channel_intensity: [f32; 3],
pub size: f32,
@ -62,7 +62,7 @@ pub(crate) struct GrainLook {
}
impl GrainLook {
pub(crate) fn from_params(ctx: &EffectCtx) -> GrainLook {
pub fn from_params(ctx: &EffectCtx) -> GrainLook {
let pr = ctx.params;
let ch = |pre: &str, a: &str, b: &str, c: &str| [a, b, c].map(|k| pr.get(&format!("{pre}{k}")).map(Value::as_f64).unwrap_or(1.0) as f32);
let ft = ctx.time * 24.0 * pr.f("animation/animationSpeed");

View File

@ -559,6 +559,10 @@ pub const DISPLAYS: &[(&str, [[f64; 2]; 3], Tf)] =
pub const VIEWS: &[&str] = &["Standard", "Tone Mapped", "Raw"];
/// Configuration popup: the built-in config, or a custom `.ocio` file (Config File: a path or
/// the config's text; spaces chosen by name, see [`crate::ocio_config`]).
pub const CONFIGS: &[&str] = &["Built-in (EffectCraft minimal)", "Custom (.ocio file)"];
/// Built-in looks: CDLs applied in ACEScct.
pub fn looks() -> Vec<(&'static str, Cdl)> {
let grey = 0.413_588_402_492_442_1; // ACEScct of 0.18
@ -647,7 +651,35 @@ fn space_at(i: u32) -> &'static Space {
&SPACES[(i as usize).min(SPACES.len() - 1)]
}
/// The custom config chosen by an effect (Configuration ▸ Custom with a readable file).
fn custom_config(ctx: &EffectCtx) -> Option<std::sync::Arc<crate::ocio_config::Config>> {
if ctx.params.e("config") != 1 {
return None;
}
crate::ocio_config::load_config(ctx.params.s("configFile"))
}
/// A space of a custom config by its typed name, else by the built-in popup's name.
fn custom_space<'c>(cfg: &'c crate::ocio_config::Config, name: &str, popup: u32) -> Option<&'c crate::ocio_config::ConfigSpace> {
cfg.space(name).or_else(|| cfg.space(space_at(popup).name))
}
fn ocio_cst(ctx: &EffectCtx, mut b: Buf) -> Buf {
// Custom configuration that can't be read: pixels pass through.
if ctx.params.e("config") == 1 && custom_config(ctx).is_none() {
return b;
}
if let Some(cfg) = custom_config(ctx) {
let (Some(a), Some(z)) = (
custom_space(&cfg, ctx.params.s("sourceName"), ctx.params.e("source")),
custom_space(&cfg, ctx.params.s("destinationName"), ctx.params.e("destination")),
) else {
return b;
};
let (a, z) = if ctx.params.e("direction") == 1 { (z, a) } else { (a, z) };
map_px(&mut b, |c| cfg.convert(a, z, c.map(|v| v as f64)).map(|v| v as f32));
return b;
}
let (a, z) = (space_at(ctx.params.e("source")), space_at(ctx.params.e("destination")));
let (a, z) = if ctx.params.e("direction") == 1 { (z, a) } else { (a, z) };
if a.name == z.name {
@ -683,6 +715,21 @@ fn ocio_cdl(ctx: &EffectCtx, mut b: Buf) -> Buf {
}
fn ocio_display(ctx: &EffectCtx, mut b: Buf) -> Buf {
// Custom configuration that can't be read: pixels pass through.
if ctx.params.e("config") == 1 && custom_config(ctx).is_none() {
return b;
}
if let Some(cfg) = custom_config(ctx) {
// The display's view names a colour space of the config.
let display = DISPLAYS.get(ctx.params.e("display") as usize).map_or("", |d| d.0);
let view = VIEWS.get(ctx.params.e("view") as usize).copied().unwrap_or("");
let dn = if ctx.params.s("displayName").is_empty() { display } else { ctx.params.s("displayName") };
let vn = if ctx.params.s("viewName").is_empty() { view } else { ctx.params.s("viewName") };
let (Some(a), Some(z)) = (custom_space(&cfg, ctx.params.s("sourceName"), ctx.params.e("source")), cfg.view_space(dn, vn)) else { return b };
let (a, z) = if ctx.params.e("direction") == 1 { (z, a) } else { (a, z) };
map_px(&mut b, |c| cfg.convert(a, z, c.map(|v| v as f64)).map(|v| v as f32));
return b;
}
let input = space_at(ctx.params.e("source"));
let f = display_xform(input, ctx.params.e("display") as usize, ctx.params.e("view") as usize, ctx.params.e("direction") == 1);
map_px(&mut b, f);
@ -758,6 +805,10 @@ fn profile(ctx: &EffectCtx, i: u32) -> (Space, bool) {
ColorSpace::Rec709 => sp("Rec.709", REC709, D65, Tf::Gamma(2.4)),
ColorSpace::Rec2020 => sp("Rec.2020", REC2020, D65, Tf::Gamma(2.4)),
ColorSpace::DisplayP3 => sp("Display P3", P3, D65, Tf::Srgb),
ColorSpace::AcesCg => *space_by_name("ACEScg").expect("built-in"),
ColorSpace::Aces2065 => *space_by_name("ACES2065-1").expect("built-in"),
// HDR encodings are output spaces, never a working space.
ColorSpace::Rec2100Pq | ColorSpace::Rec2100Hlg => sp("Rec.2020", REC2020, D65, Tf::Gamma(2.4)),
};
match i {
0 => (ws(ctx.env.working_space.unwrap_or(ColorSpace::Srgb)), ctx.env.working_linear),
@ -919,9 +970,12 @@ pub fn specs() -> Vec<EffectSpec> {
"OCIO Color Space Transform",
cc,
vec![
p("config", "Configuration", Value::Enum(0), popup(&["Built-in (EffectCraft minimal)"])),
p("config", "Configuration", Value::Enum(0), popup(CONFIGS)),
p("configFile", "Config File (.ocio)", Value::Str(String::new()), ParamUi::Text),
space_param("source", "Source", lin709),
p("sourceName", "Source (config name)", Value::Str(String::new()), ParamUi::Text),
space_param("destination", "Destination", srgb),
p("destinationName", "Destination (config name)", Value::Str(String::new()), ParamUi::Text),
direction(),
],
ocio_cst,
@ -931,10 +985,14 @@ pub fn specs() -> Vec<EffectSpec> {
"OCIO Display Transform",
cc,
vec![
p("config", "Configuration", Value::Enum(0), popup(&["Built-in (EffectCraft minimal)"])),
p("config", "Configuration", Value::Enum(0), popup(CONFIGS)),
p("configFile", "Config File (.ocio)", Value::Str(String::new()), ParamUi::Text),
space_param("source", "Input Color Space", lin709),
p("sourceName", "Source (config name)", Value::Str(String::new()), ParamUi::Text),
p("display", "Display Device", Value::Enum(0), popup(&DISPLAYS.iter().map(|d| d.0).collect::<Vec<_>>())),
p("displayName", "Display (config name)", Value::Str(String::new()), ParamUi::Text),
p("view", "View Transform", Value::Enum(0), popup(VIEWS)),
p("viewName", "View (config name)", Value::Str(String::new()), ParamUi::Text),
direction(),
],
ocio_display,

View File

@ -0,0 +1,928 @@
//! Custom OpenColorIO configuration files (`.ocio`) for the OCIO effects, read from the
//! published configuration format (a YAML document), not from OpenColorIO's source.
//!
//! Supported subset:
//!
//! * `search_path` (a string with `:`-separated entries or a list), resolved against the
//! config's folder, for `FileTransform` sources;
//! * `roles` (any role name can stand for its colour space);
//! * `colorspaces`: `name`, `aliases`, `isdata`, and the transforms to / from the reference
//! space — `to_reference` / `from_reference` (OCIO v1) or `to_scene_reference` /
//! `from_scene_reference` (v2); a space with only one direction is inverted for the other;
//! * `displays`: display → views (`!<View> {name, colorspace}`);
//! * transforms: `MatrixTransform` (`matrix` 4×4 row-major, `offset`), `FileTransform` (the LUT
//! and CDL files [`super::ocio::parse_file`] reads; `interpolation`, `ccc_id`),
//! `ExponentTransform` (`value`), `LogTransform` (`base`), `LogAffineTransform` (`base`,
//! `logSideSlope`, `logSideOffset`, `linSideSlope`, `linSideOffset`), `CDLTransform` (`slope`,
//! `offset`, `power`, `sat`), `RangeTransform` (`min_in_value`, `max_in_value`,
//! `min_out_value`, `max_out_value`) and `GroupTransform` (`children`), each with
//! `direction: inverse`. Other transforms are reported by [`Config::unsupported`] and pass
//! colours through.
//!
//! The YAML reader covers what configs use: block mappings and sequences, flow `{}` / `[]`
//! collections (also spanning lines), `!<Tag>` tags, quoted scalars, `|` / `>` block scalars
//! and comments.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
use super::ocio::{Cdl, FileXform, load_file};
// ---------------------------------------------------------------- YAML subset
/// A parsed YAML node.
#[derive(Clone, Debug, PartialEq)]
pub enum Yaml {
Str(String),
Seq(Vec<Yaml>),
Map(Vec<(String, Yaml)>),
/// A `!<Tag>` node.
Tagged(String, Box<Yaml>),
Null,
}
impl Yaml {
pub fn get(&self, k: &str) -> Option<&Yaml> {
match self {
Yaml::Map(m) => m.iter().find(|(key, _)| key == k).map(|(_, v)| v),
Yaml::Tagged(_, v) => v.get(k),
_ => None,
}
}
pub fn str(&self) -> Option<&str> {
match self {
Yaml::Str(s) => Some(s),
Yaml::Tagged(_, v) => v.str(),
_ => None,
}
}
pub fn seq(&self) -> &[Yaml] {
match self {
Yaml::Seq(v) => v,
Yaml::Tagged(_, v) => v.seq(),
_ => &[],
}
}
pub fn tag(&self) -> Option<&str> {
match self {
Yaml::Tagged(t, _) => Some(t),
_ => None,
}
}
fn f(&self) -> Option<f64> {
self.str()?.trim().parse().ok()
}
fn nums(&self) -> Vec<f64> {
match self {
Yaml::Seq(v) => v.iter().filter_map(Yaml::f).collect(),
Yaml::Str(_) => self.f().into_iter().collect(),
Yaml::Tagged(_, v) => v.nums(),
_ => vec![],
}
}
}
/// Strip a trailing comment (a `#` outside quotes, at the start or after whitespace).
fn strip_comment(line: &str) -> &str {
let (mut sq, mut dq) = (false, false);
let b = line.as_bytes();
for (i, &c) in b.iter().enumerate() {
match c {
b'\'' if !dq => sq = !sq,
b'"' if !sq => dq = !dq,
b'#' if !sq && !dq && (i == 0 || b[i - 1].is_ascii_whitespace()) => return &line[..i],
_ => {}
}
}
line
}
fn unquote(s: &str) -> String {
let t = s.trim();
if t.len() >= 2 && ((t.starts_with('"') && t.ends_with('"')) || (t.starts_with('\'') && t.ends_with('\''))) {
return t[1..t.len() - 1].replace("\\\"", "\"").replace("''", "'");
}
t.to_string()
}
/// Split `key: value` at the first `:` followed by a space or the end (outside quotes/brackets).
fn split_key(s: &str) -> Option<(String, &str)> {
let b = s.as_bytes();
let (mut sq, mut dq, mut depth) = (false, false, 0i32);
for (i, &c) in b.iter().enumerate() {
match c {
b'\'' if !dq => sq = !sq,
b'"' if !sq => dq = !dq,
b'{' | b'[' if !sq && !dq => depth += 1,
b'}' | b']' if !sq && !dq => depth -= 1,
b':' if !sq && !dq && depth == 0 && (i + 1 == b.len() || b[i + 1] == b' ' || b[i + 1] == b'\t') => {
let k = s[..i].trim();
if k.is_empty() || k.starts_with('!') || k.starts_with('{') || k.starts_with('[') {
return None;
}
return Some((unquote(k), s[i + 1..].trim()));
}
_ => {}
}
}
None
}
/// Bracket balance of a flow fragment (outside quotes).
fn balance(s: &str) -> i32 {
let (mut sq, mut dq, mut d) = (false, false, 0);
for c in s.chars() {
match c {
'\'' if !dq => sq = !sq,
'"' if !sq => dq = !dq,
'{' | '[' if !sq && !dq => d += 1,
'}' | ']' if !sq && !dq => d -= 1,
_ => {}
}
}
d
}
struct Flow<'a> {
s: &'a [u8],
i: usize,
}
impl Flow<'_> {
fn ws(&mut self) {
while self.i < self.s.len() && (self.s[self.i] as char).is_whitespace() {
self.i += 1;
}
}
fn peek(&self) -> Option<u8> {
self.s.get(self.i).copied()
}
fn value(&mut self) -> Yaml {
self.ws();
match self.peek() {
Some(b'{') => {
self.i += 1;
let mut m = vec![];
loop {
self.ws();
match self.peek() {
None => break,
Some(b'}') => {
self.i += 1;
break;
}
Some(b',') => {
self.i += 1;
continue;
}
_ => {}
}
let k = self.scalar(true);
self.ws();
if self.peek() == Some(b':') {
self.i += 1;
}
let v = self.value();
m.push((unquote(&k), v));
}
Yaml::Map(m)
}
Some(b'[') => {
self.i += 1;
let mut v = vec![];
loop {
self.ws();
match self.peek() {
None => break,
Some(b']') => {
self.i += 1;
break;
}
Some(b',') => {
self.i += 1;
continue;
}
_ => v.push(self.value()),
}
}
Yaml::Seq(v)
}
Some(b'!') => {
let start = self.i;
while self.i < self.s.len() && !(self.s[self.i] as char).is_whitespace() && self.s[self.i] != b'{' && self.s[self.i] != b'[' {
self.i += 1;
}
let tag = tag_name(std::str::from_utf8(&self.s[start..self.i]).unwrap_or(""));
self.ws();
let inner = match self.peek() {
Some(b'{') | Some(b'[') => self.value(),
_ => Yaml::Null,
};
Yaml::Tagged(tag, Box::new(inner))
}
_ => {
let s = self.scalar(false);
if s.is_empty() { Yaml::Null } else { Yaml::Str(unquote(&s)) }
}
}
}
/// A plain or quoted scalar, ending at `,` `}` `]` (and `:` + space for keys).
fn scalar(&mut self, key: bool) -> String {
self.ws();
let start = self.i;
if let Some(q @ (b'"' | b'\'')) = self.peek() {
self.i += 1;
while self.i < self.s.len() && self.s[self.i] != q {
self.i += 1;
}
self.i = (self.i + 1).min(self.s.len());
return String::from_utf8_lossy(&self.s[start..self.i]).into_owned();
}
while self.i < self.s.len() {
let c = self.s[self.i];
if c == b',' || c == b'}' || c == b']' {
break;
}
if key && c == b':' {
break;
}
self.i += 1;
}
String::from_utf8_lossy(&self.s[start..self.i]).trim().to_string()
}
}
fn tag_name(t: &str) -> String {
t.trim().trim_start_matches('!').trim_start_matches('<').trim_end_matches('>').to_string()
}
fn flow(s: &str) -> Yaml {
Flow { s: s.as_bytes(), i: 0 }.value()
}
struct Lines {
/// (indent, content without comment), blank lines removed.
v: Vec<(usize, String)>,
}
impl Lines {
fn new(text: &str) -> Lines {
let v = text
.lines()
.filter(|l| !l.trim_start().starts_with('%') && l.trim() != "---" && l.trim() != "...")
.map(|l| {
let c = strip_comment(l).trim_end();
(c.len() - c.trim_start().len(), c.trim_start().to_string())
})
.filter(|(_, c)| !c.is_empty())
.collect();
Lines { v }
}
/// The value of a `key:` / `- ` remainder `rest` whose children are indented more than
/// `indent` (from line `*i`).
fn value(&self, rest: &str, indent: usize, i: &mut usize) -> Yaml {
let rest = rest.trim();
if rest.is_empty() {
return match self.v.get(*i) {
// A sequence may sit at the key's own indent.
Some((ind, c)) if *ind > indent || (*ind == indent && (c == "-" || c.starts_with("- "))) => self.block(*ind, i),
_ => Yaml::Null,
};
}
if rest == "|" || rest == ">" || rest.starts_with("|-") || rest.starts_with(">-") || rest.starts_with("|+") || rest.starts_with(">+") {
let mut out = vec![];
while let Some((ind, c)) = self.v.get(*i) {
if *ind <= indent {
break;
}
out.push(c.clone());
*i += 1;
}
return Yaml::Str(out.join(if rest.starts_with('>') { " " } else { "\n" }));
}
if rest.starts_with('!') && !rest.contains('{') && !rest.contains('[') {
let tag = tag_name(rest);
let inner = match self.v.get(*i) {
Some((ind, _)) if *ind > indent => self.block(*ind, i),
_ => Yaml::Null,
};
return Yaml::Tagged(tag, Box::new(inner));
}
if rest.starts_with('{') || rest.starts_with('[') || (rest.starts_with('!') && (rest.contains('{') || rest.contains('['))) {
// Flow collections may continue on the next lines.
let mut text = rest.to_string();
while balance(&text) > 0 && *i < self.v.len() {
text.push(' ');
text.push_str(&self.v[*i].1);
*i += 1;
}
return flow(&text);
}
Yaml::Str(unquote(rest))
}
/// A block node whose lines start at `indent`.
fn block(&self, indent: usize, i: &mut usize) -> Yaml {
let Some((_, first)) = self.v.get(*i) else { return Yaml::Null };
if first == "-" || first.starts_with("- ") {
let mut items = vec![];
while let Some((ind, c)) = self.v.get(*i) {
if *ind != indent || !(c == "-" || c.starts_with("- ")) {
break;
}
*i += 1;
let rest = c[1..].trim_start().to_string();
// `- key: value` opens a mapping whose keys are indented to the key.
if split_key(&rest).is_some() && !rest.starts_with('{') && !rest.starts_with('!') {
let inner = indent + 1 + (c.len() - 1 - c[1..].trim_start().len());
items.push(self.map_from(Some(rest), inner.max(indent + 2), i));
} else {
items.push(self.value(&rest, indent, i));
}
}
return Yaml::Seq(items);
}
if split_key(first).is_some() {
return self.map_from(None, indent, i);
}
*i += 1;
Yaml::Str(unquote(first))
}
/// A block mapping at `indent`; `first` is an entry already taken from a `- ` line.
fn map_from(&self, first: Option<String>, indent: usize, i: &mut usize) -> Yaml {
let mut m = vec![];
if let Some(f) = first
&& let Some((k, rest)) = split_key(&f)
{
let v = self.value(rest, indent, i);
m.push((k, v));
}
while let Some((ind, c)) = self.v.get(*i) {
if *ind != indent {
if *ind < indent {
break;
}
// Over-indented stray line: skip.
*i += 1;
continue;
}
let Some((k, rest)) = split_key(c) else { break };
*i += 1;
let v = self.value(rest, indent, i);
m.push((k, v));
}
Yaml::Map(m)
}
}
/// Parse a YAML document (the subset described in the module docs).
pub fn parse_yaml(text: &str) -> Yaml {
let l = Lines::new(text);
let mut i = 0;
let indent = l.v.first().map_or(0, |(ind, _)| *ind);
l.block(indent, &mut i)
}
// ---------------------------------------------------------------- transforms
/// A colour transform of a config.
#[derive(Clone, Debug)]
pub enum Xf {
/// 4×4 row-major matrix (RGB of the first three rows/columns) plus offset.
Matrix {
m: [f64; 16],
offset: [f64; 4],
},
File {
src: String,
xf: Option<Arc<FileXform>>,
interp: u32,
ccc: String,
},
Exponent([f64; 3]),
Log {
base: f64,
},
LogAffine {
base: f64,
log_slope: [f64; 3],
log_offset: [f64; 3],
lin_slope: [f64; 3],
lin_offset: [f64; 3],
},
Cdl(Cdl),
Range {
min_in: f64,
max_in: f64,
min_out: f64,
max_out: f64,
},
Group(Vec<Xf>),
Inverse(Box<Xf>),
/// An unsupported transform (by tag): identity.
Unsupported(String),
}
fn vec3_of(y: Option<&Yaml>, d: f64) -> [f64; 3] {
let v = y.map(Yaml::nums).unwrap_or_default();
match v.len() {
0 => [d; 3],
1 | 2 => [v[0]; 3],
_ => [v[0], v[1], v[2]],
}
}
fn mat_apply(m: &[f64; 16], off: &[f64; 4], c: [f64; 3]) -> [f64; 3] {
[0, 1, 2].map(|r| m[r * 4] * c[0] + m[r * 4 + 1] * c[1] + m[r * 4 + 2] * c[2] + off[r])
}
fn mat3_inverse(m: &[f64; 16]) -> Option<[[f64; 3]; 3]> {
let a = [[m[0], m[1], m[2]], [m[4], m[5], m[6]], [m[8], m[9], m[10]]];
let det = a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1]) - a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0])
+ a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]);
(det.abs() > 1e-12).then(|| effectcraft_color::space::invert(&a))
}
fn log_base(v: f64, base: f64) -> f64 {
v.max(f64::MIN_POSITIVE).ln() / base.ln()
}
impl Xf {
fn from_yaml(y: &Yaml, dir: &Path, search: &[String]) -> Xf {
let tag = y.tag().unwrap_or("");
let body = match y {
Yaml::Tagged(_, b) => b.as_ref(),
other => other,
};
let inverse = body.get("direction").and_then(Yaml::str).is_some_and(|d| d.eq_ignore_ascii_case("inverse"));
let xf = match tag {
"MatrixTransform" => {
let v = body.get("matrix").map(Yaml::nums).unwrap_or_default();
let mut m = [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0];
if v.len() == 16 {
m.copy_from_slice(&v);
} else if v.len() == 9 {
for r in 0..3 {
for c in 0..3 {
m[r * 4 + c] = v[r * 3 + c];
}
}
}
let o = body.get("offset").map(Yaml::nums).unwrap_or_default();
let mut offset = [0.0; 4];
for (k, v) in o.iter().take(4).enumerate() {
offset[k] = *v;
}
Xf::Matrix { m, offset }
}
"FileTransform" => {
let src = body.get("src").and_then(Yaml::str).unwrap_or("").to_string();
let interp = match body.get("interpolation").and_then(Yaml::str).unwrap_or("linear").to_ascii_lowercase().as_str() {
"nearest" => 0,
"tetrahedral" | "best" => 2,
_ => 1,
};
let ccc = body.get("cccid").or_else(|| body.get("ccc_id")).and_then(Yaml::str).unwrap_or("").to_string();
let xf = resolve(&src, dir, search).and_then(|p| load_file(&p.to_string_lossy()));
Xf::File { src, xf, interp, ccc }
}
"ExponentTransform" => Xf::Exponent(vec3_of(body.get("value"), 1.0)),
"LogTransform" => Xf::Log { base: body.get("base").and_then(Yaml::f).unwrap_or(2.0) },
"LogAffineTransform" => Xf::LogAffine {
base: body.get("base").and_then(Yaml::f).unwrap_or(2.0),
log_slope: vec3_of(body.get("logSideSlope"), 1.0),
log_offset: vec3_of(body.get("logSideOffset"), 0.0),
lin_slope: vec3_of(body.get("linSideSlope"), 1.0),
lin_offset: vec3_of(body.get("linSideOffset"), 0.0),
},
"CDLTransform" => Xf::Cdl(Cdl {
slope: vec3_of(body.get("slope"), 1.0),
offset: vec3_of(body.get("offset"), 0.0),
power: vec3_of(body.get("power"), 1.0),
sat: body.get("sat").and_then(Yaml::f).unwrap_or(1.0),
}),
"RangeTransform" => Xf::Range {
min_in: body.get("min_in_value").and_then(Yaml::f).unwrap_or(0.0),
max_in: body.get("max_in_value").and_then(Yaml::f).unwrap_or(1.0),
min_out: body.get("min_out_value").and_then(Yaml::f).unwrap_or(0.0),
max_out: body.get("max_out_value").and_then(Yaml::f).unwrap_or(1.0),
},
"GroupTransform" => Xf::Group(body.get("children").map(|c| c.seq().iter().map(|t| Xf::from_yaml(t, dir, search)).collect()).unwrap_or_default()),
other => Xf::Unsupported(other.to_string()),
};
if inverse { Xf::Inverse(Box::new(xf)) } else { xf }
}
/// Apply forward (or inverse) to linear-or-encoded RGB.
pub fn apply(&self, c: [f64; 3], inverse: bool) -> [f64; 3] {
match self {
Xf::Inverse(x) => x.apply(c, !inverse),
Xf::Matrix { m, offset } => {
if !inverse {
return mat_apply(m, offset, c);
}
match mat3_inverse(m) {
Some(inv) => {
let d = [c[0] - offset[0], c[1] - offset[1], c[2] - offset[2]];
effectcraft_color::space::mul_vec(&inv, d)
}
None => c,
}
}
Xf::File { xf, interp, ccc, .. } => match xf {
Some(x) => x.apply(c.map(|v| v as f32), *interp, inverse, ccc).map(|v| v as f64),
None => c,
},
Xf::Exponent(e) => [0, 1, 2].map(|k| {
let p = if inverse { 1.0 / e[k].max(1e-9) } else { e[k] };
c[k].max(0.0).powf(p)
}),
Xf::Log { base } => {
if inverse {
c.map(|v| base.powf(v))
} else {
c.map(|v| log_base(v, *base))
}
}
Xf::LogAffine { base, log_slope, log_offset, lin_slope, lin_offset } => [0, 1, 2].map(|k| {
if inverse {
(base.powf((c[k] - log_offset[k]) / log_slope[k]) - lin_offset[k]) / lin_slope[k]
} else {
log_slope[k] * log_base(lin_slope[k] * c[k] + lin_offset[k], *base) + log_offset[k]
}
}),
Xf::Cdl(cdl) => {
if inverse {
cdl.invert(c, true)
} else {
cdl.apply(c, true)
}
}
Xf::Range { min_in, max_in, min_out, max_out } => {
let (a0, a1, b0, b1) = if inverse { (*min_out, *max_out, *min_in, *max_in) } else { (*min_in, *max_in, *min_out, *max_out) };
let k = if (a1 - a0).abs() > 1e-12 { (b1 - b0) / (a1 - a0) } else { 0.0 };
c.map(|v| b0 + (v - a0) * k)
}
Xf::Group(list) => {
let mut v = c;
if inverse {
for x in list.iter().rev() {
v = x.apply(v, true);
}
} else {
for x in list {
v = x.apply(v, false);
}
}
v
}
Xf::Unsupported(_) => c,
}
}
fn unsupported(&self, out: &mut Vec<String>) {
match self {
Xf::Unsupported(t) => out.push(t.clone()),
Xf::Inverse(x) => x.unsupported(out),
Xf::Group(v) => v.iter().for_each(|x| x.unsupported(out)),
Xf::File { src, xf: None, .. } => out.push(format!("FileTransform (missing `{src}`)")),
_ => {}
}
}
}
/// Find a FileTransform source on the search path (relative to the config's folder).
fn resolve(src: &str, dir: &Path, search: &[String]) -> Option<PathBuf> {
let p = Path::new(src);
if p.is_absolute() {
return p.exists().then(|| p.to_path_buf());
}
let mut dirs: Vec<PathBuf> = search.iter().map(|s| dir.join(s)).collect();
dirs.push(dir.to_path_buf());
dirs.into_iter().map(|d| d.join(src)).find(|c| c.exists())
}
// ---------------------------------------------------------------- the config
/// A colour space of a config.
#[derive(Clone, Debug)]
pub struct ConfigSpace {
pub name: String,
pub aliases: Vec<String>,
pub family: String,
pub is_data: bool,
pub to_ref: Option<Xf>,
pub from_ref: Option<Xf>,
}
impl ConfigSpace {
fn to_reference(&self, c: [f64; 3]) -> [f64; 3] {
match (&self.to_ref, &self.from_ref) {
(Some(x), _) => x.apply(c, false),
(None, Some(x)) => x.apply(c, true),
_ => c,
}
}
fn reference_to(&self, c: [f64; 3]) -> [f64; 3] {
match (&self.from_ref, &self.to_ref) {
(Some(x), _) => x.apply(c, false),
(None, Some(x)) => x.apply(c, true),
_ => c,
}
}
}
/// A parsed `.ocio` configuration.
#[derive(Clone, Debug, Default)]
pub struct Config {
pub name: String,
pub search_path: Vec<String>,
pub roles: Vec<(String, String)>,
pub spaces: Vec<ConfigSpace>,
/// Display → [(view, colour space)].
pub displays: Vec<(String, Vec<(String, String)>)>,
}
impl Config {
/// Parse config text; FileTransform sources resolve against `dir`.
pub fn parse(text: &str, dir: &Path) -> Result<Config, String> {
let y = parse_yaml(text);
if !matches!(y, Yaml::Map(_)) {
return Err("not an OCIO config (expected a YAML mapping)".into());
}
let search_path: Vec<String> = match y.get("search_path") {
Some(Yaml::Seq(v)) => v.iter().filter_map(|s| s.str().map(str::to_string)).collect(),
Some(s) => s.str().map(|s| s.split(':').map(|p| p.trim().to_string()).filter(|p| !p.is_empty()).collect()).unwrap_or_default(),
None => vec![],
};
let roles = match y.get("roles") {
Some(Yaml::Map(m)) => m.iter().filter_map(|(k, v)| v.str().map(|s| (k.clone(), s.to_string()))).collect(),
_ => vec![],
};
let mut spaces = vec![];
for list in ["colorspaces", "display_colorspaces"] {
for cs in y.get(list).map(Yaml::seq).unwrap_or(&[]) {
let Some(name) = cs.get("name").and_then(Yaml::str) else { continue };
let xf = |keys: &[&str]| keys.iter().find_map(|k| cs.get(k)).filter(|v| !matches!(v, Yaml::Null)).map(|v| Xf::from_yaml(v, dir, &search_path));
spaces.push(ConfigSpace {
name: name.to_string(),
aliases: cs.get("aliases").map(|a| a.seq().iter().filter_map(|s| s.str().map(str::to_string)).collect()).unwrap_or_default(),
family: cs.get("family").and_then(Yaml::str).unwrap_or("").to_string(),
is_data: cs.get("isdata").and_then(Yaml::str).is_some_and(|v| v == "true"),
to_ref: xf(&["to_reference", "to_scene_reference", "to_display_reference"]),
from_ref: xf(&["from_reference", "from_scene_reference", "from_display_reference"]),
});
}
}
if spaces.is_empty() {
return Err("the config has no colorspaces".into());
}
let displays = match y.get("displays") {
Some(Yaml::Map(m)) => m
.iter()
.map(|(d, views)| {
let v = views.seq().iter().filter_map(|v| Some((v.get("name")?.str()?.to_string(), v.get("colorspace")?.str()?.to_string()))).collect();
(d.clone(), v)
})
.collect(),
_ => vec![],
};
Ok(Config { name: y.get("name").and_then(Yaml::str).unwrap_or("").to_string(), search_path, roles, spaces, displays })
}
pub fn space_names(&self) -> Vec<&str> {
self.spaces.iter().map(|s| s.name.as_str()).collect()
}
/// A colour space by name, alias or role (case-insensitive).
pub fn space(&self, name: &str) -> Option<&ConfigSpace> {
let n = name.trim();
let by = |n: &str| self.spaces.iter().find(|s| s.name.eq_ignore_ascii_case(n) || s.aliases.iter().any(|a| a.eq_ignore_ascii_case(n)));
by(n).or_else(|| self.roles.iter().find(|(r, _)| r.eq_ignore_ascii_case(n)).and_then(|(_, s)| by(s)))
}
/// The colour space of a display's view.
pub fn view_space(&self, display: &str, view: &str) -> Option<&ConfigSpace> {
let (_, views) = self.displays.iter().find(|(d, _)| d.eq_ignore_ascii_case(display.trim())).or(self.displays.first())?;
let (_, cs) = views.iter().find(|(v, _)| v.eq_ignore_ascii_case(view.trim())).or(views.first())?;
self.space(cs)
}
/// Convert one colour from space `a` to space `b` (through the reference space; data
/// spaces pass through).
pub fn convert(&self, a: &ConfigSpace, b: &ConfigSpace, c: [f64; 3]) -> [f64; 3] {
if a.is_data || b.is_data || a.name == b.name {
return c;
}
b.reference_to(a.to_reference(c))
}
/// Transforms this module can't apply (by tag), for warnings.
pub fn unsupported(&self) -> Vec<String> {
let mut out = vec![];
for s in &self.spaces {
for x in [&s.to_ref, &s.from_ref].into_iter().flatten() {
x.unsupported(&mut out);
}
}
out.sort();
out.dedup();
out
}
}
type ConfigCache = Mutex<HashMap<String, Option<Arc<Config>>>>;
/// Load (and cache) a config from a `.ocio` path or the config text itself.
pub fn load_config(src: &str) -> Option<Arc<Config>> {
let t = src.trim();
if t.is_empty() {
return None;
}
static CACHE: OnceLock<ConfigCache> = OnceLock::new();
let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
if let Ok(m) = cache.lock()
&& let Some(v) = m.get(src)
{
return v.clone();
}
let parsed = if src.contains('\n') {
Config::parse(src, Path::new("."))
} else {
let p = Path::new(t);
std::fs::read_to_string(p).map_err(|e| e.to_string()).and_then(|text| Config::parse(&text, p.parent().unwrap_or(Path::new("."))))
}
.ok()
.map(Arc::new);
if let Ok(mut m) = cache.lock() {
m.insert(src.to_string(), parsed.clone());
}
parsed
}
#[cfg(test)]
mod tests {
use super::*;
const CONFIG: &str = r#"
ocio_profile_version: 2
name: Test Config # a comment
search_path: "luts:other"
roles:
scene_linear: lin
color_timing: "log2"
displays:
sRGB:
- !<View> {name: Standard, colorspace: gamma22}
- !<View> {name: Raw, colorspace: raw}
colorspaces:
- !<ColorSpace>
name: lin
family: ""
description: |
The reference.
Linear.
isdata: false
- !<ColorSpace>
name: gamma22
aliases: [g22, "gamma 2.2"]
to_scene_reference: !<ExponentTransform> {value: [2.2, 2.2, 2.2, 1]}
- !<ColorSpace>
name: half
from_reference: !<MatrixTransform> {matrix: [0.5, 0, 0, 0,
0, 0.5, 0, 0,
0, 0, 0.5, 0,
0, 0, 0, 1], offset: [0.1, 0.1, 0.1, 0]}
- !<ColorSpace>
name: log2
from_reference: !<GroupTransform>
children:
- !<MatrixTransform> {matrix: [2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 1]}
- !<LogTransform> {base: 2}
- !<ColorSpace>
name: affine
to_reference: !<LogAffineTransform> {base: 10, logSideSlope: 0.5, logSideOffset: 0.2, linSideSlope: 2, linSideOffset: 0.01, direction: inverse}
- !<ColorSpace>
name: lut
to_reference: !<FileTransform> {src: double.cube, interpolation: linear}
- !<ColorSpace>
name: raw
isdata: true
- !<ColorSpace>
name: weird
to_reference: !<ExposureContrastTransform> {exposure: 1}
"#;
fn close(a: [f64; 3], b: [f64; 3], tol: f64) -> bool {
(0..3).all(|i| (a[i] - b[i]).abs() < tol)
}
#[test]
fn yaml_subset() {
let y = parse_yaml("a: 1\nb:\n - x\n - {k: [1, 2], s: 'q: r'}\nc: !<T> {v: 3}\nd:\n- 4\n- 5\n");
assert_eq!(y.get("a").and_then(Yaml::str), Some("1"));
assert_eq!(y.get("b").unwrap().seq().len(), 2);
assert_eq!(y.get("b").unwrap().seq()[1].get("k").unwrap().nums(), vec![1.0, 2.0]);
assert_eq!(y.get("b").unwrap().seq()[1].get("s").and_then(Yaml::str), Some("q: r"));
assert_eq!(y.get("c").unwrap().tag(), Some("T"));
assert_eq!(y.get("c").unwrap().get("v").and_then(Yaml::str), Some("3"));
assert_eq!(y.get("d").unwrap().seq().len(), 2, "a sequence at the key's indent");
}
#[test]
fn parses_spaces_roles_displays_and_converts() {
let dir = std::env::temp_dir().join(format!("ec-ocio-{}", std::process::id()));
std::fs::create_dir_all(dir.join("luts")).unwrap();
// A 1D LUT that doubles (0..1 → 0..2).
std::fs::write(dir.join("luts/double.cube"), "LUT_1D_SIZE 2\nDOMAIN_MIN 0 0 0\nDOMAIN_MAX 1 1 1\n0 0 0\n2 2 2\n").unwrap();
let c = Config::parse(CONFIG, &dir).unwrap();
assert_eq!(c.name, "Test Config");
assert_eq!(c.search_path, vec!["luts", "other"]);
assert_eq!(c.space_names(), vec!["lin", "gamma22", "half", "log2", "affine", "lut", "raw", "weird"]);
// Roles and aliases resolve.
assert_eq!(c.space("scene_linear").unwrap().name, "lin");
assert_eq!(c.space("G22").unwrap().name, "gamma22");
assert_eq!(c.space("gamma 2.2").unwrap().name, "gamma22");
assert_eq!(c.view_space("sRGB", "Standard").unwrap().name, "gamma22");
let sp = |n: &str| c.space(n).unwrap();
// gamma22 → lin: decode with the exponent.
let g = c.convert(sp("gamma22"), sp("lin"), [0.5, 0.25, 1.0]);
assert!(close(g, [0.5f64.powf(2.2), 0.25f64.powf(2.2), 1.0], 1e-9), "{g:?}");
// lin → gamma22: the inverse (only to_reference given).
let back = c.convert(sp("lin"), sp("gamma22"), g);
assert!(close(back, [0.5, 0.25, 1.0], 1e-9), "{back:?}");
// lin → half: matrix and offset; and back through the inverse matrix.
let h = c.convert(sp("lin"), sp("half"), [0.4, 0.6, 1.0]);
assert!(close(h, [0.3, 0.4, 0.6], 1e-9), "{h:?}");
assert!(close(c.convert(sp("half"), sp("lin"), h), [0.4, 0.6, 1.0], 1e-9));
// lin → log2: a group (×2 then log2): 0.5 → 0; 2 → 2.
let l = c.convert(sp("lin"), sp("log2"), [0.5, 2.0, 4.0]);
assert!(close(l, [0.0, 2.0, 3.0], 1e-9), "{l:?}");
assert!(close(c.convert(sp("log2"), sp("lin"), l), [0.5, 2.0, 4.0], 1e-9));
// LogAffine (inverse direction as to_reference) round-trips.
let a = c.convert(sp("lin"), sp("affine"), [0.18, 0.5, 1.0]);
let expect = [0.18f64, 0.5, 1.0].map(|v| 0.5 * (2.0 * v + 0.01).log10() + 0.2);
assert!(close(a, expect, 1e-9), "{a:?} vs {expect:?}");
assert!(close(c.convert(sp("affine"), sp("lin"), a), [0.18, 0.5, 1.0], 1e-9));
// FileTransform found on the search path.
let f = c.convert(sp("lut"), sp("lin"), [0.25, 0.5, 0.0]);
assert!(close(f, [0.5, 1.0, 0.0], 1e-6), "{f:?}");
// Data spaces pass through; unsupported transforms are reported.
assert_eq!(c.convert(sp("raw"), sp("gamma22"), [0.3, 0.3, 0.3]), [0.3, 0.3, 0.3]);
assert_eq!(c.unsupported(), vec!["ExposureContrastTransform".to_string()]);
// Loading from a file path (cached), and errors.
std::fs::write(dir.join("config.ocio"), CONFIG).unwrap();
let loaded = load_config(&dir.join("config.ocio").to_string_lossy()).unwrap();
assert_eq!(loaded.spaces.len(), 8);
assert!(load_config("/no/such/config.ocio").is_none());
assert!(Config::parse("just: text\n", &dir).is_err());
}
/// The OCIO Color Space and Display Transform effects with Configuration ▸ Custom.
#[test]
fn effects_use_a_custom_config() {
use effectcraft_keyframe::Value;
let cfg = "ocio_profile_version: 1\nroles:\n default: lin\ndisplays:\n Monitor:\n - !<View> {name: Video, colorspace: g2}\ncolorspaces:\n - !<ColorSpace>\n name: lin\n - !<ColorSpace>\n name: g2\n to_reference: !<ExponentTransform> {value: [2, 2, 2, 1]}\n";
let img = crate::Image::filled(2, 2, [0.5, 0.25, 1.0, 1.0]);
let fx = |id: &str, extra: &[(&str, Value)]| {
let mut v = vec![("config", Value::Enum(1)), ("configFile", Value::Str(cfg.into()))];
v.extend(extra.iter().cloned());
crate::run_fx(id, &v, img.clone(), 0.0, crate::EffectEnv::default()).img.data[0]
};
let o = fx("ec.color.ociocolorspace", &[("sourceName", Value::Str("g2".into())), ("destinationName", Value::Str("default".into()))]);
assert!((o[0] - 0.25).abs() < 1e-6 && (o[1] - 0.0625).abs() < 1e-6 && (o[2] - 1.0).abs() < 1e-6, "{o:?}");
// Inverse direction.
let o = fx(
"ec.color.ociocolorspace",
&[("sourceName", Value::Str("g2".into())), ("destinationName", Value::Str("lin".into())), ("direction", Value::Enum(1))],
);
assert!((o[0] - 0.5f32.sqrt()).abs() < 1e-6, "{o:?}");
// Display ▸ view → its colour space.
let o = fx(
"ec.color.ociodisplay",
&[("sourceName", Value::Str("lin".into())), ("displayName", Value::Str("Monitor".into())), ("viewName", Value::Str("Video".into()))],
);
assert!((o[0] - 0.5f32.sqrt()).abs() < 1e-6, "{o:?}");
// An unreadable config leaves pixels alone.
let o = crate::run_fx(
"ec.color.ociocolorspace",
&[("config", Value::Enum(1)), ("configFile", Value::Str("/missing.ocio".into()))],
img.clone(),
0.0,
crate::EffectEnv::default(),
);
assert_eq!(o.img.data, img.data);
}
}

106
crates/effects/src/psim.rs Normal file
View File

@ -0,0 +1,106 @@
//! Pluggable particle simulation (GPU particles, M12.7).
//!
//! The stepped particle systems (CC Particle World, CC Particle Systems II and Particle
//! Playground's Cannon without interacting forces) evolve every particle independently: births
//! come from a rate (the same `carry` arithmetic as the CPU step), and each particle integrates
//! its own motion with the same fixed step. An accelerator ([`ParticleSim`], reached through
//! [`crate::EffectHost::particles`]) can therefore simulate all of them in parallel and return
//! the state the CPU's `SimCache` would reach. The CPU stays the oracle: anything the backend
//! declines (interacting forces, caps that would drop births) simulates on the CPU.
/// CC Particle World / CC Particle Systems II physics (see `sim::Phys`), with the per-step and
/// per-spawn constants already evaluated on the CPU (bit-identical to the CPU's).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct EngineDesc {
pub life: f32,
pub producer: [f32; 3],
pub radius: [f32; 3],
/// Animation (0 Explosive … 9 Fractal Uni).
pub anim: u32,
pub speed: f32,
pub gravity: [f32; 3],
/// Normalised direction axis.
pub axis: [f32; 3],
pub extra: f32,
/// Cone Axis spread factor `|tan(extra angle / 2)|` (≤ 20).
pub cone: f32,
/// Velocity damping per step `exp(−drag · dt)`.
pub damp: f32,
pub seed: u32,
/// 2D systems keep z = 0.
pub flat: bool,
}
/// Particle Playground's Cannon with gravity only (see `sim3::Pg`).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct CannonDesc {
pub pos: [f32; 2],
pub barrel_angle: f32,
pub barrel_radius: f32,
pub dir_spread: f32,
pub vel: f32,
pub vel_spread: f32,
pub gravity: [f32; 2],
pub grav_spread: f32,
pub seed: u32,
/// Particles leaving `[x0, y0, x1, y1]` are removed.
pub bounds: [f32; 4],
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ParticleSystem {
Engine(EngineDesc),
Cannon(CannonDesc),
}
/// One simulation request: the state after `steps` steps of system `key`.
#[derive(Clone, Debug)]
pub struct SimRequest<'a> {
/// Identifies the system (parameters, seed); checkpoints are reused per key.
pub key: u64,
pub steps: u64,
/// Birth step of every particle id born before `steps` (ascending).
pub births: &'a [u32],
pub system: ParticleSystem,
}
/// A live particle (ascending ids, as the CPU state keeps them).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SimParticle {
pub p: [f32; 3],
pub v: [f32; 3],
pub age: f32,
pub life: f32,
pub rnd: f32,
pub id: u32,
}
/// A particle simulation backend (the GPU).
pub trait ParticleSim: Send + Sync {
/// The live particles after `req.steps` steps, or `None` (simulate on the CPU).
fn simulate(&self, req: &SimRequest) -> Option<Vec<SimParticle>>;
}
/// Birth step of every particle born in the first `steps` steps at `rate` particles per second
/// (the CPU step's `carry` arithmetic, exactly). `None` when more than `cap` would be born (the
/// CPU drops births at its live-particle cap, which an independent simulation can't know).
pub fn births(rate: f64, steps: u64, cap: usize) -> Option<Vec<u32>> {
let per = rate / SPS;
if per * steps as f64 > cap as f64 + 2.0 {
return None;
}
let mut out = Vec::new();
let mut carry = 0.0f64;
for i in 0..steps {
carry += per;
let n = carry.floor() as u32;
carry -= n as f64;
for _ in 0..n {
out.push(i as u32);
}
}
(out.len() <= cap).then_some(out)
}
/// Steps per second of the stepped simulations.
pub const SPS: f64 = crate::sim::SPS;

View File

@ -818,6 +818,60 @@ impl Phys {
}
}
impl Phys {
/// The backend description (per-step and per-spawn constants evaluated here, in f32).
pub(crate) fn desc(&self) -> crate::psim::EngineDesc {
let mut drag = self.resistance;
if self.anim == 3 {
drag += 3.0 * self.extra.max(0.1);
}
let dt = (1.0 / SPS) as f32;
crate::psim::EngineDesc {
life: self.life,
producer: self.producer,
radius: self.radius,
anim: self.anim,
speed: self.speed,
gravity: self.gravity,
axis: norm3(self.axis),
extra: self.extra,
cone: (self.extra_angle.to_radians() * 0.5).tan().abs().min(20.0),
damp: (-drag * dt).exp(),
seed: self.seed,
flat: self.flat,
}
}
}
/// The state at layer time `ctx.time` from the host's particle backend (GPU particles), or
/// `None` (simulate on the CPU).
fn accelerated(ctx: &EffectCtx, key: u64, phys: &Phys) -> Option<std::sync::Arc<PState>> {
let backend = ctx.env.host?.particles()?;
let steps = steps_at(ctx.time);
let births = crate::psim::births(phys.rate, steps, phys.max)?;
let req = crate::psim::SimRequest { key, steps, births: &births, system: crate::psim::ParticleSystem::Engine(phys.desc()) };
let parts = backend.simulate(&req)?;
Some(std::sync::Arc::new(PState {
parts: parts.into_iter().map(|q| Particle { p: q.p, v: q.v, age: q.age, life: q.life, rnd: q.rnd, id: q.id }).collect(),
carry: 0.0,
next_id: births.len() as u32,
}))
}
/// The live particles of a stepped particle effect (`ec.sim.ccparticleworld`,
/// `ec.sim.ccparticlesystems2`) at `ctx.time`: from the host's particle backend when it has
/// one (GPU particles), else from the CPU simulation. For comparing the two.
pub fn particle_state(id: &str, ctx: &EffectCtx) -> Option<Vec<crate::psim::SimParticle>> {
let (phys, cache, salt) = match id {
"ec.sim.ccparticleworld" => (pw_phys(ctx), &PW_CACHE, 1),
"ec.sim.ccparticlesystems2" => (ps2_phys(ctx), &PS2_CACHE, 2),
_ => return None,
};
let key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, salt);
let st = accelerated(ctx, key, &phys).unwrap_or_else(|| simulate(cache, key, ctx.time, &phys));
Some(st.parts.iter().map(|q| crate::psim::SimParticle { p: q.p, v: q.v, age: q.age, life: q.life, rnd: q.rnd, id: q.id }).collect())
}
/// Run a particle system to layer time `t` through `cache`.
pub(crate) fn simulate(cache: &SimCache<PState>, key: u64, t: f64, phys: &Phys) -> std::sync::Arc<PState> {
cache.run(key, steps_at(t), PState::default, |st, i| phys.step(st, i))
@ -859,10 +913,10 @@ fn type_sprite(kind: u32, x: f32, y: f32, r: f32, c: [f32; 4], vx: f32, vy: f32,
static PW_CACHE: SimCache<PState> = SimCache::new(6);
fn particle_world(ctx: &EffectCtx, mut b: Buf) -> Buf {
/// CC Particle World's physics.
fn pw_phys(ctx: &EffectCtx) -> Phys {
let pr = ctx.params;
let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32);
let phys = Phys {
Phys {
rate: pr.f("birthRate").max(0.0) * 120.0,
life: pr.f("longevity").max(0.0) as f32,
producer: [pr.f("producer/producerX") as f32, pr.f("producer/producerY") as f32, pr.f("producer/producerZ") as f32],
@ -882,11 +936,18 @@ fn particle_world(ctx: &EffectCtx, mut b: Buf) -> Buf {
seed: (pr.f("extras/randomSeed") as u32).wrapping_mul(0x9e3779b9) ^ ctx.seed,
max: 40_000,
flat: false,
};
}
}
fn particle_world(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pr = ctx.params;
let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32);
let phys = pw_phys(ctx);
if phys.rate <= 0.0 && ctx.time <= 0.0 {
return b;
}
let st = simulate(&PW_CACHE, params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 1), ctx.time, &phys);
let key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 1);
let st = accelerated(ctx, key, &phys).unwrap_or_else(|| simulate(&PW_CACHE, key, ctx.time, &phys));
let kind = pr.e("particle/particleType");
let birth = pr.color("particle/birthColor");
let death = pr.color("particle/deathColor");
@ -936,13 +997,14 @@ fn particle_world(ctx: &EffectCtx, mut b: Buf) -> Buf {
static PS2_CACHE: SimCache<PState> = SimCache::new(6);
fn particle_systems2(ctx: &EffectCtx, mut b: Buf) -> Buf {
/// CC Particle Systems II's physics.
fn ps2_phys(ctx: &EffectCtx) -> Phys {
let pr = ctx.params;
let lh = ctx.layer_size[1] as f32;
let unit = lh.max(1.0);
let pos = pr.v2("producer/position");
let dir = (pr.f("physics/direction") as f32).to_radians();
let phys = Phys {
Phys {
rate: pr.f("birthRate").max(0.0) * 60.0,
life: pr.f("longevity").max(0.0) as f32,
producer: [pos[0] as f32 / unit, pos[1] as f32 / unit, 0.0],
@ -958,8 +1020,16 @@ fn particle_systems2(ctx: &EffectCtx, mut b: Buf) -> Buf {
seed: (pr.f("randomSeed") as u32).wrapping_mul(0x85ebca6b) ^ ctx.seed,
max: 40_000,
flat: true,
};
let st = simulate(&PS2_CACHE, params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 2), ctx.time, &phys);
}
}
fn particle_systems2(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pr = ctx.params;
let lh = ctx.layer_size[1] as f32;
let unit = lh.max(1.0);
let phys = ps2_phys(ctx);
let key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 2);
let st = accelerated(ctx, key, &phys).unwrap_or_else(|| simulate(&PS2_CACHE, key, ctx.time, &phys));
let kind = pr.e("particle/particleType");
let birth = pr.color("particle/birthColor");
let death = pr.color("particle/deathColor");

View File

@ -231,6 +231,38 @@ impl Pg {
static PG_CACHE: SimCache<PgState> = SimCache::new(6);
/// Particle Playground cannons with gravity only, simulated by the host's particle backend.
fn pg_accelerated(ctx: &EffectCtx, key: u64, pg: &Pg) -> Option<std::sync::Arc<PgState>> {
let backend = ctx.env.host?.particles()?;
let steps = steps_at(ctx.time);
let births = crate::psim::births(pg.rate, steps, 60_000)?;
let desc = crate::psim::CannonDesc {
pos: pg.pos,
barrel_angle: pg.barrel_angle,
barrel_radius: pg.barrel_radius,
dir_spread: pg.dir_spread,
vel: pg.vel,
vel_spread: pg.vel_spread,
gravity: pg.gravity,
grav_spread: pg.grav_spread,
seed: pg.seed,
bounds: pg.bounds,
};
let req = crate::psim::SimRequest { key, steps, births: &births, system: crate::psim::ParticleSystem::Cannon(desc) };
let parts = backend.simulate(&req)?;
Some(std::sync::Arc::new(PgState {
parts: parts
.into_iter()
.map(|q| {
let born = pg.spawn(q.id);
PgParticle { p: [q.p[0], q.p[1]], v: [q.v[0], q.v[1]], ..born }
})
.collect(),
carry: 0.0,
next_id: births.len() as u32,
}))
}
fn image_key(img: &Image) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
@ -244,14 +276,15 @@ fn image_key(img: &Image) -> u64 {
h.finish()
}
fn particle_playground(ctx: &EffectCtx, mut b: Buf) -> Buf {
/// Particle Playground's producers and forces.
fn pg_of(ctx: &EffectCtx) -> Pg {
let pr = ctx.params;
let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32);
let grav_dir = (pr.f("gravity/gravityDirection") as f32).to_radians();
let gf = pr.f("gravity/gravityForce") as f32;
let wall = (pr.f("wall/wallBoundary").round() as usize).checked_sub(1).and_then(|i| ctx.env.masks.get(i)).map(|m| m.points.clone());
let mapper = if pr.b("mapperEnabled") { ctx.layer_param("persistentPropertyMapper/useLayerAsMap", true).map(|lp| MapImg::from(ctx, lp)) } else { None };
let pg = Pg {
Pg {
cannon: pr.b("cannonEnabled"),
pos: { pr.v2("cannon/cannonPosition").map(|v| v as f32) },
barrel_angle: pr.f("cannon/barrelAngle") as f32,
@ -275,7 +308,34 @@ fn particle_playground(ctx: &EffectCtx, mut b: Buf) -> Buf {
],
seed: (pr.f("randomSeed") as u32).wrapping_mul(0x9e37_79b9) ^ ctx.seed,
bounds: [-lw * 2.0, -lh * 2.0, lw * 3.0, lh * 3.0],
};
}
}
/// The live particles of a Particle Playground whose cannon is the only producer and force
/// besides gravity, at `ctx.time`: from the host's particle backend when it has one (GPU
/// particles), else from the CPU simulation (`None` for other set-ups). For comparing the two.
pub fn playground_state(ctx: &EffectCtx) -> Option<Vec<crate::psim::SimParticle>> {
let pg = pg_of(ctx);
let pr = ctx.params;
let grid = pr.b("gridEnabled") && pr.f("grid/particlesAcross").round() > 0.0 && pr.f("grid/particlesDown").round() > 0.0;
let exploder = pr.b("exploderEnabled") && pr.get("layerExploder/explodeLayer").and_then(Value::as_layer).is_some();
if !pg.cannon || grid || exploder || (pg.repel != 0.0 && pg.repel_radius > 0.0) || pg.wall.is_some() || pg.mapper.is_some() {
return None;
}
let key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 0x7067);
let st = pg_accelerated(ctx, key, &pg).unwrap_or_else(|| PG_CACHE.run(key, steps_at(ctx.time), PgState::default, |st, _| pg.step(st)));
Some(
st.parts
.iter()
.map(|q| crate::psim::SimParticle { p: [q.p[0], q.p[1], 0.0], v: [q.v[0], q.v[1], 0.0], age: 0.0, life: 0.0, rnd: 0.0, id: q.id })
.collect(),
)
}
fn particle_playground(ctx: &EffectCtx, mut b: Buf) -> Buf {
let pr = ctx.params;
let (lw, lh) = (ctx.layer_size[0] as f32, ctx.layer_size[1] as f32);
let pg = pg_of(ctx);
// Initial particles: Grid and Layer Exploder.
let grid_on = pr.b("gridEnabled");
let across = pr.f("grid/particlesAcross").max(0.0).round() as u32;
@ -347,7 +407,15 @@ fn particle_playground(ctx: &EffectCtx, mut b: Buf) -> Buf {
}
st
};
let st = PG_CACHE.run(key, steps_at(ctx.time), init, |st, _| pg.step(st));
// A cannon with gravity only: every particle moves on its own (GPU particles).
let independent = pg.cannon
&& !(grid_on && across > 0 && down > 0)
&& exploder.is_none()
&& !(pg.repel != 0.0 && pg.repel_radius > 0.0)
&& pg.wall.is_none()
&& pg.mapper.is_none();
let gpu = if independent { pg_accelerated(ctx, key, &pg) } else { None };
let st = gpu.unwrap_or_else(|| PG_CACHE.run(key, steps_at(ctx.time), init, |st, _| pg.step(st)));
let s = b.scale as f32;
let sprites: Vec<Sprite> = st
.parts

View File

@ -30,7 +30,7 @@ fn scale_px(px: &mut Px, k: f32) {
/// Resample `other` into `b`'s pixel grid with placement `mode` (Gradient Wipe's Gradient
/// Placement, Texturize's Texture Placement): 0 tile from the top-left, 1 centre once,
/// 2 stretch to fit.
pub(crate) fn place_layer(ctx: &EffectCtx, b: &Buf, other: &crate::LayerPixels, mode: u32) -> effectcraft_raster::Image {
pub fn place_layer(ctx: &EffectCtx, b: &Buf, other: &crate::LayerPixels, mode: u32) -> effectcraft_raster::Image {
let (ls, os) = (ctx.layer_size, other.size);
let inv = 1.0 / b.scale.max(1e-9);
crate::util::gen_image(b.img.width, b.img.height, |x, y| {

View File

@ -258,15 +258,52 @@ fn project_settings(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(l) = p.get("blendLinear").or_else(|| p.get("blendColorsUsing1Gamma")).and_then(Value::as_bool) {
proj.settings.blend_linear = l;
}
use effectcraft_project::{ColorEngine, ColorSpace, HdrMode};
let cmd = "file.projectSettings";
if let Some(e) = str_p(p, "colorEngine") {
let engine = match e.to_ascii_lowercase().replace([' ', '-', '_'], "").as_str() {
"adobe" | "adobemanaged" | "builtin" => ColorEngine::Adobe,
"ocio" | "ociomanaged" => ColorEngine::Ocio,
_ => return Err(bad(cmd, format!("colorEngine: adobe|ocio, not `{e}`"))),
};
if engine == ColorEngine::Ocio && proj.settings.color_engine != ColorEngine::Ocio {
// The OCIO built-in config works in ACES and renders the display through its
// tone-mapped view.
if !proj.settings.working_space.is_some_and(|w| w.is_linear()) {
proj.settings.working_space = Some(ColorSpace::AcesCg);
}
if proj.settings.hdr == HdrMode::Clip {
proj.settings.hdr = HdrMode::ToneMap;
}
}
proj.settings.color_engine = engine;
}
if let Some(w) = p.get("workingSpace") {
let ids = ColorSpace::WORKING.iter().map(|c| c.id()).collect::<Vec<_>>().join("|");
proj.settings.working_space = match w.as_str() {
None | Some("none" | "None" | "") => None,
Some(n) => Some(
effectcraft_project::ColorSpace::parse(n)
.ok_or_else(|| bad("file.projectSettings", format!("workingSpace: none|srgb|rec709|rec2020|p3, not `{n}`")))?,
ColorSpace::parse(n).filter(|c| ColorSpace::WORKING.contains(c)).ok_or_else(|| bad(cmd, format!("workingSpace: none|{ids}, not `{n}`")))?,
),
};
}
if proj.settings.color_engine == ColorEngine::Ocio && !proj.settings.working_space.is_some_and(|w| w.is_linear()) {
return Err(bad(cmd, "the OCIO built-in config's working spaces are acescg and aces2065"));
}
if let Some(h) = str_p(p, "hdr") {
proj.settings.hdr = match h.to_ascii_lowercase().replace([' ', '-', '_'], "").as_str() {
"clip" | "off" | "none" => HdrMode::Clip,
"compand" => HdrMode::Compand,
"tonemap" | "tonemapped" => HdrMode::ToneMap,
_ => return Err(bad(cmd, format!("hdr: clip|compand|toneMap, not `{h}`"))),
};
}
if let Some(o) = p.get("outputSpace") {
proj.settings.output_space = match o.as_str() {
None | Some("none" | "None" | "" | "default") => None,
Some(n) => Some(ColorSpace::parse(n).ok_or_else(|| bad(cmd, format!("outputSpace: srgb|rec709|rec2020|p3|rec2100pq|rec2100hlg, not `{n}`")))?),
};
}
if let Some(r) = p.get("renderer").or_else(|| p.get("gpuAcceleration")) {
proj.settings.gpu_acceleration = match r {
Value::Bool(b) => *b,
@ -279,8 +316,8 @@ fn project_settings(s: &mut Session, p: &Value) -> Result<Value> {
};
}
if let Some(t) = str_p(p, "timeDisplay") {
proj.settings.time_display =
if t.eq_ignore_ascii_case("frames") { effectcraft_project::TimeDisplayStyle::Frames } else { effectcraft_project::TimeDisplayStyle::Timecode };
proj.settings.time_display = effectcraft_project::TimeDisplayStyle::parse(t)
.ok_or_else(|| bad("file.projectSettings", format!("timeDisplay: timecode|frames|feet35|feet16, not `{t}`")))?;
}
Ok(())
})?;
@ -353,7 +390,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Project Settings...",
["File"],
Some("Cmd+Alt+Shift+K"),
"{bitDepth?: 8|16|32, workingSpace?: none|srgb|rec709|rec2020|p3, linearize?, blendLinear?, renderer?: gpu|software, timeDisplay?: timecode|frames}",
"{bitDepth?: 8|16|32, colorEngine?: adobe|ocio, workingSpace?: none|srgb|rec709|rec2020|p3|acescg|aces2065, linearize?, blendLinear?, hdr?: clip|compand|toneMap, outputSpace?: srgb|rec709|rec2020|p3|rec2100pq|rec2100hlg, renderer?: gpu|software, timeDisplay?: timecode|frames|feet35|feet16}",
always,
project_settings
),

View File

@ -43,6 +43,7 @@ mod proxy;
pub mod puppet;
mod query;
mod render_queue;
pub(crate) mod rig3d;
pub mod roto_cmds;
mod scene_detect;
mod settings;
@ -52,7 +53,7 @@ mod styles;
mod text_anim;
pub mod text_edit;
mod three_d;
mod time;
pub mod time;
mod track;
mod view;
pub mod viewer_cmds;
@ -120,6 +121,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(styles::specs());
v.extend(three_d::specs());
v.extend(model3d::specs());
v.extend(rig3d::specs());
v.extend(text_anim::specs());
v.extend(text_edit::specs());
v.extend(layer_time::specs());

View File

@ -0,0 +1,614 @@
//! Camera and light rigs: Layer ▸ Camera ▸ Create Stereo 3D Rig / Create Orbit Null / Create
//! Cameras from 3D Model, Layer ▸ Light ▸ Create Lights from 3D Model / Control Light with
//! Camera / Create Environment Light Background Layer.
use effectcraft_color::Label;
use effectcraft_effects::controls::{STEREO_CONFIGURATIONS, STEREO_CONTROLS, STEREO_CONVERGENCE};
use effectcraft_geom::{Mat4, Vec3, vec3};
use effectcraft_keyframe::Value as KV;
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{AutoOrient, Comp, Expression, ItemId, ItemKind, Layer, LayerId, LayerSource, LightKind, Project};
use effectcraft_render::EvalCtx;
use effectcraft_render::three_d::adv::scene::model_to_layer;
use effectcraft_render::three_d::camera::{self, layer_frame};
use serde_json::{Value, json};
use super::layer::insert_layer;
use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, resolve_layer};
use crate::{EngineError, Result, Session, cmd};
fn arr(v: Vec3) -> [f64; 3] {
[v.x, v.y, v.z]
}
/// Set a property's static value (clears its keyframes and expression).
fn set(l: &mut Layer, path: &str, v: KV) {
if let Some(pr) = l.props.prop_mut(path) {
pr.value = v;
pr.keys.clear();
pr.expr = None;
}
}
fn set_expr(l: &mut Layer, path: &str, text: String) {
if let Some(pr) = l.props.prop_mut(path) {
pr.expr = Some(Expression { text, enabled: true });
}
}
/// What evaluation needs from the session (cloned so edits can evaluate their own project).
struct Env {
time: effectcraft_time::Tick,
expr: Option<std::sync::Arc<dyn effectcraft_render::ExprHost>>,
footage: std::sync::Arc<dyn effectcraft_render::FootageSource>,
}
impl Env {
fn of(s: &Session, cid: ItemId) -> Env {
Env { time: s.time_of(cid), expr: s.expr.clone(), footage: s.footage.clone() }
}
fn ctx<'a>(&'a self, proj: &'a Project, cid: ItemId, comp: &'a Comp) -> EvalCtx<'a> {
EvalCtx { project: proj, comp_id: cid, comp, time: self.time, expr: self.expr.as_deref(), footage: Some(self.footage.as_ref()) }
}
}
/// The camera a rig command acts on: `layer`, else a selected camera, else the active camera.
fn camera_p(s: &Session, p: &Value, cid: ItemId, cmd: &str) -> Result<LayerId> {
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
let id = match p.get("layer").or_else(|| p.get("camera")) {
Some(v) => resolve_layer(comp, v).ok_or_else(|| bad(cmd, "no such layer"))?,
None => s
.state
.selected_layers
.iter()
.copied()
.find(|id| comp.layer(*id).is_some_and(Layer::is_camera))
.or_else(|| comp.active_camera(s.time_of(cid)).map(|l| l.id))
.ok_or_else(|| bad(cmd, "the composition has no camera (Layer ▸ New ▸ Camera)"))?,
};
if !comp.layer(id).is_some_and(Layer::is_camera) {
return Err(bad(cmd, "the layer is not a camera"));
}
Ok(id)
}
/// Distance from a camera to what it looks at: its point of interest (two-node), else its zoom.
fn focus_distance(ctx: &EvalCtx, cam: &Layer) -> f64 {
let st = camera::layer_camera(ctx, cam);
if camera::is_two_node(cam)
&& let Some(tr) = cam.transform()
{
let poi = Vec3::from(ctx.v3(cam, tr, "poi", [0.0; 3]));
let poi = camera::parent_world(ctx, cam).apply(poi);
return (poi - st.eye).length().max(1.0);
}
st.zoom
}
// ---------------------------------------------------------------- stereo 3D rig
/// Stereo rig settings (Stereo 3D Controls).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct StereoParams {
/// 0 Stereo Pair (Left & Right), 1 Center & Right, 2 Center & Left.
pub configuration: u32,
/// Interaxial separation, % of the comp width.
pub scene_depth: f64,
pub convergence: bool,
/// 0 the camera's point of interest, 1 its zoom plane.
pub convergence_of: u32,
pub z_offset: f64,
}
/// Eye offsets along the camera's x axis, in units of the interaxial separation.
pub fn eye_offsets(configuration: u32) -> [f64; 2] {
match configuration {
1 => [0.0, 1.0],
2 => [-1.0, 0.0],
_ => [-0.5, 0.5],
}
}
/// (position, point of interest) of the left (`eye` 0) or right (1) camera, from the master
/// camera's eye, axes, focus distance and zoom.
pub fn eye_camera(sp: &StereoParams, eye: usize, comp_w: f64, master: (Vec3, Vec3, Vec3), focus: f64, zoom: f64) -> (Vec3, Vec3) {
let (e, fwd, right) = master;
let d = sp.scene_depth / 100.0 * comp_w;
let pos = e + right * (eye_offsets(sp.configuration)[eye.min(1)] * d);
let poi = if sp.convergence {
let dc = if sp.convergence_of == 1 { zoom } else { focus } + sp.z_offset;
e + fwd * dc.max(1.0)
} else {
pos + fwd * focus
};
(pos, poi)
}
/// Expression snippets that rebuild [`eye_camera`] from the master camera and the controls.
fn eye_exprs(src_comp: &str, master: &str, controls: &str, two_node: bool, eye: usize) -> (String, String, String) {
let q = |s: &str| serde_json::to_string(s).unwrap_or_else(|_| "\"\"".into());
let k = eye_offsets(0)[eye.min(1)];
let k1 = eye_offsets(1)[eye.min(1)];
let k2 = eye_offsets(2)[eye.min(1)];
let head = format!(
"var src = comp({c});\nvar m = src.layer({m});\nvar fx = src.layer({x}).effect(\"Stereo 3D Controls\");\n\
var d = fx(\"Stereo Scene Depth\").value / 100 * src.width;\nvar cfg = fx(\"Configuration\").value;\n\
var k = cfg == 2 ? {k1} : (cfg == 3 ? {k2} : {k});\n\
var E = m.toWorld([0, 0, 0]);\nvar F = normalize(m.toWorldVec([0, 0, 1]));\n\
var eye = add(E, mul(normalize(m.toWorldVec([1, 0, 0])), k * d));\n",
c = q(src_comp),
m = q(master),
x = q(controls),
);
let dm = if two_node { "Math.max(length(sub(m.transform.pointOfInterest, m.transform.position)), 1)" } else { "m.cameraOption.zoom" };
let pos = format!("{head}eye;");
let poi = format!(
"{head}var dm = {dm};\nvar dc = (fx(\"Convergence Of\").value == 2 ? m.cameraOption.zoom : dm) + fx(\"Convergence Z Offset\").value;\n\
fx(\"Enable Convergence\").value ? add(E, mul(F, Math.max(dc, 1))) : add(eye, mul(F, dm));"
);
let zoom = format!("comp({}).layer({}).cameraOption.zoom;", q(src_comp), q(master));
(pos, poi, zoom)
}
fn stereo_p(p: &Value, cmd: &str) -> Result<StereoParams> {
let pick = |k: &str, opts: &[&str], ids: &[&str]| -> Result<Option<u32>> {
match p.get(k) {
None => Ok(None),
Some(Value::Number(n)) => Ok(n.as_u64().map(|v| (v as u32).min(opts.len() as u32 - 1))),
Some(Value::String(s)) => {
let n = s.to_ascii_lowercase().replace([' ', '-', '_', '&', '(', ')'], "");
let i = ids
.iter()
.position(|v| v.to_ascii_lowercase() == n)
.or_else(|| opts.iter().position(|o| o.to_ascii_lowercase().replace([' ', '-', '_', '&', '(', ')'], "") == n));
i.map(|i| Some(i as u32)).ok_or_else(|| bad(cmd, format!("{k}: {}", ids.join("|"))))
}
_ => Err(bad(cmd, format!("{k}: {}", ids.join("|")))),
}
};
Ok(StereoParams {
configuration: pick("configuration", &STEREO_CONFIGURATIONS, &["stereoPair", "centerRight", "centerLeft"])?.unwrap_or(0),
scene_depth: f_p(p, "sceneDepth").unwrap_or(3.0).clamp(0.0, 100.0),
convergence: b_p(p, "convergence").unwrap_or(false),
convergence_of: pick("convergenceOf", &STEREO_CONVERGENCE, &["poi", "zoom"])?.unwrap_or(0),
z_offset: f_p(p, "zOffset").unwrap_or(0.0),
})
}
fn unique_item_name(proj: &Project, base: &str) -> String {
let mut name = base.to_string();
let mut k = 2;
while proj.items.values().any(|i| i.name == name) {
name = format!("{base} {k}");
k += 1;
}
name
}
/// An empty comp with the settings of `src`.
fn sibling_comp(src: &Comp) -> Comp {
let mut c = src.clone();
c.layers.clear();
c.markers.clear();
c
}
fn stereo_rig(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "camera.stereoRig";
let cid = comp_id(s, p)?;
let sp = stereo_p(p, cmd)?;
let view3d = p.get("view3d").and_then(Value::as_u64).unwrap_or(5) as u32;
let src = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let src_name = s.project.item(cid).map(|i| i.name.clone()).unwrap_or_default();
let t = s.time_of(cid);
let w = src.width as f64;
let existing = src.active_camera(t).map(|l| l.id);
let env = Env::of(s, cid);
let r = s.edit("Create Stereo 3D Rig", None, |proj, st| {
// The master camera: the comp's active camera, else a new one.
let master_id = match existing {
Some(id) => id,
None => {
let name = src.unique_layer_name("Master Camera");
let l = build::layer(proj, &src, &name, LayerSource::Camera, (src.width, src.height), None);
insert_layer(proj, st, cid, l)?
}
};
let controls_name = proj.comp(cid).map(|c| c.unique_layer_name("Stereo 3D Controls")).unwrap_or_default();
let src_now = proj.comp(cid).cloned().ok_or(EngineError::NoComp)?;
let mut ctl = build::layer(proj, &src_now, &controls_name, LayerSource::Null, (src.width, src.height), None);
ctl.switches.video = false;
let spec = effectcraft_effects::find(STEREO_CONTROLS).ok_or_else(|| bad(cmd, "Stereo 3D Controls effect missing"))?;
let mut fx = effectcraft_effects::instantiate(spec, &mut Ids(&mut proj.next_id), spec.name, [100.0, 100.0]);
for (k, v) in [
("configuration", KV::Enum(sp.configuration)),
("sceneDepth", KV::Scalar(sp.scene_depth)),
("convergence", KV::Bool(sp.convergence)),
("convergenceOf", KV::Enum(sp.convergence_of)),
("zOffset", KV::Scalar(sp.z_offset)),
] {
if let Some(pr) = fx.get_mut(k) {
pr.value = v;
}
}
if let Some(g) = ctl.props.sub_mut("effects") {
g.children.push(fx.into());
}
let ctl_id = ctl.id;
let src_now = proj.comp(cid).cloned().ok_or(EngineError::NoComp)?;
let at = src_now.layers.iter().position(|l| l.id == master_id).unwrap_or(0);
proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.insert(at, ctl);
// Master camera frame at the current time.
let src_now = proj.comp(cid).cloned().ok_or(EngineError::NoComp)?;
let master = src_now.layer(master_id).cloned().ok_or(EngineError::NoComp)?;
let ctx = env.ctx(proj, cid, &src_now);
let (e, fwd, down) = layer_frame(&ctx, &master);
let right = down.cross(fwd).normalize();
let focus = focus_distance(&ctx, &master);
let zoom = camera::layer_camera(&ctx, &master).zoom;
let two = camera::is_two_node(&master);
// Left and right eye comps: the source comp collapsed, seen by an eye camera.
let mut eye_comps = vec![];
for (eye, label) in [(0usize, "Left"), (1, "Right")] {
let name = unique_item_name(proj, &format!("{src_name} {label} Eye"));
let ec = sibling_comp(&src_now);
let eid = proj.add_item(&name, Label::Sandstone, proj.item(cid).and_then(|i| i.parent), ItemKind::Comp(ec.clone().into()));
let mut nest = build::layer(proj, &ec, &src_name, LayerSource::Comp { item: cid }, (src.width, src.height), Some(src.duration));
nest.switches.three_d = true;
nest.switches.collapse = true;
let (pos, poi) = eye_camera(&sp, eye, w, (e, fwd, right), focus, zoom);
let mut cam = build::layer(proj, &ec, &format!("{label} Eye Camera"), LayerSource::Camera, (src.width, src.height), None);
set(&mut cam, "transform/position", KV::Vec3(arr(pos)));
set(&mut cam, "transform/poi", KV::Vec3(arr(poi)));
set(&mut cam, "cameraOptions/zoom", KV::Scalar(zoom));
set(&mut cam, "cameraOptions/focusDistance", KV::Scalar(focus));
let (pe, poe, ze) = eye_exprs(&src_name, &master.name, &controls_name, two, eye);
set_expr(&mut cam, "transform/position", pe);
set_expr(&mut cam, "transform/poi", poe);
set_expr(&mut cam, "cameraOptions/zoom", ze);
cam.auto_orient = AutoOrient::TowardsPointOfInterest;
let comp = proj.comp_mut(eid).ok_or(EngineError::NoComp)?;
comp.layers = vec![cam, nest];
eye_comps.push(eid);
}
// The output comp: both eyes through 3D Glasses.
let out_name = unique_item_name(proj, &format!("{src_name} Stereo 3D"));
let oc = sibling_comp(&src_now);
let oid = proj.add_item(&out_name, Label::Sandstone, proj.item(cid).and_then(|i| i.parent), ItemKind::Comp(oc.clone().into()));
let left_name = proj.item(eye_comps[0]).map(|i| i.name.clone()).unwrap_or_default();
let right_name = proj.item(eye_comps[1]).map(|i| i.name.clone()).unwrap_or_default();
let mut left = build::layer(proj, &oc, &left_name, LayerSource::Comp { item: eye_comps[0] }, (src.width, src.height), Some(src.duration));
let mut right = build::layer(proj, &oc, &right_name, LayerSource::Comp { item: eye_comps[1] }, (src.width, src.height), Some(src.duration));
right.switches.video = false;
let glasses = effectcraft_effects::find("ec.perspective.3dglasses").ok_or_else(|| bad(cmd, "3D Glasses effect missing"))?;
let mut g = effectcraft_effects::instantiate(glasses, &mut Ids(&mut proj.next_id), glasses.name, [w, src.height as f64]);
for (k, v) in [("leftView", KV::Layer(Some(left.id.0))), ("rightView", KV::Layer(Some(right.id.0))), ("view3d", KV::Enum(view3d.min(8)))] {
if let Some(pr) = g.get_mut(k) {
pr.value = v;
}
}
if let Some(fxg) = left.props.sub_mut("effects") {
fxg.children.push(g.into());
}
let (lid, rid) = (left.id, right.id);
proj.comp_mut(oid).ok_or(EngineError::NoComp)?.layers = vec![left, right];
st.project_selection = vec![oid];
Ok(json!({
"controls": ctl_id.0,
"master": master_id.0,
"leftComp": eye_comps[0].0,
"rightComp": eye_comps[1].0,
"stereoComp": oid.0,
"leftLayer": lid.0,
"rightLayer": rid.0,
}))
})?;
Ok(r)
}
// ---------------------------------------------------------------- orbit null
/// Create Orbit Null: a 3D null at the camera's point of interest becomes the camera's parent,
/// so rotating the null orbits the camera around what it looks at.
fn orbit_null(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "camera.orbitNull";
let cid = comp_id(s, p)?;
let cam_id = camera_p(s, p, cid, cmd)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let cam = comp.layer(cam_id).cloned().ok_or(EngineError::NoComp)?;
if cam.parent.is_some() {
return Err(bad(cmd, format!("`{}` already has a parent", cam.name)));
}
let env = Env::of(s, cid);
let ctx = env.ctx(&s.project, cid, &comp);
let (eye, fwd, _) = layer_frame(&ctx, &cam);
let target = eye + fwd * focus_distance(&ctx, &cam);
let name = comp.unique_layer_name(&format!("{} Orbit Null", cam.name));
let r = s.edit("Create Orbit Null", None, |proj, st| {
let mut null = build::layer(proj, &comp, &name, LayerSource::Null, (comp.width, comp.height), None);
null.switches.three_d = true;
null.switches.video = true;
set(&mut null, "transform/position", KV::Vec3(arr(target)));
// World → null space (a translated, unrotated, unscaled null: p − P + anchor).
let anchor = Vec3::from(null.props.prop("transform/anchor").map(|a| a.value.as_vec3()).unwrap_or([0.0; 3]));
let to_null = |v: [f64; 3]| arr(Vec3::from(v) - target + anchor);
let nid = null.id;
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let at = c.layers.iter().position(|l| l.id == cam_id).unwrap_or(0);
c.layers.insert(at, null);
let l = c.layer_mut(cam_id).ok_or(EngineError::NoComp)?;
l.parent = Some(nid);
for path in ["transform/position", "transform/poi"] {
if let Some(pr) = l.props.prop_mut(path) {
pr.value = KV::Vec3(to_null(pr.value.as_vec3()));
for k in &mut pr.keys {
k.value = KV::Vec3(to_null(k.value.as_vec3()));
}
}
}
st.selected_layers = vec![nid];
Ok(json!({"null": nid.0, "camera": cam_id.0, "position": arr(target)}))
})?;
Ok(r)
}
// ---------------------------------------------------------------- control light with camera
/// Control Light with Camera: the light rides on the camera (parented at the camera's eye,
/// shining where it looks). Again (or `on: false`) releases it where it is.
fn control_light(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "light.controlWithCamera";
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let lights: Vec<LayerId> = match p.get("layers").or_else(|| p.get("layer")) {
Some(Value::Array(a)) => a.iter().filter_map(|v| resolve_layer(&comp, v)).collect(),
Some(v) => resolve_layer(&comp, v).into_iter().collect(),
None => s.state.selected_layers.iter().copied().filter(|id| comp.layer(*id).is_some_and(Layer::is_light)).collect(),
};
if lights.is_empty() || lights.iter().any(|id| !comp.layer(*id).is_some_and(Layer::is_light)) {
return Err(bad(cmd, "select a light layer"));
}
let cam_id = match p.get("camera") {
Some(v) => resolve_layer(&comp, v).filter(|id| comp.layer(*id).is_some_and(Layer::is_camera)).ok_or_else(|| bad(cmd, "no such camera"))?,
None => comp.active_camera(s.time_of(cid)).map(|l| l.id).ok_or_else(|| bad(cmd, "the composition has no camera (Layer ▸ New ▸ Camera)"))?,
};
let on = b_p(p, "on").unwrap_or_else(|| comp.layer(lights[0]).is_none_or(|l| l.parent != Some(cam_id)));
let env = Env::of(s, cid);
let ctx = env.ctx(&s.project, cid, &comp);
// Released lights keep their current world placement.
let frames: Vec<(LayerId, Vec3, Vec3)> = lights
.iter()
.filter_map(|id| comp.layer(*id))
.map(|l| {
let (e, f, _) = layer_frame(&ctx, l);
(l.id, e, f)
})
.collect();
let (w, h) = (comp.width as f64, comp.height as f64);
s.edit(if on { "Control Light with Camera" } else { "Release Light from Camera" }, None, |proj, _| {
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
for (id, e, f) in &frames {
let l = c.layer_mut(*id).ok_or(EngineError::NoComp)?;
if on {
l.parent = Some(cam_id);
set(l, "transform/position", KV::Vec3([0.0; 3]));
set(l, "transform/poi", KV::Vec3([0.0, 0.0, 1000.0]));
set(l, "transform/orientation", KV::Vec3([0.0; 3]));
for k in ["transform/rotationX", "transform/rotationY", "transform/rotation"] {
set(l, k, KV::Scalar(0.0));
}
l.auto_orient = AutoOrient::Off;
} else if l.parent == Some(cam_id) {
l.parent = None;
set(l, "transform/position", KV::Vec3(arr(*e)));
set(l, "transform/poi", KV::Vec3(arr(*e + *f * w.max(h))));
if l.transform().is_some_and(|t| t.get("poi").is_some()) {
l.auto_orient = AutoOrient::TowardsPointOfInterest;
}
}
}
Ok(())
})?;
Ok(json!({"controlled": on, "camera": cam_id.0, "lights": lights.iter().map(|l| l.0).collect::<Vec<_>>()}))
}
// ---------------------------------------------------------------- cameras and lights from models
/// Model layers a "from 3D Model" command reads: `layers`/`layer`, else the selected model layers.
fn model_layers(s: &Session, p: &Value, comp: &Comp, cmd: &str) -> Result<Vec<LayerId>> {
let ids: Vec<LayerId> = match p.get("layers").or_else(|| p.get("layer")) {
Some(Value::Array(a)) => a.iter().filter_map(|v| resolve_layer(comp, v)).collect(),
Some(v) => resolve_layer(comp, v).into_iter().collect(),
None => s.state.selected_layers.iter().copied().filter(|id| comp.layer(*id).is_some_and(|l| matches!(l.source, LayerSource::Model { .. }))).collect(),
};
if ids.is_empty() || ids.iter().any(|id| !comp.layer(*id).is_some_and(|l| matches!(l.source, LayerSource::Model { .. }))) {
return Err(bad(cmd, "select a 3D model layer"));
}
Ok(ids)
}
/// A camera or light found in a model: (name, world eye, world forward, the model data).
enum FromModel {
Camera { name: String, eye: Vec3, fwd: Vec3, zoom: f64 },
Light { name: String, eye: Vec3, fwd: Vec3, light: effectcraft_model::ModelLight },
}
fn from_model(s: &mut Session, p: &Value, lights: bool) -> Result<Value> {
let cmd = if lights { "light.fromModel" } else { "camera.fromModel" };
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let ids = model_layers(s, p, &comp, cmd)?;
let env = Env::of(s, cid);
let ctx = env.ctx(&s.project, cid, &comp);
let mut found = vec![];
for id in &ids {
let l = comp.layer(*id).ok_or(EngineError::NoComp)?;
let LayerSource::Model { item } = l.source else { continue };
let Some(ItemKind::Footage(f)) = s.project.item(item).map(|i| &i.kind) else { continue };
let Some(m) = s.footage.model(item, f) else { return Err(bad(cmd, format!("can't read the model of `{}`", l.name))) };
let unit = l.props.sub("geometryOptions").map_or(1.0, |g| ctx.f(l, g, "unitScale", 1.0));
let to_world = ctx.world_matrix(l) * model_to_layer(unit);
for pl in m.placed(lights, None, 0.0) {
let w: Mat4 = to_world * pl.world;
let eye = w.apply(Vec3::ZERO);
let fwd = w.apply_vec(vec3(0.0, 0.0, -1.0)).normalize();
if lights {
let ml = m.lights[pl.index].clone();
found.push(FromModel::Light { name: ml.name.clone(), eye, fwd, light: ml });
} else {
let mc = &m.cameras[pl.index];
let zoom = match mc.projection {
effectcraft_model::CameraProjection::Perspective { yfov, .. } => comp.height as f64 * 0.5 / (yfov * 0.5).tan(),
// Orthographic cameras become perspective ones framing the same height.
effectcraft_model::CameraProjection::Orthographic { .. } => effectcraft_geom::default_camera_zoom(comp.width as f64),
};
found.push(FromModel::Camera { name: mc.name.clone(), eye, fwd, zoom });
}
}
}
if found.is_empty() {
return Err(bad(cmd, if lights { "the model has no lights (KHR_lights_punctual)" } else { "the model has no cameras" }));
}
let made = s.edit(if lights { "Create Lights from 3D Model" } else { "Create Cameras from 3D Model" }, None, |proj, st| {
let mut made = vec![];
for f in &found {
let c = proj.comp(cid).cloned().ok_or(EngineError::NoComp)?;
let l = match f {
FromModel::Camera { name, eye, fwd, zoom } => {
let mut l = build::layer(proj, &c, &c.unique_layer_name(name), LayerSource::Camera, (c.width, c.height), None);
set(&mut l, "transform/position", KV::Vec3(arr(*eye)));
set(&mut l, "transform/poi", KV::Vec3(arr(*eye + *fwd * *zoom)));
set(&mut l, "cameraOptions/zoom", KV::Scalar(*zoom));
set(&mut l, "cameraOptions/focusDistance", KV::Scalar(*zoom));
l
}
FromModel::Light { name, eye, fwd, light } => {
let kind = match light.kind {
effectcraft_model::ModelLightKind::Directional => LightKind::Parallel,
effectcraft_model::ModelLightKind::Point => LightKind::Point,
effectcraft_model::ModelLightKind::Spot { .. } => LightKind::Spot,
};
let mut l = build::layer(proj, &c, &c.unique_layer_name(name), LayerSource::Light { kind }, (c.width, c.height), None);
set(&mut l, "transform/position", KV::Vec3(arr(*eye)));
set(&mut l, "transform/poi", KV::Vec3(arr(*eye + *fwd * c.width.max(c.height) as f64)));
let col = light.color;
set(&mut l, "lightOptions/color", KV::Color([col[0], col[1], col[2], 1.0]));
// Intensity: 1 unit of the file (lux / candela) = 100%.
set(&mut l, "lightOptions/intensity", KV::Scalar((light.intensity * 100.0).clamp(0.0, 1000.0)));
if let effectcraft_model::ModelLightKind::Spot { inner, outer } = light.kind {
set(&mut l, "lightOptions/coneAngle", KV::Scalar((outer * 2.0).to_degrees().clamp(0.0, 180.0)));
set(&mut l, "lightOptions/coneFeather", KV::Scalar(((1.0 - inner / outer.max(1e-9)) * 100.0).clamp(0.0, 100.0)));
}
if let Some(r) = light.range {
set(&mut l, "lightOptions/falloff", KV::Enum(1));
set(&mut l, "lightOptions/falloffDistance", KV::Scalar(r * 100.0));
}
l
}
};
made.push(insert_layer(proj, st, cid, l)?.0);
}
Ok(made)
})?;
Ok(json!({"layers": made}))
}
// ---------------------------------------------------------------- environment background
/// Create Environment Light Background Layer: a layer showing the environment light's image as
/// the scene's backdrop (an equirectangular sky drawn through the camera).
fn environment_background(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "light.environmentBackground";
let cid = comp_id(s, p)?;
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
let t = s.time_of(cid);
let is_env = |l: &Layer| matches!(l.source, LayerSource::Light { kind: LightKind::Environment });
let light = match p.get("layer") {
Some(v) => resolve_layer(&comp, v).and_then(|id| comp.layer(id)).filter(|l| is_env(l)).ok_or_else(|| bad(cmd, "not an environment light"))?,
None => s
.state
.selected_layers
.iter()
.filter_map(|id| comp.layer(*id))
.find(|l| is_env(l))
.or_else(|| comp.layers.iter().find(|l| is_env(l)))
.ok_or_else(|| bad(cmd, "the composition has no environment light (Layer ▸ New ▸ Light ▸ Environment)"))?,
};
let src_layer = light
.props
.prop("lightOptions/source")
.and_then(|pr| pr.value_at(light.layer_time(t)).as_layer())
.and_then(|id| comp.layer(LayerId(id)))
.or_else(|| comp.layers.iter().find(|l| l.environment))
.ok_or_else(|| bad(cmd, "the environment light has no Source (or set an Environment Layer)"))?
.clone();
if !matches!(src_layer.source, LayerSource::Footage { .. } | LayerSource::Comp { .. } | LayerSource::Solid { .. }) {
return Err(bad(cmd, "the environment source must be a footage, comp or solid layer"));
}
let light_id = light.id;
let name = comp.unique_layer_name(&format!("{} Background", src_layer.name));
let size = effectcraft_render::source_size(&s.project, &src_layer);
let id = s.edit("Create Environment Light Background Layer", None, |proj, st| {
let mut l = build::layer(proj, &comp, &name, src_layer.source.clone(), size, None);
l.switches.three_d = true;
l.environment_background = true;
// The backdrop sits at the bottom of the stack.
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
let id = l.id;
c.layers.push(l);
st.selected_layers = vec![id];
Ok(id)
})?;
Ok(json!({"layer": id.0, "light": light_id.0, "source": src_layer.id.0}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"camera.stereoRig",
"Create Stereo 3D Rig",
["Layer", "Camera"],
None,
"{comp?, configuration?: stereoPair|centerRight|centerLeft, sceneDepth? (% of comp width, default 3), convergence?: bool, convergenceOf?: poi|zoom, zOffset?, view3d?: 3D Glasses view index (default 5 Balanced Colored Red Blue)}",
has_comp,
stereo_rig
),
cmd!("camera.orbitNull", "Create Orbit Null", ["Layer", "Camera"], None, "{comp?, layer?: camera (default selected/active)}", has_comp, orbit_null),
cmd!(
"camera.fromModel",
"Create Cameras from 3D Model",
["Layer", "Camera"],
None,
"{comp?, layers?: 3D model layers (default selected)}",
has_comp,
|s, p| { from_model(s, p, false) }
),
cmd!(
"light.fromModel",
"Create Lights from 3D Model",
["Layer", "Light"],
None,
"{comp?, layers?: 3D model layers (default selected)}",
has_comp,
|s, p| { from_model(s, p, true) }
),
cmd!(
"light.controlWithCamera",
"Control Light with Camera",
["Layer", "Light"],
None,
"{comp?, layers?: lights (default selected), camera? (default active), on?: bool (default toggle)}",
has_comp,
control_light
),
cmd!(
"light.environmentBackground",
"Create Environment Light Background Layer",
["Layer", "Light"],
None,
"{comp?, layer?: environment light (default selected / first)}",
has_comp,
environment_background
),
]
}

View File

@ -19,17 +19,6 @@ pub fn specs() -> Vec<CommandSpec> {
vec![
// Mask options not in the mask model yet; pen / vertex editing (timeline milestone).
// Cameras / lights / materials / 3D views (3D milestone).
stub!("camera.stereoRig", "Create Stereo 3D Rig", ["Layer", "Camera"], "{}"),
stub!("camera.orbitNull", "Create Orbit Null", ["Layer", "Camera"], "{}"),
stub!("camera.fromModel", "Create Cameras from 3D Model", ["Layer", "Camera"], "{}"),
stub!("light.fromModel", "Create Lights from 3D Model", ["Layer", "Light"], "{}"),
stub!("light.controlWithCamera", "Control Light with Camera", ["Layer", "Light"], "{}"),
stub!("light.environmentBackground", "Create Environment Light Background Layer", ["Layer", "Light"], "{}"),
stub!("view.3d.default", "Default", ["View", "Switch 3D View"], "{}"),
stub!("view.splitLockedViewer", "Split with New Locked Viewer", ["View"], "{}"),
// Color management.
stub!("view.displayColorManagement", "Use Display Color Management", ["View"], "{}"),
stub!("view.simulateOutput", "Simulate Output", [], "{profile}"),
// Keyframes / text / tracking.
stub!("keys.audioToKeyframes", "Convert Audio to Keyframes", ["Animation", "Keyframe Assistant"], "{}"),
stub!("keys.rpfCameraImport", "RPF Camera Import", ["Animation", "Keyframe Assistant"], "{}"),

View File

@ -297,7 +297,7 @@ fn views(s: &mut Session, cid: ItemId) -> &mut Views3D {
}
fn view_p(p: &Value, cmd: &str) -> Result<View3D> {
let v = str_p(p, "view").ok_or_else(|| bad(cmd, "missing `view` (activeCamera|front|left|top|back|right|bottom|custom1|custom2|custom3)"))?;
let v = str_p(p, "view").ok_or_else(|| bad(cmd, "missing `view` (activeCamera|default|front|left|top|back|right|bottom|custom1|custom2|custom3)"))?;
View3D::from_id(v).ok_or_else(|| bad(cmd, format!("unknown view `{v}`")))
}
@ -334,6 +334,7 @@ view_cmd!(view_bottom, View3D::Bottom);
view_cmd!(view_custom1, View3D::Custom1);
view_cmd!(view_custom2, View3D::Custom2);
view_cmd!(view_custom3, View3D::Custom3);
view_cmd!(view_default, View3D::Default);
fn last_view(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
@ -697,11 +698,12 @@ pub fn specs() -> Vec<CommandSpec> {
"Switch 3D View",
[],
None,
"{view: activeCamera|front|left|top|back|right|bottom|custom1|custom2|custom3, comp?}",
"{view: activeCamera|default|front|left|top|back|right|bottom|custom1|custom2|custom3, comp?}",
has_comp,
set_3d_view
),
cmd!("view.3d.activeCamera", "Active Camera", ["View", "Switch 3D View"], Some("F12"), "{comp?}", has_comp, view_active),
cmd!("view.3d.default", "Default", ["View", "Switch 3D View"], None, "{comp?}", has_comp, view_default),
cmd!("view.3d.front", "Front", ["View", "Switch 3D View"], Some("F10"), "{comp?}", has_comp, view_front),
cmd!("view.3d.left", "Left", ["View", "Switch 3D View"], None, "{comp?}", has_comp, view_left),
cmd!("view.3d.top", "Top", ["View", "Switch 3D View"], None, "{comp?}", has_comp, view_top),

View File

@ -1,5 +1,6 @@
//! Time navigation (CTI) commands.
use effectcraft_project::{Comp, TimeDisplayStyle};
use effectcraft_time::Tick;
use serde_json::{Value, json};
@ -9,7 +10,36 @@ use crate::{EngineError, Result, Session, cmd};
fn report(s: &Session) -> Value {
let t = s.time();
let frame = s.active_comp().map(|c| c.frame_rate.frame_at(t)).unwrap_or(0);
json!({"time": t.seconds(), "frame": frame})
let display = s.active_comp().map(|c| display_time(s, c, t));
json!({"time": t.seconds(), "frame": frame, "display": display})
}
/// A comp time as the project's Time Display Style shows it: timecode (`0:00:02:15`, `;` for
/// drop-frame), frames (`00063`, from Start Numbering Frames At) or Feet + Frames (`0003+15`).
pub fn display_time(s: &Session, comp: &Comp, t: Tick) -> String {
let fr = comp.frame_rate;
let st = &s.project.settings;
match st.time_display {
TimeDisplayStyle::Frames => format!("{:05}", fr.frame_at(t) + st.frame_start),
TimeDisplayStyle::Feet35 | TimeDisplayStyle::Feet16 => {
effectcraft_time::format_feet_frames(fr.frame_at(t + comp.display_start) + st.frame_start, st.time_display.frames_per_foot().unwrap_or(16))
}
TimeDisplayStyle::Timecode => effectcraft_time::format_timecode_ae(fr.frame_at(t + comp.display_start), fr, false),
}
}
/// Parse a time typed in the project's Time Display Style (relative `+n`/`-n` from frame
/// `current`) into a comp frame.
pub fn parse_display_time(s: &Session, comp: &Comp, text: &str, current: i64) -> std::result::Result<i64, String> {
let fr = comp.frame_rate;
let st = &s.project.settings;
match st.time_display.frames_per_foot() {
Some(pf) => {
let start = fr.frame_at(comp.display_start) + st.frame_start;
effectcraft_time::parse_feet_frames(text, pf, current + start).map(|f| f - start).map_err(|e| e.0)
}
None => effectcraft_time::parse_timecode(text, fr, fr.supports_drop_frame(), current).map_err(|e| e.0),
}
}
fn set(s: &mut Session, p: &Value) -> Result<Value> {
@ -18,7 +48,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
comp.frame_rate.tick_of(f)
} else if let Some(tc) = str_p(p, "timecode") {
let cur = comp.frame_rate.frame_at(s.time());
let f = effectcraft_time::parse_timecode(tc, comp.frame_rate, comp.frame_rate.supports_drop_frame(), cur).map_err(|e| super::bad("time.set", e.0))?;
let f = parse_display_time(s, comp, tc, cur).map_err(|e| super::bad("time.set", e))?;
comp.frame_rate.tick_of(f)
} else {
Tick::from_seconds_f64(f_p(p, "time").ok_or_else(|| super::bad("time.set", "need `time`, `frame` or `timecode`"))?)

View File

@ -10,8 +10,8 @@ use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use super::{CommandSpec, always, b_p, bad, comp_id, f_p, has_comp, merge_p, str_p};
use crate::viewer::{Channel, Snapshot};
use crate::{EngineError, Result, Session, VertexRef, cmd};
use crate::viewer::{Channel, SimProfile, Simulation, Snapshot};
use crate::{EngineError, Result, Session, VertexRef, cmd, query};
/// Composition ▸ Preview ▸ Fast Previews modes.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@ -61,8 +61,8 @@ impl FastPreviews {
}
}
/// Viewer display options (Show Channel, exposure, snapshot, Fast Previews).
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
/// Viewer display options (Show Channel, exposure, snapshot, Fast Previews, display colour).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct ViewOptions {
pub channel: Channel,
@ -73,6 +73,36 @@ pub struct ViewOptions {
/// Show Snapshot (F5 held): the viewer shows the snapshot instead of the frame.
pub show_snapshot: bool,
pub fast_previews: FastPreviews,
/// View ▸ Use Display Color Management (with a working space): the frame is converted
/// from the working space to the display; off shows the working space's numbers.
pub display_color_management: bool,
/// View ▸ Simulate Output.
pub simulation: Simulation,
/// The profile View ▸ Simulate Output ▸ Custom... uses.
pub custom_simulation: Simulation,
}
impl Default for ViewOptions {
fn default() -> Self {
ViewOptions {
channel: Channel::default(),
colorized: false,
exposure: 0.0,
show_snapshot: false,
fast_previews: FastPreviews::default(),
display_color_management: true,
simulation: Simulation::default(),
custom_simulation: Simulation { profile: SimProfile::Linear, preserve_rgb: true },
}
}
}
/// View ▸ Split with New Locked Viewer: a second Composition viewer that keeps showing one
/// comp and 3D view whatever comp is active.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct LockedViewer {
pub comp: effectcraft_project::ItemId,
pub view: effectcraft_render::three_d::View3D,
}
fn snapping(s: &mut Session, p: &Value) -> Result<Value> {
@ -281,9 +311,97 @@ fn shape_layer_or_none(s: &Session) -> std::result::Result<(), String> {
has_comp(s)
}
// ---------------------------------------------------------------- display colour, locked viewer
/// View ▸ Use Display Color Management (toggle, or `value`).
fn display_cm(s: &mut Session, p: &Value) -> Result<Value> {
let v = b_p(p, "value").unwrap_or(!s.state.viewer.display_color_management);
s.state.viewer.display_color_management = v;
s.events.push(crate::Event::ProjectChanged { revision: s.revision });
Ok(json!({"displayColorManagement": v, "workingSpace": s.project.settings.working_space.map(|w| w.id()), "display": s.prefs.previews.display_profile}))
}
/// View ▸ Simulate Output ▸ (profile). `custom` uses (and with `space` sets) the Custom profile.
fn simulate_output(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "view.simulateOutput";
let name = str_p(p, "profile").unwrap_or("none");
let ids = SimProfile::ALL.iter().map(|p| p.id()).collect::<Vec<_>>().join("|");
let sim = if name.eq_ignore_ascii_case("custom") {
let mut c = s.state.viewer.custom_simulation;
if let Some(sp) = str_p(p, "space") {
c.profile = SimProfile::parse(sp).ok_or_else(|| bad(cmd, format!("space: {ids}")))?;
}
if let Some(v) = b_p(p, "preserveRgb") {
c.preserve_rgb = v;
}
s.state.viewer.custom_simulation = c;
c
} else {
let profile = SimProfile::parse(name).ok_or_else(|| bad(cmd, format!("profile: {ids}|custom")))?;
Simulation { profile, preserve_rgb: b_p(p, "preserveRgb").unwrap_or(false) }
};
s.state.viewer.simulation = sim;
s.events.push(crate::Event::ProjectChanged { revision: s.revision });
Ok(json!({"profile": sim.profile.id(), "label": sim.profile.label(), "preserveRgb": sim.preserve_rgb}))
}
/// View ▸ Split with New Locked Viewer: lock a second viewer to the comp (default the active
/// one) and its current 3D view (or `view`).
fn split_locked(s: &mut Session, p: &Value) -> Result<Value> {
let cid = comp_id(s, p)?;
let view = match str_p(p, "view") {
Some(v) => effectcraft_render::three_d::View3D::from_id(v).ok_or_else(|| bad("view.splitLockedViewer", format!("unknown view `{v}`")))?,
None => s.state.views3d.get(&cid).map(|v| v.current).unwrap_or_default(),
};
s.state.locked_viewer = Some(LockedViewer { comp: cid, view });
s.events.push(crate::Event::ProjectChanged { revision: s.revision });
Ok(json!({"comp": cid.0, "view": view.id()}))
}
fn close_locked(s: &mut Session, _: &Value) -> Result<Value> {
let was = s.state.locked_viewer.take().is_some();
s.events.push(crate::Event::ProjectChanged { revision: s.revision });
Ok(json!({"closed": was}))
}
fn has_locked(s: &Session) -> std::result::Result<(), String> {
if s.state.locked_viewer.is_some() { Ok(()) } else { Err("no locked viewer".into()) }
}
/// The viewer's colour settings and what they do.
fn display_color_state(s: &mut Session, _: &Value) -> Result<Value> {
let v = &s.state.viewer;
Ok(json!({
"displayColorManagement": v.display_color_management,
"simulation": {"profile": v.simulation.profile.id(), "preserveRgb": v.simulation.preserve_rgb},
"display": s.prefs.previews.display_profile,
"workingSpace": s.project.settings.working_space.map(|w| w.id()),
"active": crate::viewer::DisplayColor::of(s).is_some(),
"lockedViewer": s.state.locked_viewer.map(|l| json!({"comp": l.comp.0, "view": l.view.id()})),
}))
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("view.snapping", "Snapping", ["View"], None, "{value?}", always, snapping),
cmd!("view.displayColorManagement", "Use Display Color Management", ["View"], None, "{value?}", always, display_cm),
cmd!(
"view.simulateOutput",
"Simulate Output",
[],
None,
"{profile: none|rec709|ntsc|pal|mac18|srgb|rec2020|p3|linear|custom, preserveRgb?, space? (custom)}",
always,
simulate_output
),
cmd!("view.splitLockedViewer", "Split with New Locked Viewer", ["View"], None, "{comp?, view?: 3D view id}", has_comp, split_locked),
cmd!("view.closeLockedViewer", "Close Locked Viewer", [], None, "{}", has_locked, close_locked),
query!(
"view.displayColor",
"Viewer Color State",
"{} → display colour management, output simulation, display profile, locked viewer",
display_color_state
),
cmd!("view.channel", "Show Channel", [], None, "{channel: rgb|red|green|blue|alpha|rgbStraight, colorized?, toggle?}", always, channel),
cmd!("view.exposure", "Adjust Exposure", [], None, "{stops? | delta?}", always, exposure),
cmd!("view.resetExposure", "Reset Exposure", [], None, "{}", always, reset_exposure),

View File

@ -228,6 +228,9 @@ pub struct EditorState {
/// Content-Aware Fill panel settings.
#[serde(default)]
pub content_fill: commands::content_fill::FillSettings,
/// View ▸ Split with New Locked Viewer: the second viewer's comp and 3D view.
#[serde(default)]
pub locked_viewer: Option<commands::viewer_cmds::LockedViewer>,
}
fn one_view() -> u8 {
@ -710,6 +713,8 @@ mod tests_anim_tools;
#[cfg(test)]
mod tests_camera_track;
#[cfg(test)]
mod tests_color_view;
#[cfg(test)]
mod tests_disk_cache;
#[cfg(test)]
mod tests_effects;
@ -732,6 +737,8 @@ mod tests_project_items;
#[cfg(test)]
mod tests_proxy;
#[cfg(test)]
mod tests_rig3d;
#[cfg(test)]
mod tests_settings;
#[cfg(test)]
mod tests_stubs;

View File

@ -168,6 +168,17 @@ pub fn checked(s: &Session, command: &str, params: &Value) -> Option<bool> {
"view.layout" => Some(params.get("views").and_then(Value::as_u64) == Some(s.state.view_layout.max(1) as u64)),
"view.shareViewOptions" => Some(s.state.share_view_options),
"view.snapping" => Some(s.state.snapping),
"view.displayColorManagement" => Some(s.state.viewer.display_color_management),
"view.simulateOutput" => {
let want = pstr("profile")?;
let sim = s.state.viewer.simulation;
Some(if want == "custom" {
sim.profile != crate::viewer::SimProfile::None && sim == s.state.viewer.custom_simulation
} else {
crate::viewer::SimProfile::parse(want) == Some(sim.profile)
&& (sim != s.state.viewer.custom_simulation || sim.profile == crate::viewer::SimProfile::None)
})
}
"layer.mask.motionBlur" | "layer.mask.featherFalloff" | "path.rotoBezier" => {
use effectcraft_project::{FeatherFalloff, GroupKind, MaskMotionBlur};
let GroupKind::Mask { motion_blur, feather_falloff, roto_bezier, .. } = layer?.masks()?.groups().next()?.kind else { return None };
@ -855,6 +866,9 @@ View
SDTV PAL | view.simulateOutput {"profile":"pal"}
Legacy Macintosh RGB (Gamma 1.8) | view.simulateOutput {"profile":"mac18"}
Internet Standard RGB (sRGB) | view.simulateOutput {"profile":"srgb"}
UHDTV (Rec. 2020) | view.simulateOutput {"profile":"rec2020"}
Display P3 | view.simulateOutput {"profile":"p3"}
Linear (1.0 Gamma) | view.simulateOutput {"profile":"linear"}
Custom... | view.simulateOutput {"profile":"custom"}
---
Show Rulers | view.rulers

View File

@ -104,6 +104,8 @@ page!(Previews {
/// `faster` or `moreAccurate`.
zoom_quality: String = "moreAccurate".into(),
show_gpu_info: bool = false,
/// The monitor's colour space for View ▸ Use Display Color Management: `srgb` or `p3`.
display_profile: String = "srgb".into(),
});
page!(Appearance {
@ -728,6 +730,7 @@ pub fn pages() -> Vec<Page> {
s("previews.cacheFramesWhenIdle", "Cache Frames When Idle", B, true),
s("previews.fastPreviews", "Fast Previews (Draft 3D, Faster Effects)", B, true),
s("previews.zoomQuality", "Viewer Zoom Quality", Kind::Choice(&[("Faster", "faster"), ("More Accurate", "moreAccurate")]), false),
s("previews.displayProfile", "Display Color Space", Kind::Choice(&[("sRGB", "srgb"), ("Display P3", "p3")]), true),
Button { label: "GPU Information...", command: "app.gpuInfo", params: "{}" },
],
},

View File

@ -0,0 +1,223 @@
//! Viewer and colour completion (M7.7): View ▸ Switch 3D View ▸ Default, Split with New Locked
//! Viewer, Use Display Color Management, Simulate Output; Project Settings ▸ Color Engine,
//! ACES working spaces, HDR compand / tone mapping, Rec. 2100 output, Feet + Frames.
use effectcraft_project::{ColorEngine, ColorSpace, HdrMode, Project, TimeDisplayStyle};
use effectcraft_render::RenderOpts;
use effectcraft_render::three_d::View3D;
use effectcraft_time::Tick;
use serde_json::json;
use crate::Session;
use crate::viewer::{DisplayColor, SimProfile, Simulation};
fn session() -> Session {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "C", "width": 64, "height": 48, "frameRate": 24, "duration": 10})).unwrap();
s
}
#[test]
fn default_3d_view_ignores_camera_layers() {
let mut s = session();
let cid = s.active_comp_id().unwrap();
s.execute("layer.newCamera", json!({"position": [32, 24, -300], "poi": [32, 24, 0]})).unwrap();
assert_eq!(s.execute("view.3d.default", json!({})).unwrap()["view"], "default");
let vs = s.state.views3d.get(&cid).unwrap().clone();
assert_eq!(vs.current, View3D::Default);
// The comp's default camera: at −zoom looking at the centre, not the camera layer.
let cam = s.view_camera(cid).unwrap();
let zoom = effectcraft_geom::default_camera_zoom(64.0);
assert!((cam.eye.z + zoom).abs() < 1e-9 && (cam.eye.x - 32.0).abs() < 1e-9 && !cam.ortho, "{:?}", cam.eye);
// Orbitable like a custom view; Reset 3D View restores it; Last view goes back.
s.execute("camera.orbit", json!({"yaw": 30})).unwrap();
assert!((s.view_camera(cid).unwrap().eye.x - 32.0).abs() > 1.0);
s.execute("view.reset3DView", json!({})).unwrap();
assert!((s.view_camera(cid).unwrap().eye.x - 32.0).abs() < 1e-9);
s.execute("view.3d.last", json!({})).unwrap();
assert_eq!(s.state.views3d[&cid].current, View3D::ActiveCamera);
assert_eq!(s.execute("view.set3DView", json!({"view": "Default"})).unwrap()["view"], "default");
}
#[test]
fn locked_viewer_keeps_its_comp() {
let mut s = session();
let a = s.active_comp_id().unwrap();
s.execute("view.3d.top", json!({})).unwrap();
assert!(!s.is_enabled("view.closeLockedViewer"));
let r = s.execute("view.splitLockedViewer", json!({})).unwrap();
assert_eq!(r, json!({"comp": a.0, "view": "top"}));
// Another comp becomes active; the locked viewer stays on the first.
s.execute("comp.new", json!({"name": "B"})).unwrap();
assert_ne!(s.active_comp_id(), Some(a));
assert_eq!(s.state.locked_viewer.unwrap().comp, a);
let st = s.execute("view.displayColor", json!({})).unwrap();
assert_eq!(st["lockedViewer"]["comp"], a.0);
// Serde of the editor state; explicit comp and view.
let back: crate::EditorState = serde_json::from_value(serde_json::to_value(&s.state).unwrap()).unwrap();
assert_eq!(back.locked_viewer, s.state.locked_viewer);
s.execute("view.splitLockedViewer", json!({"comp": a.0, "view": "custom2"})).unwrap();
assert_eq!(s.state.locked_viewer.unwrap().view, View3D::Custom2);
assert!(s.execute("view.splitLockedViewer", json!({"view": "sideways"})).is_err());
assert_eq!(s.execute("view.closeLockedViewer", json!({})).unwrap()["closed"], true);
assert!(s.state.locked_viewer.is_none());
}
fn px(r: u8, g: u8, b: u8) -> Vec<[u8; 4]> {
vec![[r, g, b, 255]]
}
#[test]
fn display_color_management_and_simulation() {
let mut s = session();
// Unmanaged projects: nothing to convert.
assert!(DisplayColor::of(&s).is_none());
s.execute("file.projectSettings", json!({"workingSpace": "rec2020"})).unwrap();
// Managed, sRGB display, no simulation: the frame is already display-referred.
assert!(DisplayColor::of(&s).is_none());
assert_eq!(crate::menus::checked(&s, "view.displayColorManagement", &json!({})), Some(true));
// A Display P3 monitor: pure sRGB red becomes less saturated P3 numbers.
s.execute("prefs.set", json!({"key": "previews.displayProfile", "value": "p3"})).unwrap();
let mut p = px(255, 0, 0);
DisplayColor::of(&s).unwrap().apply(&mut p);
assert!((p[0][0] as i32 - 234).abs() <= 2 && (p[0][1] as i32 - 51).abs() <= 3 && (p[0][2] as i32 - 35).abs() <= 3, "{p:?}");
// Display colour management off: the working space's (Rec. 2020) numbers go to the screen.
s.execute("view.displayColorManagement", json!({})).unwrap();
assert_eq!(crate::menus::checked(&s, "view.displayColorManagement", &json!({})), Some(false));
let mut q = px(255, 0, 0);
DisplayColor::of(&s).unwrap().apply(&mut q);
assert!(q[0][0] < 230 && q[0][1] > 60, "raw Rec. 2020 numbers: {q:?}");
s.execute("view.displayColorManagement", json!({"value": true})).unwrap();
s.execute("prefs.set", json!({"key": "previews.displayProfile", "value": "srgb"})).unwrap();
// Simulate Output ▸ Linear with Preserve RGB: linear numbers shown as-is (darker mid-grey).
s.execute("view.simulateOutput", json!({"profile": "linear", "preserveRgb": true})).unwrap();
let mut g = px(128, 128, 128);
DisplayColor::of(&s).unwrap().apply(&mut g);
assert!((g[0][0] as i32 - 55).abs() <= 2, "{g:?}");
// Without Preserve RGB the round trip through 8-bit linear bands the shadows.
s.execute("view.simulateOutput", json!({"profile": "linear"})).unwrap();
let dc = DisplayColor::of(&s).unwrap();
let levels: std::collections::BTreeSet<u8> = (0..32u8)
.map(|v| {
let mut p = px(v, v, v);
dc.apply(&mut p);
p[0][0]
})
.collect();
assert!(levels.len() < 20, "banding: {levels:?}");
assert_eq!(crate::menus::checked(&s, "view.simulateOutput", &json!({"profile": "linear"})), Some(true));
// Rec. 709 simulation keeps in-gamut colours; legacy Mac gamma 1.8 with Preserve RGB darkens.
s.execute("view.simulateOutput", json!({"profile": "rec709"})).unwrap();
let mut c = px(200, 100, 50);
DisplayColor::of(&s).unwrap().apply(&mut c);
assert!((c[0][0] as i32 - 200).abs() <= 2 && (c[0][1] as i32 - 100).abs() <= 2, "{c:?}");
s.execute("view.simulateOutput", json!({"profile": "mac18", "preserveRgb": true})).unwrap();
let mut m = px(128, 128, 128);
DisplayColor::of(&s).unwrap().apply(&mut m);
assert!(m[0][0] < 120, "gamma 1.8 numbers on an sRGB display look darker: {m:?}");
// Custom… uses (and sets) the custom profile.
let r = s.execute("view.simulateOutput", json!({"profile": "custom", "space": "p3", "preserveRgb": false})).unwrap();
assert_eq!(r["profile"], "p3");
assert_eq!(s.state.viewer.custom_simulation, Simulation { profile: SimProfile::P3, preserve_rgb: false });
assert_eq!(crate::menus::checked(&s, "view.simulateOutput", &json!({"profile": "custom"})), Some(true));
s.execute("view.simulateOutput", json!({"profile": "none"})).unwrap();
assert!(DisplayColor::of(&s).is_none());
assert!(s.execute("view.simulateOutput", json!({"profile": "cmyk"})).is_err());
// Every menu profile parses.
for p in SimProfile::ALL {
assert_eq!(SimProfile::parse(p.id()), Some(p));
}
}
fn red_solid(s: &mut Session, hex: &str) {
s.execute("layer.newSolid", json!({"color": hex, "width": 64, "height": 48})).unwrap();
}
#[test]
fn color_engine_aces_working_spaces_and_hdr() {
let mut s = session();
let cid = s.active_comp_id().unwrap();
// OCIO engine: ACEScg working space and the tone-mapped display by default.
s.execute("file.projectSettings", json!({"colorEngine": "ocio"})).unwrap();
let st = s.project.settings.clone();
assert_eq!((st.color_engine, st.working_space, st.hdr), (ColorEngine::Ocio, Some(ColorSpace::AcesCg), HdrMode::ToneMap));
assert!(s.execute("file.projectSettings", json!({"workingSpace": "rec709"})).is_err(), "not in the OCIO config");
s.execute("file.projectSettings", json!({"workingSpace": "aces2065"})).unwrap();
assert!(s.execute("file.projectSettings", json!({"workingSpace": "rec2100pq"})).is_err(), "not a working space");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project.settings.working_space, Some(ColorSpace::AcesCg));
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project.settings.color_engine, ColorEngine::Adobe);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.project.settings.color_engine, ColorEngine::Ocio);
// 32 bpc, a solid of 4.0 (over-range) in ACEScg: tone mapped below white, not clipped.
s.execute("file.projectSettings", json!({"bitDepth": 32, "hdr": "toneMap"})).unwrap();
s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 64, "height": 48})).unwrap();
let lid = s.active_comp().unwrap().layers[0].id.0;
s.execute("effect.apply", json!({"layers": [lid], "effect": "Exposure"})).unwrap();
s.execute("prop.set", json!({"layer": lid, "path": "effects/#1/master/exposure", "value": 2.0})).unwrap();
let mapped = s.render(cid, Tick::ZERO, RenderOpts::default()).data[0];
assert!(mapped[0] > 0.5 && mapped[0] < 0.99, "tone mapped: {mapped:?}");
s.execute("file.projectSettings", json!({"hdr": "clip"})).unwrap();
let clipped = s.render(cid, Tick::ZERO, RenderOpts::default()).data[0];
assert!(clipped[0] > 1.1, "32 bpc keeps over-range: {clipped:?}");
s.execute("file.projectSettings", json!({"hdr": "compand"})).unwrap();
let comp = s.render(cid, Tick::ZERO, RenderOpts::default()).data[0];
assert!(comp[0] > 0.9 && comp[0] < 1.0, "companded: {comp:?}");
// Rec. 2100 PQ output: HDR, no tone mapping: over-range white encodes above SDR white's 0.58.
s.execute("file.projectSettings", json!({"outputSpace": "rec2100pq", "hdr": "toneMap"})).unwrap();
let pq = s.render(cid, Tick::ZERO, RenderOpts::default()).data[0];
assert!(pq[0] > 0.6 && pq[0] < 0.8, "PQ: {pq:?}");
s.execute("file.projectSettings", json!({"outputSpace": "rec2100hlg"})).unwrap();
let hlg = s.render(cid, Tick::ZERO, RenderOpts::default()).data[0];
assert!(hlg[0] > 0.85, "HLG: {hlg:?}");
assert!(s.execute("file.projectSettings", json!({"hdr": "glow"})).is_err());
// Serde keeps the new settings; older files load with the defaults.
let back: Project = serde_json::from_str(&serde_json::to_string(&*s.project).unwrap()).unwrap();
assert_eq!(back.settings, s.project.settings);
let mut v = serde_json::to_value(&s.project.settings).unwrap();
for k in ["color_engine", "hdr", "output_space"] {
v.as_object_mut().unwrap().remove(k);
}
let old: effectcraft_project::ProjectSettings = serde_json::from_value(v).unwrap();
assert_eq!((old.color_engine, old.hdr, old.output_space), (ColorEngine::Adobe, HdrMode::Clip, None));
}
#[test]
fn aces_working_space_matches_the_ocio_config() {
// ACEScg in the colour module and in the OCIO effects' built-in config agree.
let mut s = session();
s.execute("file.projectSettings", json!({"workingSpace": "acescg"})).unwrap();
red_solid(&mut s, "#808080");
let cid = s.active_comp_id().unwrap();
// Authored ACEScg values (0.5 linear AP1 grey) → sRGB display.
let out = s.render(cid, Tick::ZERO, RenderOpts::default()).data[0];
let want = effectcraft_color::linear_to_srgb(128.0 / 255.0);
assert!((out[0] - want).abs() < 0.01 && (out[1] - want).abs() < 0.01, "{out:?} vs {want}");
}
#[test]
fn feet_and_frames_display_and_entry() {
let mut s = session();
let comp = s.active_comp().unwrap().clone();
s.execute("file.projectSettings", json!({"timeDisplay": "feet35"})).unwrap();
assert_eq!(s.project.settings.time_display, TimeDisplayStyle::Feet35);
let t = comp.frame_rate.tick_of(16 * 3 + 5);
assert_eq!(crate::commands::time::display_time(&s, &comp, t), "0003+05");
// Typed feet+frames in the time field.
let r = s.execute("time.set", json!({"timecode": "2+10"})).unwrap();
assert_eq!(r["frame"], 42);
assert_eq!(r["display"], "0002+10");
let r = s.execute("time.set", json!({"timecode": "+6"})).unwrap();
assert_eq!(r["frame"], 48);
s.execute("file.projectSettings", json!({"timeDisplay": "feet16"})).unwrap();
assert_eq!(crate::commands::time::display_time(&s, &comp, comp.frame_rate.tick_of(95)), "0002+15");
s.execute("file.projectSettings", json!({"timeDisplay": "frames"})).unwrap();
assert_eq!(crate::commands::time::display_time(&s, &comp, comp.frame_rate.tick_of(95)), "00095");
s.execute("file.projectSettings", json!({"timeDisplay": "timecode"})).unwrap();
assert_eq!(crate::commands::time::display_time(&s, &comp, comp.frame_rate.tick_of(48)), "0:00:02:00");
assert!(s.execute("file.projectSettings", json!({"timeDisplay": "furlongs"})).is_err());
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project.settings.time_display, TimeDisplayStyle::Frames);
assert_eq!(serde_json::to_value(TimeDisplayStyle::Feet16).unwrap(), json!("Feet16"));
}

View File

@ -487,7 +487,7 @@ fn frontend_commands_emit_events_and_stubs_are_disabled() {
let ev = frontend_events(&mut s);
assert_eq!(ev[0].0, "view.zoomIn");
assert_eq!(ev[1], ("window.panel".to_string(), json!({"panel": "align"})));
for id in ["keys.audioToKeyframes", "camera.stereoRig", "view.displayColorManagement", "view.3d.default", "layer.create", "track.motion"] {
for id in ["keys.audioToKeyframes", "layer.autoTrace", "layer.sceneEditDetection", "layer.create", "track.motion"] {
assert!(!s.is_enabled(id), "{id}");
assert!(matches!(s.execute(id, json!({})), Err(EngineError::Disabled(..))), "{id}");
}

View File

@ -0,0 +1,338 @@
//! Camera and light rigs (M7.7): Create Stereo 3D Rig, Create Orbit Null, Control Light with
//! Camera, Create Cameras / Lights from 3D Model and Create Environment Light Background Layer
//! (behaviour, values, pixels, undo/redo, serde).
use std::sync::Arc;
use effectcraft_geom::{Vec3, vec3};
use effectcraft_keyframe::Value as KV;
use effectcraft_project::{Footage, FootageKind, GroupKind, ItemId, Layer, LayerId, LayerSource, LightKind, Project};
use effectcraft_render::three_d::camera::layer_frame;
use effectcraft_render::{EvalCtx, FootageSource, RenderOpts};
use effectcraft_time::{FrameRate, Tick};
use serde_json::{Value, json};
use crate::{Importer, Session};
fn session() -> Session {
let mut s = Session { footage: Arc::new(DiskModels), importer: Some(Arc::new(ModelImporter)), ..Default::default() };
s.execute("comp.new", json!({"name": "Scene", "width": 640, "height": 360, "frameRate": 30, "duration": 2})).unwrap();
s
}
fn cid(s: &Session) -> ItemId {
s.active_comp_id().unwrap()
}
fn layer_in(s: &Session, comp: ItemId, id: u64) -> Layer {
s.project.comp(comp).unwrap().layer(LayerId(id)).unwrap().clone()
}
fn v3(l: &Layer, path: &str) -> [f64; 3] {
l.props.prop(path).unwrap().value.as_vec3()
}
fn close3(a: [f64; 3], b: [f64; 3], tol: f64) -> bool {
(0..3).all(|i| (a[i] - b[i]).abs() <= tol)
}
/// World (eye, forward) of a layer in the active comp at time 0.
fn frame(s: &Session, id: u64) -> (Vec3, Vec3) {
let c = cid(s);
let comp = s.project.comp(c).unwrap();
let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: Tick::ZERO, expr: None, footage: None };
let (e, f, _) = layer_frame(&ctx, comp.layer(LayerId(id)).unwrap());
(e, f)
}
fn camera(s: &mut Session) -> u64 {
s.execute("layer.newCamera", json!({"name": "Cam", "position": [320, 180, -800], "poi": [320, 180, 0], "zoom": 900})).unwrap()["layer"].as_u64().unwrap()
}
#[test]
fn stereo_rig_builds_eye_comps_and_glasses() {
let mut s = session();
let src = cid(&s);
let cam = camera(&mut s);
let items_before = s.project.items.len();
let r = s.execute("camera.stereoRig", json!({"sceneDepth": 4})).unwrap();
assert_eq!(s.project.items.len(), items_before + 3, "left, right and stereo comps");
assert_eq!(r["master"].as_u64(), Some(cam));
// Controls on a hidden null in the source comp.
let ctl = layer_in(&s, src, r["controls"].as_u64().unwrap());
assert_eq!(ctl.name, "Stereo 3D Controls");
assert!(!ctl.switches.video);
let fx = ctl.effects().unwrap().groups().next().unwrap().clone();
assert!(matches!(&fx.kind, GroupKind::Effect { effect } if effect == effectcraft_effects::controls::STEREO_CONTROLS));
assert_eq!(fx.get("sceneDepth").unwrap().value, KV::Scalar(4.0));
// Eye comps: an eye camera (two-node, linked by expressions) and the collapsed source comp.
let d = 0.04 * 640.0;
for (key, name, x) in [("leftComp", "Scene Left Eye", 320.0 - d / 2.0), ("rightComp", "Scene Right Eye", 320.0 + d / 2.0)] {
let ec = ItemId(r[key].as_u64().unwrap());
assert_eq!(s.project.item(ec).unwrap().name, name);
let c = s.project.comp(ec).unwrap();
assert_eq!((c.width, c.height), (640, 360));
assert_eq!(c.layers.len(), 2);
let cam = &c.layers[0];
assert!(cam.is_camera() && effectcraft_render::three_d::camera::is_two_node(cam));
assert!(close3(v3(cam, "transform/position"), [x, 180.0, -800.0], 1e-6), "{:?}", v3(cam, "transform/position"));
// Parallel eyes (no convergence): each looks straight ahead.
assert!(close3(v3(cam, "transform/poi"), [x, 180.0, 0.0], 1e-6), "{:?}", v3(cam, "transform/poi"));
assert_eq!(cam.props.prop("cameraOptions/zoom").unwrap().value, KV::Scalar(900.0));
for p in ["transform/position", "transform/poi", "cameraOptions/zoom"] {
let e = cam.props.prop(p).unwrap().expr.clone().unwrap();
assert!(e.text.contains("comp(\"Scene\")") && e.text.contains("Cam"), "{p}: {}", e.text);
}
let nest = &c.layers[1];
assert_eq!(nest.source, LayerSource::Comp { item: src });
assert!(nest.switches.collapse && nest.switches.three_d);
}
// The stereo comp: left eye on top with 3D Glasses fed by both eyes; right eye hidden.
let oc = ItemId(r["stereoComp"].as_u64().unwrap());
let out = s.project.comp(oc).unwrap();
assert_eq!(s.project.item(oc).unwrap().name, "Scene Stereo 3D");
let (l, rr) = (r["leftLayer"].as_u64().unwrap(), r["rightLayer"].as_u64().unwrap());
assert_eq!(out.layers[0].id.0, l);
assert!(!out.layers[1].switches.video);
let g = out.layers[0].effects().unwrap().groups().next().unwrap().clone();
assert!(matches!(&g.kind, GroupKind::Effect { effect } if effect == "ec.perspective.3dglasses"));
assert_eq!(g.get("leftView").unwrap().value, KV::Layer(Some(l)));
assert_eq!(g.get("rightView").unwrap().value, KV::Layer(Some(rr)));
// One undo step; redo; serde.
let json = serde_json::to_string(&*s.project).unwrap();
let back: Project = serde_json::from_str(&json).unwrap();
assert_eq!(back, *s.project);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project.items.len(), items_before);
assert_eq!(s.project.comp(src).unwrap().layers.len(), 1);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.project.items.len(), items_before + 3);
}
#[test]
fn stereo_rig_configurations_and_convergence() {
let mut s = session();
camera(&mut s);
let r = s.execute("camera.stereoRig", json!({"configuration": "centerRight", "sceneDepth": 5, "convergence": true})).unwrap();
let d = 0.05 * 640.0;
let eye = |key: &str| s.project.comp(ItemId(r[key].as_u64().unwrap())).unwrap().layers[0].clone();
let (l, rr) = (eye("leftComp"), eye("rightComp"));
assert!(close3(v3(&l, "transform/position"), [320.0, 180.0, -800.0], 1e-6));
assert!(close3(v3(&rr, "transform/position"), [320.0 + d, 180.0, -800.0], 1e-6));
// Converged on the master's point of interest.
assert!(close3(v3(&l, "transform/poi"), [320.0, 180.0, 0.0], 1e-6));
assert!(close3(v3(&rr, "transform/poi"), [320.0, 180.0, 0.0], 1e-6));
// A comp without a camera gets a Master Camera.
let mut s = session();
let r = s.execute("camera.stereoRig", json!({"configuration": "centerLeft"})).unwrap();
let m = layer_in(&s, cid(&s), r["master"].as_u64().unwrap());
assert_eq!(m.name, "Master Camera");
assert!(s.execute("camera.stereoRig", json!({"configuration": "sideways"})).is_err());
}
/// The eye cameras' expressions reproduce the static values (and follow the master).
#[test]
fn stereo_rig_maths() {
use crate::commands::rig3d::{StereoParams, eye_camera};
let sp = StereoParams { configuration: 0, scene_depth: 10.0, convergence: false, convergence_of: 0, z_offset: 0.0 };
let master = (vec3(0.0, 0.0, -100.0), vec3(0.0, 0.0, 1.0), vec3(1.0, 0.0, 0.0));
let (p0, q0) = eye_camera(&sp, 0, 1000.0, master, 100.0, 500.0);
assert!(close3([p0.x, p0.y, p0.z], [-50.0, 0.0, -100.0], 1e-9) && close3([q0.x, q0.y, q0.z], [-50.0, 0.0, 0.0], 1e-9));
let sp = StereoParams { convergence: true, convergence_of: 1, z_offset: 50.0, ..sp };
let (_, q1) = eye_camera(&sp, 1, 1000.0, master, 100.0, 500.0);
// Zoom plane (500) + Z offset (50) in front of the master eye.
assert!(close3([q1.x, q1.y, q1.z], [0.0, 0.0, 450.0], 1e-9), "{q1:?}");
}
#[test]
fn orbit_null_parents_the_camera_at_its_point_of_interest() {
let mut s = session();
let cam = camera(&mut s);
let (eye0, f0) = frame(&s, cam);
let r = s.execute("camera.orbitNull", json!({})).unwrap();
let nid = r["null"].as_u64().unwrap();
let n = layer_in(&s, cid(&s), nid);
assert!(n.is_3d() && matches!(n.source, LayerSource::Null));
assert_eq!(n.name, "Cam Orbit Null");
assert!(close3(v3(&n, "transform/position"), [320.0, 180.0, 0.0], 1e-9));
let c = layer_in(&s, cid(&s), cam);
assert_eq!(c.parent, Some(LayerId(nid)));
// The camera stays where it was.
let (eye1, f1) = frame(&s, cam);
assert!((eye1 - eye0).length() < 1e-6 && (f1 - f0).length() < 1e-6, "{eye0:?} {eye1:?}");
// Turning the null orbits the camera around the point of interest.
s.execute("prop.set", json!({"layer": nid, "path": "transform/rotationY", "value": 90})).unwrap();
let (eye2, f2) = frame(&s, cam);
let poi = vec3(320.0, 180.0, 0.0);
assert!(((eye2 - poi).length() - 800.0).abs() < 1e-6, "{eye2:?}");
assert!((eye2.z).abs() < 1e-6 && (eye2.x - 320.0).abs() > 700.0, "{eye2:?}");
assert!(((poi - eye2).normalize() - f2).length() < 1e-6, "still looks at the POI: {f2:?}");
// A parented camera can't get another orbit null; undo restores the camera.
assert!(s.execute("camera.orbitNull", json!({"layer": cam})).is_err());
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(layer_in(&s, cid(&s), cam).parent, None);
assert!(s.active_comp().unwrap().layer(LayerId(nid)).is_none());
}
#[test]
fn control_light_with_camera_follows_and_releases() {
let mut s = session();
let cam = camera(&mut s);
let light = s.execute("layer.newLight", json!({"kind": "Spot", "position": [0, 0, -500], "poi": [100, 100, 0]})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.select", json!({"layers": [light]})).unwrap();
let r = s.execute("light.controlWithCamera", json!({})).unwrap();
assert_eq!(r["controlled"], true);
let l = layer_in(&s, cid(&s), light);
assert_eq!(l.parent, Some(LayerId(cam)));
let (ce, cf) = frame(&s, cam);
let (le, lf) = frame(&s, light);
assert!((le - ce).length() < 1e-6 && (lf - cf).length() < 1e-6, "light at the camera: {le:?} {lf:?}");
// Moving the camera carries the light.
s.execute("prop.set", json!({"layer": cam, "path": "transform/position", "value": [100, 180, -800]})).unwrap();
let (le2, _) = frame(&s, light);
assert!((le2 - vec3(100.0, 180.0, -800.0)).length() < 1e-6, "{le2:?}");
// Again: released where it is (unparented, aimed the same way).
let r = s.execute("light.controlWithCamera", json!({"layers": [light]})).unwrap();
assert_eq!(r["controlled"], false);
let l = layer_in(&s, cid(&s), light);
assert_eq!(l.parent, None);
let (le3, lf3) = frame(&s, light);
assert!((le3 - le2).length() < 1e-6, "{le3:?}");
let (_, cf2) = frame(&s, cam);
assert!((lf3 - cf2).length() < 1e-6, "{lf3:?} {cf2:?}");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(layer_in(&s, cid(&s), light).parent, Some(LayerId(cam)));
// Not a light → error.
assert!(s.execute("light.controlWithCamera", json!({"layers": [cam]})).is_err());
}
// ---------------------------------------------------------------- from 3D model
struct DiskModels;
impl FootageSource for DiskModels {
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<effectcraft_raster::Image>> {
None
}
fn model(&self, _: ItemId, f: &Footage) -> Option<Arc<effectcraft_model::Model>> {
let bytes = std::fs::read(&f.path).ok()?;
effectcraft_model::load(&f.path, &bytes, &|_| None).ok().map(Arc::new)
}
}
struct ModelImporter;
impl Importer for ModelImporter {
fn probe(&self, path: &str) -> Result<Footage, String> {
effectcraft_model::format_of(path).ok_or("not a model")?;
Ok(Footage { path: path.into(), kind: FootageKind::Model, frame_rate: FrameRate::FPS_30, codec: "glTF".into(), ..Default::default() })
}
}
const RIGGED: &str = r#"{
"asset": {"version": "2.0"},
"scenes": [{"nodes": [0, 1, 2]}],
"nodes": [
{"name": "ShotCam", "camera": 0, "translation": [0, 0, 5]},
{"name": "Key", "translation": [0, 3, 0], "rotation": [-0.7071068, 0, 0, 0.7071068], "extensions": {"KHR_lights_punctual": {"light": 0}}},
{"name": "Fill", "translation": [2, 0, 0], "extensions": {"KHR_lights_punctual": {"light": 1}}}
],
"cameras": [{"type": "perspective", "name": "Shot", "perspective": {"yfov": 0.6, "znear": 0.1}}],
"extensions": {"KHR_lights_punctual": {"lights": [
{"type": "spot", "name": "Key Light", "color": [1, 0.8, 0.6], "intensity": 1.5, "spot": {"innerConeAngle": 0.25, "outerConeAngle": 0.5}},
{"type": "point", "name": "Fill Light", "intensity": 0.5, "range": 4}
]}}
}"#;
fn rigged_model(s: &mut Session) -> u64 {
let dir = std::env::temp_dir().join(format!("ec-rig3d-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("rigged.gltf");
std::fs::write(&path, RIGGED).unwrap();
let r = s.execute("layer.newModel", json!({"path": path.to_string_lossy()})).unwrap();
r["layer"].as_u64().unwrap()
}
#[test]
fn cameras_and_lights_from_model() {
let mut s = session();
let model = rigged_model(&mut s);
let m = layer_in(&s, cid(&s), model);
let unit = m.props.prop("geometryOptions/unitScale").unwrap().value.as_f64();
let pos = v3(&m, "transform/position");
s.execute("layer.select", json!({"layers": [model]})).unwrap();
let r = s.execute("camera.fromModel", json!({})).unwrap();
let cams = r["layers"].as_array().unwrap();
assert_eq!(cams.len(), 1);
let c = layer_in(&s, cid(&s), cams[0].as_u64().unwrap());
assert_eq!(c.name, "Shot");
// glTF +Z (towards the viewer) is AE −Z; the camera looks into the scene (+Z).
let want = [pos[0], pos[1], pos[2] - 5.0 * unit];
assert!(close3(v3(&c, "transform/position"), want, 1e-6), "{:?} vs {want:?}", v3(&c, "transform/position"));
let zoom = 180.0 / (0.3f64).tan();
assert!((c.props.prop("cameraOptions/zoom").unwrap().value.as_f64() - zoom).abs() < 1e-6);
let (_, f) = frame(&s, c.id.0);
assert!((f - vec3(0.0, 0.0, 1.0)).length() < 1e-6, "{f:?}");
// Lights: a spot (cone 2 × outer, feather from inner/outer) and a point light.
s.execute("layer.select", json!({"layers": [model]})).unwrap();
let r = s.execute("light.fromModel", json!({})).unwrap();
let ls: Vec<Layer> = r["layers"].as_array().unwrap().iter().map(|v| layer_in(&s, cid(&s), v.as_u64().unwrap())).collect();
assert_eq!(ls.len(), 2);
let key = ls.iter().find(|l| l.name == "Key Light").unwrap();
assert_eq!(key.source, LayerSource::Light { kind: LightKind::Spot });
assert!((key.props.prop("lightOptions/coneAngle").unwrap().value.as_f64() - 1.0f64.to_degrees()).abs() < 1e-6);
assert!((key.props.prop("lightOptions/coneFeather").unwrap().value.as_f64() - 50.0).abs() < 1e-6);
assert!((key.props.prop("lightOptions/intensity").unwrap().value.as_f64() - 150.0).abs() < 1e-9);
// Points down in glTF (−Y) = down on screen in AE (+Y).
let (ke, kf) = frame(&s, key.id.0);
assert!((kf - vec3(0.0, 1.0, 0.0)).length() < 1e-5, "{kf:?}");
assert!((ke.y - (pos[1] - 3.0 * unit)).abs() < 1e-6, "{ke:?}");
let fill = ls.iter().find(|l| l.name == "Fill Light").unwrap();
assert_eq!(fill.source, LayerSource::Light { kind: LightKind::Point });
assert!((fill.props.prop("lightOptions/falloffDistance").unwrap().value.as_f64() - 400.0).abs() < 1e-9);
s.execute("edit.undo", json!({})).unwrap();
assert!(s.active_comp().unwrap().layer(key.id).is_none());
// Not a model layer → error.
assert!(s.execute("camera.fromModel", json!({"layers": [c.id.0]})).is_err());
}
// ---------------------------------------------------------------- environment background
fn px(img: &effectcraft_raster::Image, x: u32, y: u32) -> [f32; 4] {
img.data[(y * img.width + x) as usize]
}
#[test]
fn environment_background_draws_the_sky_through_the_camera() {
let mut s = session();
let scene = cid(&s);
// An equirectangular "sky": red upper hemisphere, blue lower.
let sky = s.execute("comp.new", json!({"name": "Sky", "width": 200, "height": 100, "frameRate": 30, "duration": 2})).unwrap()["comp"].as_u64().unwrap();
s.execute("layer.newSolid", json!({"color": "#0000ff", "width": 200, "height": 100})).unwrap();
let top = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 200, "height": 50})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.set", json!({"layer": top, "path": "transform/position", "value": [100, 25]})).unwrap();
s.execute("comp.open", json!({"comp": scene})).unwrap();
let src = s.execute("layer.addItem", json!({"item": sky})).unwrap()["layer"].as_u64().unwrap();
s.execute("layer.environment", json!({"layers": [src], "on": true})).unwrap();
// No environment light yet.
assert!(s.execute("light.environmentBackground", json!({})).is_err());
let env = s.execute("layer.newLight", json!({"kind": "Environment"})).unwrap()["layer"].as_u64().unwrap();
let r = s.execute("light.environmentBackground", json!({})).unwrap();
assert_eq!(r["light"].as_u64(), Some(env));
assert_eq!(r["source"].as_u64(), Some(src));
let bg = layer_in(&s, scene, r["layer"].as_u64().unwrap());
assert!(bg.environment_background && bg.is_3d());
assert_eq!(bg.source, LayerSource::Comp { item: ItemId(sky) });
assert_eq!(s.active_comp().unwrap().layers.last().unwrap().id, bg.id, "at the bottom");
let img = s.render(scene, Tick::ZERO, RenderOpts::default());
// Looking at the horizon: rays above it see red, below it blue.
let (t, b) = (px(&img, 320, 5), px(&img, 320, 354));
assert!(t[0] > 0.9 && t[2] < 0.1 && t[3] > 0.99, "top {t:?}");
assert!(b[2] > 0.9 && b[0] < 0.1, "bottom {b:?}");
// Serde keeps the flag; undo removes the layer.
let json = serde_json::to_value(&bg).unwrap();
assert_eq!(json["environment_background"], Value::Bool(true));
s.execute("edit.undo", json!({})).unwrap();
assert!(s.active_comp().unwrap().layer(bg.id).is_none());
}

View File

@ -332,7 +332,7 @@ fn implemented_stubs_are_enabled_and_workspaces_are_frontend() {
assert!(s.drain_events().iter().any(|e| matches!(e, Event::Frontend { command, .. } if command == id)));
}
// What remains a stub is still disabled.
for id in ["view.displayColorManagement", "keys.audioToKeyframes"] {
for id in ["layer.autoTrace", "keys.audioToKeyframes"] {
assert!(!s.is_enabled(id), "{id}");
}
}

View File

@ -5,6 +5,7 @@
use std::sync::Arc;
use effectcraft_color::{ColorSpace, space};
use effectcraft_geom::vec2;
use effectcraft_project::{Layer, LayerId};
use effectcraft_raster::Image;
@ -359,6 +360,273 @@ pub fn display_transform(px: &mut [[u8; 4]], channel: Channel, colorized: bool,
}
}
// ---------------------------------------------------------------- display colour management
/// View ▸ Simulate Output: the output device a preview imitates.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SimProfile {
#[default]
None,
/// HDTV (Rec. 709): ITU-R BT.709 primaries, BT.1886 gamma 2.4.
Rec709,
/// UHDTV (Rec. 2020): ITU-R BT.2020 primaries, gamma 2.4.
Rec2020,
/// Display P3: SMPTE EG 432-1 primaries, D65, the sRGB curve.
P3,
/// Internet Standard RGB (sRGB, IEC 61966-2-1).
Srgb,
/// Linear light with Rec. 709 primaries (no transfer curve).
Linear,
/// SDTV NTSC: SMPTE 170M primaries, gamma 2.4.
Ntsc,
/// SDTV PAL: EBU Tech 3213 primaries, gamma 2.4.
Pal,
/// Legacy Macintosh RGB: Rec. 709 primaries, gamma 1.8.
Mac18,
}
/// A transfer curve of a simulated or display profile.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Curve {
Srgb,
Gamma(f32),
Linear,
/// A colour space's own curve (PQ, HLG…).
Space(space::Curve),
}
impl Curve {
fn decode(self, v: f32) -> f32 {
let a = v.abs();
let l = match self {
Curve::Srgb => srgb_to_linear(a),
Curve::Gamma(g) => a.powf(g),
Curve::Linear => a,
Curve::Space(c) => c.decode(a),
};
l.copysign(v)
}
fn encode(self, v: f32) -> f32 {
let a = v.abs();
let e = match self {
Curve::Srgb => linear_to_srgb(a),
Curve::Gamma(g) => a.powf(1.0 / g),
Curve::Linear => a,
Curve::Space(c) => c.encode(a),
};
e.copysign(v)
}
}
const P709: [[f64; 2]; 3] = [[0.640, 0.330], [0.300, 0.600], [0.150, 0.060]];
impl SimProfile {
pub const ALL: [SimProfile; 9] = [
SimProfile::None,
SimProfile::Rec709,
SimProfile::Ntsc,
SimProfile::Pal,
SimProfile::Mac18,
SimProfile::Srgb,
SimProfile::Rec2020,
SimProfile::P3,
SimProfile::Linear,
];
pub fn id(self) -> &'static str {
match self {
SimProfile::None => "none",
SimProfile::Rec709 => "rec709",
SimProfile::Rec2020 => "rec2020",
SimProfile::P3 => "p3",
SimProfile::Srgb => "srgb",
SimProfile::Linear => "linear",
SimProfile::Ntsc => "ntsc",
SimProfile::Pal => "pal",
SimProfile::Mac18 => "mac18",
}
}
pub fn label(self) -> &'static str {
match self {
SimProfile::None => "No Output Simulation",
SimProfile::Rec709 => "HDTV (Rec. 709)",
SimProfile::Rec2020 => "UHDTV (Rec. 2020)",
SimProfile::P3 => "Display P3",
SimProfile::Srgb => "Internet Standard RGB (sRGB)",
SimProfile::Linear => "Linear (1.0 Gamma)",
SimProfile::Ntsc => "SDTV NTSC",
SimProfile::Pal => "SDTV PAL",
SimProfile::Mac18 => "Legacy Macintosh RGB (Gamma 1.8)",
}
}
pub fn parse(s: &str) -> Option<SimProfile> {
let k: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase();
SimProfile::ALL
.into_iter()
.find(|p| p.id() == k || p.label().chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase() == k)
.or(match k.as_str() {
"off" => Some(SimProfile::None),
"hdtv" | "bt709" => Some(SimProfile::Rec709),
"bt2020" | "uhdtv" => Some(SimProfile::Rec2020),
"displayp3" => Some(SimProfile::P3),
"lin" | "linearlight" => Some(SimProfile::Linear),
_ => None,
})
}
/// (primaries, curve); `None` for No Output Simulation.
pub fn space(self) -> Option<([[f64; 2]; 3], Curve)> {
Some(match self {
SimProfile::None => return None,
SimProfile::Rec709 => (P709, Curve::Gamma(2.4)),
SimProfile::Rec2020 => (ColorSpace::Rec2020.primaries(), Curve::Gamma(2.4)),
SimProfile::P3 => (ColorSpace::DisplayP3.primaries(), Curve::Srgb),
SimProfile::Srgb => (P709, Curve::Srgb),
SimProfile::Linear => (P709, Curve::Linear),
SimProfile::Ntsc => ([[0.630, 0.340], [0.310, 0.595], [0.155, 0.070]], Curve::Gamma(2.4)),
SimProfile::Pal => ([[0.640, 0.330], [0.290, 0.600], [0.150, 0.060]], Curve::Gamma(2.4)),
SimProfile::Mac18 => (P709, Curve::Gamma(1.8)),
})
}
}
/// View ▸ Simulate Output setting: the profile, and Preserve RGB (the output numbers are sent
/// to the display unconverted, as on a device that doesn't colour-manage).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(default)]
pub struct Simulation {
pub profile: SimProfile,
pub preserve_rgb: bool,
}
/// An RGB space for viewer conversions: primaries (D65) and curve.
#[derive(Clone, Copy, Debug, PartialEq)]
struct Prof {
/// Linear RGB → XYZ D65.
xyz: [[f64; 3]; 3],
curve: Curve,
}
impl Prof {
fn new(prims: [[f64; 2]; 3], curve: Curve) -> Prof {
Prof { xyz: space::rgb_to_xyz(prims, D65), curve }
}
/// A colour space (linear when `linear`).
fn of(cs: ColorSpace, linear: bool) -> Prof {
let curve = if linear { Curve::Linear } else { Curve::Space(cs.curve()) };
Prof { xyz: cs.to_xyz(), curve }
}
}
/// A linear-RGB matrix plus curves between two profiles.
#[derive(Clone, Copy, Debug)]
struct Conv {
from: Curve,
m: Option<[[f32; 3]; 3]>,
to: Curve,
}
impl Conv {
fn new(a: Prof, b: Prof) -> Conv {
let m = (a.xyz != b.xyz).then(|| {
let m = space::mul(&space::invert(&b.xyz), &a.xyz);
m.map(|r| r.map(|v| v as f32))
});
Conv { from: a.curve, m, to: b.curve }
}
fn apply(&self, c: [f32; 3]) -> [f32; 3] {
let l = c.map(|v| self.from.decode(v));
let l = match &self.m {
Some(m) => [0, 1, 2].map(|i| m[i][0] * l[0] + m[i][1] * l[1] + m[i][2] * l[2]),
None => l,
};
l.map(|v| self.to.encode(v))
}
}
const D65: [f64; 2] = [0.3127, 0.3290];
/// The viewer's colour conversion from the rendered frame (sRGB-encoded display pixels) to
/// what the monitor shows (View ▸ Use Display Color Management, View ▸ Simulate Output and
/// the display profile in Settings ▸ Previews). `None` when nothing changes.
#[derive(Clone, Copy, Debug)]
pub struct DisplayColor {
sim: Option<(Conv, Option<Conv>)>,
display: Option<Conv>,
}
impl DisplayColor {
/// `working`: the project working space (None = unmanaged: nothing is converted, as in
/// After Effects); `linear`: the working space is linear; `source`: the space rendered
/// frames are in (the project's output space, sRGB by default); `dcm`: Use Display Color
/// Management; `display`: the monitor's space.
pub fn new(working: Option<ColorSpace>, linear: bool, source: ColorSpace, dcm: bool, display: ColorSpace, sim: Simulation) -> Option<DisplayColor> {
let ws = working?;
let src = Prof::of(source, false);
if !dcm {
// Without display colour management the working space's numbers go to the screen.
let raw = Conv::new(src, Prof::of(ws, linear));
return Some(DisplayColor { sim: None, display: Some(raw) });
}
let sim = sim.profile.space().map(|(prims, curve)| {
let out = Prof::new(prims, curve);
(
Conv::new(src, out),
Some(if sim.preserve_rgb {
Conv::new(Prof::new(P709, Curve::Srgb), Prof::of(display, false))
} else {
Conv::new(out, Prof::of(display, false))
}),
)
});
let display = (display != source).then(|| Conv::new(src, Prof::of(display, false)));
(sim.is_some() || display.is_some()).then_some(DisplayColor { sim, display })
}
/// The conversion a session's viewer uses.
pub fn of(s: &crate::Session) -> Option<DisplayColor> {
let st = &s.project.settings;
let display = ColorSpace::parse(&s.prefs.previews.display_profile).unwrap_or(ColorSpace::Srgb);
let v = &s.state.viewer;
let linear = st.working_space.is_some_and(|w| st.linearize || w.is_linear());
DisplayColor::new(st.working_space, linear, st.output_space.unwrap_or(ColorSpace::Srgb), v.display_color_management, display, v.simulation)
}
/// One straight colour (0..1 floats).
pub fn apply_straight(&self, c: [f32; 3]) -> [f32; 3] {
let c = match &self.sim {
// Through the output's 8-bit encoding (clipped and quantised like the real output),
// then shown on the display: converted from the output profile, or — Preserve RGB —
// the output numbers taken as if they were sRGB.
Some((to, show)) => {
let o = to.apply(c).map(|v| (v.clamp(0.0, 1.0) * 255.0).round() / 255.0);
show.map_or(o, |b| b.apply(o))
}
None => self.display.map_or(c, |d| d.apply(c)),
};
c.map(|v| v.clamp(0.0, 1.0))
}
/// Premultiplied 8-bit RGBA pixels in place.
pub fn apply(&self, px: &mut [[u8; 4]]) {
use rayon::prelude::*;
px.par_iter_mut().for_each(|p| {
let a = p[3];
if a == 0 {
return;
}
let af = a as f32 / 255.0;
let c = [0, 1, 2].map(|i| (p[i] as f32 / 255.0 / af).min(1.0));
let o = self.apply_straight(c);
for i in 0..3 {
p[i] = (o[i] * af * 255.0 + 0.5) as u8;
}
});
}
}
// ---------------------------------------------------------------- snapshot
/// Take Snapshot (Shift+F5): a rendered frame kept for comparison (Show Snapshot, F5).

View File

@ -526,6 +526,8 @@ Layer.prototype = {
get opacity() { return this.transform.opacity; },
get anchorPoint() { return this.transform.anchorPoint; },
get text() { return this.__group('text'); },
get cameraOption() { return this.__group('cameraOptions'); },
get lightOption() { return this.__group('lightOptions'); },
get marker() { return new Markers(this.__c, this.__l); },
effect: function (k) {
var g = __child(this.__c, this.__l, '', 'effects');

233
crates/gpu/src/classic3d.rs Normal file
View File

@ -0,0 +1,233 @@
//! Classic 3D runs on the GPU (`classic3d.wgsl`): the CPU prepares the planes
//! ([`Renderer::prepare_3d_run`](effectcraft_render::Renderer::prepare_3d_run): layer buffers,
//! homographies, materials, lights, track mattes, bokeh depth of field), the GPU packs the
//! buffers into one atlas texture and runs the per-pixel depth sort, lighting, ray-cast shadows
//! and blending of `render::three_d::compose` in one kernel.
use std::collections::HashMap;
use effectcraft_geom::Mat4;
use effectcraft_project::LightKind;
use effectcraft_render::three_d::{LightState, PlaneGeo, Run3d};
use crate::context::{Enc, GpuImage, Params};
use crate::ops::mode_id;
/// Fragments kept per pixel (`MAX_FRAGS` in the shader). Runs that could stack more planes on
/// one pixel render on the CPU.
const MAX_FRAGS: usize = 32;
/// Little-endian words for the shader's storage structs.
#[derive(Default)]
struct Words(Vec<u8>);
impl Words {
fn f(&mut self, v: [f32; 4]) {
for x in v {
self.0.extend_from_slice(&x.to_le_bytes());
}
}
fn u(&mut self, v: [u32; 4]) {
for x in v {
self.0.extend_from_slice(&x.to_le_bytes());
}
}
fn d(&mut self, v: [f64; 4]) {
self.f(v.map(|x| x as f32));
}
/// Pad an empty array to one element (`size` bytes) so its binding is valid.
fn at_least(mut self, size: usize) -> Vec<u8> {
if self.0.len() < size {
self.0.resize(size, 0);
}
self.0
}
}
fn affine_rows(w: &mut Words, m: &Mat4) {
for r in 0..3 {
w.d(m.0[r]);
}
}
/// Shelf-pack rectangles into an atlas no larger than `max` on a side: (width, height,
/// positions).
fn pack(sizes: &[(u32, u32)], max: u32) -> Option<(u32, u32, Vec<(u32, u32)>)> {
let maxw = sizes.iter().map(|s| s.0).max()?;
if maxw > max {
return None;
}
let area: f64 = sizes.iter().map(|s| s.0 as f64 * s.1 as f64).sum();
let limit = maxw.max((area.sqrt() * 1.25).ceil() as u32).min(max);
let mut order: Vec<usize> = (0..sizes.len()).collect();
order.sort_by_key(|&i| std::cmp::Reverse(sizes[i].1));
let mut pos = vec![(0, 0); sizes.len()];
let (mut x, mut y, mut shelf, mut width) = (0u32, 0u32, 0u32, 0u32);
for i in order {
let (w, h) = sizes[i];
if x + w > limit {
y += shelf;
x = 0;
shelf = 0;
}
pos[i] = (x, y);
x += w;
shelf = shelf.max(h);
width = width.max(x);
}
let height = y + shelf;
(height <= max).then_some((width.max(1), height.max(1), pos))
}
/// Upper bound of the planes drawn on any one pixel: a pixel covered by a set of planes lies in
/// each one's box, so every plane of the set overlaps the box of any member.
fn max_overlap(boxes: &[[i64; 4]]) -> usize {
boxes.iter().map(|a| boxes.iter().filter(|b| a[0] < b[2] && b[0] < a[2] && a[1] < b[3] && b[1] < a[3]).count()).max().unwrap_or(0)
}
fn light_kind(k: LightKind) -> u32 {
match k {
LightKind::Ambient | LightKind::Environment => 0,
LightKind::Parallel => 1,
LightKind::Point => 2,
LightKind::Spot => 3,
}
}
fn write_geo(w: &mut Words, g: &PlaneGeo) {
// The homography is defined up to scale: normalise it (by a positive factor, keeping the
// sign tests) into f32's comfortable range.
let m = g.hinv.0.iter().flatten().fold(0.0f64, |a, v| a.max(v.abs()));
let k = if m > 0.0 { 1.0 / m } else { 1.0 };
for r in 0..3 {
w.d([g.hinv.0[r][0] * k, g.hinv.0[r][1] * k, g.hinv.0[r][2] * k, 0.0]);
}
w.d([g.depth[0], g.depth[1], g.depth[2], 0.0]);
affine_rows(w, &g.world);
w.d([g.normal.x, g.normal.y, g.normal.z, 0.0]);
w.d([g.cam.eye.x, g.cam.eye.y, g.cam.eye.z, if g.cam.ortho { 1.0 } else { 0.0 }]);
let f = g.cam.forward();
w.d([f.x, f.y, f.z, 0.0]);
}
fn write_light(w: &mut Words, l: &LightState) {
w.u([light_kind(l.kind), l.falloff, l.casts_shadows as u32, 0]);
w.d([l.pos.x, l.pos.y, l.pos.z, 0.0]);
w.d([l.dir.x, l.dir.y, l.dir.z, 0.0]);
w.f([l.color[0], l.color[1], l.color[2], 0.0]);
w.d([l.cone_half, l.feather, l.radius, l.falloff_distance]);
w.d([l.shadow_darkness, l.shadow_diffusion, 0.0, 0.0]);
}
const PLANE_BYTES: usize = 12 * 16;
const GEO_BYTES: usize = 10 * 16;
const LIGHT_BYTES: usize = 6 * 16;
/// Composite a prepared Classic 3D run over `canvas`. `None` when it cannot run here (the
/// caller draws it on the CPU).
pub(crate) fn draw_run(e: &mut Enc, run: &Run3d, canvas: &GpuImage) -> Option<GpuImage> {
let (w, h) = (canvas.width, canvas.height);
let boxes: Vec<[i64; 4]> = run.planes.iter().filter(|p| p.draw).map(|p| p.bbox).collect();
if boxes.is_empty() {
return Some(canvas.clone());
}
if max_overlap(&boxes) > MAX_FRAGS
|| run.planes.iter().any(|p| p.winv.0[3] != [0.0, 0.0, 0.0, 1.0] || p.geos.iter().any(|g| g.world.0[3] != [0.0, 0.0, 0.0, 1.0]))
{
return None;
}
// Plane textures: the layer uploads (cached per buffer; bokeh depth of field is already in
// them).
let mut texs: Vec<(GpuImage, [f64; 2])> = Vec::with_capacity(run.planes.len());
for p in &run.planes {
texs.push((e.g.upload_buf(&p.buf)?, p.buf.offset));
}
let sizes: Vec<(u32, u32)> = texs.iter().map(|(t, _)| (t.width, t.height)).collect();
let (aw, ah, pos) = pack(&sizes, e.g.max_dim)?;
if (aw as u64) * (ah as u64) * 16 > 2 << 30 {
return None;
}
let atlas = e.image(aw, ah);
for ((t, _), &(x, y)) in texs.iter().zip(&pos) {
e.copy_into(t, &atlas, x, y);
}
// Track mattes (shared by the planes of one layer).
let mut mattes: Vec<f32> = vec![];
let mut matte_ix: HashMap<usize, u32> = HashMap::new();
let n_px = w as usize * h as usize;
let mut planes = Words::default();
let mut geos = Words::default();
let mut n_geos = 0u32;
for ((p, (t, off)), &(ax, ay)) in run.planes.iter().zip(&texs).zip(&pos) {
let matte = match &p.matte {
Some(m) if m.len() == n_px => {
let key = std::sync::Arc::as_ptr(m) as usize;
let next = matte_ix.len() as u32 + 1;
let ix = *matte_ix.entry(key).or_insert(next);
if ix == next {
mattes.extend_from_slice(m);
}
ix
}
Some(_) => return None,
None => 0,
};
planes.f([ax as f32, ay as f32, t.width as f32, t.height as f32]);
planes.d([off[0], off[1], p.buf.scale, if p.bicubic { 1.0 } else { 0.0 }]);
planes.d(p.bbox.map(|v| v as f64));
planes.d(p.bounds);
affine_rows(&mut planes, &p.winv);
planes.f([p.mat.ambient, p.mat.diffuse, p.mat.specular, p.mat.exponent() as f32]);
planes.f([p.mat.metal, p.mat.light_transmission, p.opacity, 0.0]);
let flags = p.draw as u32 | (p.preserve as u32) << 1 | (p.mat.accepts_lights as u32) << 2;
planes.u([n_geos, p.geos.len() as u32, flags, mode_id(p.mode)]);
planes.u([p.mat.casts_shadows, p.mat.accepts_shadows, p.layer_id, p.order as u32]);
planes.u([matte, run.seed ^ p.layer_id, 0, 0]);
for g in &p.geos {
write_geo(&mut geos, g);
}
n_geos += p.geos.len() as u32;
}
if (mattes.len() * 4) as u64 > e.g.device.limits().max_storage_buffer_binding_size {
return None;
}
let mut lights = Words::default();
for l in &run.lights {
write_light(&mut lights, l);
}
let mut casters = Words::default();
for c in &run.casters {
casters.0.extend_from_slice(&(*c as u32).to_le_bytes());
}
let pb = e.bytes(planes.at_least(PLANE_BYTES));
let gb = e.bytes(geos.at_least(GEO_BYTES));
let lb = e.bytes(lights.at_least(LIGHT_BYTES));
let cb = e.bytes(casters.at_least(16));
let mb = e.data(&mattes);
let mut p = Params::default();
p.u[0] = [run.planes.len() as u32, run.lights.len() as u32, run.casters.len() as u32, 0];
let out = e.image(w, h);
e.dispatch_ext("classic3d", &p, &atlas, Some(canvas), &out, Some(&mb), (w.div_ceil(16), h.div_ceil(16)), Some([&pb, &gb, &lb, &cb]));
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shelf_packing_fits_and_does_not_overlap() {
let sizes = [(100, 40), (30, 90), (60, 60), (100, 10), (5, 5)];
let (w, h, pos) = pack(&sizes, 256).unwrap();
for (i, (&(x, y), &(sw, sh))) in pos.iter().zip(&sizes).enumerate() {
assert!(x + sw <= w && y + sh <= h);
for (j, (&(x2, y2), &(sw2, sh2))) in pos.iter().zip(&sizes).enumerate() {
if i != j {
assert!(x + sw <= x2 || x2 + sw2 <= x || y + sh <= y2 || y2 + sh2 <= y, "{i} and {j} overlap");
}
}
}
assert!(pack(&[(300, 10)], 256).is_none());
assert_eq!(max_overlap(&[[0, 0, 10, 10], [5, 5, 15, 15], [20, 20, 30, 30]]), 2);
}
}

View File

@ -26,8 +26,13 @@ const ENTRIES: &[&str] = &[
"shadow_make",
"shadow_combine",
"pointwise",
"adjust_mix",
];
/// Entry points that also bind group 1 (four read-only storage buffers; see
/// [`Enc::dispatch_ext`]).
const EXT_ENTRIES: &[&str] = &["classic3d"];
/// Pixel format of every working texture: premultiplied RGBA, 32-bit float (32 bpc headroom;
/// 8/16 bpc are emulated by clamping and quantising, as on the CPU).
pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float;
@ -88,6 +93,7 @@ pub struct GpuContext {
pub(crate) name: String,
pub(crate) max_dim: u32,
bgl: wgpu::BindGroupLayout,
bgl_ext: wgpu::BindGroupLayout,
pipelines: HashMap<&'static str, wgpu::ComputePipeline>,
display_bgl: wgpu::BindGroupLayout,
display: wgpu::ComputePipeline,
@ -96,6 +102,9 @@ pub struct GpuContext {
uploads: Mutex<Uploads>,
/// Advanced 3D render pipelines (built on first use).
pub(crate) adv3d: std::sync::OnceLock<crate::adv3d::Pipes>,
/// GPU particle pipeline (built on first use) and simulation checkpoints.
pub(crate) particles: crate::particles::PipesCell,
pub(crate) particle_states: crate::particles::StatesCell,
}
fn tex_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
@ -135,7 +144,15 @@ impl GpuContext {
}
}
let name = format!("{} ({:?})", info.name, info.backend);
let src = [include_str!("shaders/common.wgsl"), include_str!("shaders/kernels.wgsl")].concat();
let src = [
include_str!("shaders/common.wgsl"),
include_str!("shaders/kernels.wgsl"),
include_str!("shaders/classic3d.wgsl"),
include_str!("shaders/fx_color.wgsl"),
include_str!("shaders/fx_distort.wgsl"),
include_str!("shaders/fx_generate.wgsl"),
]
.concat();
let module =
device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("effectcraft kernels"), source: wgpu::ShaderSource::Wgsl(src.into()) });
let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
@ -167,12 +184,32 @@ impl GpuContext {
bind_group_layouts: &[Some(&bgl)],
immediate_size: 0,
});
let storage = |binding: u32| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { read_only: true }, has_dynamic_offset: false, min_binding_size: None },
count: None,
};
let bgl_ext = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("effectcraft kernels (ext)"),
entries: &[storage(0), storage(1), storage(2), storage(3)],
});
let layout_ext = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("effectcraft kernels (ext)"),
bind_group_layouts: &[Some(&bgl), Some(&bgl_ext)],
immediate_size: 0,
});
let pipelines = ENTRIES
.iter()
.map(|e| {
.chain(crate::fx_color::KERNELS)
.chain(crate::fx_distort::KERNELS)
.chain(crate::fx_generate::KERNELS)
.map(|e| (e, &layout))
.chain(EXT_ENTRIES.iter().map(|e| (e, &layout_ext)))
.map(|(e, layout)| {
let p = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some(e),
layout: Some(&layout),
layout: Some(layout),
module: &module,
entry_point: Some(e),
compilation_options: Default::default(),
@ -221,6 +258,7 @@ impl GpuContext {
name,
max_dim,
bgl,
bgl_ext,
pipelines,
display_bgl,
display,
@ -228,6 +266,8 @@ impl GpuContext {
dummy_buf,
uploads: Mutex::new(Uploads::default()),
adv3d: std::sync::OnceLock::new(),
particles: Default::default(),
particle_states: Default::default(),
})
}
@ -374,6 +414,22 @@ impl<'g> Enc<'g> {
out: &GpuImage,
data: Option<&wgpu::Buffer>,
groups: (u32, u32),
) {
self.dispatch_ext(entry, p, src, aux, out, data, groups, None);
}
/// [`Enc::dispatch`] for the kernels in `EXT_ENTRIES`, with their group 1 storage buffers.
#[allow(clippy::too_many_arguments)]
pub fn dispatch_ext(
&mut self,
entry: &str,
p: &Params,
src: &GpuImage,
aux: Option<&GpuImage>,
out: &GpuImage,
data: Option<&wgpu::Buffer>,
groups: (u32, u32),
ext: Option<[&wgpu::Buffer; 4]>,
) {
let g = self.g;
let Some(pipe) = g.pipelines.get(entry) else {
@ -395,11 +451,26 @@ impl<'g> Enc<'g> {
wgpu::BindGroupEntry { binding: 4, resource: data.unwrap_or(&g.dummy_buf).as_entire_binding() },
],
});
let bg_ext = ext.map(|b| {
g.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &g.bgl_ext,
entries: &[
wgpu::BindGroupEntry { binding: 0, resource: b[0].as_entire_binding() },
wgpu::BindGroupEntry { binding: 1, resource: b[1].as_entire_binding() },
wgpu::BindGroupEntry { binding: 2, resource: b[2].as_entire_binding() },
wgpu::BindGroupEntry { binding: 3, resource: b[3].as_entire_binding() },
],
})
});
let enc = self.encoder();
{
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some(entry), timestamp_writes: None });
pass.set_pipeline(pipe);
pass.set_bind_group(0, &bg, &[]);
if let Some(b) = &bg_ext {
pass.set_bind_group(1, b, &[]);
}
pass.dispatch_workgroups(groups.0.max(1), groups.1.max(1), 1);
}
self.pending += 1;
@ -525,7 +596,33 @@ impl<'g> Enc<'g> {
for x in v {
bytes.extend_from_slice(&x.to_le_bytes());
}
while bytes.len() < 16 {
self.bytes(bytes)
}
/// Copy an image into a storage buffer (RGBA f32 rows) for kernels that read a third image
/// through `data`: (buffer, row length in pixels).
pub fn image_rows(&mut self, img: &GpuImage) -> (wgpu::Buffer, u32) {
let row = (img.width * 16).div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let buf = self.g.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("image rows"),
size: row as u64 * img.height as u64,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
self.encoder().copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo { texture: &img.texture, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All },
wgpu::TexelCopyBufferInfo {
buffer: &buf,
layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(row), rows_per_image: Some(img.height) },
},
wgpu::Extent3d { width: img.width, height: img.height, depth_or_array_layers: 1 },
);
(buf, row / 16)
}
/// A read-only storage buffer holding `bytes` (padded to 16 bytes).
pub fn bytes(&self, mut bytes: Vec<u8>) -> wgpu::Buffer {
while bytes.len() < 16 || !bytes.len().is_multiple_of(4) {
bytes.push(0);
}
self.g.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("data"), contents: &bytes, usage: wgpu::BufferUsages::STORAGE })

View File

@ -16,19 +16,19 @@ pub fn supports(id: &str) -> bool {
}
/// A layer buffer on the GPU (see [`Buf`]).
struct GBuf {
img: GpuImage,
offset: [f64; 2],
scale: f64,
pub(crate) struct GBuf {
pub img: GpuImage,
pub offset: [f64; 2],
pub scale: f64,
}
impl GBuf {
fn to_px(&self, p: [f64; 2]) -> (f64, f64) {
pub fn to_px(&self, p: [f64; 2]) -> (f64, f64) {
(p[0] * self.scale + self.offset[0], p[1] * self.scale + self.offset[1])
}
/// Buf::pad: grow by `pad` transparent pixels on every side.
fn pad(&mut self, e: &mut Enc, pad: u32) -> Option<()> {
pub fn pad(&mut self, e: &mut Enc, pad: u32) -> Option<()> {
if pad == 0 {
return Some(());
}
@ -62,6 +62,48 @@ pub(crate) fn run_chain(e: &mut Enc, chain: &[FxStep], buf: &Buf, levels: Option
Some(Buf { img, offset: b.offset, scale: b.scale })
}
/// An effect stack running on a GPU-resident image (adjustment layers): GPU chains stay on
/// the device; CPU effects read the image back, run, and upload the result.
pub(crate) struct GpuFx<'e, 'g> {
e: &'e mut Enc<'g>,
b: Option<GBuf>,
levels: Option<f32>,
}
impl<'e, 'g> GpuFx<'e, 'g> {
pub fn new(e: &'e mut Enc<'g>, img: GpuImage, offset: [f64; 2], scale: f64, levels: Option<f32>) -> Self {
GpuFx { e, b: Some(GBuf { img, offset, scale }), levels }
}
/// The result: (image, offset, scale); `None` when a step failed.
pub fn finish(self) -> Option<(GpuImage, [f64; 2], f64)> {
self.b.map(|b| (b.img, b.offset, b.scale))
}
}
impl effectcraft_render::FxTarget for GpuFx<'_, '_> {
fn gpu(&mut self, steps: &[FxStep]) -> bool {
let Some(b) = &self.b else { return false };
let mut cur = GBuf { img: b.img.clone(), offset: b.offset, scale: b.scale };
for step in steps {
let Some(next) = apply(self.e, step.spec.id, &step.ctx, cur) else { return false };
cur = next;
if let Some(l) = self.levels {
cur.img = ops::quantize(self.e, &cur.img, l);
}
}
self.b = Some(cur);
true
}
fn cpu(&mut self, f: &mut dyn FnMut(Buf) -> Buf) {
let Some(b) = self.b.take() else { return };
let Some(img) = self.e.download(&b.img) else { return };
let out = f(Buf { img, offset: b.offset, scale: b.scale });
self.b = self.e.g.upload_image(&out.img).map(|img| GBuf { img, offset: out.offset, scale: out.scale });
}
}
fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
match id {
"ec.blur.gaussian" => gaussian(e, ctx, b),
@ -72,6 +114,9 @@ fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
"ec.distort.transform" => transform(e, ctx, b),
"ec.color.curves" => curves(e, ctx, b),
_ if id.starts_with("ec.control.") => Some(b),
_ if crate::fx_color::IDS.contains(&id) => crate::fx_color::apply(e, id, ctx, b),
_ if crate::fx_distort::IDS.contains(&id) => crate::fx_distort::apply(e, id, ctx, b),
_ if crate::fx_generate::IDS.contains(&id) => crate::fx_generate::apply(e, id, ctx, b),
_ => pointwise(e, id, ctx, b),
}
}
@ -79,7 +124,7 @@ fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
// ---------------------------------------------------------------- blurs
/// raster::blur::box_radii.
fn box_radii(sigma: f64, n: usize) -> Vec<usize> {
pub(crate) fn box_radii(sigma: f64, n: usize) -> Vec<usize> {
if sigma <= 0.0 {
return vec![0; n];
}
@ -95,7 +140,7 @@ fn box_radii(sigma: f64, n: usize) -> Vec<usize> {
}
/// Horizontal passes with radii `rx`, then vertical passes with radii `ry` (raster::blur).
fn box_passes(e: &mut Enc, img: &GpuImage, rx: &[usize], ry: &[usize], repeat: bool) -> GpuImage {
pub(crate) fn box_passes(e: &mut Enc, img: &GpuImage, rx: &[usize], ry: &[usize], repeat: bool) -> GpuImage {
let mut cur = img.clone();
let passes = rx.iter().map(|&r| (false, r)).chain(ry.iter().map(|&r| (true, r))).filter(|p| p.1 > 0);
for (vertical, r) in passes {
@ -111,7 +156,7 @@ fn box_passes(e: &mut Enc, img: &GpuImage, rx: &[usize], ry: &[usize], repeat: b
}
/// raster::gaussian_blur (3 box passes per axis).
fn gaussian_blur(e: &mut Enc, img: &GpuImage, sx: f64, sy: f64, repeat: bool) -> GpuImage {
pub(crate) fn gaussian_blur(e: &mut Enc, img: &GpuImage, sx: f64, sy: f64, repeat: bool) -> GpuImage {
let rx = if sx > 0.05 { box_radii(sx, 3) } else { vec![] };
let ry = if sy > 0.05 { box_radii(sy, 3) } else { vec![] };
box_passes(e, img, &rx, &ry, repeat)

396
crates/gpu/src/fx_color.rs Normal file
View File

@ -0,0 +1,396 @@
//! GPU effects, colour family (kernels in `shaders/fx_color.wgsl`): the CPU effects' exact
//! steps with the pixel loops as compute kernels.
//!
//! Colour Balance, Vibrance, Black & White, Tritone, Colorama, Channel Mixer, Selective Color
//! and Linear Color Key are one per-pixel pass (`fxc_point`); Lumetri Color is a per-pixel pass
//! plus, with Sharpen, a luminance plane blurred like `util::gauss_plane`; Key Light runs its
//! matte pipeline (pre-blur, clip, rollback, despot, shrink / grow, softness, edge band) as
//! plane passes. Key Light with an Inside / Outside mask, or Hard Colour replacement after
//! Screen Softness, and Colorama with Interpolate Palette off run on the CPU.
use effectcraft_color::{luminance, rgb_to_hsl};
use effectcraft_effects::EffectCtx;
use crate::context::{Enc, GpuImage, Params};
use crate::effects::{GBuf, gaussian_blur};
/// Compute entry points in `fx_color.wgsl`.
pub(crate) const KERNELS: &[&str] = &[
"fxc_point",
"fxc_lumetri",
"fxc_luma",
"fxc_sharpen",
"fxc_morph",
"fxc_lerp",
"fxc_kl_raw",
"fxc_kl_rollback",
"fxc_kl_pack",
"fxc_kl_band",
"fxc_kl_edge",
"fxc_kl_final",
];
/// Effect ids implemented here.
pub(crate) const IDS: &[&str] = &[
"ec.color.colorbalance",
"ec.color.vibrance",
"ec.color.lumetri",
"ec.color.blackwhite",
"ec.color.tritone",
"ec.color.colorama",
"ec.color.channelmixer",
"ec.color.selectivecolor",
"ec.key.linearcolor",
"ec.keying.keylight",
];
/// Run effect `id` (one of [`IDS`]); `None` = this parameter combination runs on the CPU.
pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
match id {
"ec.color.lumetri" => lumetri(e, ctx, b),
"ec.keying.keylight" => key_light(e, ctx, b),
_ => point(e, id, ctx, b),
}
}
fn rgb(c: [f32; 4]) -> [f32; 4] {
[c[0], c[1], c[2], 0.0]
}
/// Same selective colour ids as `color2::SC_IDS`.
const SC_IDS: [[&str; 4]; 9] = [
["redsCyan", "redsMagenta", "redsYellow", "redsBlack"],
["yellowsCyan", "yellowsMagenta", "yellowsYellow", "yellowsBlack"],
["greensCyan", "greensMagenta", "greensYellow", "greensBlack"],
["cyansCyan", "cyansMagenta", "cyansYellow", "cyansBlack"],
["bluesCyan", "bluesMagenta", "bluesYellow", "bluesBlack"],
["magentasCyan", "magentasMagenta", "magentasYellow", "magentasBlack"],
["whitesCyan", "whitesMagenta", "whitesYellow", "whitesBlack"],
["neutralsCyan", "neutralsMagenta", "neutralsYellow", "neutralsBlack"],
["blacksCyan", "blacksMagenta", "blacksYellow", "blacksBlack"],
];
fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let pr = ctx.params;
let f = |k: &str| pr.f(k) as f32;
let mut p = Params::default();
let mut data = None;
match id {
"ec.color.colorbalance" => {
let g = |k: &str| f(k) / 100.0;
p.u[0] = [1, pr.b("preserveLuminosity") as u32, 0, 0];
p.f[0] = [g("shadowRed"), g("shadowGreen"), g("shadowBlue"), 0.0];
p.f[1] = [g("midRed"), g("midGreen"), g("midBlue"), 0.0];
p.f[2] = [g("hiRed"), g("hiGreen"), g("hiBlue"), 0.0];
}
"ec.color.vibrance" => {
p.u[0][0] = 2;
p.f[0] = [f("vibrance") / 100.0, f("saturation") / 100.0, 0.0, 0.0];
}
"ec.color.blackwhite" => {
let w = ["reds", "yellows", "greens", "cyans", "blues", "magentas"].map(|k| f(k) / 100.0);
let tc = pr.color("tintColor");
let (th, ts, _) = rgb_to_hsl(tc[0], tc[1], tc[2]);
p.u[0] = [3, pr.b("tint") as u32, 0, 0];
p.f[0] = [w[0], w[1], w[2], w[3]];
p.f[1] = [w[4], w[5], 0.0, 0.0];
p.f[2] = [th, ts, 0.0, 0.0];
}
"ec.color.tritone" => {
p.u[0][0] = 4;
p.f[0] = rgb(pr.color("highlights"));
p.f[1] = rgb(pr.color("midtones"));
p.f[2] = rgb(pr.color("shadows"));
p.f[3][0] = 1.0 - f("blend") / 100.0;
}
"ec.color.colorama" => {
let smooth = pr.get("outputCycle/interpolatePalette").is_none_or(|v| v.as_bool());
// The posterized palette is discontinuous: phases on a step (common with 8 bpc
// input) can round to the other side in the GPU's float maths, so it runs on the CPU.
if !smooth {
return None;
}
p.u[0] = [5, pr.e("inputPhase/mode"), smooth as u32, 0];
p.f[0] = [pr.f("outputCycle/cycles").max(0.0) as f32, f("inputPhase/phaseShift") / 360.0, 1.0 - f("blend") / 100.0, 0.0];
}
"ec.color.channelmixer" => {
let g = |k: &str| f(k) / 100.0;
p.u[0] = [6, pr.b("monochrome") as u32, 0, 0];
p.f[0] = [g("rr"), g("rg"), g("rb"), g("rc")];
p.f[1] = [g("gr"), g("gg"), g("gb"), g("gc")];
p.f[2] = [g("br"), g("bg"), g("bb"), g("bc")];
}
"ec.color.selectivecolor" => {
let adj: Vec<f32> = SC_IDS.iter().flat_map(|ids| ids.map(|k| f(k) / 100.0)).collect();
if adj.iter().all(|v| *v == 0.0) {
return Some(b);
}
p.u[0] = [7, (pr.e("method") == 0) as u32, 0, 0];
data = Some(e.data(&adj));
}
"ec.key.linearcolor" => {
let view = pr.e("view");
if view == 1 {
return Some(b);
}
let k = pr.color("keyColor");
let s = k[0] + k[1] + k[2];
let kc = if s <= 1e-6 { [1.0 / 3.0, 1.0 / 3.0] } else { [k[0] / s, k[1] / s] };
let (kh, _, _) = rgb_to_hsl(k[0], k[1], k[2]);
p.u[0] = [8, pr.e("matchColors"), (pr.e("keyOperation") == 1) as u32, view];
p.f[0] = [k[0], k[1], k[2], f("tolerance") / 100.0];
p.f[1] = [f("softness") / 100.0, kc[0], kc[1], kh];
}
_ => return None,
}
let out = e.image(b.img.width, b.img.height);
e.pixels("fxc_point", &p, &b.img, None, &out, data.as_ref());
Some(GBuf { img: out, ..b })
}
/// color3::tint_offset.
fn tint_offset(c: [f32; 4]) -> [f32; 3] {
let l = (c[0] + c[1] + c[2]) / 3.0;
[c[0] - l, c[1] - l, c[2] - l]
}
fn lumetri(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let pr = ctx.params;
let f = |k: &str| (pr.f(k) / 100.0) as f32;
let mut d: Vec<f32> = vec![
f("basicCorrection/whiteBalance/temperature"),
f("basicCorrection/whiteBalance/tint"),
2f32.powf(pr.f("basicCorrection/tone/exposure") as f32),
f("basicCorrection/tone/contrast"),
f("basicCorrection/tone/highlights"),
f("basicCorrection/tone/shadows"),
f("basicCorrection/tone/whites"),
f("basicCorrection/tone/blacks"),
f("basicCorrection/saturation"),
f("creative/lookIntensity"),
f("creative/adjustments/fadedFilm"),
f("creative/adjustments/vibrance"),
f("creative/adjustments/creativeSaturation"),
(0.5 + 0.25 * f("creative/adjustments/tintBalance")).clamp(0.05, 0.95),
];
d.extend(tint_offset(pr.color("creative/adjustments/shadowTint")));
d.extend(tint_offset(pr.color("creative/adjustments/highlightTint")));
for c in ["Master", "Red", "Green", "Blue"] {
for part in ["Shadows", "Midtones", "Highlights"] {
d.push(f(&format!("curves/curve{c}{part}")));
}
}
for w in ["shadowsWheel", "midtonesWheel", "highlightsWheel"] {
d.extend(tint_offset(pr.color(&format!("colorWheels/{w}"))));
}
let (lx, ly) = (b.offset[0], b.offset[1]);
let (lw, lh) = (ctx.layer_size[0] * b.scale, ctx.layer_size[1] * b.scale);
d.extend([
pr.f("vignette/vignetteAmount") as f32,
f("vignette/vignetteMidpoint") * 0.9,
f("vignette/vignetteRoundness"),
f("vignette/vignetteFeather"),
(lx + lw * 0.5) as f32,
(ly + lh * 0.5) as f32,
lw as f32,
lh as f32,
(lw / lh.max(1e-6)) as f32,
]);
debug_assert_eq!(d.len(), 50);
let buf = e.data(&d);
let out = e.image(b.img.width, b.img.height);
e.pixels("fxc_lumetri", &Params::default(), &b.img, None, &out, Some(&buf));
let sharpen = f("creative/adjustments/sharpen");
if sharpen == 0.0 {
return Some(GBuf { img: out, ..b });
}
let luma = e.image(out.width, out.height);
e.pixels("fxc_luma", &Params::default(), &out, None, &luma, None);
let blurred = gaussian_blur(e, &luma, b.scale, b.scale, true);
let mut p = Params::default();
p.f[0][0] = sharpen * 2.0;
let sharp = e.image(out.width, out.height);
e.pixels("fxc_sharpen", &p, &out, Some(&blurred), &sharp, None);
Some(GBuf { img: sharp, ..b })
}
// ---------------------------------------------------------------- planes
/// util::morph_plane with the same radius on both axes; `modes` = per-channel operation
/// (0 erode, 1 dilate, 2 keep).
fn morph(e: &mut Enc, img: &GpuImage, r: u32, modes: [u32; 4]) -> GpuImage {
if r == 0 {
return img.clone();
}
let mut cur = img.clone();
for vertical in [0, 1] {
let mut p = Params::default();
p.u[0] = [r, vertical, 0, 0];
p.u[1] = modes;
let out = e.image(cur.width, cur.height);
e.pixels("fxc_morph", &p, &cur, None, &out, None);
cur = out;
}
cur
}
/// util::morph_frac (blend between the two nearest integer radii).
fn morph_frac(e: &mut Enc, img: &GpuImage, radius: f64, modes: [u32; 4]) -> GpuImage {
if radius <= 0.0 {
return img.clone();
}
let r0 = radius.floor() as u32;
let t = (radius - r0 as f64) as f32;
let a = morph(e, img, r0, modes);
if t < 1e-4 {
return a;
}
let b = morph(e, img, r0 + 1, modes);
let mut p = Params::default();
p.f[0][0] = t;
let out = e.image(img.width, img.height);
e.pixels("fxc_lerp", &p, &a, Some(&b), &out, None);
out
}
const ERODE_X: [u32; 4] = [0, 2, 2, 2];
const DILATE_X: [u32; 4] = [1, 2, 2, 2];
// ---------------------------------------------------------------- Key Light
/// keylight::primary.
fn primary(s: [f32; 3]) -> [u32; 3] {
let pi: usize = if s[1] >= s[0] && s[1] >= s[2] {
1
} else if s[2] >= s[0] {
2
} else {
0
};
let (a, b) = match pi {
0 => (1, 2),
1 => (0, 2),
_ => (0, 1),
};
let ix = if s[a] >= s[b] { [pi, a, b] } else { [pi, b, a] };
ix.map(|i| i as u32)
}
fn neutralise(c: [f32; 3], bias: [f32; 3]) -> [f32; 3] {
let l = luminance(bias[0], bias[1], bias[2]).max(1e-4);
[0, 1, 2].map(|i| c[i] * l / bias[i].max(1e-4))
}
fn key_light(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let pr = ctx.params;
let view = pr.e("view");
if view == 0 {
return Some(b);
}
// Inside / Outside masks (polygon coverage) run on the CPU.
let has_mask = |id: &str| pr.e(id).checked_sub(1).is_some_and(|i| ctx.env.masks.get(i as usize).is_some());
if has_mask("insideMask/insideMask") || has_mask("outsideMask/outsideMask") {
return None;
}
let sc = pr.color("screenColour");
let screen = [sc[0], sc[1], sc[2]];
let ix = primary(screen);
let (pi, o1, o2) = (ix[0] as usize, ix[1] as usize, ix[2] as usize);
let gain = pr.f("screenGain") as f32 / 100.0;
let bal = pr.f("screenBalance") as f32 / 100.0;
let dbias = pr.color("despillBias");
let abias = if pr.b("lockBiasesTogether") { dbias } else { pr.color("alphaBias") };
let (dbias, abias) = ([dbias[0], dbias[1], dbias[2]], [abias[0], abias[1], abias[2]]);
let s_alpha = neutralise(screen, abias);
let sd = (s_alpha[pi] - (bal * s_alpha[o1] + (1.0 - bal) * s_alpha[o2])).max(1e-3);
let cb = pr.f("screenMatte/clipBlack") as f32 / 100.0;
let cw = pr.f("screenMatte/clipWhite") as f32 / 100.0;
let rollback = pr.f("screenMatte/clipRollback") * b.scale;
let preblur = pr.f("screenPreblur") * b.scale;
let sg = pr.f("screenMatte/screenShrinkGrow") * b.scale;
let soft = pr.f("screenMatte/screenSoftness") * b.scale;
let despot_b = pr.f("screenMatte/screenDespotBlack") * b.scale;
let despot_w = pr.f("screenMatte/screenDespotWhite") * b.scale;
// Hard Colour replaces wherever the screen matte exceeds the raw matte by any amount; after
// Screen Softness's blur that compares float-rounded sums, so it runs on the CPU.
if pr.e("screenMatte/replaceMethod") == 2 && soft > 0.0 {
return None;
}
let (w, h) = (b.img.width, b.img.height);
let key_src = if preblur > 0.0 { gaussian_blur(e, &b.img, preblur * 0.5, preblur * 0.5, true) } else { b.img.clone() };
let mut p = Params::default();
p.u[0] = [ix[0], ix[1], ix[2], 0];
p.f[0] = [abias[0], abias[1], abias[2], sd];
p.f[1] = [gain, bal, cb, cw];
let raw = e.image(w, h);
e.pixels("fxc_kl_raw", &p, &key_src, None, &raw, None);
let mut m = raw.clone();
if rollback > 0.0 {
let sg_img = morph_frac(e, &m, rollback, [0, 1, 2, 2]);
let out = e.image(w, h);
e.pixels("fxc_kl_rollback", &Params::default(), &sg_img, Some(&raw), &out, None);
m = out;
}
if despot_b > 0.0 {
let d = morph_frac(e, &m, despot_b, DILATE_X);
m = morph_frac(e, &d, despot_b, ERODE_X);
}
if despot_w > 0.0 {
let d = morph_frac(e, &m, despot_w, ERODE_X);
m = morph_frac(e, &d, despot_w, DILATE_X);
}
if sg < 0.0 {
m = morph_frac(e, &m, -sg, ERODE_X);
} else if sg > 0.0 {
m = morph_frac(e, &m, sg, DILATE_X);
}
if soft > 0.0 {
m = gaussian_blur(e, &m, soft * 0.5, soft * 0.5, true);
}
let mut planes = e.image(w, h);
e.pixels("fxc_kl_pack", &Params::default(), &m, Some(&raw), &planes, None);
let src_alpha_mode = pr.e("insideMask/sourceAlpha");
if pr.b("edgeColourCorrection/enableEdgeColourCorrection") {
let grow = pr.f("edgeColourCorrection/edgeGrow") * b.scale;
let hard = (pr.f("edgeColourCorrection/edgeHardness") / 100.0) as f32;
let esoft = pr.f("edgeColourCorrection/edgeSoftness") * b.scale;
let mut p = Params::default();
p.u[0][0] = src_alpha_mode;
let mut band = e.image(w, h);
e.pixels("fxc_kl_band", &p, &b.img, Some(&planes), &band, None);
if grow > 0.0 {
band = morph_frac(e, &band, grow, DILATE_X);
}
if esoft > 0.0 {
band = gaussian_blur(e, &band, esoft * 0.5, esoft * 0.5, true);
}
let mut p = Params::default();
p.f[0][0] = hard;
let out = e.image(w, h);
e.pixels("fxc_kl_edge", &p, &planes, Some(&band), &out, None);
planes = out;
}
let pc = |k: &str| pr.f(k) as f32 / 100.0;
let l0 = (luminance(dbias[0], dbias[1], dbias[2]) / dbias[pi].max(1e-4)).max(1e-4);
let mut p = Params::default();
p.u[0] = [ix[0], ix[1], ix[2], view];
p.u[1] = [
pr.e("screenMatte/replaceMethod"),
pr.b("foregroundColourCorrection/enableColourCorrection") as u32,
pr.b("unpremultiplyResult") as u32,
src_alpha_mode,
];
p.f[0] = [screen[0], screen[1], screen[2], bal];
p.f[1] = [dbias[0], dbias[1], dbias[2], l0];
p.f[2] = pr.color("screenMatte/replaceColour");
p.f[3] =
[pc("foregroundColourCorrection/saturation"), pc("foregroundColourCorrection/contrast") + 1.0, pc("foregroundColourCorrection/brightness") + 1.0, 0.0];
p.f[4] = [pc("edgeColourCorrection/edgeSaturation"), pc("edgeColourCorrection/edgeContrast") + 1.0, pc("edgeColourCorrection/edgeBrightness") + 1.0, 0.0];
p.f[5] = ["sourceCrops/cropLeft", "sourceCrops/cropRight", "sourceCrops/cropTop", "sourceCrops/cropBottom"].map(|k| (pr.f(k) / 100.0) as f32);
p.f[6] = [b.offset[0] as f32, b.offset[1] as f32, b.scale as f32, 0.0];
p.f[7] = [ctx.layer_size[0].max(1e-9) as f32, ctx.layer_size[1].max(1e-9) as f32, 0.0, 0.0];
let out = e.image(w, h);
e.pixels("fxc_kl_final", &p, &b.img, Some(&planes), &out, None);
Some(GBuf { img: out, ..b })
}

View File

@ -0,0 +1,413 @@
//! GPU effects, distortion and stylize family (kernels in `shaders/fx_distort.wgsl`): the CPU effects' exact
//! steps with the pixel loops as compute kernels.
//!
//! Parameter conversions, padding and anything with a per-row or per-column structure (map
//! coordinates, tile bounds, Mesh Warp's triangle buckets) are computed here in f64 exactly as
//! the CPU does and uploaded; the kernels do the per-pixel work.
use std::f64::consts::TAU;
use effectcraft_effects::EffectCtx;
use effectcraft_effects::util::{Src, src_at};
use crate::context::{Enc, Params};
use crate::effects::GBuf;
/// Compute entry points in `fx_distort.wgsl`.
pub(crate) const KERNELS: &[&str] = &[
"dst_twirl",
"dst_bulge",
"dst_wavewarp",
"dst_ripple",
"dst_cclens",
"dst_turbulent",
"dst_displace",
"dst_mesh",
"dst_mosaic_rows",
"dst_mosaic_tiles",
"dst_mosaic_out",
"dst_mosaic_sharp",
"dst_findedges",
"dst_emboss",
];
/// Effect ids implemented here.
pub(crate) const IDS: &[&str] = &[
"ec.distort.turbulentdisplace",
"ec.distort.displacementmap",
"ec.distort.wavewarp",
"ec.distort.ripple",
"ec.distort.twirl",
"ec.distort.bulge",
"ec.distort.cclens",
"ec.distort.meshwarp",
"ec.stylize.mosaic",
"ec.stylize.findedges",
"ec.stylize.emboss",
];
/// Run effect `id` (one of [`IDS`]); `None` = this parameter combination runs on the CPU.
pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
match id {
"ec.distort.turbulentdisplace" => turbulent(e, ctx, b),
"ec.distort.displacementmap" => displacement_map(e, ctx, b),
"ec.distort.wavewarp" => wave_warp(e, ctx, b),
"ec.distort.ripple" => ripple(e, ctx, b),
"ec.distort.twirl" => twirl(e, ctx, b),
"ec.distort.bulge" => bulge(e, ctx, b),
"ec.distort.cclens" => cc_lens(e, ctx, b),
"ec.distort.meshwarp" => mesh_warp(e, ctx, b),
"ec.stylize.mosaic" => mosaic(e, ctx, b),
"ec.stylize.findedges" => find_edges(e, ctx, b),
"ec.stylize.emboss" => emboss(e, ctx, b),
_ => None,
}
}
/// Run a same-size per-pixel kernel over the buffer.
fn run(e: &mut Enc, entry: &str, p: &Params, mut b: GBuf, data: Option<&wgpu::Buffer>) -> Option<GBuf> {
let out = e.image(b.img.width, b.img.height);
e.pixels(entry, p, &b.img, None, &out, data);
b.img = out;
Some(b)
}
/// Buf::layer_w / layer_h (distort.rs).
fn layer_wh(ctx: &EffectCtx) -> (f64, f64) {
(ctx.layer_size[0].max(1.0), ctx.layer_size[1].max(1.0))
}
/// util::layer_rect.
fn layer_rect(ctx: &EffectCtx, b: &GBuf) -> [f32; 4] {
[b.offset[0] as f32, b.offset[1] as f32, (ctx.layer_size[0] * b.scale) as f32, (ctx.layer_size[1] * b.scale) as f32]
}
// ---------------------------------------------------------------- distort.rs
fn twirl(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let ang = ctx.params.f("angle").to_radians();
let (lw, lh) = layer_wh(ctx);
let r = (ctx.params.f("radius") / 100.0 * lw.max(lh) * b.scale).max(1.0);
let c = b.to_px(ctx.params.v2("center"));
let mut p = Params::default();
p.f[0] = [c.0 as f32, c.1 as f32, r as f32, ang as f32];
run(e, "dst_twirl", &p, b, None)
}
fn bulge(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let rx = (ctx.params.f("hradius") * b.scale).max(1.0);
let ry = (ctx.params.f("vradius") * b.scale).max(1.0);
let c = b.to_px(ctx.params.v2("center"));
let h = ctx.params.f("height");
let taper = 1.0 + ctx.params.f("taperRadius").max(0.0) / 100.0 * 3.0;
let pin = ctx.params.b("pinAllEdges");
let (lw, lh) = layer_wh(ctx);
let (lw, lh) = (lw * b.scale, lh * b.scale);
let edge = (lw.min(lh) * 0.1).max(1.0);
let mut p = Params::default();
p.f[0] = [c.0 as f32, c.1 as f32, rx as f32, ry as f32];
p.f[1] = [h as f32, taper as f32, if pin { 1.0 } else { 0.0 }, edge as f32];
p.f[2] = [b.offset[0] as f32, b.offset[1] as f32, lw as f32, lh as f32];
run(e, "dst_bulge", &p, b, None)
}
fn wave_warp(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
let h = ctx.params.f("height") * b.scale;
let w = (ctx.params.f("width") * b.scale).max(1.0);
let dir = ctx.params.f("direction").to_radians();
let speed = ctx.params.f("speed");
let phase = ctx.params.f("phase").to_radians() + ctx.time * speed * TAU;
let (dx, dy) = (dir.sin(), -dir.cos());
if !ctx.adjustment {
b.pad(e, h.abs().ceil() as u32 + 1)?;
}
let (lw, lh) = layer_wh(ctx);
let rect = [b.offset[0] as f32, b.offset[1] as f32, (lw * b.scale).max(1.0) as f32, (lh * b.scale).max(1.0) as f32];
// The wave phase in cycles, (x·dx + y·dy) / width + phase / 2π, is separable: per column
// and per row (fraction, whole cycles) in f64, so the kernel's sum keeps the fraction exact
// to f32 (the circle shapes have vertical tangents where a phase error is amplified).
let (bw, bh) = (b.img.width as usize, b.img.height as usize);
let mut data = vec![0.0f32; 2 * (bw + bh)];
let split = |v: f64| (v - v.floor(), v.floor());
for x in 0..bw {
let (f, i) = split((x as f64 + 0.5) * dx / w);
data[x] = f as f32;
data[bw + x] = i as f32;
}
for y in 0..bh {
let (f, i) = split((y as f64 + 0.5) * dy / w + phase / TAU);
data[2 * bw + y] = f as f32;
data[2 * bw + bh + y] = i as f32;
}
let mut p = Params::default();
p.u[0] = [ctx.params.e("waveType"), ctx.params.e("pinning"), ctx.seed ^ 0x3a7e, bw as u32];
p.f[0] = [dx as f32, dy as f32, h as f32, 0.0];
p.f[2] = rect;
let buf = e.data(&data);
run(e, "dst_wavewarp", &p, b, Some(&buf))
}
fn ripple(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let (lw, lh) = layer_wh(ctx);
let r = (ctx.params.f("radius") / 100.0 * lw.max(lh) * 0.5 * b.scale).max(1.0);
let asymmetric = ctx.params.e("conversion") == 0;
let c = b.to_px(ctx.params.v2("center"));
let w = (ctx.params.f("waveWidth") * b.scale).max(1.0);
let h = ctx.params.f("waveHeight") * b.scale;
let phase = ctx.params.f("phase").to_radians() - ctx.time * ctx.params.f("speed") * TAU;
let mut p = Params::default();
p.u[0][0] = asymmetric as u32;
p.f[0] = [c.0 as f32, c.1 as f32, r as f32, w as f32];
p.f[1] = [h as f32, phase.rem_euclid(TAU) as f32, 0.0, 0.0];
run(e, "dst_ripple", &p, b, None)
}
// ---------------------------------------------------------------- distort2.rs
fn cc_lens(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let c = b.to_px(ctx.params.v2("center"));
let (lw, lh) = (ctx.layer_size[0] * b.scale, ctx.layer_size[1] * b.scale);
let r = (ctx.params.f("size") / 100.0 * lw.max(lh) * 0.5).max(0.5);
let k = 2f64.powf(ctx.params.f("convergence") / 50.0);
let mut p = Params::default();
p.f[0] = [c.0 as f32, c.1 as f32, r as f32, k as f32];
run(e, "dst_cclens", &p, b, None)
}
fn turbulent(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
let amount = ctx.params.f("amount") * b.scale;
if amount.abs() < 1e-9 {
return Some(b);
}
let kind = ctx.params.e("displacement");
let size = (ctx.params.f("size") * b.scale).max(1.0);
if ctx.params.b("resizeLayer") && !ctx.adjustment {
b.pad(e, amount.abs().ceil() as u32 + 1)?;
}
let off = b.to_px(ctx.params.v2("offset"));
let oct = ctx.params.f("complexity").clamp(1.0, 10.0);
let evo = ctx.params.f("evolution") / 360.0;
let cycle = ctx.params.b("evolutionOptions/cycleEvolution").then(|| ctx.params.f("evolutionOptions/cycle").round().max(1.0));
let falloff = if (3..=5).contains(&kind) { 0.3 } else { 0.5 };
let rect = layer_rect(ctx, &b);
let edge = ((ctx.layer_size[0] * b.scale).min(ctx.layer_size[1] * b.scale) * 0.1).max(1.0);
let seed = (ctx.seed ^ 0x7d15).wrapping_add((ctx.params.f("evolutionOptions/randomSeed") as i64 as u32).wrapping_mul(0x9e37_79b9));
let (z0, z1, t) = match cycle {
Some(c) => {
let z = evo.rem_euclid(c);
(z as f32, (z - c) as f32, (z / c) as f32)
}
None => (evo as f32, 0.0, 0.0),
};
let mut p = Params::default();
p.u[0] = [kind, ctx.params.e("pinning"), seed, oct.ceil().max(1.0) as u32];
p.u[1][0] = cycle.is_some() as u32;
p.f[0] = [off.0 as f32, off.1 as f32, size as f32, amount as f32];
p.f[1] = [z0, z1, t, (oct - oct.floor()) as f32];
p.f[2] = rect;
p.f[3] = [edge as f32, falloff, 0.0, 0.0];
run(e, "dst_turbulent", &p, b, None)
}
fn displacement_map(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
let hs = src_at(ctx.params.e("useForHorizontal"));
let vs = src_at(ctx.params.e("useForVertical"));
let mh = ctx.params.f("maxHorizontal") * b.scale;
let mv = ctx.params.f("maxVertical") * b.scale;
let wrap = ctx.params.b("wrapPixelsAround");
if ctx.params.b("expandOutput") && !wrap && !ctx.adjustment {
b.pad(e, mh.abs().max(mv.abs()).ceil() as u32)?;
}
let layer = ctx.layer_param("displacementMapLayer", false);
let behavior = ctx.params.e("displacementMapBehavior");
let (w, h) = (b.img.width as usize, b.img.height as usize);
let inv = 1.0 / b.scale.max(1e-9);
let ls = ctx.layer_size;
// Per column / row: the map layer's buffer coordinate (fit_layer / Tile Map) and whether
// the pixel lies inside the centred map (Center Map).
let mut data = vec![0.0f32; 2 * (w + h)];
let mut mode = 0u32;
let mut center = false;
let map = match &layer {
Some(o) => {
if o.buf.img.is_empty() {
return None;
}
let os = o.size;
let axis = |i: usize, k: usize| -> (f64, bool) {
let q = (i as f64 + 0.5 - b.offset[k]) * inv;
let map_q = match behavior {
2 => (q + (os[k] - ls[k]) * 0.5).rem_euclid(os[k].max(1.0)),
1 if ls[0] > 0.0 && ls[1] > 0.0 => q * (os[k] / ls[k]),
1 => q,
_ => q + (os[k] - ls[k]) * 0.5,
};
let c = q + (os[k] - ls[k]) * 0.5;
(map_q * o.buf.scale + o.buf.offset[k], (0.0..os[k]).contains(&c))
};
for x in 0..w {
let (m, inside) = axis(x, 0);
data[x] = m as f32;
data[w + h + x] = inside as u32 as f32;
}
for y in 0..h {
let (m, inside) = axis(y, 1);
data[w + y] = m as f32;
data[2 * w + h + y] = inside as u32 as f32;
}
mode = if behavior == 2 { 2 } else { 1 };
center = behavior == 0;
Some(e.g.upload_image(&o.buf.img)?)
}
None => None,
};
let idx = |s: Src| effectcraft_effects::util::SRC_ORDER.iter().position(|x| *x == s).unwrap_or(10) as u32;
let mut p = Params::default();
p.u[0] = [idx(hs), idx(vs), mode, wrap as u32 | (center as u32) << 1];
p.u[1] = [w as u32, h as u32, 0, 0];
p.f[0] = [mh as f32, mv as f32, 0.0, 0.0];
let buf = e.data(&data);
let out = e.image(b.img.width, b.img.height);
e.pixels("dst_displace", &p, &b.img, map.as_ref(), &out, Some(&buf));
b.img = out;
Some(b)
}
fn mesh_warp(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let rows = ctx.params.f("rows").round().clamp(1.0, 31.0) as usize;
let cols = ctx.params.f("columns").round().clamp(1.0, 31.0) as usize;
let q = ctx.params.f("quality").round().clamp(1.0, 10.0) as usize;
let offs = effectcraft_effects::parse_mesh(ctx.params.s("mesh"));
if offs.len() != (rows + 1) * (cols + 1) || offs.iter().all(|o| o.0 == 0.0 && o.1 == 0.0) {
return Some(b);
}
let (lw, lh) = (ctx.layer_size[0], ctx.layer_size[1]);
let vert = |i: usize, j: usize| {
let o = offs[j * (cols + 1) + i];
b.to_px([i as f64 / cols as f64 * lw + o.0, j as f64 / rows as f64 * lh + o.1])
};
let (nx, ny) = (cols * q, rows * q);
let mut dest = Vec::with_capacity((nx + 1) * (ny + 1));
let mut srcp = Vec::with_capacity((nx + 1) * (ny + 1));
for gj in 0..=ny {
for gi in 0..=nx {
let (ci, cj) = ((gi / q).min(cols - 1), (gj / q).min(rows - 1));
let (u, v) = ((gi - ci * q) as f64 / q as f64, (gj - cj * q) as f64 / q as f64);
let (p00, p10, p01, p11) = (vert(ci, cj), vert(ci + 1, cj), vert(ci, cj + 1), vert(ci + 1, cj + 1));
let x = (p00.0 * (1.0 - u) + p10.0 * u) * (1.0 - v) + (p01.0 * (1.0 - u) + p11.0 * u) * v;
let y = (p00.1 * (1.0 - u) + p10.1 * u) * (1.0 - v) + (p01.1 * (1.0 - u) + p11.1 * u) * v;
dest.push((x, y));
srcp.push(b.to_px([gi as f64 / nx as f64 * lw, gj as f64 / ny as f64 * lh]));
}
}
// distort2::grid_warp: triangles in order, bucketed per output row.
let idx = |i: usize, j: usize| j * (nx + 1) + i;
let hh = b.img.height as usize;
let mut tri_data: Vec<f32> = Vec::with_capacity(nx * ny * 24);
let mut buckets: Vec<Vec<u32>> = vec![Vec::new(); hh];
let mut t = 0u32;
for j in 0..ny {
for i in 0..nx {
let (a, b2, c, d) = (idx(i, j), idx(i + 1, j), idx(i + 1, j + 1), idx(i, j + 1));
for tri in [[a, b2, c], [a, c, d]] {
let [ia, ib, ic] = tri;
let (pa, pb, pc) = (dest[ia], dest[ib], dest[ic]);
let den: f64 = (pb.1 - pc.1) * (pa.0 - pc.0) + (pc.0 - pb.0) * (pa.1 - pc.1);
// Degenerate triangles are skipped per pixel on the CPU; leaving them out of
// the buckets is the same.
if den.abs() < 1e-12 {
continue;
}
let ys = [pa.1, pb.1, pc.1];
let y0 = (ys[0].min(ys[1]).min(ys[2]) - 0.5).floor().max(0.0) as usize;
let y1 = (ys[0].max(ys[1]).max(ys[2]) + 0.5).ceil().max(0.0) as usize;
for bucket in buckets.iter_mut().take(y1.min(hh)).skip(y0) {
bucket.push(t);
}
let (sa, sb, sc) = (srcp[ia], srcp[ib], srcp[ic]);
tri_data.extend(
[(pb.1 - pc.1) / den, (pc.0 - pb.0) / den, (pc.1 - pa.1) / den, (pa.0 - pc.0) / den, pc.0, pc.1, sa.0, sa.1, sb.0, sb.1, sc.0, sc.1]
.map(|v| v as f32),
);
t += 1;
}
}
}
let row_starts = tri_data.len();
let list_start = row_starts + hh + 1;
let total: usize = buckets.iter().map(Vec::len).sum();
if list_start + total >= 1 << 24 {
// Indices past 2^24 are not exact in the f32 data buffer.
return None;
}
let mut data = tri_data;
data.reserve(hh + 1 + total);
let mut n = 0usize;
for bucket in &buckets {
data.push(n as f32);
n += bucket.len();
}
data.push(n as f32);
for bucket in &buckets {
data.extend(bucket.iter().map(|&t| t as f32));
}
let mut p = Params::default();
p.u[0] = [row_starts as u32, list_start as u32, 0, 0];
let buf = e.data(&data);
run(e, "dst_mesh", &p, b, Some(&buf))
}
// ---------------------------------------------------------------- misc.rs (Stylize)
fn mosaic(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
let hb = ctx.params.f("horizontal").round().max(1.0) as usize;
let vb = ctx.params.f("vertical").round().max(1.0) as usize;
let (w, h) = (b.img.width as usize, b.img.height as usize);
if w == 0 || h == 0 {
return Some(b);
}
let cw = (w as f64 / hb as f64).max(1.0);
let ch = (h as f64 / vb as f64).max(1.0);
let mut p = Params::default();
if ctx.params.b("sharpColors") {
let centre = |i: usize, c: f64| (((i as f64 / c).floor() + 0.5) * c) as i64 as f32;
let data: Vec<f32> = (0..w).map(|x| centre(x, cw)).chain((0..h).map(|y| centre(y, ch))).collect();
p.u[0][0] = w as u32;
let buf = e.data(&data);
return run(e, "dst_mosaic_sharp", &p, b, Some(&buf));
}
let (nx, ny) = ((w as f64 / cw).ceil() as usize, (h as f64 / ch).ceil() as usize);
let tx: Vec<usize> = (0..w).map(|x| ((x as f64 / cw) as usize).min(nx - 1)).collect();
let ty: Vec<usize> = (0..h).map(|y| ((y as f64 / ch) as usize).min(ny - 1)).collect();
// Tile bounds: tile i covers the columns with tx == i (tx is non-decreasing).
let bounds = |t: &[usize], n: usize| -> Vec<f32> { (0..=n).map(|i| t.partition_point(|&v| v < i) as f32).collect() };
let mut data: Vec<f32> = tx.iter().chain(&ty).map(|&v| v as f32).collect();
data.extend(bounds(&tx, nx));
data.extend(bounds(&ty, ny));
p.u[0] = [w as u32, h as u32, nx as u32, ny as u32];
let buf = e.data(&data);
let rows = e.image(nx as u32, h as u32);
e.pixels("dst_mosaic_rows", &p, &b.img, None, &rows, Some(&buf));
let tiles = e.image(nx as u32, ny as u32);
e.pixels("dst_mosaic_tiles", &p, &rows, None, &tiles, Some(&buf));
let out = e.image(b.img.width, b.img.height);
e.pixels("dst_mosaic_out", &p, &tiles, None, &out, Some(&buf));
b.img = out;
Some(b)
}
fn find_edges(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let mut p = Params::default();
p.u[0][0] = ctx.params.b("invert") as u32;
p.f[0][0] = ctx.params.f("blend") as f32 / 100.0;
run(e, "dst_findedges", &p, b, None)
}
fn emboss(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let ang = ctx.params.f("direction").to_radians();
let relief = ctx.params.f("relief") * b.scale;
let (dx, dy) = (ang.cos() * relief, -ang.sin() * relief);
let mut p = Params::default();
p.f[0] = [dx as f32, dy as f32, ctx.params.f("contrast") as f32 / 100.0, ctx.params.f("blend") as f32 / 100.0];
run(e, "dst_emboss", &p, b, None)
}

View File

@ -0,0 +1,404 @@
//! GPU effects, blur, transition and generate family (kernels in `shaders/fx_generate.wgsl`): the CPU effects' exact
//! steps with the pixel loops as compute kernels.
//!
//! Every effect here covers all of its parameters. Where the CPU works in f64 around a hard
//! edge (the wipes), the per-column and per-row terms are computed in f64 here and uploaded as
//! (hi, lo) f32 pairs so the GPU puts the edge where the CPU does.
use effectcraft_color::BlendMode;
use effectcraft_effects::{EffectCtx, GrainLook};
use crate::context::{Enc, Params};
use crate::effects::{GBuf, gaussian_blur};
/// Compute entry points in `fx_generate.wgsl`.
pub(crate) const KERNELS: &[&str] = &[
"gen_radial_blur",
"gen_ccradialfast",
"gen_lens_pre",
"gen_row_prefix",
"gen_lens_blur",
"gen_linear_wipe",
"gen_venetian",
"gen_radial_wipe",
"gen_gradient_wipe",
"gen_cell_pattern",
"gen_checker",
"gen_grid",
"gen_fourcolor",
"gen_noise",
"gen_grain_planes",
"gen_grain_apply",
];
/// Effect ids implemented here.
pub(crate) const IDS: &[&str] = &[
"ec.blur.radial",
"ec.blur.cameralens",
"ec.blur.ccradialfast",
"ec.transition.venetian",
"ec.transition.linearwipe",
"ec.transition.radialwipe",
"ec.transition.gradientwipe",
"ec.generate.cellpattern",
"ec.generate.checkerboard",
"ec.generate.grid",
"ec.generate.fourcolor",
"ec.noise.noise",
"ec.noise.addgrain",
];
/// Run effect `id` (one of [`IDS`]); `None` = this parameter combination runs on the CPU.
pub(crate) fn apply(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
match id {
"ec.blur.radial" => radial_blur(e, ctx, b),
"ec.blur.cameralens" => camera_lens(e, ctx, b),
"ec.blur.ccradialfast" => radial_fast(e, ctx, b),
"ec.transition.venetian" => venetian(e, ctx, b),
"ec.transition.linearwipe" => linear_wipe(e, ctx, b),
"ec.transition.radialwipe" => radial_wipe(e, ctx, b),
"ec.transition.gradientwipe" => gradient_wipe(e, ctx, b),
"ec.generate.cellpattern" => cell_pattern(e, ctx, b),
"ec.generate.checkerboard" => checkerboard(e, ctx, b),
"ec.generate.grid" => grid(e, ctx, b),
"ec.generate.fourcolor" => four_color(e, ctx, b),
"ec.noise.noise" => noise(e, ctx, b),
"ec.noise.addgrain" => add_grain(e, ctx, b),
_ => None,
}
}
/// Run a per-pixel kernel over the buffer.
fn run(e: &mut Enc, entry: &str, p: &Params, b: GBuf, aux: Option<&crate::context::GpuImage>, data: Option<&[f32]>) -> Option<GBuf> {
let buf = data.map(|d| e.data(d));
let out = e.image(b.img.width, b.img.height);
e.pixels(entry, p, &b.img, aux, &out, buf.as_ref());
Some(GBuf { img: out, ..b })
}
/// `v` as an f32 (hi, lo) pair.
fn hl(v: f64) -> [f32; 2] {
let h = v as f32;
[h, (v - h as f64) as f32]
}
/// Generate blending mode index → `BlendMode::ALL` index (255 = None: replace).
fn gen_mode(i: u32) -> u32 {
match effectcraft_effects::gen_mode(i) {
None => 255,
Some(m) => BlendMode::ALL.iter().position(|&x| x == m).unwrap_or(0) as u32,
}
}
// ---------------------------------------------------------------- blurs
fn radial_blur(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let amt = ctx.params.f("amount");
let zoom = ctx.params.e("type") == 1;
let c = b.to_px(ctx.params.v2("center"));
let amount = if zoom { amt / 100.0 } else { amt };
if amount.abs() < 1e-6 {
return Some(b);
}
let n = 32usize;
let mut data = Vec::with_capacity(2 * n);
for i in 0..n {
let t = i as f64 / (n - 1) as f64 - 0.5;
if zoom {
data.push((1.0 + t * amount) as f32);
} else {
let (s, c) = (t * amount).to_radians().sin_cos();
data.extend([s as f32, c as f32]);
}
}
let mut p = Params::default();
p.u[0] = [zoom as u32, n as u32, 0, 0];
p.f[0] = [c.0 as f32, c.1 as f32, 0.0, 0.0];
run(e, "gen_radial_blur", &p, b, None, Some(&data))
}
fn radial_fast(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let s = (ctx.params.f("amount") / 100.0).clamp(0.0, 1.0) * 0.5;
if s <= 0.0 {
return Some(b);
}
let c = b.to_px(ctx.params.v2("center"));
let mut p = Params::default();
p.u[0][0] = ctx.params.e("zoom");
p.f[0] = [c.0 as f32, c.1 as f32, s as f32, 0.0];
run(e, "gen_ccradialfast", &p, b, None, None)
}
fn camera_lens(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option<GBuf> {
let r = ctx.params.f("blurRadius").max(0.0) * b.scale;
if r < 0.5 {
return Some(b);
}
let (spans, reach) = effectcraft_effects::camera_lens_spans(ctx, r);
let repeat = ctx.params.b("repeatEdge") || ctx.adjustment;
if !repeat {
b.pad(e, reach.ceil() as u32 + 1)?;
}
let linear = ctx.params.b("useLinear");
let thr = ctx.params.f("highlight/specularThreshold") as f32 / 255.0;
let boost = 1.0 + ctx.params.f("highlight/specularBrightness").max(0.0) as f32 / 100.0 * 4.0;
let sat = (ctx.params.f("highlight/highlightSaturation") / 100.0).clamp(0.0, 1.0) as f32;
let (w, h) = (b.img.width, b.img.height);
let mut src = b.img.clone();
if linear || boost > 1.0 {
let mut p = Params::default();
p.u[0] = [linear as u32, (boost > 1.0) as u32, 0, 0];
p.f[0] = [thr, boost, sat, 0.0];
let out = e.image(w, h);
e.pixels("gen_lens_pre", &p, &src, None, &out, None);
src = out;
}
let count: i64 = spans.iter().map(|(_, l, r)| r - l + 1).sum();
let norm = 1.0 / count as f64;
// Large irises sum each span from per-row prefix sums; small ones add the pixels directly.
let prefix = count > 256;
let pre = if prefix {
if !e.g.fits(w + 1, h) {
return None;
}
let pre = e.image(w + 1, h);
e.dispatch("gen_row_prefix", &Params::default(), &src, None, &pre, None, (h.div_ceil(64), 1));
Some(pre)
} else {
None
};
let data: Vec<f32> = spans.iter().flat_map(|&(dy, l, r)| [dy as f32, l as f32, r as f32]).collect();
let mut p = Params::default();
p.u[0] = [spans.len() as u32, repeat as u32, linear as u32, prefix as u32];
p.f[0][0] = norm as f32;
let buf = e.data(&data);
let out = e.image(w, h);
e.pixels("gen_lens_blur", &p, &src, pre.as_ref(), &out, Some(&buf));
b.img = out;
Some(b)
}
// ---------------------------------------------------------------- transitions
fn linear_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let done = ctx.params.f("completion") / 100.0;
let ang = ctx.params.f("angle").to_radians();
let feather = (ctx.params.f("feather") * b.scale).max(0.001);
let (w, h) = (b.img.width as f64, b.img.height as f64);
let (dx, dy) = (ang.sin(), -ang.cos());
let corners = [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)];
let proj: Vec<f64> = corners.iter().map(|(x, y)| x * dx + y * dy).collect();
let (lo, hi) = (proj.iter().cloned().fold(f64::INFINITY, f64::min), proj.iter().cloned().fold(f64::NEG_INFINITY, f64::max));
let edge = lo - feather + (hi - lo + 2.0 * feather) * done;
let mut data = Vec::with_capacity(2 * (b.img.width + b.img.height) as usize);
for x in 0..b.img.width {
data.extend(hl((x as f64 + 0.5) * dx - edge));
}
for y in 0..b.img.height {
data.extend(hl((y as f64 + 0.5) * dy));
}
let mut p = Params::default();
p.u[0][0] = 2 * b.img.width;
p.f[0][0] = feather as f32;
run(e, "gen_linear_wipe", &p, b, None, Some(&data))
}
fn venetian(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let done = ctx.params.f("completion") / 100.0;
let ang = ctx.params.f("direction").to_radians();
let width = (ctx.params.f("width") * b.scale).max(1.0);
let feather = (ctx.params.f("feather") * b.scale).max(0.001);
let (dx, dy) = (ang.cos(), ang.sin());
let mut data = Vec::with_capacity((6 * b.img.width + 2 * b.img.height) as usize);
for x in 0..b.img.width {
let cx = ((x as f64 + 0.5) * dx).rem_euclid(width);
data.extend(hl(cx - done * width));
data.extend(hl(cx - width - done * width));
data.extend(hl(cx - width));
}
for y in 0..b.img.height {
data.extend(hl(((y as f64 + 0.5) * dy).rem_euclid(width)));
}
let mut p = Params::default();
p.u[0][0] = 6 * b.img.width;
p.f[0][0] = feather as f32;
run(e, "gen_venetian", &p, b, None, Some(&data))
}
fn radial_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let done = ctx.params.f("completion") / 100.0;
let start = ctx.params.f("startAngle");
let c = b.to_px(ctx.params.v2("center"));
let dir = ctx.params.e("wipe");
let feather = ctx.params.f("feather").max(0.01);
let edge = if dir == 2 { done * 180.0 } else { done * 360.0 };
// Rotations: the start angle, and the start angle ± the edge (where the wipe's edge lies).
let rots = [start, start + edge, start - edge].map(|r| r.to_radians().sin_cos());
let mut data = Vec::with_capacity(9 * (b.img.width + b.img.height) as usize);
for x in 0..b.img.width {
let ux = x as f64 + 0.5 - c.0;
for (s, co) in rots {
data.extend(hl(ux * co));
data.push((ux * s) as f32);
}
}
for y in 0..b.img.height {
let uy = -(y as f64 + 0.5 - c.1);
for (s, co) in rots {
data.extend(hl(uy * s));
data.push((uy * co) as f32);
}
}
let mut p = Params::default();
p.u[0] = [9 * b.img.width, dir.min(2), 0, 0];
p.f[0] = [edge as f32, feather as f32, 0.0, 0.0];
run(e, "gen_radial_wipe", &p, b, None, Some(&data))
}
fn gradient_wipe(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let done = (ctx.params.f("completion") / 100.0).clamp(0.0, 1.0) as f32;
if done <= 0.0 {
return Some(b);
}
let soft = (ctx.params.f("softness") / 100.0) as f32;
// The gradient layer is fetched and placed on the CPU (as the CPU effect does).
let grad = match ctx.layer_param("gradientLayer", false) {
Some(o) => {
// place_layer reads only the buffer's size, offset and scale.
let img = effectcraft_raster::Image { width: b.img.width, height: b.img.height, data: vec![] };
let cpu = effectcraft_effects::Buf { img, offset: b.offset, scale: b.scale };
let img = effectcraft_effects::place_layer(ctx, &cpu, &o, ctx.params.e("gradientPlacement"));
Some(e.g.upload_image(&img)?)
}
None => None,
};
let mut p = Params::default();
p.u[0][0] = ctx.params.b("invert") as u32;
p.f[0] = [done, soft, 0.0, 0.0];
let out = e.image(b.img.width, b.img.height);
e.pixels("gen_gradient_wipe", &p, &b.img, Some(grad.as_ref().unwrap_or(&b.img)), &out, None);
Some(GBuf { img: out, ..b })
}
// ---------------------------------------------------------------- generate
fn cell_pattern(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let pr = ctx.params;
let size = (pr.f("size") * b.scale).max(1.0);
let off = b.to_px(pr.v2("offset"));
let mut evo = pr.f("evolution") / 360.0;
if pr.b("evolutionOptions/cycleEvolution") {
evo = evo.rem_euclid(pr.f("evolutionOptions/cycle").round().max(1.0));
}
let seed = (pr.f("evolutionOptions/randomSeed") as i64 as u32).wrapping_mul(7919) ^ 0xce11;
let tile = pr.b("tilingOptions/enableTiling");
let nx = pr.f("tilingOptions/cellsHorizontal").round().max(1.0) as i64;
let ny = pr.f("tilingOptions/cellsVertical").round().max(1.0) as i64;
if nx > i32::MAX as i64 || ny > i32::MAX as i64 {
return None;
}
let mut p = Params::default();
p.u[0] = [effectcraft_effects::cell_pattern_kind(pr.e("cellPattern")), pr.e("overflow"), pr.b("invert") as u32, seed];
p.u[1] = [tile as u32, nx as u32, ny as u32, 0];
// cos / sin of 2π·(h + evo) only see evo modulo 1.
p.f[0] = [pr.f("contrast") as f32 / 100.0, pr.f("disperse").clamp(0.0, 1.5) as f32, size as f32, evo.rem_euclid(1.0) as f32];
p.f[1] = [(0.5 - off.0) as f32, (0.5 - off.1) as f32, 0.0, 0.0];
run(e, "gen_cell_pattern", &p, b, None, None)
}
/// generate::cell_size.
fn cell_size(ctx: &EffectCtx, b: &GBuf, anchor: (f64, f64)) -> (f64, f64) {
let (w, h) = match ctx.params.e("sizeFrom") {
0 => {
let c = b.to_px(ctx.params.v2("corner"));
((c.0 - anchor.0).abs(), (c.1 - anchor.1).abs())
}
1 => (ctx.params.f("width") * b.scale, ctx.params.f("width") * b.scale),
_ => (ctx.params.f("width") * b.scale, ctx.params.f("height") * b.scale),
};
(w.max(1.0), h.max(1.0))
}
fn checkerboard(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let anchor = b.to_px(ctx.params.v2("anchor"));
let (w, h) = cell_size(ctx, &b, anchor);
let fw = (ctx.params.f("feather/featherWidth") * b.scale).max(1.0);
let fh = (ctx.params.f("feather/featherHeight") * b.scale).max(1.0);
let mut p = Params::default();
p.u[0][0] = gen_mode(ctx.params.e("blendingMode"));
p.f[0] = [(0.5 - anchor.0) as f32, (0.5 - anchor.1) as f32, w as f32, h as f32];
p.f[1] = [fw as f32, fh as f32, ctx.params.f("opacity") as f32 / 100.0, 0.0];
p.f[2] = ctx.params.color("color");
run(e, "gen_checker", &p, b, None, None)
}
fn grid(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let anchor = b.to_px(ctx.params.v2("anchor"));
let (w, h) = cell_size(ctx, &b, anchor);
let border = (ctx.params.f("border") * b.scale).max(0.0);
let fw = (ctx.params.f("feather/featherWidth") * b.scale).max(1.0);
let fh = (ctx.params.f("feather/featherHeight") * b.scale).max(1.0);
let mut p = Params::default();
p.u[0] = [gen_mode(ctx.params.e("blendingMode")), ctx.params.b("invertGrid") as u32, 0, 0];
p.f[0] = [(0.5 - anchor.0) as f32, (0.5 - anchor.1) as f32, w as f32, h as f32];
p.f[1] = [fw as f32, fh as f32, ctx.params.f("opacity") as f32 / 100.0, border as f32];
p.f[2] = ctx.params.color("color");
run(e, "gen_grid", &p, b, None, None)
}
fn four_color(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let mut p = Params::default();
for i in 0..4 {
let q = b.to_px(ctx.params.v2(&format!("positionsColors/point{}", i + 1)));
p.f[i] = [q.0 as f32, q.1 as f32, 0.0, 0.0];
p.f[4 + i] = ctx.params.color(&format!("positionsColors/color{}", i + 1));
}
let blend = ctx.params.f("blend").max(1.0);
let jitter = (ctx.params.f("jitter") / 100.0).clamp(0.0, 1.0) as f32;
p.u[0] = [gen_mode(ctx.params.e("blendingMode")), ctx.seed, 0, 0];
p.f[8] = [(blend * 100.0) as f32, jitter, ctx.params.f("opacity") as f32 / 100.0, 0.0];
run(e, "gen_fourcolor", &p, b, None, None)
}
// ---------------------------------------------------------------- noise
fn noise(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let amt = ctx.params.f("amount") as f32 / 100.0;
let clip = ctx.params.get("clip").is_none_or(|v| v.as_bool());
let frame_seed = (ctx.time * 1000.0) as u32 ^ ctx.seed;
let mut p = Params::default();
p.u[0] = [frame_seed, ctx.params.b("color") as u32, clip as u32, b.img.width];
p.f[0][0] = amt;
run(e, "gen_noise", &p, b, None, None)
}
fn add_grain(e: &mut Enc, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
let look = GrainLook::from_params(ctx);
if look.intensity <= 0.0 {
return Some(b);
}
let (w, h) = (b.img.width, b.img.height);
// GrainLook::planes.
let size = look.size * b.scale as f32;
let s = |k: usize| (size * if look.mono { 1.0 } else { look.channel_size[k].max(0.05) }).max(0.05);
let mut p = Params::default();
p.u[0] = [look.mono as u32, look.seed, 101, 0];
p.f[0] = [s(0) * look.aspect, s(1) * look.aspect, s(2) * look.aspect, look.frame];
p.f[1] = [s(0), s(1), s(2), 0.0];
let mut planes = e.image(w, h);
e.pixels("gen_grain_planes", &p, &b.img, None, &planes, None);
let soft = look.softness * b.scale;
if soft > 0.05 {
planes = gaussian_blur(e, &planes, soft * look.aspect as f64, soft, true);
}
// GrainLook::color's tint factors.
let l = effectcraft_color::luminance(look.tint[0], look.tint[1], look.tint[2]).max(1e-3);
let tint = [0, 1, 2].map(|k| (1.0 - look.tint_amount) + look.tint_amount * look.tint[k] / l);
let mut p = Params::default();
p.u[0] = [look.mono as u32, look.mode, (look.tint_amount > 0.0) as u32, 0];
p.f[0] = [look.intensity * 0.25, look.saturation, look.midpoint, 0.0];
p.f[1] = [tint[0], tint[1], tint[2], 0.0];
p.f[2] = [look.channel_intensity[0], look.channel_intensity[1], look.channel_intensity[2], 0.0];
p.f[3] = [look.tones[0], look.tones[1], look.tones[2], 0.0];
run(e, "gen_grain_apply", &p, b, Some(&planes), None)
}

View File

@ -6,8 +6,10 @@
//! layer buffers (once per buffer), applies the layer transforms with the CPU's sampling
//! (nearest / bilinear / bicubic, minification pre-filter), motion-blur sub-samples, track
//! mattes, Preserve Transparency, layer styles' passes, all 38 blend modes and the 8/16 bpc
//! clamping and quantisation, and converts colour spaces. 3D runs, adjustment layers and
//! wireframes run on the CPU between GPU steps (read back, draw, upload).
//! 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).
//!
//! Advanced 3D comps rasterise on a render pipeline (`advanced3d.wgsl`: depth buffer, PBR,
//! image-based light, shadow maps), see [`Accelerator::raster_3d`].
@ -16,15 +18,24 @@
//! effect's exact steps (padding, box-blur radii, parameter conversions); chains of them are
//! uploaded and read back once.
//!
//! GPU particles (`particles`): the stepped particle effects hand their simulation to
//! [`effectcraft_effects::psim::ParticleSim`], implemented here with one invocation per particle
//! and GPU-resident checkpoints.
//!
//! Plug a [`Gpu`] into [`Renderer::accel`] (it implements [`Accelerator`]); renders then use it
//! when [`RenderOpts::backend`](effectcraft_render::RenderOpts) asks for it. The viewer can
//! skip readback entirely with [`Gpu::render_display`], which leaves an RGBA8 texture for
//! egui-wgpu to draw.
mod adv3d;
mod classic3d;
mod context;
mod effects;
mod fx_color;
mod fx_distort;
mod fx_generate;
mod ops;
mod particles;
mod walk;
use std::sync::Arc;
@ -135,9 +146,31 @@ impl Accelerator for Gpu {
fn raster_3d(&self, scene: &effectcraft_render::three_d::adv::Scene) -> Option<effectcraft_render::three_d::adv::Target> {
adv3d::render(&self.ctx, scene)
}
fn particles(&self) -> Option<&dyn effectcraft_effects::psim::ParticleSim> {
self.ctx.can_readback().then_some(self as &dyn effectcraft_effects::psim::ParticleSim)
}
}
impl effectcraft_effects::psim::ParticleSim for Gpu {
fn simulate(&self, req: &effectcraft_effects::psim::SimRequest) -> Option<Vec<effectcraft_effects::psim::SimParticle>> {
particles::simulate(&self.ctx, req)
}
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_3d;
#[cfg(test)]
mod tests_adjust;
#[cfg(test)]
mod tests_adv3d;
#[cfg(test)]
mod tests_fx_color;
#[cfg(test)]
mod tests_fx_distort;
#[cfg(test)]
mod tests_fx_generate;
#[cfg(test)]
mod tests_particles;

View File

@ -134,6 +134,17 @@ pub fn channel_mix(e: &mut Enc, canvas: &GpuImage, tmp: &GpuImage, channels: [bo
out
}
/// Adjustment layer finish: move `canvas` toward `adjusted` by `matte`'s alpha × `opacity`.
pub fn adjust_mix(e: &mut Enc, canvas: &GpuImage, adjusted: &GpuImage, matte: &GpuImage, opacity: f32) -> GpuImage {
let (rows, stride) = e.image_rows(matte);
let mut p = Params::default();
p.u[0][0] = stride;
p.f[0][0] = opacity;
let out = e.image(canvas.width, canvas.height);
e.pixels("adjust_mix", &p, canvas, Some(adjusted), &out, Some(&rows));
out
}
/// Clamp and quantise to `levels` steps (8/16 bpc).
pub fn quantize(e: &mut Enc, img: &GpuImage, levels: f32) -> GpuImage {
let mut p = Params::default();
@ -144,11 +155,13 @@ pub fn quantize(e: &mut Enc, img: &GpuImage, levels: f32) -> GpuImage {
}
fn curve_id(s: Option<effectcraft_color::ColorSpace>) -> u32 {
use effectcraft_color::ColorSpace::*;
match s {
None => 0,
Some(Srgb | DisplayP3) => 1,
Some(Rec709 | Rec2020) => 2,
use effectcraft_color::space::Curve;
match s.map(|s| s.curve()) {
None | Some(Curve::Linear) => 0,
Some(Curve::Srgb) => 1,
Some(Curve::Gamma24) => 2,
Some(Curve::Pq) => 3,
Some(Curve::Hlg) => 4,
}
}

209
crates/gpu/src/particles.rs Normal file
View File

@ -0,0 +1,209 @@
//! GPU particles (`particles.wgsl`): the stepped particle systems of `effectcraft-effects`
//! ([`effectcraft_effects::psim`]) simulated with one invocation per particle. States stay on
//! the GPU as checkpoints (per system key, like the CPU's `SimCache`), so playback advances from
//! the previous frame's state instead of simulating from layer time 0.
use std::sync::{Mutex, OnceLock};
use effectcraft_effects::psim::{ParticleSystem, SimParticle, SimRequest};
use wgpu::util::DeviceExt;
use crate::context::GpuContext;
/// Bytes of one particle state (`PState` in the shader).
const STATE_BYTES: u64 = 48;
/// Checkpoints kept (all keys).
const CHECKPOINTS: usize = 24;
pub(crate) struct Pipes {
bgl: wgpu::BindGroupLayout,
pipe: wgpu::ComputePipeline,
}
struct Checkpoint {
key: u64,
steps: u64,
n: u32,
buf: wgpu::Buffer,
used: u64,
}
#[derive(Default)]
pub(crate) struct Checkpoints {
list: Vec<Checkpoint>,
clock: u64,
}
fn pipes(g: &GpuContext) -> &Pipes {
g.particles.get_or_init(|| {
let d = &g.device;
let src = [include_str!("shaders/common.wgsl"), include_str!("shaders/kernels.wgsl"), include_str!("shaders/particles.wgsl")].concat();
let module =
d.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("effectcraft particles"), source: wgpu::ShaderSource::Wgsl(src.into()) });
let buffer = |binding: u32, ty: wgpu::BufferBindingType| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer { ty, has_dynamic_offset: false, min_binding_size: None },
count: None,
};
let bgl = d.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("effectcraft particles"),
entries: &[
buffer(0, wgpu::BufferBindingType::Uniform),
buffer(1, wgpu::BufferBindingType::Storage { read_only: true }),
buffer(2, wgpu::BufferBindingType::Storage { read_only: true }),
buffer(3, wgpu::BufferBindingType::Storage { read_only: false }),
],
});
let layout = d.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("effectcraft particles"),
bind_group_layouts: &[None, Some(&bgl)],
immediate_size: 0,
});
let pipe = d.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("particles"),
layout: Some(&layout),
module: &module,
entry_point: Some("particles"),
compilation_options: Default::default(),
cache: None,
});
Pipes { bgl, pipe }
})
}
/// The shader's parameter block.
fn uniforms(req: &SimRequest, n: u32, s0: u64, prev_n: u32) -> Vec<u8> {
let dt = (1.0 / effectcraft_effects::psim::SPS) as f32;
let mut u = [n, s0 as u32, req.steps as u32, 0];
let u2;
let mut f = [[0.0f32; 4]; 6];
match &req.system {
ParticleSystem::Engine(e) => {
u2 = [e.anim, e.flat as u32, e.seed, prev_n];
f[0] = [e.life, e.speed, e.extra, e.cone];
f[1] = [e.producer[0], e.producer[1], e.producer[2], 0.0];
f[2] = [e.radius[0], e.radius[1], e.radius[2], 0.0];
f[3] = [e.gravity[0], e.gravity[1], e.gravity[2], 0.0];
f[4] = [e.axis[0], e.axis[1], e.axis[2], 0.0];
f[5] = [e.damp, dt, 0.0, 0.0];
}
ParticleSystem::Cannon(c) => {
u[3] = 1;
u2 = [0, 1, c.seed, prev_n];
f[0] = [c.pos[0], c.pos[1], c.barrel_angle, c.barrel_radius];
f[1] = [c.dir_spread, c.vel, c.vel_spread, c.grav_spread];
f[2] = [c.gravity[0], c.gravity[1], dt, 0.0];
f[3] = c.bounds;
}
}
let mut out = Vec::with_capacity(128);
for x in u.iter().chain(&u2) {
out.extend_from_slice(&x.to_le_bytes());
}
for x in f.iter().flatten() {
out.extend_from_slice(&x.to_le_bytes());
}
out
}
/// Simulate `req` (see [`effectcraft_effects::psim::ParticleSim`]).
pub(crate) fn simulate(g: &GpuContext, req: &SimRequest) -> Option<Vec<SimParticle>> {
if !g.can_readback() || req.steps > u32::MAX as u64 {
return None;
}
let n = req.births.len() as u32;
if n == 0 {
return Some(vec![]);
}
let size = n as u64 * STATE_BYTES;
if size > g.device.limits().max_storage_buffer_binding_size {
return None;
}
let p = pipes(g);
// The latest checkpoint at or before the target step.
let (s0, prev, prev_n) = {
let mut c = g.particle_states.lock().ok()?;
c.clock += 1;
let now = c.clock;
match c.list.iter_mut().filter(|k| k.key == req.key && k.steps <= req.steps).max_by_key(|k| k.steps) {
Some(k) => {
k.used = now;
(k.steps, Some(k.buf.clone()), k.n)
}
None => (0, None, 0),
}
};
let d = &g.device;
let births: Vec<u8> = req.births.iter().flat_map(|b| b.to_le_bytes()).collect();
let births = d.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("births"), contents: &births, usage: wgpu::BufferUsages::STORAGE });
let prev = prev.unwrap_or_else(|| {
d.create_buffer(&wgpu::BufferDescriptor { label: Some("particles"), size: STATE_BYTES, usage: wgpu::BufferUsages::STORAGE, mapped_at_creation: false })
});
let next = d.create_buffer(&wgpu::BufferDescriptor {
label: Some("particles"),
size,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let ub = d.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("particles"),
contents: &uniforms(req, n, s0, prev_n),
usage: wgpu::BufferUsages::UNIFORM,
});
let bg = d.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &p.bgl,
entries: &[
wgpu::BindGroupEntry { binding: 0, resource: ub.as_entire_binding() },
wgpu::BindGroupEntry { binding: 1, resource: births.as_entire_binding() },
wgpu::BindGroupEntry { binding: 2, resource: prev.as_entire_binding() },
wgpu::BindGroupEntry { binding: 3, resource: next.as_entire_binding() },
],
});
let read = d.create_buffer(&wgpu::BufferDescriptor {
label: Some("particles readback"),
size,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = d.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("particles") });
{
let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("particles"), timestamp_writes: None });
pass.set_pipeline(&p.pipe);
pass.set_bind_group(1, &bg, &[]);
pass.dispatch_workgroups(n.div_ceil(64), 1, 1);
}
enc.copy_buffer_to_buffer(&next, 0, &read, 0, size);
g.queue.submit([enc.finish()]);
let (tx, rx) = std::sync::mpsc::channel();
read.map_async(wgpu::MapMode::Read, .., move |r| {
let _ = tx.send(r);
});
g.device.poll(wgpu::PollType::wait_indefinitely()).ok()?;
rx.recv().ok()?.ok()?;
let view = read.get_mapped_range(..).ok()?;
let f: Vec<f32> = view.chunks_exact(4).map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])).collect();
drop(view);
read.unmap();
let parts = f
.chunks_exact(12)
.enumerate()
.filter(|(_, s)| s[9] != 0.0)
.map(|(id, s)| SimParticle { p: [s[0], s[1], s[2]], v: [s[4], s[5], s[6]], age: s[3], life: s[7], rnd: s[8], id: id as u32 })
.collect();
// Keep the new state as a checkpoint.
if let Ok(mut c) = g.particle_states.lock() {
let now = c.clock;
c.list.retain(|k| !(k.key == req.key && k.steps == req.steps));
c.list.push(Checkpoint { key: req.key, steps: req.steps, n, buf: next, used: now });
while c.list.len() > CHECKPOINTS {
let Some(i) = c.list.iter().enumerate().min_by_key(|(_, k)| k.used).map(|(i, _)| i) else { break };
c.list.remove(i);
}
}
Some(parts)
}
pub(crate) type PipesCell = OnceLock<Pipes>;
pub(crate) type StatesCell = Mutex<Checkpoints>;

View File

@ -0,0 +1,460 @@
// Classic 3D on the GPU: the per-pixel compositor of `render::three_d::compose` (draw_run).
//
// Every plane covering an output pixel is inverse-mapped through its homography (one per
// motion-blur sub-sample), sampled from the plane atlas (`src`), shaded by the comp's lights
// (Blinn-Phong, ray-cast shadows against the caster planes) and multiplied by its track matte;
// the pixel's fragments are insertion-sorted far → near (coplanar fragments in stack order) and
// blended over the canvas (`aux`) with each layer's mode and opacity.
//
// Bindings: group 0 as every kernel (P, src = atlas, aux = canvas, out, data = track mattes);
// group 1 = planes, geometries, lights, caster indices.
// P.u[0] = (planes, lights, casters, _)
struct Plane {
// Atlas rectangle (x, y, w, h) in texels.
atlas: vec4<f32>,
// Buffer offset (x, y), scale, bicubic (0 / 1).
tex: vec4<f32>,
// Output pixel bounds [x0, y0, x1, y1).
bbox: vec4<f32>,
// Layer-space bounds of the buffer.
bounds: vec4<f32>,
// World → layer (affine rows).
winv0: vec4<f32>,
winv1: vec4<f32>,
winv2: vec4<f32>,
// Ambient, diffuse, specular, Blinn-Phong exponent.
mat0: vec4<f32>,
// Metal, light transmission, opacity.
mat1: vec4<f32>,
// First geometry, geometry count, flags (1 draw, 2 preserve transparency, 4 accepts
// lights), blend mode.
ints: vec4<u32>,
// Casts shadows, accepts shadows (0 off, 1 on, 2 only), layer id, stack order.
ints2: vec4<u32>,
// Track matte (index + 1; 0 = none), dissolve seed.
ints3: vec4<u32>,
}
struct Geo {
// Output pixel → homogeneous layer coordinates (rows).
h0: vec4<f32>,
h1: vec4<f32>,
h2: vec4<f32>,
// Camera depth = d.x·u + d.y·v + d.z.
depth: vec4<f32>,
// Layer → world (affine rows).
w0: vec4<f32>,
w1: vec4<f32>,
w2: vec4<f32>,
// Unit world normal.
normal: vec4<f32>,
// Camera eye (xyz) and orthographic flag (w).
eye: vec4<f32>,
// Camera forward (xyz).
fwd: vec4<f32>,
}
struct Light {
// Kind (0 ambient, 1 parallel, 2 point, 3 spot), falloff, casts shadows.
ints: vec4<u32>,
pos: vec4<f32>,
dir: vec4<f32>,
color: vec4<f32>,
// Cone half-angle, feather, radius, falloff distance.
cone: vec4<f32>,
// Shadow darkness, shadow diffusion.
shadow: vec4<f32>,
}
@group(1) @binding(0) var<storage, read> planes: array<Plane>;
@group(1) @binding(1) var<storage, read> geos: array<Geo>;
@group(1) @binding(2) var<storage, read> lights: array<Light>;
@group(1) @binding(3) var<storage, read> casters: array<u32>;
const NEAR3: f32 = 1.0;
const MAX_FRAGS: u32 = 32u;
fn atlas_get(i: u32, x: i32, y: i32) -> vec4<f32> {
let r = planes[i].atlas;
if (x < 0 || y < 0 || x >= i32(r.z) || y >= i32(r.w)) {
return vec4<f32>(0.0);
}
return textureLoad(src, vec2<i32>(i32(r.x) + x, i32(r.y) + y), 0);
}
// Image::sample_bilinear / sample_bicubic on a plane's buffer at layer coordinates (u, v).
fn texel(i: u32, u: f32, v: f32) -> vec4<f32> {
let t = planes[i].tex;
let x = u * t.z + t.x;
let y = v * t.z + t.y;
let fx = x - 0.5;
let fy = y - 0.5;
let x0 = floor(fx);
let y0 = floor(fy);
let xi = i32(x0);
let yi = i32(y0);
if (t.w > 0.5) {
let wx = cubic_w(fx - x0);
let wy = cubic_w(fy - y0);
var o = vec4<f32>(0.0);
for (var j = 0; j < 4; j++) {
for (var k = 0; k < 4; k++) {
o += atlas_get(i, xi - 1 + k, yi - 1 + j) * (wx[k] * wy[j]);
}
}
return max(o, vec4<f32>(0.0));
}
let tx = fx - x0;
let ty = fy - y0;
let a = atlas_get(i, xi, yi);
let b = atlas_get(i, xi + 1, yi);
let c = atlas_get(i, xi, yi + 1);
let d = atlas_get(i, xi + 1, yi + 1);
let top = a + (b - a) * tx;
let bot = c + (d - c) * tx;
return top + (bot - top) * ty;
}
fn affine(r0: vec4<f32>, r1: vec4<f32>, r2: vec4<f32>, p: vec3<f32>) -> vec3<f32> {
return vec3<f32>(dot(r0.xyz, p) + r0.w, dot(r1.xyz, p) + r1.w, dot(r2.xyz, p) + r2.w);
}
fn linear3(r0: vec4<f32>, r1: vec4<f32>, r2: vec4<f32>, v: vec3<f32>) -> vec3<f32> {
return vec3<f32>(dot(r0.xyz, v), dot(r1.xyz, v), dot(r2.xyz, v));
}
// compose::unproject: (u, v, depth, ok).
fn unproject(g: u32, sx: f32, sy: f32) -> vec4<f32> {
let gm = geos[g];
let ortho = gm.eye.w > 0.5;
let qz = gm.h2.x * sx + gm.h2.y * sy + gm.h2.z;
if ((!ortho && qz <= 0.0) || qz == 0.0) {
return vec4<f32>(0.0);
}
let u = (gm.h0.x * sx + gm.h0.y * sy + gm.h0.z) / qz;
let v = (gm.h1.x * sx + gm.h1.y * sy + gm.h1.z) / qz;
let z = gm.depth.x * u + gm.depth.y * v + gm.depth.z;
if (!ortho && z < NEAR3) {
return vec4<f32>(0.0);
}
return vec4<f32>(u, v, z, 1.0);
}
// LightState::to_light: unit vector towards the light (xyz) and the distance (w; -1 = ∞).
fn to_light(l: u32, p: vec3<f32>) -> vec4<f32> {
let li = lights[l];
if (li.ints.x == 1u) {
return vec4<f32>(-li.dir.xyz, -1.0);
}
let d = li.pos.xyz - p;
let len = length(d);
if (len < 1e-9) {
return vec4<f32>(0.0, 0.0, -1.0, 0.0);
}
return vec4<f32>(d / len, len);
}
fn smoothstep01(t: f32) -> f32 {
return 1.0 - t * t * (3.0 - 2.0 * t);
}
// LightState::attenuation.
fn attenuation(l: u32, p: vec3<f32>) -> f32 {
let li = lights[l];
let tl = to_light(l, p);
let dist = tl.w;
let finite = dist >= 0.0;
let radius = li.cone.z;
let fd = li.cone.w;
var k = 1.0;
if (li.ints.y == 1u && finite) {
if (dist <= radius) {
k = 1.0;
} else if (fd <= 0.0) {
k = 0.0;
} else {
k = smoothstep01(clamp((dist - radius) / fd, 0.0, 1.0));
}
} else if (li.ints.y == 2u && finite) {
if (dist <= radius || (radius <= 0.0 && dist <= 1.0)) {
k = 1.0;
} else {
let q = max(radius, 1.0) / dist;
k = q * q;
}
}
if (li.ints.x == 3u) {
let cos_a = clamp(dot(-tl.xyz, li.dir.xyz), -1.0, 1.0);
let a = acos(cos_a);
let outer = li.cone.x;
let inner = outer * (1.0 - li.cone.y);
if (a <= inner) {
} else if (a >= outer) {
k = 0.0;
} else {
k *= smoothstep01((a - inner) / max(outer - inner, 1e-9));
}
}
return k;
}
// compose::occlusion: RGB transmittance of light `l` reaching `p` past caster `c`.
fn occlusion(c: u32, p: vec3<f32>, l: u32) -> vec3<f32> {
let pl = planes[c];
let li = lights[l];
let a = affine(pl.winv0, pl.winv1, pl.winv2, p);
var hit: vec3<f32>;
var d_pc: f32;
var d_cl: f32;
if (li.ints.x == 1u) {
// Towards the light, 1e6 away (in direction form: exact in f32).
let dl = linear3(pl.winv0, pl.winv1, pl.winv2, -li.dir.xyz);
if (dl.z == 0.0) {
return vec3<f32>(1.0);
}
let s = -a.z / dl.z;
if (s <= 1.0 || s >= 1.0e6) {
return vec3<f32>(1.0);
}
hit = a + dl * s;
d_pc = length(li.dir.xyz) * s;
d_cl = 1000.0;
} else {
let b = affine(pl.winv0, pl.winv1, pl.winv2, li.pos.xyz);
if (a.z == b.z || (a.z < 0.0) == (b.z < 0.0)) {
return vec3<f32>(1.0);
}
let t = a.z / (a.z - b.z);
if (t <= 1e-6 || t >= 1.0) {
return vec3<f32>(1.0);
}
hit = a + (b - a) * t;
let seg = length(li.pos.xyz - p);
d_pc = seg * t;
d_cl = max(seg * (1.0 - t), 1.0);
}
let r = li.shadow.y * min(d_pc / d_cl, 4.0);
let bd = pl.bounds;
if (hit.x < bd.x - r || hit.y < bd.y - r || hit.x > bd.z + r || hit.y > bd.w + r) {
return vec3<f32>(1.0);
}
var px = vec4<f32>(0.0);
if (r * pl.tex.z < 0.5) {
px = texel(c, hit.x, hit.y);
} else {
for (var j = -1; j <= 1; j++) {
for (var i = -1; i <= 1; i++) {
px += texel(c, hit.x + f32(i) * r * 0.66, hit.y + f32(j) * r * 0.66) / 9.0;
}
}
}
let alpha = clamp(px.w * pl.mat1.z, 0.0, 1.0);
if (alpha <= 0.0) {
return vec3<f32>(1.0);
}
let tr = pl.mat1.y;
let dark = li.shadow.x;
var o = vec3<f32>(1.0);
for (var k = 0; k < 3; k++) {
var st = 0.0;
if (px.w > 0.0) {
st = clamp(px[k] / px.w, 0.0, 1.0);
}
let through = (1.0 - alpha) + alpha * tr * st;
o[k] = 1.0 - dark * (1.0 - through);
}
return o;
}
fn shadow_of(it: u32, l: u32, p: vec3<f32>) -> vec3<f32> {
var tr = vec3<f32>(1.0);
let n = P.u[0].z;
let lid = planes[it].ints2.z;
for (var k = 0u; k < n; k++) {
let c = casters[k];
if (c == it || planes[c].ints2.z == lid) {
continue;
}
tr *= occlusion(c, p, l);
}
return tr;
}
// compose::shade_texel (light::shade).
fn shade_texel(it: u32, g: u32, u: f32, v: f32, t: vec4<f32>) -> vec4<f32> {
let pl = planes[it];
let n_lights = P.u[0].y;
let accepts_shadows = pl.ints2.y;
if (n_lights == 0u || (pl.ints.z & 4u) == 0u) {
if (accepts_shadows == 2u) {
return vec4<f32>(0.0);
}
return t;
}
let gm = geos[g];
let a = t.w;
let rgb = t.xyz / a;
let p = affine(gm.w0, gm.w1, gm.w2, vec3<f32>(u, v, 0.0));
var to_viewer: vec3<f32>;
if (gm.eye.w > 0.5) {
to_viewer = -gm.fwd.xyz;
} else {
to_viewer = normalize(gm.eye.xyz - p);
}
var n = gm.normal.xyz;
if (dot(n, to_viewer) < 0.0) {
n = -n;
}
var amb = vec3<f32>(0.0);
var diff = vec3<f32>(0.0);
var spec = vec3<f32>(0.0);
var shadow_amt = 0.0;
let ex = pl.mat0.w;
let metal = pl.mat1.x;
let transmission = pl.mat1.y;
let specular = pl.mat0.z;
for (var i = 0u; i < n_lights; i++) {
let li = lights[i];
if (li.ints.x == 0u) {
amb += li.color.xyz;
continue;
}
let k = attenuation(i, p);
if (k <= 0.0) {
continue;
}
let lv = to_light(i, p).xyz;
let ndl = dot(n, lv);
let front = ndl > 0.0;
var lambert = ndl;
if (!front) {
lambert = -ndl * transmission;
}
if (lambert <= 0.0) {
continue;
}
var sh = vec3<f32>(1.0);
if (li.ints.z != 0u && accepts_shadows != 0u) {
sh = shadow_of(it, i, p);
}
shadow_amt = max(shadow_amt, 1.0 - (sh.x + sh.y + sh.z) / 3.0);
diff += li.color.xyz * (lambert * k) * sh;
if (front && specular > 0.0) {
let h = normalize(lv + to_viewer);
let s = powz(max(dot(n, h), 0.0), ex) * k;
let tint = vec3<f32>(1.0) + (rgb - vec3<f32>(1.0)) * metal;
spec += li.color.xyz * s * sh * tint;
}
}
let lit = rgb * (pl.mat0.x * amb + pl.mat0.y * diff) + specular * spec;
if (accepts_shadows == 2u) {
return vec4<f32>(0.0, 0.0, 0.0, a * shadow_amt);
}
return vec4<f32>(lit * a, a);
}
// compose::before: a draws before b (farther; coplanar → lower stack order first).
fn before(za: f32, oa: u32, zb: f32, ob: u32) -> bool {
let tol = 1e-5 * max(max(abs(za), abs(zb)), 1.0);
if (abs(za - zb) <= tol) {
return oa < ob;
}
return za > zb;
}
@compute @workgroup_size(16, 16)
fn classic3d(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = out_dims();
let x = i32(gid.x);
let y = i32(gid.y);
if (x >= dims.x || y >= dims.y) {
return;
}
let pix = vec2<i32>(x, y);
var d = textureLoad(aux, pix, 0);
let fx = f32(x);
let fy = f32(y);
let sx = fx + 0.5;
let sy = fy + 0.5;
var fz: array<f32, MAX_FRAGS>;
var fo: array<u32, MAX_FRAGS>;
var fi: array<u32, MAX_FRAGS>;
var fp: array<vec4<f32>, MAX_FRAGS>;
var nf = 0u;
let n_planes = P.u[0].x;
for (var i = 0u; i < n_planes; i++) {
let pl = planes[i];
if ((pl.ints.z & 1u) == 0u) {
continue;
}
if (fx < pl.bbox.x || fx >= pl.bbox.z || fy < pl.bbox.y || fy >= pl.bbox.w) {
continue;
}
// compose::fragment: average of the motion-blur sub-samples.
let g0 = pl.ints.x;
let ng = pl.ints.y;
var acc = vec4<f32>(0.0);
var depth = 0.0;
var hit = false;
for (var k = 0u; k < ng; k++) {
let q = unproject(g0 + k, sx, sy);
if (q.w == 0.0) {
continue;
}
let t = texel(i, q.x, q.y);
if (t.w <= 1e-6) {
continue;
}
if (!hit) {
depth = q.z;
hit = true;
}
acc += shade_texel(i, g0 + k, q.x, q.y, t);
}
if (!hit) {
continue;
}
if (ng > 1u) {
acc *= 1.0 / f32(ng);
}
let m = pl.ints3.x;
if (m != 0u) {
let f = data[(m - 1u) * u32(dims.x) * u32(dims.y) + u32(y) * u32(dims.x) + u32(x)];
acc *= f;
}
if (!(acc.w > 0.0 || acc.x != 0.0 || acc.y != 0.0 || acc.z != 0.0) || nf >= MAX_FRAGS) {
continue;
}
// Insertion: far → near, coplanar in stack order (stable, as the CPU's insertion sort).
let order = pl.ints2.w;
var j = nf;
while (j > 0u && before(depth, order, fz[j - 1u], fo[j - 1u])) {
fz[j] = fz[j - 1u];
fo[j] = fo[j - 1u];
fi[j] = fi[j - 1u];
fp[j] = fp[j - 1u];
j--;
}
fz[j] = depth;
fo[j] = order;
fi[j] = i;
fp[j] = acc;
nf++;
}
for (var k = 0u; k < nf; k++) {
let pl = planes[fi[k]];
var op = pl.mat1.z;
if ((pl.ints.z & 2u) != 0u) {
op *= d.w;
}
let s = fp[k] * op;
let mode = pl.ints.w;
var n = 0.5;
if (mode == 1u || mode == 2u) {
n = hash_noise(u32(x), u32(y), pl.ints3.y);
}
d = blend_pixel(mode, d, s, n);
}
textureStore(out, pix, d);
}

View File

@ -428,7 +428,44 @@ fn hsl_to_rgb(h: f32, s: f32, l: f32) -> vec3<f32> {
return vec3<f32>(hsl_channel(h + 1.0 / 3.0, p, q), hsl_channel(h, p, q), hsl_channel(h - 1.0 / 3.0, p, q));
}
// ColorSpace transfer curves: 1 = sRGB (sRGB, Display P3), 2 = 2.4 power (Rec. 709 / 2020).
// ColorSpace transfer curves: 1 = sRGB (sRGB, Display P3), 2 = 2.4 power (Rec. 709 / 2020),
// 3 = SMPTE ST 2084 PQ and 4 = BT.2100 HLG (linear 1.0 = the BT.2408 reference white).
const PQ_M1: f32 = 0.1593017578125;
const PQ_M2: f32 = 78.84375;
const PQ_C1: f32 = 0.8359375;
const PQ_C2: f32 = 18.8515625;
const PQ_C3: f32 = 18.6875;
const PQ_WHITE: f32 = 0.0203;
const HLG_A: f32 = 0.17883277;
const HLG_B: f32 = 0.28466892;
const HLG_C: f32 = 0.55991073;
const HLG_REF: f32 = 0.26496256;
fn pq_decode(e: f32) -> f32 {
let p = pow(clamp(e, 0.0, 1.0), 1.0 / PQ_M2);
return pow(max(p - PQ_C1, 0.0) / (PQ_C2 - PQ_C3 * p), 1.0 / PQ_M1) / PQ_WHITE;
}
fn pq_encode(l: f32) -> f32 {
let y = pow(max(l * PQ_WHITE, 0.0), PQ_M1);
return pow((PQ_C1 + PQ_C2 * y) / (1.0 + PQ_C3 * y), PQ_M2);
}
fn hlg_decode(v: f32) -> f32 {
if (v <= 0.5) {
return v * v / 3.0 / HLG_REF;
}
return (exp((v - HLG_C) / HLG_A) + HLG_B) / 12.0 / HLG_REF;
}
fn hlg_encode(l: f32) -> f32 {
let e = max(l * HLG_REF, 0.0);
if (e <= 1.0 / 12.0) {
return sqrt(3.0 * e);
}
return HLG_A * log(12.0 * e - HLG_B) + HLG_C;
}
fn curve_decode(curve: u32, v: f32) -> f32 {
let a = abs(v);
var l = a;
@ -436,6 +473,10 @@ fn curve_decode(curve: u32, v: f32) -> f32 {
l = srgb_to_linear(a);
} else if (curve == 2u) {
l = powz(a, 2.4);
} else if (curve == 3u) {
l = pq_decode(a);
} else if (curve == 4u) {
l = hlg_decode(a);
}
return select(l, -l, v < 0.0);
}
@ -447,6 +488,10 @@ fn curve_encode(curve: u32, v: f32) -> f32 {
e = linear_to_srgb(a);
} else if (curve == 2u) {
e = powz(a, 1.0 / 2.4);
} else if (curve == 3u) {
e = pq_encode(a);
} else if (curve == 4u) {
e = hlg_encode(a);
}
return select(e, -e, v < 0.0);
}

View File

@ -0,0 +1,707 @@
// GPU effects (color family): see src/fx_color.rs. Each entry point mirrors the CPU effect in
// effectcraft-effects operation for operation; parameter layouts are documented per entry.
// Every name here is prefixed `fxc_` (the family files share one module).
// ---------------------------------------------------------------- helpers
// util::unpremul: straight colour (0 when alpha ≤ 1e-6).
fn fxc_unpremul(px: vec4<f32>) -> vec3<f32> {
if (px.w > 1e-6) {
return px.xyz / px.w;
}
return vec3<f32>(0.0);
}
// util::smoothstep.
fn fxc_smoothstep(e0: f32, e1: f32, x: f32) -> f32 {
if (abs(e1 - e0) < 1e-9) {
return select(1.0, 0.0, x < e0);
}
let t = clamp((x - e0) / (e1 - e0), 0.0, 1.0);
return t * t * (3.0 - 2.0 * t);
}
fn fxc_mix(a: vec3<f32>, b: vec3<f32>, t: f32) -> vec3<f32> {
return a + (b - a) * t;
}
// color::hsv_to_rgb.
fn fxc_hsv_to_rgb(h: f32, s: f32, v: f32) -> vec3<f32> {
let h6 = fract_euclid(h) * 6.0;
let i = floor(h6);
let f = h6 - i;
let p = v * (1.0 - s);
let q = v * (1.0 - s * f);
let t = v * (1.0 - s * (1.0 - f));
switch i32(i) {
case 0: { return vec3<f32>(v, t, p); }
case 1: { return vec3<f32>(q, v, p); }
case 2: { return vec3<f32>(p, v, t); }
case 3: { return vec3<f32>(p, q, v); }
case 4: { return vec3<f32>(t, p, v); }
default: { return vec3<f32>(v, p, q); }
}
}
fn fxc_pixel(gid: vec3<u32>) -> vec2<i32> {
let dims = out_dims();
let p = vec2<i32>(gid.xy);
if (p.x >= dims.x || p.y >= dims.y) {
return vec2<i32>(-1);
}
return p;
}
// ---------------------------------------------------------------- per-pixel colour effects
// Colorama's input phase (color_fx::colorama_phase).
fn fxc_colorama_phase(c: vec3<f32>, a: f32, mode: u32) -> f32 {
var v: f32;
switch mode {
case 1u: { v = c.x; }
case 2u: { v = c.y; }
case 3u: { v = c.z; }
case 4u: { v = rgb_to_hsl(c).x; }
case 5u: { v = rgb_to_hsl(c).z; }
case 6u: { v = rgb_to_hsl(c).y; }
case 7u: { v = max(max(c.x, c.y), c.z); }
case 8u: { v = a; }
case 9u: { v = 0.0; }
default: { v = luminance(c); }
}
return clamp(v, 0.0, 1.0);
}
// Selective Color range weights (color2::sc_weights).
fn fxc_sc_weights(c0: vec3<f32>) -> array<f32, 9> {
let c = clamp(c0, vec3<f32>(0.0), vec3<f32>(1.0));
// Stable descending sort of the channel indices.
var idx = vec3<u32>(0u, 1u, 2u);
for (var i = 1u; i < 3u; i++) {
var j = i;
while (j > 0u && c[idx[j]] > c[idx[j - 1u]]) {
let t = idx[j];
idx[j] = idx[j - 1u];
idx[j - 1u] = t;
j--;
}
}
let mx = c[idx.x];
let md = c[idx.y];
let mn = c[idx.z];
var w = array<f32, 9>(0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
var prim = array<u32, 3>(0u, 2u, 4u);
var sec = array<u32, 3>(3u, 5u, 1u);
w[prim[idx.x]] = mx - md;
w[sec[idx.z]] = md - mn;
w[6] = max((mn - 0.5) * 2.0, 0.0);
w[8] = max((0.5 - mx) * 2.0, 0.0);
w[7] = clamp(1.0 - (abs(mx - 0.5) + abs(mn - 0.5)), 0.0, 1.0);
return w;
}
// u[0].x = op:
// 1 Color Balance f[0] = shadows, f[1] = midtones, f[2] = highlights; u[0].y = preserve luminosity
// 2 Vibrance f[0] = (vibrance, saturation)
// 3 Black & White f[0] = (reds, yellows, greens, cyans), f[1] = (blues, magentas);
// u[0].y = tint; f[2] = (tint hue, tint saturation)
// 4 Tritone f[0] = highlights, f[1] = midtones, f[2] = shadows; f[3].x = 1 − blend
// 5 Colorama u[0].y = phase mode, u[0].z = interpolate; f[0] = (cycles, shift, 1 − blend)
// 6 Channel Mixer f[0..3] = output rows (r, g, b, const); u[0].y = monochrome
// 7 Selective Color data = 9 × (cyan, magenta, yellow, black); u[0].y = relative
// 8 Linear Color Key f[0] = (key rgb, tolerance), f[1] = (softness, key chroma xy, key hue);
// u[0] = (_, match mode, keep colours, view)
@compute @workgroup_size(16, 16)
fn fxc_point(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = fxc_pixel(gid);
if (p.x < 0) {
return;
}
var px = textureLoad(src, p, 0);
let a = px.w;
switch P.u[0].x {
case 1u: {
if (a > 0.0) {
let c = px.xyz / a;
let l = clamp(luminance(c), 0.0, 1.0);
let ws = clamp(1.0 - l * 2.0, 0.0, 1.0);
let wh = clamp(l * 2.0 - 1.0, 0.0, 1.0);
let wm = 1.0 - ws - wh;
var o = max(c + (P.f[0].xyz * ws + P.f[1].xyz * wm + P.f[2].xyz * wh) * 0.5, vec3<f32>(0.0));
if (P.u[0].y != 0u) {
let d = luminance(c) - luminance(o);
o = max(o + d, vec3<f32>(0.0));
}
px = vec4<f32>(o * a, a);
}
}
case 2u: {
if (a > 0.0) {
let c = px.xyz / a;
let hsl = rgb_to_hsl(c);
let vib = P.f[0].x;
let sat = P.f[0].y;
let s = clamp(hsl.y * (1.0 + sat) + vib * (1.0 - hsl.y) * min(hsl.y, 0.5), 0.0, 1.0);
px = vec4<f32>(hsl_to_rgb(hsl.x, s, hsl.z) * a, a);
}
}
case 3u: {
if (a > 0.0) {
let c = px.xyz / a;
var w = array<f32, 6>(P.f[0].x, P.f[0].y, P.f[0].z, P.f[0].w, P.f[1].x, P.f[1].y);
let hsl = rgb_to_hsl(c);
let hp = hsl.x * 6.0;
let i = u32(max(floor(hp), 0.0)) % 6u;
let f = hp - floor(hp);
let wt = w[i] + (w[(i + 1u) % 6u] - w[i]) * f;
let base = luminance(c);
let gray = max(base + (wt - 0.5) * hsl.y * 0.6, 0.0);
var o = vec3<f32>(gray);
if (P.u[0].y != 0u) {
o = hsl_to_rgb(P.f[2].x, P.f[2].y, min(gray, 1.0));
}
px = vec4<f32>(o * a, a);
}
}
case 4u: {
if (a > 0.0) {
let c = px.xyz / a;
let l = clamp(luminance(c), 0.0, 1.0);
var t: vec3<f32>;
if (l < 0.5) {
t = fxc_mix(P.f[2].xyz, P.f[1].xyz, l * 2.0);
} else {
t = fxc_mix(P.f[1].xyz, P.f[0].xyz, (l - 0.5) * 2.0);
}
px = vec4<f32>(fxc_mix(c, t, P.f[3].x) * a, a);
}
}
case 5u: {
if (a > 0.0) {
let c = px.xyz / a;
var ph = fract_euclid(fxc_colorama_phase(c, a, P.u[0].y) * P.f[0].x + P.f[0].y);
if (P.u[0].z == 0u) {
ph = floor(ph * 6.0) / 6.0;
}
let o = fxc_mix(c, fxc_hsv_to_rgb(ph, 1.0, 1.0), P.f[0].z);
px = vec4<f32>(o * a, a);
}
}
case 6u: {
if (a > 0.0) {
let c = px.xyz / a;
var o: vec3<f32>;
for (var i = 0; i < 3; i++) {
let r = P.f[i];
o[i] = max(c.x * r.x + c.y * r.y + c.z * r.z + r.w, 0.0);
}
if (P.u[0].y != 0u) {
o = vec3<f32>(o.x);
}
px = vec4<f32>(o * a, a);
}
}
case 7u: {
// color2::map_ca: fully transparent black stays.
if (a <= 0.0 && px.x == 0.0 && px.y == 0.0 && px.z == 0.0) {
textureStore(out, p, px);
return;
}
let c = fxc_unpremul(px);
var w = fxc_sc_weights(c);
var o = c;
let relative = P.u[0].y != 0u;
for (var r = 0u; r < 9u; r++) {
let wr = w[r];
if (wr <= 0.0) {
continue;
}
let k = data[r * 4u + 3u];
for (var i = 0u; i < 3u; i++) {
let ink = 1.0 - c[i];
var delta = data[r * 4u + i] + k;
if (relative) {
delta = delta * max(ink, 0.0);
}
o[i] -= wr * delta;
}
}
let na = clamp(max(a, 0.0), 0.0, 1.0);
px = vec4<f32>(max(o, vec3<f32>(0.0)) * na, na);
}
case 8u: {
let c = fxc_unpremul(px);
let key = P.f[0].xyz;
let tol = P.f[0].w;
let soft = P.f[1].x;
var d: f32;
switch P.u[0].y {
case 1u: {
let hsl = rgb_to_hsl(c);
if (hsl.y < 0.02) {
d = 1.0;
} else {
let dh = abs(hsl.x - P.f[1].w);
d = min(dh, 1.0 - dh) * 2.0;
}
}
case 2u: {
let s = c.x + c.y + c.z;
var pc = vec2<f32>(1.0 / 3.0, 1.0 / 3.0);
if (s > 1e-6) {
pc = vec2<f32>(c.x / s, c.y / s);
}
let dx = pc.x - P.f[1].y;
let dy = pc.y - P.f[1].z;
d = sqrt(dx * dx + dy * dy) / 1.4142135;
}
default: {
let e = c - key;
d = sqrt(e.x * e.x + e.y * e.y + e.z * e.z) / 1.7320508;
}
}
var v: f32;
if (soft > 1e-6) {
v = clamp((d - tol) / soft, 0.0, 1.0);
} else if (d > tol) {
v = 1.0;
} else {
v = 0.0;
}
if (P.u[0].z != 0u) {
v = 1.0 - v;
}
let k = clamp(v, 0.0, 1.0);
if (P.u[0].w == 2u) {
let m = px.w * k;
px = vec4<f32>(m, m, m, 1.0);
} else {
px = px * k;
}
}
default: {}
}
textureStore(out, p, px);
}
// ---------------------------------------------------------------- Lumetri Color (color3)
fn fxc_tone_curve(x: f32, s: f32, m: f32, h: f32) -> f32 {
if (s == 0.0 && m == 0.0 && h == 0.0) {
return x;
}
let t = clamp(x, 0.0, 1.0);
let d0 = (t - 0.25) / 0.25;
let d1 = (t - 0.5) / 0.25;
let d2 = (t - 0.75) / 0.25;
let b0 = max(1.0 - d0 * d0, 0.0);
let b1 = max(1.0 - d1 * d1, 0.0);
let b2 = max(1.0 - d2 * d2, 0.0);
return x + 0.25 * (s * (b0 * b0) + m * (b1 * b1) + h * (b2 * b2));
}
fn fxc_d3(i: u32) -> vec3<f32> {
return vec3<f32>(data[i], data[i + 1u], data[i + 2u]);
}
// data: 0 temperature, 1 tint, 2 exposure gain, 3 contrast, 4 highlights, 5 shadows, 6 whites,
// 7 blacks, 8 saturation, 9 look intensity, 10 faded film, 11 vibrance, 12 creative saturation,
// 13 tint balance pivot, 14..17 shadow tint, 17..20 highlight tint, 20..32 curves (master, red,
// green, blue × shadows / midtones / highlights), 32..41 wheels (shadows, midtones, highlights),
// 41 vignette amount, 42 vignette start, 43 roundness, 44 feather, 45 cx, 46 cy, 47 lw, 48 lh,
// 49 aspect.
@compute @workgroup_size(16, 16)
fn fxc_lumetri(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = fxc_pixel(gid);
if (p.x < 0) {
return;
}
var px = textureLoad(src, p, 0);
let a = px.w;
if (a <= 0.0) {
textureStore(out, p, px);
return;
}
var c = fxc_unpremul(px);
let temp = data[0];
let tint = data[1];
if (temp != 0.0 || tint != 0.0) {
c = vec3<f32>(c.x * (1.0 + 0.25 * temp), c.y * (1.0 - 0.25 * tint), c.z * (1.0 - 0.25 * temp));
}
let expo = data[2];
if (expo != 1.0) {
c = c * expo;
}
let contrast = data[3];
if (contrast != 0.0) {
c = 0.5 + (c - 0.5) * (1.0 + contrast);
}
let hi = data[4];
let sh = data[5];
let wh = data[6];
let bl = data[7];
if (hi != 0.0 || sh != 0.0 || wh != 0.0 || bl != 0.0) {
let l = max(luminance(c), 0.0);
let lt = min(l, 1.0);
let il = 1.0 - lt;
let d = 0.25 * hi * fxc_smoothstep(0.5, 1.0, lt) + 0.25 * sh * (1.0 - fxc_smoothstep(0.0, 0.5, lt)) + 0.2 * wh * lt * lt + 0.2 * bl * (il * il);
c = c + d;
}
let l0 = luminance(c);
let sat = data[8];
if (sat != 1.0) {
c = l0 + (c - l0) * sat;
}
let intensity = data[9];
let faded = data[10];
let vib = data[11];
let csat = data[12];
let st = fxc_d3(14u);
let ht = fxc_d3(17u);
let zero = vec3<f32>(0.0);
if (intensity != 0.0 && (faded != 0.0 || vib != 0.0 || csat != 1.0 || any(st != zero) || any(ht != zero))) {
var d = c;
if (faded != 0.0) {
d = d * (1.0 - 0.25 * faded) + 0.12 * faded;
}
let l = luminance(d);
if (vib != 0.0) {
let mx = max(max(d.x, d.y), d.z);
let mn = min(min(d.x, d.y), d.z);
var s = 0.0;
if (mx > 1e-6) {
s = (mx - mn) / mx;
}
let k = 1.0 + vib * (1.0 - clamp(s, 0.0, 1.0));
d = l + (d - l) * k;
}
if (csat != 1.0) {
d = l + (d - l) * csat;
}
let pivot = data[13];
let lt = clamp(l, 0.0, 1.0);
let wsh = 1.0 - fxc_smoothstep(0.0, pivot, lt);
let whi = fxc_smoothstep(pivot, 1.0, lt);
d = d + 0.5 * (st * wsh + ht * whi);
c = c + (d - c) * intensity;
}
for (var i = 0u; i < 3u; i++) {
c[i] = fxc_tone_curve(c[i], data[20], data[21], data[22]);
}
for (var i = 0u; i < 3u; i++) {
let b = 23u + i * 3u;
c[i] = fxc_tone_curve(c[i], data[b], data[b + 1u], data[b + 2u]);
}
let ws = fxc_d3(32u);
let wm = fxc_d3(35u);
let wh3 = fxc_d3(38u);
if (any(ws != zero) || any(wm != zero) || any(wh3 != zero)) {
let lt = clamp(luminance(c), 0.0, 1.0);
let ka = 1.0 - fxc_smoothstep(0.0, 0.5, lt);
let kz = fxc_smoothstep(0.5, 1.0, lt);
let km = 1.0 - ka - kz;
c = c + 0.5 * (ws * ka + wm * km + wh3 * kz);
}
let vig = data[41];
if (vig != 0.0) {
let x = f32(p.x) + 0.5;
let y = f32(p.y) + 0.5;
let lw = data[47];
let lh = data[48];
var nx = (x - data[45]) / max(lw * 0.5, 1e-6);
var ny = (y - data[46]) / max(lh * 0.5, 1e-6);
let vround = data[43];
let aspect = data[49];
if (vround > 0.0) {
nx = nx * (1.0 + (max(aspect, 1.0) - 1.0) * vround);
ny = ny * (1.0 + (max(1.0 / aspect, 1.0) - 1.0) * vround);
}
let pw = 2.0 + max(-vround, 0.0) * 6.0;
let r = powz(powz(abs(nx), pw) + powz(abs(ny), pw), 1.0 / pw) / 1.4142135;
let start = data[42];
let f = fxc_smoothstep(start, start + 0.05 + data[44] * (1.05 - start), r);
if (vig > 0.0) {
c = c + (1.0 - c) * min(vig * 0.2 * f, 1.0);
} else {
let k = 1.0 + vig * 0.2 * f;
c = c * max(k, 0.0);
}
}
c = max(c, zero);
textureStore(out, p, vec4<f32>(c * a, a));
}
// Straight-colour luminance plane (util::Plane::luma) in x.
@compute @workgroup_size(16, 16)
fn fxc_luma(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = fxc_pixel(gid);
if (p.x < 0) {
return;
}
let px = textureLoad(src, p, 0);
textureStore(out, p, vec4<f32>(luminance(fxc_unpremul(px)), 0.0, 0.0, 0.0));
}
// Lumetri Sharpen: `src` + (luma − blurred luma `aux`.x) × amount. f[0].x = sharpen × 2.
@compute @workgroup_size(16, 16)
fn fxc_sharpen(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = fxc_pixel(gid);
if (p.x < 0) {
return;
}
let px = textureLoad(src, p, 0);
let a = px.w;
if (a <= 0.0) {
textureStore(out, p, px);
return;
}
let c = fxc_unpremul(px);
let d = (luminance(c) - textureLoad(aux, p, 0).x) * P.f[0].x;
textureStore(out, p, vec4<f32>(max(c + d, vec3<f32>(0.0)) * a, a));
}
// ---------------------------------------------------------------- planes (util)
// One morphology pass (util::minmax_row, edges repeated) of radius u[0].x along rows (u[0].y = 0)
// or columns; u[1] = per-channel operation: 0 min, 1 max, 2 copy.
@compute @workgroup_size(16, 16)
fn fxc_morph(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = fxc_pixel(gid);
if (p.x < 0) {
return;
}
let dims = out_dims();
let r = i32(P.u[0].x);
let vertical = P.u[0].y != 0u;
let modes = P.u[1];
let centre = textureLoad(src, p, 0);
var lo = centre;
var hi = centre;
for (var k = -r; k <= r; k++) {
var q = p;
if (vertical) {
q.y = clamp(p.y + k, 0, dims.y - 1);
} else {
q.x = clamp(p.x + k, 0, dims.x - 1);
}
let v = textureLoad(src, q, 0);
lo = min(lo, v);
hi = max(hi, v);
}
var o = centre;
for (var i = 0; i < 4; i++) {
if (modes[i] == 0u) {
o[i] = lo[i];
} else if (modes[i] == 1u) {
o[i] = hi[i];
}
}
textureStore(out, p, o);
}
// `src` + (`aux` − `src`) × f[0].x (Plane::zip_map lerp).
@compute @workgroup_size(16, 16)
fn fxc_lerp(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = fxc_pixel(gid);
if (p.x < 0) {
return;
}
let x = textureLoad(src, p, 0);
let y = textureLoad(aux, p, 0);
textureStore(out, p, x + (y - x) * P.f[0].x);
}
// ---------------------------------------------------------------- Key Light (keylight.rs)
fn fxc_neutralise(c: vec3<f32>, bias: vec3<f32>) -> vec3<f32> {
let l = max(luminance(bias), 1e-4);
return vec3<f32>(c.x * l / max(bias.x, 1e-4), c.y * l / max(bias.y, 1e-4), c.z * l / max(bias.z, 1e-4));
}
fn fxc_screen_diff(c: vec3<f32>, ix: vec3<u32>, bal: f32) -> f32 {
return c[ix.x] - (bal * c[ix.y] + (1.0 - bal) * c[ix.z]);
}
fn fxc_correct(c0: vec3<f32>, sat: f32, contrast: f32, bright: f32) -> vec3<f32> {
let l = luminance(c0);
let c = l + (c0 - l) * sat;
return ((c - 0.5) * contrast + 0.5) * bright;
}
// Raw screen matte of `src` (the key source): out = (clipped, clipped, raw, 0).
// u[0].xyz = primary, other channels; f[0] = (alpha bias, screen difference);
// f[1] = (gain, balance, clip black, clip white).
@compute @workgroup_size(16, 16)
fn fxc_kl_raw(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = fxc_pixel(gid);
if (p.x < 0) {
return;
}
let c = fxc_unpremul(textureLoad(src, p, 0));
let d = fxc_screen_diff(fxc_neutralise(c, P.f[0].xyz), P.u[0].xyz, P.f[1].y);
let raw = clamp(1.0 - P.f[1].x * d / P.f[0].w, 0.0, 1.0);
let cb = P.f[1].z;
let m = clamp((raw - cb) / max(P.f[1].w - cb, 1e-4), 0.0, 1.0);
textureStore(out, p, vec4<f32>(m, m, raw, 0.0));
}
// Clip Rollback: `src` = (shrunk, grown) clipped matte, `aux` = raw planes (clipped, _, raw).
@compute @workgroup_size(16, 16)
fn fxc_kl_rollback(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = fxc_pixel(gid);
if (p.x < 0) {
return;
}
let sg = textureLoad(src, p, 0);
let r = textureLoad(aux, p, 0);
var m = r.x;
if (sg.y > sg.x + 1e-4) {
m = min(max(m, r.z), sg.y);
}
textureStore(out, p, vec4<f32>(m, m, r.z, 0.0));
}
// Planes for the final pass: (screen matte from `src`.x, raw from `aux`.z, edge 0, 0).
@compute @workgroup_size(16, 16)
fn fxc_kl_pack(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = fxc_pixel(gid);
if (p.x < 0) {
return;
}
textureStore(out, p, vec4<f32>(textureLoad(src, p, 0).x, textureLoad(aux, p, 0).z, 0.0, 0.0));
}
// Combined matte from the screen matte and the source alpha (no inside / outside masks).
fn fxc_kl_combined(sm: f32, src_a: f32, mode: u32) -> f32 {
var a = sm;
let sa = clamp(src_a, 0.0, 1.0);
var ins = 0.0;
if (mode == 1u) {
ins = max(ins, sa);
}
a = max(a, ins);
if (mode == 2u) {
a = a * sa;
}
return clamp(a, 0.0, 1.0);
}
// Edge band of the combined matte: `src` = source, `aux` = planes; u[0].x = source alpha mode.
@compute @workgroup_size(16, 16)
fn fxc_kl_band(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = fxc_pixel(gid);
if (p.x < 0) {
return;
}
let a = fxc_kl_combined(textureLoad(aux, p, 0).x, textureLoad(src, p, 0).w, P.u[0].x);
textureStore(out, p, vec4<f32>(select(0.0, 1.0, a > 1e-3 && a < 0.999), 0.0, 0.0, 0.0));
}
// Planes `src` with the edge weight from the processed band `aux`.x; f[0].x = hardness.
@compute @workgroup_size(16, 16)
fn fxc_kl_edge(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = fxc_pixel(gid);
if (p.x < 0) {
return;
}
let pl = textureLoad(src, p, 0);
let e = min(textureLoad(aux, p, 0).x * (1.0 + P.f[0].x * 4.0), 1.0);
textureStore(out, p, vec4<f32>(pl.x, pl.y, e, 0.0));
}
fn fxc_kl_replace(fg: vec3<f32>, c: vec3<f32>, method: u32, rc: vec3<f32>, amt: f32) -> vec3<f32> {
if (amt <= 0.0) {
return fg;
}
switch method {
case 1u: { return fg + (c - fg) * amt; }
case 2u: { return rc; }
case 3u: {
let l = luminance(c) / max(luminance(rc), 1e-4);
return fg + (rc * l - fg) * amt;
}
default: { return fg; }
}
}
// Final pass: `src` = source, `aux` = planes (screen matte, raw, edge).
// u[0] = (primary, others, view); u[1] = (replace method, foreground cc, unpremultiply,
// source alpha mode); f[0] = (screen, balance); f[1] = (despill bias, l0); f[2] = replace colour;
// f[3] = fg cc (sat, contrast, bright); f[4] = edge cc; f[5] = crops (l, r, t, b);
// f[6] = (offset, scale, _); f[7] = (layer w, layer h).
@compute @workgroup_size(16, 16)
fn fxc_kl_final(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = fxc_pixel(gid);
if (p.x < 0) {
return;
}
let s = textureLoad(src, p, 0);
let pl = textureLoad(aux, p, 0);
let ix = P.u[0].xyz;
let view = P.u[0].w;
let c = fxc_unpremul(s);
let sm = pl.x;
let raw = pl.y;
let edge = pl.z;
let a = fxc_kl_combined(sm, s.w, P.u[1].w);
let screen = P.f[0].xyz;
let bal = P.f[0].w;
let dbias = P.f[1].xyz;
let fg_cc = P.u[1].y != 0u;
let ar = max(raw, 1e-4);
var fg = max((c - (1.0 - ar) * screen) / ar, vec3<f32>(0.0));
let spill = max(fxc_screen_diff(fxc_neutralise(fg, dbias), ix, bal), 0.0);
fg[ix.x] -= spill / P.f[1].w;
let intermediate = fg;
fg = fxc_kl_replace(fg, c, P.u[1].x, P.f[2].xyz, max(sm - raw, 0.0));
if (fg_cc) {
fg = fxc_correct(fg, P.f[3].x, P.f[3].y, P.f[3].z);
}
if (edge > 0.0) {
let ce = fxc_correct(fg, P.f[4].x, P.f[4].y, P.f[4].z);
fg = fg + (ce - fg) * edge;
}
var o: vec4<f32>;
switch view {
case 1u: { o = vec4<f32>(s.w, s.w, s.w, 1.0); }
case 2u: {
var cs = fxc_neutralise(c, dbias);
let s2 = max(fxc_screen_diff(cs, ix, bal), 0.0);
cs[ix.x] -= s2;
if (fg_cc) {
cs = fxc_correct(cs, P.f[3].x, P.f[3].y, P.f[3].z);
}
o = vec4<f32>(cs, 1.0);
}
case 3u: { o = vec4<f32>(edge, edge, edge, 1.0); }
case 4u: { o = vec4<f32>(sm, sm, sm, 1.0); }
case 5u, 6u: { o = vec4<f32>(0.0, 0.0, 0.0, 1.0); }
case 7u: { o = vec4<f32>(a, a, a, 1.0); }
case 8u: {
var v = 0.5;
if (a <= 1e-3) {
v = 0.0;
} else if (a >= 0.999) {
v = 1.0;
}
o = vec4<f32>(v, v, v, 1.0);
}
case 9u: { o = vec4<f32>(intermediate * sm, sm); }
default: {
let x = f32(p.x) + 0.5;
let y = f32(p.y) + 0.5;
let lx = (x - P.f[6].x) / P.f[6].z / P.f[7].x;
let ly = (y - P.f[6].y) / P.f[6].z / P.f[7].y;
let cr = P.f[5];
let cropped = lx < cr.x || lx > 1.0 - cr.y || ly < cr.z || ly > 1.0 - cr.w;
if (cropped) {
o = vec4<f32>(0.0);
} else if (P.u[1].z != 0u) {
o = vec4<f32>(fg * a, a);
} else {
o = vec4<f32>(fg * a * a, a);
}
}
}
textureStore(out, p, o);
}

View File

@ -0,0 +1,554 @@
// GPU effects (distort family): see src/fx_distort.rs. Each entry point mirrors the CPU effect in
// effectcraft-effects operation for operation; parameter layouts are documented per entry.
// Coordinates are pixel centres (x + 0.5, y + 0.5) in buffer pixels, as in util::remap.
const DST_TAU: f32 = 6.2831855;
const DST_HALF_PI: f32 = 1.5707964;
fn dst_centre(p: vec2<i32>) -> vec2<f32> {
return vec2<f32>(f32(p.x) + 0.5, f32(p.y) + 0.5);
}
fn dst_inside(p: vec2<i32>) -> bool {
let d = out_dims();
return p.x < d.x && p.y < d.y;
}
// ---------------------------------------------------------------- Twirl (distort::twirl)
// f[0] = (cx, cy, radius, angle in radians)
@compute @workgroup_size(16, 16)
fn dst_twirl(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if (!dst_inside(p)) {
return;
}
let q = dst_centre(p);
let c = P.f[0].xy;
let r = P.f[0].z;
let dx = q.x - c.x;
let dy = q.y - c.y;
let d = sqrt(dx * dx + dy * dy);
var s = q;
if (d < r) {
let t = 1.0 - d / r;
let a = -P.f[0].w * t * t;
let sn = sin(a);
let co = cos(a);
s = vec2<f32>(c.x + dx * co - dy * sn, c.y + dx * sn + dy * co);
}
textureStore(out, p, sample_bilinear(src, s.x, s.y));
}
// ---------------------------------------------------------------- Bulge (distort::bulge)
// f[0] = (cx, cy, rx, ry); f[1] = (height, taper, pin, edge); f[2] = layer rect (x, y, w, h)
@compute @workgroup_size(16, 16)
fn dst_bulge(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if (!dst_inside(p)) {
return;
}
let q = dst_centre(p);
let c = P.f[0].xy;
let rx = P.f[0].z;
let ry = P.f[0].w;
let dx = (q.x - c.x) / rx;
let dy = (q.y - c.y) / ry;
let d2 = dx * dx + dy * dy;
var s = q;
if (d2 < 1.0) {
var amt = P.f[1].x * pow(1.0 - d2, P.f[1].y) * 0.5;
if (P.f[1].z > 0.5) {
let l = P.f[2];
let edge = P.f[1].w;
let ex = clamp(min(q.x - l.x - 0.5, l.x + l.z - 0.5 - q.x) / edge, 0.0, 1.0);
let ey = clamp(min(q.y - l.y - 0.5, l.y + l.w - 0.5 - q.y) / edge, 0.0, 1.0);
amt *= min(ex, ey);
}
let k = 1.0 - amt;
s = vec2<f32>(c.x + dx * rx * k, c.y + dy * ry * k);
}
textureStore(out, p, sample_bilinear(src, s.x, s.y));
}
// ---------------------------------------------------------------- Wave Warp (distort::wave_warp)
fn dst_half_circle(u: f32) -> f32 {
let v = 2.0 * u - 1.0;
return sqrt(max(1.0 - v * v, 0.0));
}
fn dst_wave_noise(i: f32, seed: u32) -> f32 {
return hash_noise(bitcast<u32>(i32(i)), 0x5eedu, seed) * 2.0 - 1.0;
}
// distort::wave_shape at the phase `cf + ci` in cycles (t / 2π), split into a fraction cf in
// [0, 2) and whole cycles ci so the fraction keeps full precision.
fn dst_wave_shape(kind: u32, cf: f32, ci: f32, seed: u32) -> f32 {
let ph = cf - floor(cf);
switch kind {
case 1u: {
return select(-1.0, 1.0, ph < 0.5);
}
case 2u: {
return 1.0 - 4.0 * abs(ph - 0.5);
}
case 3u: {
return 2.0 * ph - 1.0;
}
case 4u: {
if (ph < 0.5) {
return dst_half_circle(ph * 2.0);
}
return -dst_half_circle(ph * 2.0 - 1.0);
}
case 5u: {
return 2.0 * dst_half_circle(fract(ph * 2.0)) - 1.0;
}
case 6u: {
return 1.0 - 2.0 * dst_half_circle(fract(ph * 2.0));
}
case 7u: {
return dst_wave_noise(floor(cf * 2.0) + 2.0 * ci, seed);
}
case 8u: {
let u = cf * 2.0;
let i = floor(u);
let f = u - i;
let s = f * f * (3.0 - 2.0 * f);
let n = i + 2.0 * ci;
return dst_wave_noise(n, seed) * (1.0 - s) + dst_wave_noise(n + 1.0, seed) * s;
}
default: {
return sin(ph * DST_TAU);
}
}
}
// distort::wave_pin.
fn dst_wave_pin(pin: u32, x: f32, y: f32, r: vec4<f32>) -> f32 {
let u = clamp((x - r.x - 0.5) / max(r.z - 1.0, 1.0), 0.0, 1.0);
let v = clamp((y - r.y - 0.5) / max(r.w - 1.0, 1.0), 0.0, 1.0);
let horiz = clamp(min(v, 1.0 - v) * 2.0, 0.0, 1.0);
let vert = clamp(min(u, 1.0 - u) * 2.0, 0.0, 1.0);
switch pin {
case 1u: { return min(horiz, vert); }
case 2u: { return min(sqrt((u - 0.5) * (u - 0.5) + (v - 0.5) * (v - 0.5)) * 2.0, 1.0); }
case 3u: { return u; }
case 4u: { return v; }
case 5u: { return 1.0 - u; }
case 6u: { return 1.0 - v; }
case 7u: { return horiz; }
case 8u: { return vert; }
default: { return 1.0; }
}
}
// u[0] = (wave type, pinning, seed, buffer width); f[0] = (dx, dy, height); f[2] = layer rect
// (x, y, w, h). data: the phase in cycles (along / width + phase / 2π) as per-column and per-row
// (fraction, whole cycles) pairs: fx (w), ix (w), fy (h), iy (h).
@compute @workgroup_size(16, 16)
fn dst_wavewarp(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if (!dst_inside(p)) {
return;
}
let q = dst_centre(p);
let dx = P.f[0].x;
let dy = P.f[0].y;
let w = P.u[0].w;
let x = u32(p.x);
let y = u32(p.y);
let cf = data[x] + data[2u * w + y];
let ci = data[w + x] + data[2u * w + u32(out_dims().y) + y];
let disp = dst_wave_shape(P.u[0].x, cf, ci, P.u[0].z) * P.f[0].z * dst_wave_pin(P.u[0].y, q.x, q.y, P.f[2]);
textureStore(out, p, sample_bilinear(src, q.x + dy * disp, q.y - dx * disp));
}
// ---------------------------------------------------------------- Ripple (distort::ripple)
// u[0].x = asymmetric; f[0] = (cx, cy, radius, wave width); f[1] = (wave height, phase mod 2π)
@compute @workgroup_size(16, 16)
fn dst_ripple(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if (!dst_inside(p)) {
return;
}
let q = dst_centre(p);
let c = P.f[0].xy;
let r = P.f[0].z;
let dx = q.x - c.x;
let dy = q.y - c.y;
let d = sqrt(dx * dx + dy * dy);
var s = q;
if (d < r && d != 0.0) {
let h = P.f[1].x;
let fall = 1.0 - d / r;
let arg = d / P.f[0].w * DST_TAU + P.f[1].y;
let o = sin(arg) * h * fall;
var t = 0.0;
if (P.u[0].x != 0u) {
t = cos(arg) * h * fall * 0.5;
}
let ux = dx / d;
let uy = dy / d;
s = vec2<f32>(q.x + ux * o - uy * t, q.y + uy * o + ux * t);
}
textureStore(out, p, sample_bilinear(src, s.x, s.y));
}
// ---------------------------------------------------------------- CC Lens (distort2::cc_lens)
// f[0] = (cx, cy, radius, convergence exponent)
@compute @workgroup_size(16, 16)
fn dst_cclens(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if (!dst_inside(p)) {
return;
}
let q = dst_centre(p);
let c = P.f[0].xy;
let r = P.f[0].z;
let dx = q.x - c.x;
let dy = q.y - c.y;
let d = sqrt(dx * dx + dy * dy);
if (d >= r) {
textureStore(out, p, vec4<f32>(0.0));
return;
}
var s = q;
if (d >= 1e-9) {
let g = powz(asin(min(d / r, 1.0)) / DST_HALF_PI, P.f[0].w);
let k = r * g / d;
s = vec2<f32>(c.x + dx * k, c.y + dy * k);
}
textureStore(out, p, sample_bilinear(src, s.x, s.y));
}
// ---------------------------------------------------------------- Turbulent Displace (distort2)
// distort2::fbm. u[0].w = octave count; f[1].w = fraction of the last octave; f[3].y = falloff.
fn dst_fbm(u: f32, v: f32, z: f32, seed: u32) -> f32 {
let n = P.u[0].w;
let frac = P.f[1].w;
let falloff = P.f[3].y;
var sum = 0.0;
var norm = 0.0;
var amp = 1.0;
var f = 1.0;
for (var o = 0u; o < n; o++) {
var w = 1.0;
if (o + 1u == n && frac > 0.0) {
w = frac;
}
sum += value_noise(u * f, v * f, z + f32(o) * 5.17, seed + o * 101u) * amp * w;
norm += amp * w;
amp *= falloff;
f *= 2.0;
}
return sum / max(norm, 1e-6);
}
// The centred noise `n(u, v, s)` (cross-faded over the evolution cycle when u[1].x is set).
// f[1] = (z, z - cycle, cycle position t).
fn dst_turb_n(u: f32, v: f32, s: u32) -> f32 {
let seed = P.u[0].z + s;
if (P.u[1].x != 0u) {
let t = P.f[1].z;
return dst_fbm(u, v, P.f[1].x, seed) * (1.0 - t) + dst_fbm(u, v, P.f[1].y, seed) * t - 0.5;
}
return dst_fbm(u, v, P.f[1].x, seed) - 0.5;
}
// u[0] = (displacement kind, pinning, seed, octaves); u[1].x = cycle evolution;
// f[0] = (offset x, offset y, size, amount); f[1] = (z0, z1, t, last-octave fraction);
// f[2] = layer rect (x, y, w, h); f[3] = (edge, falloff)
@compute @workgroup_size(16, 16)
fn dst_turbulent(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if (!dst_inside(p)) {
return;
}
let q = dst_centre(p);
let kind = P.u[0].x;
let amount = P.f[0].w;
let u = (q.x - P.f[0].x) / P.f[0].z;
let v = (q.y - P.f[0].y) / P.f[0].z;
var d = vec2<f32>(0.0);
switch kind {
case 1u, 2u, 4u, 5u: {
let e = 0.05;
let gx = (dst_turb_n(u + e, v, 0u) - dst_turb_n(u - e, v, 0u)) / (2.0 * e);
let gy = (dst_turb_n(u, v + e, 0u) - dst_turb_n(u, v - e, 0u)) / (2.0 * e);
let k = amount * 0.25;
if (kind == 1u || kind == 4u) {
d = vec2<f32>(gx * k, gy * k);
} else {
d = vec2<f32>(-gy * k, gx * k);
}
}
case 6u: {
d = vec2<f32>(0.0, dst_turb_n(u, v, 17u) * amount);
}
case 7u: {
d = vec2<f32>(dst_turb_n(u, v, 0u) * amount, 0.0);
}
case 8u: {
let a = dst_turb_n(u, v, 0u) * amount;
d = vec2<f32>(a, a);
}
default: {
d = vec2<f32>(dst_turb_n(u, v, 0u) * amount, dst_turb_n(u, v, 17u) * amount);
}
}
let r = P.f[2];
let edge = P.f[3].x;
let l = clamp((q.x - r.x - 0.5) / edge, 0.0, 1.0);
let rr = clamp((r.x + r.z - 0.5 - q.x) / edge, 0.0, 1.0);
let t = clamp((q.y - r.y - 0.5) / edge, 0.0, 1.0);
let bt = clamp((r.y + r.w - 0.5 - q.y) / edge, 0.0, 1.0);
var k = 1.0;
switch P.u[0].y {
case 1u: { k = min(min(l, rr), min(t, bt)); }
case 2u: { k = min(t, bt); }
case 3u: { k = min(l, rr); }
case 4u: { k = l; }
case 5u: { k = t; }
case 6u: { k = rr; }
case 7u: { k = bt; }
default: {}
}
textureStore(out, p, sample_bilinear(src, q.x + d.x * k, q.y + d.y * k));
}
// ---------------------------------------------------------------- Displacement Map (distort2)
// util::pick for a straight colour (source index in util::SRC_ORDER).
fn dst_pick(s: u32, c: vec3<f32>, a: f32) -> f32 {
switch s {
case 0u: { return c.x; }
case 1u: { return c.y; }
case 2u: { return c.z; }
case 3u: { return a; }
case 4u: { return luminance(c); }
case 5u: { return rgb_to_hsl(c).x; }
case 6u: { return rgb_to_hsl(c).z; }
case 7u: { return rgb_to_hsl(c).y; }
case 8u: { return 1.0; }
case 9u: { return 0.5; }
default: { return 0.0; }
}
}
// u[0] = (horizontal source, vertical source, map mode: 0 self, 1 fitted layer (transparent
// outside), 2 tiled layer (edges repeat), flags: 1 wrap, 2 Center Map bounds);
// u[1] = (buffer width, buffer height); f[0] = (max horizontal, max vertical).
// data: per-column map x, per-row map y (the map layer's buffer pixels), per-column and
// per-row "inside the centred map" flags. aux = the map layer.
@compute @workgroup_size(16, 16)
fn dst_displace(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if (!dst_inside(p)) {
return;
}
let w = P.u[1].x;
let h = P.u[1].y;
let x = u32(p.x);
let y = u32(p.y);
let px = textureLoad(src, p, 0);
let flags = P.u[0].w;
if ((flags & 2u) != 0u && (data[w + h + x] < 0.5 || data[2u * w + h + y] < 0.5)) {
textureStore(out, p, px);
return;
}
var m = px;
let mode = P.u[0].z;
if (mode == 1u) {
m = sample_bilinear(aux, data[x], data[w + y]);
} else if (mode == 2u) {
m = sample_bilinear_clamped(aux, data[x], data[w + y]);
}
var c = vec3<f32>(0.0);
let a = m.w;
if (a > 1e-6) {
c = m.xyz / a;
}
let al = max(a, 0.0);
var dx = 0.0;
var dy = 0.0;
if (P.u[0].x != 10u) {
dx = (dst_pick(P.u[0].x, c, al) - 0.5) * 2.0 * P.f[0].x;
}
if (P.u[0].y != 10u) {
dy = (dst_pick(P.u[0].y, c, al) - 0.5) * 2.0 * P.f[0].y;
}
if (dx == 0.0 && dy == 0.0) {
textureStore(out, p, px);
return;
}
var sx = f32(p.x) + 0.5 + dx;
var sy = f32(p.y) + 0.5 + dy;
if ((flags & 1u) != 0u) {
let fw = f32(w);
let fh = f32(h);
sx = sx - floor(sx / fw) * fw;
sy = sy - floor(sy / fh) * fh;
textureStore(out, p, sample_bilinear_clamped(src, sx, sy));
} else {
textureStore(out, p, sample_bilinear(src, sx, sy));
}
}
// ---------------------------------------------------------------- Mesh Warp (distort2::grid_warp)
// u[0] = (row starts offset, list offset). data: 12 floats per triangle (the barycentric
// coefficients k11 k12 k21 k22 relative to vertex c, c, then the source points a, b, c), then
// per output row the start of its triangle list (height + 1 entries), then the lists (triangle
// indices in the CPU's bucket order).
@compute @workgroup_size(16, 16)
fn dst_mesh(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if (!dst_inside(p)) {
return;
}
let q = dst_centre(p);
let rs = P.u[0].x;
let lo = P.u[0].y;
let i0 = u32(data[rs + u32(p.y)]);
let i1 = u32(data[rs + u32(p.y) + 1u]);
var cur = vec4<f32>(0.0);
for (var i = i0; i < i1; i++) {
if (cur.w > 0.0) {
break;
}
let b = u32(data[lo + i]) * 12u;
let ex = q.x - data[b + 4u];
let ey = q.y - data[b + 5u];
let l1 = data[b] * ex + data[b + 1u] * ey;
let l2 = data[b + 2u] * ex + data[b + 3u] * ey;
let l3 = 1.0 - l1 - l2;
// The CPU tests against -1e-9 in f64; f32 rounding needs a wider margin so pixels on
// shared edges are not dropped by both triangles.
let e = -1e-5;
if (l1 < e || l2 < e || l3 < e) {
continue;
}
let sx = data[b + 6u] * l1 + data[b + 8u] * l2 + data[b + 10u] * l3;
let sy = data[b + 7u] * l1 + data[b + 9u] * l2 + data[b + 11u] * l3;
cur = sample_bilinear(src, sx, sy);
}
textureStore(out, p, cur);
}
// ---------------------------------------------------------------- Mosaic (misc::mosaic)
// data: column tile index (w), row tile index (h), tile column bounds (nx + 1), tile row bounds
// (ny + 1); u[0] = (w, h, nx, ny).
// Row sums of each tile's columns: out is nx × h.
@compute @workgroup_size(16, 16)
fn dst_mosaic_rows(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if (!dst_inside(p)) {
return;
}
let w = P.u[0].x;
let h = P.u[0].y;
let xb = w + h + u32(p.x);
var s = vec4<f32>(0.0);
for (var x = u32(data[xb]); x < u32(data[xb + 1u]); x++) {
s += textureLoad(src, vec2<i32>(i32(x), p.y), 0);
}
textureStore(out, p, s);
}
// Tile averages: out is nx × ny (src = the row sums).
@compute @workgroup_size(16, 16)
fn dst_mosaic_tiles(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if (!dst_inside(p)) {
return;
}
let w = P.u[0].x;
let h = P.u[0].y;
let xb = w + h + u32(p.x);
let yb = w + h + P.u[0].z + 1u + u32(p.y);
let y0 = u32(data[yb]);
let y1 = u32(data[yb + 1u]);
var s = vec4<f32>(0.0);
for (var y = y0; y < y1; y++) {
s += textureLoad(src, vec2<i32>(p.x, i32(y)), 0);
}
let n = f32((u32(data[xb + 1u]) - u32(data[xb])) * (y1 - y0));
textureStore(out, p, s / max(n, 1.0));
}
// Each pixel takes its tile's average (src = the tile averages).
@compute @workgroup_size(16, 16)
fn dst_mosaic_out(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if (!dst_inside(p)) {
return;
}
let tx = i32(data[u32(p.x)]);
let ty = i32(data[P.u[0].x + u32(p.y)]);
textureStore(out, p, textureLoad(src, vec2<i32>(tx, ty), 0));
}
// Sharp Colors: data = per-column / per-row source pixel; u[0].x = w.
@compute @workgroup_size(16, 16)
fn dst_mosaic_sharp(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if (!dst_inside(p)) {
return;
}
let sx = i32(data[u32(p.x)]);
let sy = i32(data[P.u[0].x + u32(p.y)]);
textureStore(out, p, tex_get_clamped(src, sx, sy));
}
// ---------------------------------------------------------------- Find Edges / Emboss (misc)
fn dst_blend_with(orig: vec4<f32>, fx: vec4<f32>, k: f32) -> vec4<f32> {
return fx + (orig - fx) * k;
}
fn dst_lum_at(x: i32, y: i32) -> f32 {
return luminance(tex_get_clamped(src, x, y).xyz);
}
// u[0].x = invert; f[0].x = blend with original.
@compute @workgroup_size(16, 16)
fn dst_findedges(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if (!dst_inside(p)) {
return;
}
let x = p.x;
let y = p.y;
let gx = dst_lum_at(x + 1, y - 1) + 2.0 * dst_lum_at(x + 1, y) + dst_lum_at(x + 1, y + 1) - dst_lum_at(x - 1, y - 1) - 2.0 * dst_lum_at(x - 1, y)
- dst_lum_at(x - 1, y + 1);
let gy = dst_lum_at(x - 1, y + 1) + 2.0 * dst_lum_at(x, y + 1) + dst_lum_at(x + 1, y + 1) - dst_lum_at(x - 1, y - 1) - 2.0 * dst_lum_at(x, y - 1)
- dst_lum_at(x + 1, y - 1);
let e = min(sqrt(gx * gx + gy * gy), 1.0);
var v = 1.0 - e;
if (P.u[0].x != 0u) {
v = e;
}
let px = textureLoad(src, p, 0);
let a = px.w;
textureStore(out, p, dst_blend_with(px, vec4<f32>(v * a, v * a, v * a, a), P.f[0].x));
}
// f[0] = (dx, dy, contrast, blend with original).
@compute @workgroup_size(16, 16)
fn dst_emboss(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if (!dst_inside(p)) {
return;
}
let q = dst_centre(p);
let d = P.f[0].xy;
let a = sample_bilinear_clamped(src, q.x + d.x, q.y + d.y);
let c = sample_bilinear_clamped(src, q.x - d.x, q.y - d.y);
let l = luminance(a.xyz) - luminance(c.xyz);
let v = clamp(0.5 + l * P.f[0].z, 0.0, 1.0);
let px = textureLoad(src, p, 0);
let al = px.w;
textureStore(out, p, dst_blend_with(px, vec4<f32>(v * al, v * al, v * al, al), P.f[0].w));
}

View File

@ -0,0 +1,631 @@
// GPU effects (generate family): see src/fx_generate.rs. Each entry point mirrors the CPU effect in
// effectcraft-effects operation for operation; parameter layouts are documented per entry.
//
// Hard edges (wipes) take per-column and per-row terms computed in f64 on the CPU and uploaded
// as (hi, lo) f32 pairs in `data`: near an edge the hi parts cancel exactly, so the edge sits
// where the CPU's f64 arithmetic puts it.
const GEN_DEG: f32 = 57.29577951308232;
// util::unpremul: (straight colour, alpha).
fn gen_unpremul(p: vec4<f32>) -> vec4<f32> {
if (p.w > 1e-6) {
return vec4<f32>(p.xyz / p.w, p.w);
}
return vec4<f32>(0.0, 0.0, 0.0, max(p.w, 0.0));
}
// util::smoothstep.
fn gen_smoothstep(e0: f32, e1: f32, x: f32) -> f32 {
if (abs(e1 - e0) < 1e-9) {
return select(1.0, 0.0, x < e0);
}
let t = clamp((x - e0) / (e1 - e0), 0.0, 1.0);
return t * t * (3.0 - 2.0 * t);
}
// generate::gen_blend: mode = index into BlendMode::ALL, 255 = None (replace).
fn gen_blend(o: vec4<f32>, g: vec4<f32>, mode: u32) -> vec4<f32> {
if (mode == 255u) {
return g;
}
return blend_pixel(mode, o, g, 0.5);
}
// A (hi, lo) pair from `data`.
fn gen_hl(i: u32) -> vec2<f32> {
return vec2<f32>(data[i], data[i + 1u]);
}
// (a + b) for (hi, lo) pairs whose hi parts cancel near zero.
fn gen_sum(a: vec2<f32>, b: vec2<f32>) -> f32 {
return (a.x + b.x) + (a.y + b.y);
}
// ---------------------------------------------------------------- Radial Blur (raster::radial_blur)
// u[0] = (zoom, samples); f[0] = (cx, cy); data = per sample (sin, cos) (spin) or k (zoom).
@compute @workgroup_size(16, 16)
fn gen_radial_blur(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = out_dims();
let p = vec2<i32>(gid.xy);
if (p.x >= dims.x || p.y >= dims.y) {
return;
}
let zoom = P.u[0].x != 0u;
let n = P.u[0].y;
let c = P.f[0].xy;
let v = vec2<f32>(f32(p.x) + 0.5 - c.x, f32(p.y) + 0.5 - c.y);
var acc = vec4<f32>(0.0);
for (var i = 0u; i < n; i++) {
var s: vec2<f32>;
if (zoom) {
let k = data[i];
s = vec2<f32>(c.x + v.x * k, c.y + v.y * k);
} else {
let sn = data[2u * i];
let cs = data[2u * i + 1u];
s = vec2<f32>(c.x + v.x * cs - v.y * sn, c.y + v.x * sn + v.y * cs);
}
acc += sample_bilinear(src, s.x, s.y);
}
textureStore(out, p, acc / f32(n));
}
// ---------------------------------------------------------------- CC Radial Fast Blur
// u[0].x = zoom mode (0 standard, 1 brightest, 2 darkest); f[0] = (cx, cy, s).
@compute @workgroup_size(16, 16)
fn gen_ccradialfast(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = out_dims();
let p = vec2<i32>(gid.xy);
if (p.x >= dims.x || p.y >= dims.y) {
return;
}
let mode = P.u[0].x;
let c = P.f[0].xy;
let s = P.f[0].z;
let d = vec2<f32>(f32(p.x) + 0.5 - c.x, f32(p.y) + 0.5 - c.y);
let len = sqrt(d.x * d.x + d.y * d.y) * s;
let n = clamp(u32(ceil(len)), 2u, 64u);
var acc = vec4<f32>(0.0);
if (mode == 1u) {
acc = vec4<f32>(-3.0e38);
} else if (mode == 2u) {
acc = vec4<f32>(3.0e38);
}
for (var i = 0u; i < n; i++) {
let k = 1.0 - s * f32(i) / f32(n - 1u);
let q = sample_bilinear(src, c.x + d.x * k, c.y + d.y * k);
if (mode == 1u) {
acc = max(acc, q);
} else if (mode == 2u) {
acc = min(acc, q);
} else {
acc += q / f32(n);
}
}
let a = acc.w;
textureStore(out, p, vec4<f32>(max(min(acc.xyz, vec3<f32>(a)), vec3<f32>(0.0)), clamp(a, 0.0, 1.0)));
}
// ---------------------------------------------------------------- Camera Lens Blur
// Linear working space and highlight boost before the blur.
// u[0] = (linear, boost); f[0] = (threshold, boost, saturation).
@compute @workgroup_size(16, 16)
fn gen_lens_pre(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = out_dims();
let p = vec2<i32>(gid.xy);
if (p.x >= dims.x || p.y >= dims.y) {
return;
}
var px = textureLoad(src, p, 0);
if (P.u[0].x != 0u) {
let u = gen_unpremul(px);
px = vec4<f32>(srgb_to_linear(u.x) * u.w, srgb_to_linear(u.y) * u.w, srgb_to_linear(u.z) * u.w, u.w);
}
if (P.u[0].y != 0u) {
let u = gen_unpremul(px);
let c = u.xyz;
let a = u.w;
if (a > 0.0 && luminance(c) >= P.f[0].x) {
let boost = P.f[0].y;
let sat = P.f[0].z;
let m = max(max(c.x, c.y), c.z);
let tinted = c + (m - c) * (1.0 - sat);
px = vec4<f32>((c + tinted * (boost - 1.0)) * a, px.w);
}
}
textureStore(out, p, px);
}
// Per-row prefix sums: out(x + 1, y) = sum of src(0..=x, y), out(0, y) = 0. One thread per row.
@compute @workgroup_size(64, 1)
fn gen_row_prefix(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = vec2<i32>(textureDimensions(src));
let y = i32(gid.x);
if (y >= dims.y) {
return;
}
var acc = vec4<f32>(0.0);
textureStore(out, vec2<i32>(0, y), acc);
for (var x = 0; x < dims.x; x++) {
acc += textureLoad(src, vec2<i32>(x, y), 0);
textureStore(out, vec2<i32>(x + 1, y), acc);
}
}
// The iris sum. data = spans (dy, left, right); aux = row prefix sums (when u[0].w).
// u[0] = (spans, repeat, linear, prefix); f[0].x = 1 / pixel count.
@compute @workgroup_size(16, 16)
fn gen_lens_blur(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = out_dims();
let p = vec2<i32>(gid.xy);
if (p.x >= dims.x || p.y >= dims.y) {
return;
}
let n = P.u[0].x;
let repeat = P.u[0].y != 0u;
let prefix = P.u[0].w != 0u;
let w = dims.x;
let h = dims.y;
var acc = vec4<f32>(0.0);
for (var s = 0u; s < n; s++) {
var yy = p.y + i32(data[3u * s]);
if (yy < 0 || yy >= h) {
if (!repeat) {
continue;
}
yy = clamp(yy, 0, h - 1);
}
var a = p.x + i32(data[3u * s + 1u]);
var bnd = p.x + i32(data[3u * s + 2u]);
if (!prefix) {
for (var i = a; i <= bnd; i++) {
if (repeat) {
acc += textureLoad(src, vec2<i32>(clamp(i, 0, w - 1), yy), 0);
} else if (i >= 0 && i < w) {
acc += textureLoad(src, vec2<i32>(i, yy), 0);
}
}
continue;
}
if (repeat) {
if (a < 0) {
let k = min(-a, bnd - a + 1);
acc += textureLoad(src, vec2<i32>(0, yy), 0) * f32(k);
a = 0;
}
if (bnd >= w) {
let k = min(bnd - (w - 1), bnd - a + 1);
acc += textureLoad(src, vec2<i32>(w - 1, yy), 0) * f32(k);
bnd = w - 1;
}
} else {
a = max(a, 0);
bnd = min(bnd, w - 1);
}
if (a <= bnd) {
acc += textureLoad(aux, vec2<i32>(bnd + 1, yy), 0) - textureLoad(aux, vec2<i32>(a, yy), 0);
}
}
var px = acc * P.f[0].x;
px.w = clamp(px.w, 0.0, 1.0);
if (P.u[0].z != 0u) {
let u = gen_unpremul(px);
px = vec4<f32>(linear_to_srgb(max(u.x, 0.0)) * u.w, linear_to_srgb(max(u.y, 0.0)) * u.w, linear_to_srgb(max(u.z, 0.0)) * u.w, u.w);
}
textureStore(out, p, px);
}
// ---------------------------------------------------------------- wipes
// Linear Wipe. data = per column ((x + 0.5)·dx − edge) (hi, lo), then per row ((y + 0.5)·dy)
// (hi, lo); u[0].x = row table offset; f[0].x = feather.
@compute @workgroup_size(16, 16)
fn gen_linear_wipe(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = out_dims();
let p = vec2<i32>(gid.xy);
if (p.x >= dims.x || p.y >= dims.y) {
return;
}
let d = gen_sum(gen_hl(2u * u32(p.x)), gen_hl(P.u[0].x + 2u * u32(p.y)));
let k = clamp(d / P.f[0].x + 0.5, 0.0, 1.0);
textureStore(out, p, textureLoad(src, p, 0) * k);
}
// Venetian Blinds. Per column (6 floats): (cx − done·w), (cx − w − done·w), (cx − w) as
// (hi, lo), with cx = ((x + 0.5)·dx) mod w; per row (2 floats): ((y + 0.5)·dy) mod w.
// u[0].x = row table offset; f[0].x = feather.
@compute @workgroup_size(16, 16)
fn gen_venetian(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = out_dims();
let p = vec2<i32>(gid.xy);
if (p.x >= dims.x || p.y >= dims.y) {
return;
}
let col = 6u * u32(p.x);
let row = gen_hl(P.u[0].x + 2u * u32(p.y));
// (d mod w) wraps once the column and row terms reach w.
let wrapped = gen_sum(gen_hl(col + 4u), row) >= 0.0;
var t: f32;
if (wrapped) {
t = gen_sum(gen_hl(col + 2u), row);
} else {
t = gen_sum(gen_hl(col), row);
}
let k = clamp(t / P.f[0].x + 0.5, 0.0, 1.0);
textureStore(out, p, textureLoad(src, p, 0) * k);
}
// Angle (degrees, clockwise from up) of the pixel vector rotated by rotation `r` (0 = start,
// 1 = start + edge, 2 = start − edge), from the per-column / per-row tables of Radial Wipe.
fn gen_psi(r: u32, x: u32, y: u32) -> f32 {
let c = 9u * x + 3u * r;
let w = P.u[0].x + 9u * y + 3u * r;
// x' = ux·cos R − uy·sin R (cancels at the edge), y' = uy·cos R + ux·sin R.
let xr = (data[c] - data[w]) + (data[c + 1u] - data[w + 1u]);
let yr = data[w + 2u] + data[c + 2u];
return atan2(xr, yr) * GEN_DEG;
}
// Radial Wipe. data: per column 9 floats (for each rotation: ux·cos R (hi, lo), ux·sin R),
// per row 9 floats (uy·sin R (hi, lo), uy·cos R); u[0] = (row offset, wipe);
// f[0] = (edge, feather).
@compute @workgroup_size(16, 16)
fn gen_radial_wipe(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = out_dims();
let p = vec2<i32>(gid.xy);
if (p.x >= dims.x || p.y >= dims.y) {
return;
}
let x = u32(p.x);
let y = u32(p.y);
let dir = P.u[0].y;
let edge = P.f[0].x;
var a = gen_psi(0u, x, y);
if (a < 0.0) {
a += 360.0;
}
var base: f32;
if (dir == 0u || (dir == 2u && a <= 180.0)) {
base = a - edge;
if (abs(base) < 90.0) {
base = gen_psi(1u, x, y);
}
} else {
base = (360.0 - a) - edge;
if (abs(base) < 90.0) {
base = -gen_psi(2u, x, y);
}
}
let k = clamp(base / P.f[0].y + 0.5, 0.0, 1.0);
textureStore(out, p, textureLoad(src, p, 0) * k);
}
// Gradient Wipe: aux = the placed gradient layer. u[0].x = invert; f[0] = (done, softness).
@compute @workgroup_size(16, 16)
fn gen_gradient_wipe(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = out_dims();
let p = vec2<i32>(gid.xy);
if (p.x >= dims.x || p.y >= dims.y) {
return;
}
let done = P.f[0].x;
let soft = P.f[0].y;
let g = gen_unpremul(textureLoad(aux, p, 0));
var t = clamp(luminance(g.xyz), 0.0, 1.0);
if (P.u[0].x != 0u) {
t = 1.0 - t;
}
var k = 0.0;
if (done >= 1.0) {
k = 0.0;
} else if (soft > 0.0) {
k = clamp((t - (done * (1.0 + soft) - soft)) / soft, 0.0, 1.0);
} else if (t >= done) {
k = 1.0;
}
textureStore(out, p, textureLoad(src, p, 0) * k);
}
// ---------------------------------------------------------------- Cell Pattern (generate2)
// Worley distances for (u, v) in cell units: (F1, F2, hash of the nearest cell).
fn gen_worley(u: f32, v: f32, disperse: f32, evo: f32, seed: u32) -> vec3<f32> {
let tile = P.u[1].x != 0u;
let nx = i32(P.u[1].y);
let ny = i32(P.u[1].z);
let cx = i32(floor(u));
let cy = i32(floor(v));
var f1 = 3.0e38;
var f2 = 3.0e38;
var id = 0.0;
for (var j = -1; j <= 1; j++) {
for (var i = -1; i <= 1; i++) {
let gx = cx + i;
let gy = cy + j;
var tx = gx;
var ty = gy;
if (tile) {
tx = ((gx % nx) + nx) % nx;
ty = ((gy % ny) + ny) % ny;
}
let hx = bitcast<u32>(tx);
let hy = bitcast<u32>(ty);
let h1 = hash_noise(hx, hy, seed);
let h2 = hash_noise(hx, hy, seed + 1u);
let h3 = hash_noise(hx, hy, seed + 2u);
let ang = 6.283185307179586 * (h3 + evo);
let px = f32(gx) + 0.5 + (h1 - 0.5) * disperse + 0.15 * disperse * cos(ang);
let py = f32(gy) + 0.5 + (h2 - 0.5) * disperse + 0.15 * disperse * sin(ang);
let d = sqrt((px - u) * (px - u) + (py - v) * (py - v));
if (d < f1) {
f2 = f1;
f1 = d;
id = hash_noise(hx, hy, seed + 3u);
} else if (d < f2) {
f2 = d;
}
}
}
return vec3<f32>(f1, f2, id);
}
fn gen_cell_value(pattern: u32, f1: f32, f2: f32, id: f32) -> f32 {
let e = f2 - f1;
switch pattern {
case 0u: { return clamp(1.0 - f1 * 1.3, 0.0, 1.0); }
case 1u: { return id * 0.8 + 0.2 * clamp(1.0 - f1, 0.0, 1.0); }
case 2u: { return clamp(e * 2.0, 0.0, 1.0); }
case 3u: { return id * gen_smoothstep(0.0, 0.06, e); }
case 4u: { return id; }
case 5u: { return clamp(1.0 - f1 * f1 * 2.0, 0.0, 1.0); }
case 6u: { return id * clamp(e * 3.0, 0.0, 1.0); }
default: { return 1.0 - clamp(abs(e - 0.15) * 5.0, 0.0, 1.0); }
}
}
fn gen_overflow(v: f32, mode: u32) -> f32 {
if (mode == 1u) {
return 0.5 + 0.5 * tanh((v - 0.5) * 2.0);
}
if (mode == 2u) {
let t = v - 2.0 * floor(v * 0.5);
return select(t, 2.0 - t, t > 1.0);
}
return clamp(v, 0.0, 1.0);
}
// u[0] = (pattern kind, overflow, invert, seed); u[1] = (tile, columns, rows);
// f[0] = (contrast, disperse, size, evolution mod 1); f[1] = (0.5 − offset x, 0.5 − offset y).
@compute @workgroup_size(16, 16)
fn gen_cell_pattern(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = out_dims();
let p = vec2<i32>(gid.xy);
if (p.x >= dims.x || p.y >= dims.y) {
return;
}
let size = P.f[0].z;
let u = (f32(p.x) + P.f[1].x) / size;
let v = (f32(p.y) + P.f[1].y) / size;
let w = gen_worley(u, v, P.f[0].y, P.f[0].w, P.u[0].w);
var val = gen_cell_value(P.u[0].x, w.x, w.y, w.z);
val = gen_overflow((val - 0.5) * P.f[0].x + 0.5, P.u[0].y);
if (P.u[0].z != 0u) {
val = 1.0 - val;
}
textureStore(out, p, vec4<f32>(val, val, val, 1.0));
}
// ---------------------------------------------------------------- Checkerboard / Grid / 4-Color
// Signed distance (in feather units) to the nearest cell edge, positive in even cells.
fn gen_axis(q: f32, size: f32, f: f32) -> f32 {
let u = q / size;
let fl = floor(u);
let fr = u - fl;
let d = min(fr, 1.0 - fr) * size;
let n = i32(fl);
let s = select(-1.0, 1.0, ((n % 2) + 2) % 2 == 0);
return s * min(d / (f * 0.5), 1.0);
}
// u[0].x = mode; f[0] = (0.5 − anchor x, 0.5 − anchor y, cell w, cell h);
// f[1] = (feather w, feather h, opacity); f[2] = colour.
@compute @workgroup_size(16, 16)
fn gen_checker(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = out_dims();
let p = vec2<i32>(gid.xy);
if (p.x >= dims.x || p.y >= dims.y) {
return;
}
let sx = gen_axis(f32(p.x) + P.f[0].x, P.f[0].z, P.f[1].x);
let sy = gen_axis(f32(p.y) + P.f[0].y, P.f[0].w, P.f[1].y);
let cov = clamp(0.5 + 0.5 * sx * sy, 0.0, 1.0) * P.f[1].z;
let c = P.f[2].xyz;
textureStore(out, p, gen_blend(textureLoad(src, p, 0), vec4<f32>(c * cov, cov), P.u[0].x));
}
// f32::rem_euclid.
fn gen_rem(x: f32, m: f32) -> f32 {
let r = x - m * trunc(x / m);
return select(r, r + m, r < 0.0);
}
// u[0] = (mode, invert); f[0] = (0.5 − anchor x, 0.5 − anchor y, cell w, cell h);
// f[1] = (feather w, feather h, opacity, border); f[2] = colour.
@compute @workgroup_size(16, 16)
fn gen_grid(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = out_dims();
let p = vec2<i32>(gid.xy);
if (p.x >= dims.x || p.y >= dims.y) {
return;
}
let w = P.f[0].z;
let h = P.f[0].w;
let border = P.f[1].w;
let gx = gen_rem(f32(p.x) + P.f[0].x, w);
let gy = gen_rem(f32(p.y) + P.f[0].y, h);
let dx = min(gx, w - gx);
let dy = min(gy, h - gy);
let cx = clamp((border / 2.0 - dx) / P.f[1].x + 0.5, 0.0, 1.0);
let cy = clamp((border / 2.0 - dy) / P.f[1].y + 0.5, 0.0, 1.0);
var cov = 0.0;
if (border > 0.0) {
cov = max(cx, cy);
}
if (P.u[0].y != 0u) {
cov = 1.0 - cov;
}
let a = cov * P.f[1].z;
textureStore(out, p, gen_blend(textureLoad(src, p, 0), vec4<f32>(P.f[2].xyz * a, a), P.u[0].x));
}
// u[0] = (mode, seed); f[0..4] = points (x, y); f[4..8] = colours;
// f[8] = (blend · 100, jitter, opacity).
@compute @workgroup_size(16, 16)
fn gen_fourcolor(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = out_dims();
let p = vec2<i32>(gid.xy);
if (p.x >= dims.x || p.y >= dims.y) {
return;
}
let bl = P.f[8].x;
var acc = vec3<f32>(0.0);
var wsum = 0.0;
for (var i = 0; i < 4; i++) {
let q = P.f[i].xy;
let dx = f32(p.x) + 0.5 - q.x;
let dy = f32(p.y) + 0.5 - q.y;
let d2 = dx * dx + dy * dy;
let w = 1.0 / pow(d2 / bl + 1e-6, 1.5);
acc += P.f[4 + i].xyz * w;
wsum += w;
}
var c = acc / wsum;
let jitter = P.f[8].y;
if (jitter > 0.0) {
let n = (hash_noise(u32(p.x), u32(p.y), P.u[0].y) - 0.5) * jitter * (8.0 / 255.0);
c += vec3<f32>(n);
}
let op = P.f[8].z;
textureStore(out, p, gen_blend(textureLoad(src, p, 0), vec4<f32>(c * op, op), P.u[0].x));
}
// ---------------------------------------------------------------- Noise / Add Grain
// Noise (misc::noise). u[0] = (frame seed, colour, clip, width); f[0].x = amount.
@compute @workgroup_size(16, 16)
fn gen_noise(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = out_dims();
let p = vec2<i32>(gid.xy);
if (p.x >= dims.x || p.y >= dims.y) {
return;
}
var px = textureLoad(src, p, 0);
let a = px.w;
if (a <= 0.0) {
textureStore(out, p, px);
return;
}
let seed = P.u[0].x;
let w = P.u[0].w;
let amt = P.f[0].x;
let x = u32(p.x);
let y = u32(p.y);
var n = vec3<f32>((hash_noise(x, y, seed) - 0.5) * amt);
if (P.u[0].y != 0u) {
n.y = (hash_noise(x + w, y, seed) - 0.5) * amt;
n.z = (hash_noise(x + 2u * w, y, seed) - 0.5) * amt;
}
if (P.u[0].z != 0u) {
px = vec4<f32>(clamp(px.xyz + n * a, vec3<f32>(0.0), vec3<f32>(a)), a);
} else {
// Unclipped: values past black / white wrap around.
px = vec4<f32>(fract_euclid(px.x / a + n.x) * a, fract_euclid(px.y / a + n.y) * a, fract_euclid(px.z / a + n.z) * a, a);
}
textureStore(out, p, px);
}
// Add Grain's noise planes (GrainLook::planes before Softness): rgb = per-channel zero-mean
// value noise. u[0] = (mono, seed, salt); f[0].xyz = x divisors (size · aspect), f[0].w = frame;
// f[1].xyz = y divisors (size).
@compute @workgroup_size(16, 16)
fn gen_grain_planes(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = out_dims();
let p = vec2<i32>(gid.xy);
if (p.x >= dims.x || p.y >= dims.y) {
return;
}
let x = f32(p.x);
let y = f32(p.y);
let frame = P.f[0].w;
let seed = P.u[0].y;
let salt = P.u[0].z;
let n0 = value_noise(x / P.f[0].x, y / P.f[1].x, frame, seed) - 0.5;
var g = vec3<f32>(n0);
if (P.u[0].x == 0u) {
g.y = value_noise(x / P.f[0].y, y / P.f[1].y, frame, seed + salt) - 0.5;
g.z = value_noise(x / P.f[0].z, y / P.f[1].z, frame, seed + 2u * salt) - 0.5;
}
textureStore(out, p, vec4<f32>(g, 0.0));
}
// GrainLook::blend.
fn gen_grain_blend(mode: u32, c: f32, n: f32) -> f32 {
var v: f32;
switch mode {
case 1u: { v = c * (1.0 + n); }
case 2u: { v = c + n; }
case 3u: { v = 1.0 - (1.0 - c) * (1.0 - abs(n)); }
case 4u: { v = c + n * 4.0 * c * (1.0 - c); }
default: { v = c + n * (1.0 - 0.5 * c); }
}
return max(v, 0.0);
}
// Add Grain applied: aux = noise planes. u[0] = (mono, mode, tint); f[0] = (amp, saturation,
// midpoint); f[1].xyz = tint factors; f[2].xyz = channel intensities; f[3].xyz = tone weights.
@compute @workgroup_size(16, 16)
fn gen_grain_apply(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = out_dims();
let p = vec2<i32>(gid.xy);
if (p.x >= dims.x || p.y >= dims.y) {
return;
}
let px = textureLoad(src, p, 0);
let u = gen_unpremul(px);
let a = u.w;
if (a <= 0.0) {
textureStore(out, p, px);
return;
}
let c = u.xyz;
var g = textureLoad(aux, p, 0).xyz;
if (P.u[0].x != 0u) {
g = vec3<f32>(g.x);
} else {
let gm = (g.x + g.y + g.z) / 3.0;
g = gm + (g - gm) * P.f[0].y;
}
if (P.u[0].z != 0u) {
g = g * P.f[1].xyz;
}
let mid = P.f[0].z;
let l = clamp(luminance(c), 0.0, 1.0);
let s = 1.0 - gen_smoothstep(0.0, mid, l);
let hi = gen_smoothstep(mid, 1.0, l);
let tones = P.f[3].xyz;
let weight = tones.x * s + tones.y * (1.0 - s - hi) + tones.z * hi;
let amp = P.f[0].x;
let ci = P.f[2].xyz;
let mode = P.u[0].y;
let o = vec3<f32>(
gen_grain_blend(mode, c.x, g.x * amp * ci.x * weight),
gen_grain_blend(mode, c.y, g.y * amp * ci.y * weight),
gen_grain_blend(mode, c.z, g.z * amp * ci.z * weight),
);
textureStore(out, p, vec4<f32>(o * a, a));
}

View File

@ -828,3 +828,19 @@ fn pointwise(@builtin(global_invocation_id) gid: vec3<u32>) {
}
textureStore(out, p, px);
}
// Adjustment layer finish (Renderer::draw_adjustment): canvas `src` moves toward the adjusted
// comp `aux` by the footprint's alpha × opacity. The footprint is `data` (RGBA f32 rows of
// u[0].x pixels); f[0].x = opacity.
@compute @workgroup_size(16, 16)
fn adjust_mix(@builtin(global_invocation_id) gid: vec3<u32>) {
let dims = out_dims();
let p = vec2<i32>(gid.xy);
if (p.x >= dims.x || p.y >= dims.y) {
return;
}
let c = textureLoad(src, p, 0);
let a = textureLoad(aux, p, 0);
let k = data[(gid.y * P.u[0].x + gid.x) * 4u + 3u] * P.f[0].x;
textureStore(out, p, c + (a - c) * k);
}

View File

@ -0,0 +1,270 @@
// GPU particles: effects::psim systems, one invocation per particle id. Mirrors the CPU step
// (effects::sim::Phys::spawn / step, effects::sim3::Pg::spawn / step) operation for operation.
//
// Concatenated after common.wgsl and kernels.wgsl (hash_noise, value_noise, powz); bindings in
// group 1 (group 0 is unused here).
struct PSim {
// Particles, from step, to step, system (0 engine, 1 cannon).
u: vec4<u32>,
// Animation, flat, seed, particles in `prev`.
u2: vec4<u32>,
// Engine: f[0] = (life, speed, extra, cone), f[1] = producer, f[2] = radius,
// f[3] = gravity, f[4] = axis, f[5] = (damp, dt).
// Cannon: f[0] = (pos.x, pos.y, barrel angle, barrel radius), f[1] = (dir spread, vel,
// vel spread, grav spread), f[2] = (gravity.x, gravity.y, dt), f[3] = bounds.
f: array<vec4<f32>, 6>,
}
struct PState {
// Position, age.
p: vec4<f32>,
// Velocity, life.
v: vec4<f32>,
// rnd, alive (1 / 0), id.
m: vec4<f32>,
}
@group(1) @binding(0) var<uniform> S: PSim;
@group(1) @binding(1) var<storage, read> births: array<u32>;
@group(1) @binding(2) var<storage, read> prev: array<PState>;
@group(1) @binding(3) var<storage, read_write> next: array<PState>;
const PS_TAU: f32 = 6.2831855;
fn ps_h(i: u32, k: u32) -> f32 {
return hash_noise(i, k, S.u2.z);
}
fn ps_hs(i: u32, k: u32) -> f32 {
return hash_noise(i, k, S.u2.z) * 2.0 - 1.0;
}
// sim::norm3.
fn ps_norm3(v: vec3<f32>) -> vec3<f32> {
let l = sqrt(v.x * v.x + v.y * v.y + v.z * v.z);
if (l > 1e-9) {
return v / l;
}
return vec3<f32>(0.0, -1.0, 0.0);
}
// sim::rand_dir.
fn ps_rand_dir(i: u32, k: u32, flat: bool) -> vec3<f32> {
if (flat) {
let a = ps_h(i, k) * PS_TAU;
return vec3<f32>(cos(a), sin(a), 0.0);
}
let z = ps_hs(i, k);
let a = ps_h(i, k + 1u) * PS_TAU;
let r = sqrt(max(1.0 - z * z, 0.0));
return vec3<f32>(r * cos(a), r * sin(a), z);
}
struct Part {
p: vec3<f32>,
v: vec3<f32>,
age: f32,
life: f32,
rnd: f32,
}
// sim::Phys::spawn (+ the step's sub-step birth offset).
fn ps_spawn_engine(id: u32) -> Part {
let flat = S.u2.y != 0u;
let anim = S.u2.x;
let producer = S.f[1].xyz;
let radius = S.f[2].xyz;
let u = ps_rand_dir(id, 1u, flat);
var e = 1.0 / 3.0;
if (flat) {
e = 0.5;
}
let rr = powz(ps_h(id, 3u), e);
var p = producer + u * radius * rr;
if (flat) {
p.z = 0.0;
}
let ax = S.f[4].xyz;
let jitter = ps_rand_dir(id, 5u, flat);
var dir = jitter;
switch anim {
case 1u, 9u: {
dir = ps_norm3(ax + jitter * 0.08);
}
case 2u: {
let k = S.f[0].w;
dir = ps_norm3(ax + jitter * k * ps_h(id, 8u));
}
case 5u: {
var z = jitter.z;
if (flat) {
z = 0.0;
}
dir = vec3<f32>(jitter.x, 0.0, z);
}
case 6u: {
dir = ps_norm3(vec3<f32>(jitter.x * 0.25, -1.0, jitter.z * 0.25));
}
case 7u: {
dir = ps_norm3(vec3<f32>(1.0, jitter.y * 0.05, jitter.z * 0.05));
}
default: {}
}
let sp = S.f[0].y * (0.5 + 0.5 * ps_h(id, 9u));
var v = dir * sp;
if (flat) {
v.z = 0.0;
}
let life = S.f[0].x * (0.8 + 0.4 * ps_h(id, 10u));
let dt = S.f[5].y;
let frac = ps_h(id, 12u) * dt;
return Part(p + v * frac, v, frac, life, ps_h(id, 11u));
}
// sim::Phys::step for one particle at step `j`.
fn ps_step_engine(q: Part, j: u32) -> Part {
let flat = S.u2.y != 0u;
let anim = S.u2.x;
let seed = S.u2.z;
let dt = S.f[5].y;
let t = f32(j) * dt;
let producer = S.f[1].xyz;
let gravity = S.f[3].xyz;
let extra = S.f[0].z;
var a = gravity;
switch anim {
case 4u: {
let dx = q.p.x - producer.x;
let dz = q.p.z - producer.z;
let k = 4.0 * extra;
a.x += -dz * k;
a.z += dx * k;
if (flat) {
let dy = q.p.y - producer.y;
a.x += -dy * k;
a.y += dx * k;
}
}
case 5u: {
let dx = q.p.x - producer.x;
let dy = q.p.y - producer.y;
let k = 6.0 * extra;
a.x += -dy * k - dx * 2.0;
a.y += dx * k - dy * 2.0;
}
case 6u: {
let n = value_noise(q.p.x * 8.0, q.p.y * 8.0, t * 3.0, seed) - 0.5;
a.x += n * 4.0 * extra;
a.y -= gravity.y * 1.5 + 0.3 * extra;
}
case 8u, 9u: {
let f = 6.0 * extra;
a.x += (value_noise(q.p.x * 5.0, q.p.y * 5.0, q.p.z * 5.0 + t, seed) - 0.5) * f;
a.y += (value_noise(q.p.x * 5.0 + 17.0, q.p.y * 5.0, q.p.z * 5.0 + t, seed) - 0.5) * f;
if (!flat) {
a.z += (value_noise(q.p.x * 5.0, q.p.y * 5.0 + 31.0, q.p.z * 5.0 + t, seed) - 0.5) * f;
}
}
default: {}
}
let damp = S.f[5].x;
var o = q;
o.v = (q.v + a * dt) * damp;
o.p = q.p + o.v * dt;
if (flat) {
o.p.z = 0.0;
o.v.z = 0.0;
}
o.age = q.age + dt;
return o;
}
// sim3::Pg::spawn (+ the sub-step birth offset).
fn ps_spawn_cannon(id: u32) -> Part {
let pos = S.f[0].xy;
let angle = S.f[0].z;
let r = S.f[0].w;
let spread = S.f[1].x;
let vel = S.f[1].y;
let vel_spread = S.f[1].z;
let a = radians(angle + ps_hs(id, 1u) * spread * 0.5);
let d = vec2<f32>(sin(a), -cos(a));
let speed = max(vel + ps_hs(id, 3u) * vel_spread, 0.0);
var off: vec2<f32>;
if (r >= 0.0) {
off = vec2<f32>(ps_hs(id, 2u) * r, ps_hs(id, 4u) * r);
} else {
let aa = ps_h(id, 5u) * PS_TAU;
let rr = sqrt(ps_h(id, 6u)) * -r;
off = vec2<f32>(cos(aa) * rr, sin(aa) * rr);
}
let v = d * speed;
let dt = S.f[2].z;
let frac = ps_h(id, 9u) * dt;
let p = pos + off + v * frac;
return Part(vec3<f32>(p, 0.0), vec3<f32>(v, 0.0), 0.0, 0.0, 0.0);
}
// sim3::Pg::step for one particle (gravity only); returns false when it leaves the bounds.
fn ps_step_cannon(q: ptr<function, Part>, id: u32) -> bool {
let dt = S.f[2].z;
let spread = S.f[1].w;
var g = 1.0;
if (spread > 0.0) {
g = 1.0 + (ps_h(id, 7u) * 2.0 - 1.0) * spread;
}
let a = S.f[2].xy * g;
let v = ((*q).v.xy + a * dt) * 1.0;
let p = (*q).p.xy + v * dt;
(*q).v = vec3<f32>(v, 0.0);
(*q).p = vec3<f32>(p, 0.0);
let b = S.f[3];
return p.x > b.x && p.x < b.z && p.y > b.y && p.y < b.w;
}
@compute @workgroup_size(64, 1)
fn particles(@builtin(global_invocation_id) gid: vec3<u32>) {
let id = gid.x;
if (id >= S.u.x) {
return;
}
let s0 = S.u.y;
let s1 = S.u.z;
let cannon = S.u.w == 1u;
let b = births[id];
var q: Part;
var j: u32;
if (b < s0 && id < S.u2.w) {
let o = prev[id];
if (o.m.y == 0.0) {
next[id] = o;
return;
}
q = Part(o.p.xyz, o.v.xyz, o.p.w, o.v.w, o.m.x);
j = s0;
} else {
if (cannon) {
q = ps_spawn_cannon(id);
} else {
q = ps_spawn_engine(id);
}
j = b + 1u;
}
var alive = true;
for (; j < s1; j++) {
if (cannon) {
if (!ps_step_cannon(&q, id)) {
alive = false;
break;
}
} else {
q = ps_step_engine(q, j);
if (q.age >= q.life) {
alive = false;
break;
}
}
}
next[id] = PState(vec4<f32>(q.p, q.age), vec4<f32>(q.v, q.life), vec4<f32>(q.rnd, select(0.0, 1.0, alive), 0.0, 0.0));
}

View File

@ -26,7 +26,7 @@ use effectcraft_time::{FrameRate, Tick};
use crate::Gpu;
fn gpu() -> Option<&'static Gpu> {
pub(crate) fn gpu() -> Option<&'static Gpu> {
static G: OnceLock<Option<Gpu>> = OnceLock::new();
G.get_or_init(|| {
let g = Gpu::headless();
@ -40,9 +40,9 @@ fn gpu() -> Option<&'static Gpu> {
/// Procedural footage: smooth colour gradients, fine noise and an alpha with soft, opaque and
/// fully transparent regions (different per item).
struct Pattern;
pub(crate) struct Pattern;
fn pattern(seed: u32, w: u32, h: u32) -> Image {
pub(crate) fn pattern(seed: u32, w: u32, h: u32) -> Image {
let mut img = Image::new(w, h);
let k = seed as f32 * 0.37;
for y in 0..h {
@ -66,14 +66,14 @@ impl FootageSource for Pattern {
}
}
struct Scene {
p: Project,
cid: ItemId,
comp: Comp,
pub(crate) struct Scene {
pub(crate) p: Project,
pub(crate) cid: ItemId,
pub(crate) comp: Comp,
}
impl Scene {
fn new(depth: BitDepth) -> Scene {
pub(crate) fn new(depth: BitDepth) -> Scene {
let mut p = Project::default();
p.settings.bit_depth = depth;
let comp = Comp::new(97, 61, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
@ -81,7 +81,7 @@ impl Scene {
Scene { p, cid, comp }
}
fn footage(&mut self, w: u32, h: u32) -> Layer {
pub(crate) fn footage(&mut self, w: u32, h: u32) -> Layer {
let f = Footage {
path: "pattern.png".into(),
kind: FootageKind::Still,
@ -106,19 +106,19 @@ impl Scene {
build::layer(&mut self.p, &self.comp, "Pattern", LayerSource::Footage { item: fid }, (w, h), None)
}
fn solid(&mut self, color: [f32; 3], w: u32, h: u32) -> Layer {
pub(crate) fn solid(&mut self, color: [f32; 3], w: u32, h: u32) -> Layer {
let sid = self.p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color, width: w, height: h, pixel_aspect: 1.0 }));
build::layer(&mut self.p, &self.comp, "Solid", LayerSource::Solid { item: sid }, (w, h), None)
}
/// Add on top of the stack.
fn push(&mut self, l: Layer) -> effectcraft_project::LayerId {
pub(crate) fn push(&mut self, l: Layer) -> effectcraft_project::LayerId {
let id = l.id;
self.p.comp_mut(self.cid).unwrap().layers.insert(0, l);
id
}
fn effect(&mut self, l: &mut Layer, id: &str, vals: &[(&str, Value)]) {
pub(crate) fn effect(&mut self, l: &mut Layer, id: &str, vals: &[(&str, Value)]) {
let spec = effectcraft_effects::find(id).unwrap();
let mut next = self.p.next_id;
let size = effectcraft_render::source_size(&self.p, l);
@ -131,27 +131,27 @@ impl Scene {
}
}
fn set(l: &mut Layer, path: &str, v: Value) {
pub(crate) fn set(l: &mut Layer, path: &str, v: Value) {
l.props.prop_mut(path).unwrap_or_else(|| panic!("no {path}")).value = v;
}
fn v3(x: f64, y: f64) -> Value {
pub(crate) fn v3(x: f64, y: f64) -> Value {
Value::Vec3([x, y, 0.0])
}
/// Differences of a GPU render against the CPU render.
#[derive(Debug)]
struct Diff {
max: f32,
pub(crate) struct Diff {
pub(crate) max: f32,
/// Pixels over the tolerance.
over: usize,
total: usize,
worst: (u32, u32, [f32; 4], [f32; 4]),
pub(crate) over: usize,
pub(crate) total: usize,
pub(crate) worst: (u32, u32, [f32; 4], [f32; 4]),
/// 8/16 bpc render.
quantized: bool,
pub(crate) quantized: bool,
}
fn diff(cpu: &Image, gpu: &Image, tol: f32) -> Diff {
pub(crate) fn diff(cpu: &Image, gpu: &Image, tol: f32) -> Diff {
assert_eq!((cpu.width, cpu.height), (gpu.width, gpu.height), "size");
let mut d = Diff { max: 0.0, over: 0, total: cpu.data.len(), worst: (0, 0, [0.0; 4], [0.0; 4]), quantized: false };
for (i, (a, b)) in cpu.data.iter().zip(&gpu.data).enumerate() {
@ -169,7 +169,7 @@ fn diff(cpu: &Image, gpu: &Image, tol: f32) -> Diff {
d
}
fn tolerance(depth: BitDepth) -> f32 {
pub(crate) fn tolerance(depth: BitDepth) -> f32 {
match depth {
BitDepth::Bpc8 => 1.0 / 255.0 + 1e-6,
_ => 1e-3,
@ -177,7 +177,7 @@ fn tolerance(depth: BitDepth) -> f32 {
}
/// Render on the CPU and the GPU; `Some(diff)` (None without an adapter).
fn compare_at(s: &Scene, opts: RenderOpts, t: Tick) -> Option<Diff> {
pub(crate) fn compare_at(s: &Scene, opts: RenderOpts, t: Tick) -> Option<Diff> {
let g = gpu()?;
let cpu = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Cpu, ..opts }).comp_frame_cpu(s.cid, t);
let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts });
@ -188,12 +188,12 @@ fn compare_at(s: &Scene, opts: RenderOpts, t: Tick) -> Option<Diff> {
Some(d)
}
fn opts() -> RenderOpts {
pub(crate) fn opts() -> RenderOpts {
RenderOpts { motion_blur: true, ..Default::default() }
}
/// Assert agreement; `allow` = fraction of pixels allowed over the tolerance.
fn check(label: &str, d: Option<Diff>, allow: f64) {
pub(crate) fn check(label: &str, d: Option<Diff>, allow: f64) {
let Some(d) = d else { return };
let frac = d.over as f64 / d.total as f64;
// Quantised depths: rounding-boundary flips (see the module docs).
@ -379,7 +379,7 @@ fn layer_styles_collapsed_precomps_and_cpu_fallbacks() {
/// The effect alone on a buffer: GPU kernels vs the CPU effect (32-bit float), including the
/// buffer geometry (padding, offset).
fn effect_direct(id: &str, vals: &[(&str, Value)], adjustment: bool) {
pub(crate) fn effect_direct(id: &str, vals: &[(&str, Value)], adjustment: bool) {
let Some(g) = gpu() else { return };
let spec = effectcraft_effects::find(id).unwrap();
let size = [70.0, 44.0];
@ -401,7 +401,7 @@ fn effect_direct(id: &str, vals: &[(&str, Value)], adjustment: bool) {
}
/// One footage layer with `fx` applied, composited over a background, at 8 and 32 bpc.
fn effect_case(id: &str, vals: &[(&str, Value)]) {
pub(crate) fn effect_case(id: &str, vals: &[(&str, Value)]) {
effect_direct(id, vals, false);
effect_direct(id, vals, true);
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
@ -417,11 +417,11 @@ fn effect_case(id: &str, vals: &[(&str, Value)]) {
}
}
fn n(v: f64) -> Value {
pub(crate) fn n(v: f64) -> Value {
Value::Scalar(v)
}
fn c(r: f64, g: f64, b: f64) -> Value {
pub(crate) fn c(r: f64, g: f64, b: f64) -> Value {
Value::Color([r, g, b, 1.0])
}

220
crates/gpu/src/tests_3d.rs Normal file
View File

@ -0,0 +1,220 @@
//! Classic 3D on the GPU vs the CPU compositor: intersecting planes, blend modes, lights,
//! ray-cast shadows, depth of field, motion blur, track mattes, collapsed precomps and
//! adjustment layers splitting a run. Same tolerances as `tests.rs`; pixels on the exact
//! intersection line of two planes may sort differently (f32 vs f64 depths), so intersecting
//! scenes allow 0.5 % of pixels over.
use effectcraft_color::{BlendMode, Label};
use effectcraft_keyframe::{Keyframe, Value};
use effectcraft_project::build;
use effectcraft_project::{BitDepth, Comp, ItemKind, Layer, LayerSource, LightKind, MatteKind, TrackMatte};
use effectcraft_render::{Backend, RenderOpts, Renderer};
use effectcraft_time::{FrameRate, Tick};
use crate::tests::{Pattern, Scene, check, compare_at, gpu, n, opts, set};
const W: u32 = 160;
const H: u32 = 100;
fn scene(depth: BitDepth) -> Scene {
let mut s = Scene::new(depth);
let comp = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
*s.p.comp_mut(s.cid).unwrap() = comp.clone();
s.comp = comp;
s
}
fn three_d(mut l: Layer, pos: [f64; 3], rot_y: f64) -> Layer {
l.switches.three_d = true;
set(&mut l, "transform/position", Value::Vec3(pos));
set(&mut l, "transform/rotationY", Value::Scalar(rot_y));
l
}
fn camera(s: &mut Scene, pos: [f64; 3], dof: Option<(f64, f64)>) {
let mut cam = build::layer(&mut s.p, &s.comp, "Camera", LayerSource::Camera, (W, H), None);
set(&mut cam, "cameraOptions/zoom", Value::Scalar(W as f64 * 50.0 / 36.0));
set(&mut cam, "transform/position", Value::Vec3(pos));
set(&mut cam, "transform/poi", Value::Vec3([W as f64 / 2.0, H as f64 / 2.0, 0.0]));
if let Some((focus, aperture)) = dof {
set(&mut cam, "cameraOptions/dof", Value::Bool(true));
set(&mut cam, "cameraOptions/focusDistance", Value::Scalar(focus));
set(&mut cam, "cameraOptions/aperture", Value::Scalar(aperture));
}
s.push(cam);
}
fn light(s: &mut Scene, kind: LightKind, pos: [f64; 3], vals: &[(&str, Value)]) {
let mut l = build::layer(&mut s.p, &s.comp, "Light", LayerSource::Light { kind }, (W, H), None);
if kind != LightKind::Ambient {
set(&mut l, "transform/position", Value::Vec3(pos));
}
if l.props.prop_mut("transform/poi").is_some() {
set(&mut l, "transform/poi", Value::Vec3([W as f64 / 2.0, H as f64 / 2.0, 0.0]));
}
for (k, v) in vals {
set(&mut l, &format!("lightOptions/{k}"), v.clone());
}
s.push(l);
}
/// Background, a far plane, and two planes crossing each other (one in `mode`).
fn crossing(depth: BitDepth, mode: BlendMode) -> Scene {
let mut s = scene(depth);
let bg = s.solid([0.1, 0.12, 0.2], W, H);
s.push(bg);
let far = three_d(s.footage(150, 90), [80.0, 50.0, 120.0], 0.0);
s.push(far);
let a = three_d(s.footage(90, 70), [80.0, 50.0, 0.0], 35.0);
s.push(a);
let mut b = three_d(s.solid([0.9, 0.5, 0.2], 90, 70), [80.0, 52.0, 0.0], -35.0);
b.blend_mode = mode;
set(&mut b, "transform/opacity", Value::Scalar(80.0));
s.push(b);
s
}
#[test]
fn intersecting_planes_and_blend_modes() {
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
for mode in [BlendMode::Normal, BlendMode::Screen, BlendMode::Multiply, BlendMode::Add, BlendMode::Dissolve] {
let mut s = crossing(depth, mode);
check(&format!("crossing {mode:?} {depth:?} (default camera)"), compare_at(&s, opts(), Tick::ZERO), 0.005);
camera(&mut s, [30.0, 20.0, -260.0], None);
check(&format!("crossing {mode:?} {depth:?} (camera)"), compare_at(&s, opts(), Tick::ZERO), 0.005);
}
}
}
#[test]
fn lights_specular_transmission_and_shadows() {
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
let mut s = crossing(depth, BlendMode::Normal);
// Every layer casts shadows; the crossing pair transmits light.
for l in &mut s.p.comp_mut(s.cid).unwrap().layers {
if l.switches.three_d {
set(l, "materialOptions/castsShadows", Value::Enum(1));
set(l, "materialOptions/specularIntensity", Value::Scalar(60.0));
set(l, "materialOptions/specularShininess", Value::Scalar(30.0));
set(l, "materialOptions/lightTransmission", Value::Scalar(40.0));
}
}
camera(&mut s, [-20.0, -10.0, -250.0], None);
light(&mut s, LightKind::Ambient, [0.0, 0.0, 0.0], &[("intensity", n(25.0))]);
light(
&mut s,
LightKind::Spot,
[20.0, -60.0, -150.0],
&[("castsShadows", Value::Bool(true)), ("shadowDarkness", n(80.0)), ("coneAngle", n(70.0)), ("coneFeather", n(40.0)), ("intensity", n(120.0))],
);
light(
&mut s,
LightKind::Point,
[150.0, 20.0, -80.0],
&[
("castsShadows", Value::Bool(true)),
("shadowDiffusion", n(6.0)),
("falloff", Value::Enum(1)),
("radius", n(120.0)),
("falloffDistance", n(200.0)),
],
);
light(&mut s, LightKind::Parallel, [-100.0, -100.0, -100.0], &[("castsShadows", Value::Bool(true)), ("color", Value::Color([0.5, 0.7, 1.0, 1.0]))]);
check(&format!("lights + shadows {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005);
// Accepts Shadows: Only, Accepts Lights off, Casts Shadows: Only.
for l in &mut s.p.comp_mut(s.cid).unwrap().layers {
match l.name.as_str() {
"Pattern" => set(l, "materialOptions/acceptsShadows", Value::Enum(2)),
"Solid" if l.switches.three_d => set(l, "materialOptions/acceptsLights", Value::Bool(false)),
_ => {}
}
}
check(&format!("shadow-only materials {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005);
}
}
#[test]
fn depth_of_field_and_motion_blur() {
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
let mut s = crossing(depth, BlendMode::Normal);
camera(&mut s, [80.0, 50.0, -220.0], Some((220.0, 30.0)));
check(&format!("dof {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005);
// Animated plane with motion blur (sub-sampled geometry, equal weights).
let mut s = scene(depth);
let mut l = three_d(s.footage(60, 40), [40.0, 50.0, 0.0], 20.0);
l.switches.motion_blur = true;
l.props.prop_mut("transform/position").unwrap().keys =
vec![Keyframe::new(Tick::ZERO, Value::Vec3([40.0, 50.0, 0.0])), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Vec3([140.0, 40.0, -60.0]))];
s.push(l);
s.p.comp_mut(s.cid).unwrap().enable_motion_blur = true;
light(&mut s, LightKind::Point, [80.0, 0.0, -100.0], &[]);
check(&format!("3d motion blur {depth:?}"), compare_at(&s, opts(), Tick::from_seconds_f64(0.5)), 0.005);
}
}
#[test]
fn mattes_preserve_transparency_precomps_and_adjustment_splits() {
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
let mut s = crossing(depth, BlendMode::Normal);
// A 2D luma matte for the top plane, preserve transparency on the far plane.
let m = s.solid([0.7, 0.7, 0.7], 70, 50);
let mid = s.push(m);
let layers = &mut s.p.comp_mut(s.cid).unwrap().layers;
layers[1].track_matte = Some(TrackMatte { layer: mid, kind: MatteKind::Luma });
if let Some(far) = layers.iter_mut().find(|l| l.switches.three_d && l.name == "Pattern") {
far.preserve_transparency = true;
}
// Move the matte below the bottom so the 3D run stays one run.
let ml = layers.remove(0);
layers.push(ml);
layers.last_mut().unwrap().switches.video = false;
check(&format!("matte + preserve {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005);
// An adjustment layer in the middle of the run splits it.
let mut s = crossing(depth, BlendMode::Normal);
let mut adj = s.solid([1.0, 1.0, 1.0], W, H);
adj.switches.adjustment = true;
adj.switches.three_d = true;
s.effect(&mut adj, "ec.channel.invert", &[]);
s.p.comp_mut(s.cid).unwrap().layers.insert(1, adj);
check(&format!("adjustment split {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005);
// A collapsed 3D precomp: its layers join the parent's 3D space.
let mut s = crossing(depth, BlendMode::Normal);
let inner = Comp::new(80, 60, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let inner_id = s.p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.clone().into()));
let mut a = s.footage(50, 40);
a.switches.three_d = true;
set(&mut a, "transform/rotationX", Value::Scalar(40.0));
let b = s.solid([0.2, 0.8, 0.4], 30, 20);
s.p.comp_mut(inner_id).unwrap().layers = vec![b, a];
let mut pre = build::layer(&mut s.p, &s.comp, "Inner", LayerSource::Comp { item: inner_id }, (80, 60), None);
pre.switches.collapse = true;
pre.switches.three_d = true;
set(&mut pre, "transform/position", Value::Vec3([70.0, 45.0, -10.0]));
s.push(pre);
check(&format!("collapsed 3d precomp {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.005);
}
}
#[test]
fn the_gpu_draws_classic_3d_runs_itself() {
let Some(g) = gpu() else { return };
let s = crossing(BitDepth::Bpc32, BlendMode::Normal);
let r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() });
let ctx = r.eval_ctx(s.cid, Tick::ZERO).unwrap();
let vis = r.visible_layers(&ctx);
let run: Vec<_> = vis.iter().copied().filter(|l| l.is_3d()).collect();
let prep = r.prepare_3d_run(&ctx, &run, (W, H)).expect("a classic run is prepared for the GPU");
assert_eq!(prep.planes.iter().filter(|p| p.draw).count(), 3);
let mut e = crate::context::Enc::new(g.context());
let canvas = e.image(W, H);
let out = crate::classic3d::draw_run(&mut e, &prep, &canvas).expect("the kernel draws the run");
let img = e.download(&out).unwrap();
let mut cpu = effectcraft_raster::Image::new(W, H);
r.draw_3d_run(&ctx, &run, &mut cpu);
let covered = cpu.data.iter().filter(|p| p[3] > 0.5).count();
assert!(covered > (W * H / 3) as usize, "the planes cover the frame ({covered} px)");
let d = crate::tests::diff(&cpu, &img, 1e-3);
assert!(d.over * 200 <= d.total, "{d:?}");
}

View File

@ -0,0 +1,105 @@
//! Adjustment layers on the GPU vs the CPU: GPU-only stacks (resident on the device), mixed
//! stacks (CPU effects in between read back and upload), masks, opacity, colour management.
use effectcraft_keyframe::{ShapePath, Value};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{BitDepth, ColorSpace, MaskMode};
use effectcraft_render::{Backend, FxStep, FxTarget, RenderOpts, Renderer};
use effectcraft_time::Tick;
use crate::tests::{Pattern, Scene, c, check, compare_at, gpu, n, opts, set};
fn adjustment_scene(depth: BitDepth, stack: &[(&str, Vec<(&str, Value)>)], masked: bool) -> Scene {
let mut s = Scene::new(depth);
let bg = s.footage(97, 61);
s.push(bg);
let mut l = s.footage(60, 40);
set(&mut l, "transform/rotation", Value::Scalar(15.0));
s.push(l);
let mut adj = s.solid([1.0, 1.0, 1.0], 80, 50);
adj.switches.adjustment = true;
set(&mut adj, "transform/opacity", Value::Scalar(75.0));
set(&mut adj, "transform/rotation", Value::Scalar(-6.0));
for (id, vals) in stack {
s.effect(&mut adj, id, vals);
}
if masked {
let mut next = s.p.next_id;
let m = build::mask(&mut Ids(&mut next), "Mask 1", ShapePath::ellipse([40.0, 25.0], 60.0, 40.0), MaskMode::Add, [255, 255, 0]);
s.p.next_id = next;
adj.props.sub_mut("masks").unwrap().children.push(m.into());
}
s.push(adj);
let top = s.solid([0.2, 0.6, 0.9], 20, 20);
s.push(top);
s
}
fn gpu_stack() -> Vec<(&'static str, Vec<(&'static str, Value)>)> {
vec![
("ec.blur.gaussian", vec![("blurriness", n(6.0))]),
("ec.color.levels", vec![("inBlack", n(0.1)), ("gamma", n(1.4))]),
("ec.color.tint", vec![("black", c(0.1, 0.0, 0.2)), ("amount", n(60.0))]),
]
}
#[test]
fn gpu_adjustment_chains_match_the_cpu() {
for depth in [BitDepth::Bpc8, BitDepth::Bpc16, BitDepth::Bpc32] {
for masked in [false, true] {
let s = adjustment_scene(depth, &gpu_stack(), masked);
check(&format!("gpu adjustment {depth:?} masked {masked}"), compare_at(&s, opts(), Tick::ZERO), 0.0);
check(&format!("gpu adjustment {depth:?} masked {masked} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0);
}
}
}
#[test]
fn mixed_adjustment_chains_fall_back_per_effect() {
let mut stack = gpu_stack();
stack.insert(1, ("ec.stylize.posterize", vec![]));
stack.push(("ec.stylize.glow", vec![("threshold", n(30.0))]));
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
let s = adjustment_scene(depth, &stack, true);
check(&format!("mixed adjustment {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.0);
}
// Colour-managed: effects run in the working space, compositing in linear light.
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
let mut s = adjustment_scene(depth, &stack, false);
s.p.settings.working_space = Some(ColorSpace::Rec709);
s.p.settings.blend_linear = true;
check(&format!("colour-managed adjustment {depth:?}"), compare_at(&s, opts(), Tick::ZERO), 0.0);
}
}
/// Counts where each run of the stack goes.
#[derive(Default)]
struct Probe {
gpu: usize,
cpu: usize,
}
impl FxTarget for Probe {
fn gpu(&mut self, steps: &[FxStep]) -> bool {
self.gpu += steps.len();
true
}
fn cpu(&mut self, _f: &mut dyn FnMut(effectcraft_effects::Buf) -> effectcraft_effects::Buf) {
self.cpu += 1;
}
}
#[test]
fn adjustment_stacks_split_into_gpu_runs_and_cpu_steps() {
let Some(g) = gpu() else { return };
let mut stack = gpu_stack();
stack.insert(1, ("ec.stylize.posterize", vec![]));
let s = adjustment_scene(BitDepth::Bpc32, &stack, false);
let mut r = Renderer::new(&s.p, &Pattern, RenderOpts { backend: Backend::Gpu, ..opts() });
r.accel = Some(g);
let ctx = r.eval_ctx(s.cid, Tick::ZERO).unwrap();
let adj = ctx.comp.layers.iter().find(|l| l.switches.adjustment).unwrap();
let mut p = Probe::default();
r.run_effects_on(&ctx, adj, true, &mut p);
assert_eq!((p.gpu, p.cpu), (3, 1));
}

View File

@ -0,0 +1,163 @@
//! GPU colour-family effects vs the CPU effects (the oracle): direct on a buffer at full and
//! half resolution, as adjustment, and composited at 8 and 32 bpc.
use effectcraft_keyframe::Value;
use crate::tests::{c, effect_case, n};
fn e(v: u32) -> Value {
Value::Enum(v)
}
fn on() -> Value {
Value::Bool(true)
}
#[test]
fn color_balance_vibrance_tritone_mixer() {
effect_case("ec.color.colorbalance", &[("shadowRed", n(40.0)), ("midGreen", n(-30.0)), ("hiBlue", n(60.0))]);
effect_case("ec.color.colorbalance", &[("shadowBlue", n(-50.0)), ("midRed", n(70.0)), ("hiGreen", n(25.0)), ("preserveLuminosity", on())]);
effect_case("ec.color.vibrance", &[("vibrance", n(60.0)), ("saturation", n(-20.0))]);
effect_case("ec.color.vibrance", &[("vibrance", n(-40.0)), ("saturation", n(35.0))]);
effect_case("ec.color.tritone", &[("midtones", c(0.6, 0.3, 0.5)), ("shadows", c(0.0, 0.1, 0.3)), ("blend", n(30.0))]);
effect_case("ec.color.tritone", &[("highlights", c(1.0, 0.9, 0.6))]);
effect_case("ec.color.channelmixer", &[("rr", n(80.0)), ("rg", n(30.0)), ("gb", n(-40.0)), ("bc", n(10.0)), ("br", n(50.0))]);
effect_case("ec.color.channelmixer", &[("rr", n(40.0)), ("rg", n(40.0)), ("rb", n(20.0)), ("rc", n(-5.0)), ("monochrome", on())]);
}
#[test]
fn black_white_colorama_selective_color() {
effect_case("ec.color.blackwhite", &[]);
effect_case("ec.color.blackwhite", &[("reds", n(120.0)), ("blues", n(-50.0)), ("cyans", n(200.0)), ("tint", on())]);
effect_case("ec.color.colorama", &[]);
effect_case("ec.color.colorama", &[("inputPhase/mode", e(4)), ("inputPhase/phaseShift", n(75.0)), ("outputCycle/cycles", n(2.5)), ("blend", n(40.0))]);
effect_case("ec.color.colorama", &[("inputPhase/mode", e(8)), ("outputCycle/cycles", n(1.3))]);
effect_case("ec.color.selectivecolor", &[("redsCyan", n(60.0)), ("bluesYellow", n(-40.0)), ("neutralsBlack", n(20.0)), ("whitesMagenta", n(30.0))]);
effect_case(
"ec.color.selectivecolor",
&[("method", e(1)), ("greensMagenta", n(50.0)), ("yellowsCyan", n(-30.0)), ("blacksYellow", n(40.0)), ("magentasBlack", n(25.0))],
);
}
#[test]
fn lumetri() {
effect_case(
"ec.color.lumetri",
&[
("basicCorrection/whiteBalance/temperature", n(30.0)),
("basicCorrection/whiteBalance/tint", n(-20.0)),
("basicCorrection/tone/exposure", n(0.5)),
("basicCorrection/tone/contrast", n(25.0)),
("basicCorrection/tone/highlights", n(-40.0)),
("basicCorrection/tone/shadows", n(30.0)),
("basicCorrection/tone/whites", n(10.0)),
("basicCorrection/tone/blacks", n(-15.0)),
("basicCorrection/saturation", n(130.0)),
],
);
effect_case(
"ec.color.lumetri",
&[
("creative/lookIntensity", n(80.0)),
("creative/adjustments/fadedFilm", n(30.0)),
("creative/adjustments/vibrance", n(40.0)),
("creative/adjustments/creativeSaturation", n(70.0)),
("creative/adjustments/shadowTint", c(0.3, 0.5, 0.7)),
("creative/adjustments/highlightTint", c(0.7, 0.55, 0.4)),
("creative/adjustments/tintBalance", n(-30.0)),
("curves/curveMasterMidtones", n(30.0)),
("curves/curveRedShadows", n(-20.0)),
("curves/curveBlueHighlights", n(40.0)),
],
);
effect_case(
"ec.color.lumetri",
&[
("colorWheels/shadowsWheel", c(0.4, 0.5, 0.6)),
("colorWheels/highlightsWheel", c(0.6, 0.5, 0.45)),
("vignette/vignetteAmount", n(-2.0)),
("vignette/vignetteMidpoint", n(30.0)),
("vignette/vignetteRoundness", n(40.0)),
("creative/adjustments/sharpen", n(50.0)),
],
);
effect_case(
"ec.color.lumetri",
&[
("vignette/vignetteAmount", n(1.5)),
("vignette/vignetteRoundness", n(-60.0)),
("vignette/vignetteFeather", n(20.0)),
("creative/adjustments/sharpen", n(-40.0)),
],
);
}
#[test]
fn linear_color_key() {
effect_case("ec.key.linearcolor", &[("keyColor", c(0.6, 0.5, 0.4)), ("tolerance", n(15.0)), ("softness", n(20.0))]);
effect_case("ec.key.linearcolor", &[("keyColor", c(0.2, 0.8, 0.3)), ("matchColors", e(1)), ("keyOperation", e(1)), ("view", e(2))]);
effect_case("ec.key.linearcolor", &[("keyColor", c(0.7, 0.3, 0.6)), ("matchColors", e(2)), ("tolerance", n(5.0)), ("softness", n(30.0))]);
}
#[test]
fn key_light() {
let screen = ("screenColour", c(0.3, 0.75, 0.35));
effect_case("ec.keying.keylight", std::slice::from_ref(&screen));
effect_case(
"ec.keying.keylight",
&[
// A low gain against a pure screen keeps the raw matte away from 0 (the screen
// removal divides by it; near 0 it amplifies the blurs' float rounding).
("screenColour", c(0.0, 1.0, 0.0)),
("screenGain", n(60.0)),
("screenBalance", n(30.0)),
("screenPreblur", n(3.0)),
("screenMatte/clipBlack", n(10.0)),
("screenMatte/clipWhite", n(80.0)),
("screenMatte/clipRollback", n(2.5)),
("screenMatte/screenSoftness", n(3.0)),
("screenMatte/replaceMethod", e(3)),
("screenMatte/replaceColour", c(0.6, 0.4, 0.3)),
("unpremultiplyResult", Value::Bool(false)),
],
);
effect_case(
"ec.keying.keylight",
&[
("screenColour", c(0.2, 0.4, 0.8)),
("lockBiasesTogether", Value::Bool(false)),
("alphaBias", c(0.5, 0.45, 0.6)),
("despillBias", c(0.55, 0.5, 0.45)),
("screenMatte/clipWhite", n(70.0)),
("screenMatte/screenDespotBlack", n(2.0)),
("screenMatte/screenDespotWhite", n(1.5)),
("screenMatte/screenShrinkGrow", n(-1.5)),
("screenMatte/replaceMethod", e(2)),
("foregroundColourCorrection/enableColourCorrection", on()),
("foregroundColourCorrection/saturation", n(120.0)),
("foregroundColourCorrection/contrast", n(15.0)),
("edgeColourCorrection/enableEdgeColourCorrection", on()),
("edgeColourCorrection/edgeSoftness", n(2.0)),
("edgeColourCorrection/edgeHardness", n(30.0)),
("edgeColourCorrection/edgeBrightness", n(-20.0)),
("sourceCrops/cropLeft", n(13.0)),
("sourceCrops/cropBottom", n(7.0)),
("insideMask/sourceAlpha", e(1)),
],
);
// Soft Colour without Screen Softness (after the blur, the screen-removed foreground of
// nearly transparent pixels, divided by a raw matte near 1e-4, amplifies float rounding).
effect_case(
"ec.keying.keylight",
&[
screen.clone(),
("screenMatte/clipBlack", n(15.0)),
("screenMatte/clipRollback", n(1.5)),
("screenMatte/replaceColour", c(0.6, 0.4, 0.3)),
("insideMask/sourceAlpha", e(0)),
],
);
for view in [1, 2, 4, 7, 9] {
effect_case("ec.keying.keylight", &[screen.clone(), ("view", e(view)), ("screenMatte/screenShrinkGrow", n(1.0)), ("screenMatte/replaceMethod", e(1))]);
}
}

View File

@ -0,0 +1,119 @@
//! Distort / stylize GPU effects vs the CPU effects (the oracle): see `fx_distort.rs`.
use effectcraft_keyframe::Value;
use effectcraft_project::BitDepth;
use effectcraft_render::RenderOpts;
use effectcraft_time::Tick;
use crate::tests::{Scene, check, compare_at, effect_case, n, opts, set};
fn e(v: u32) -> Value {
Value::Enum(v)
}
fn on() -> Value {
Value::Bool(true)
}
#[test]
fn twirl_bulge_ripple_wave_warp() {
effect_case("ec.distort.twirl", &[("angle", n(120.0)), ("radius", n(40.0))]);
effect_case("ec.distort.twirl", &[("angle", n(-400.0)), ("radius", n(70.0)), ("center", Value::Vec2([25.0, 18.0]))]);
effect_case("ec.distort.bulge", &[("hradius", n(30.0)), ("vradius", n(20.0)), ("height", n(1.8))]);
effect_case("ec.distort.bulge", &[("height", n(-2.5)), ("taperRadius", n(60.0)), ("pinAllEdges", on()), ("center", Value::Vec2([30.0, 25.0]))]);
effect_case("ec.distort.ripple", &[("radius", n(80.0)), ("waveWidth", n(9.0)), ("waveHeight", n(4.0))]);
effect_case("ec.distort.ripple", &[("conversion", e(1)), ("phase", n(70.0)), ("speed", n(3.0)), ("center", Value::Vec2([20.0, 30.0]))]);
effect_case("ec.distort.wavewarp", &[("height", n(6.0)), ("width", n(25.0)), ("direction", n(30.0))]);
effect_case("ec.distort.wavewarp", &[("waveType", e(2)), ("pinning", e(1)), ("phase", n(400.0)), ("speed", n(-2.0))]);
effect_case("ec.distort.wavewarp", &[("waveType", e(8)), ("pinning", e(2)), ("height", n(-5.0)), ("direction", n(200.0))]);
effect_case("ec.distort.wavewarp", &[("waveType", e(4)), ("pinning", e(7)), ("width", n(33.0))]);
}
#[test]
fn turbulent_displace_and_cc_lens() {
effect_case("ec.distort.turbulentdisplace", &[("size", n(30.0)), ("amount", n(20.0))]);
effect_case(
"ec.distort.turbulentdisplace",
&[("displacement", e(1)), ("complexity", n(3.5)), ("evolution", n(90.0)), ("size", n(25.0)), ("pinning", e(2)), ("resizeLayer", on())],
);
effect_case(
"ec.distort.turbulentdisplace",
&[
("displacement", e(5)),
("evolutionOptions/cycleEvolution", on()),
("evolutionOptions/cycle", n(2.0)),
("evolution", n(500.0)),
("evolutionOptions/randomSeed", n(7.0)),
("pinning", e(0)),
("amount", n(-30.0)),
],
);
effect_case("ec.distort.turbulentdisplace", &[("displacement", e(8)), ("size", n(15.0)), ("pinning", e(6))]);
effect_case("ec.distort.cclens", &[("size", n(70.0))]);
effect_case("ec.distort.cclens", &[("size", n(45.0)), ("convergence", n(60.0)), ("center", Value::Vec2([30.0, 20.0]))]);
effect_case("ec.distort.cclens", &[("convergence", n(-70.0))]);
}
#[test]
fn mesh_warp() {
// 2 rows × 3 columns: 12 vertices.
let mesh = "0,0 0,0 0,0 0,0 0,0 6,-4 -5,3 0,0 0,0 0,0 0,0 0,0";
effect_case("ec.distort.meshwarp", &[("rows", n(2.0)), ("columns", n(3.0)), ("quality", n(3.0)), ("mesh", Value::Str(mesh.into()))]);
let mesh = "-4,-3 2,0 8,5 0,-2 9,9 1,0 -3,4 2,2 4,-6";
effect_case("ec.distort.meshwarp", &[("rows", n(2.0)), ("columns", n(2.0)), ("quality", n(5.0)), ("mesh", Value::Str(mesh.into()))]);
}
#[test]
fn mosaic_find_edges_emboss() {
effect_case("ec.stylize.mosaic", &[]);
effect_case("ec.stylize.mosaic", &[("horizontal", n(7.0)), ("vertical", n(3.0))]);
effect_case("ec.stylize.mosaic", &[("horizontal", n(13.0)), ("vertical", n(9.0)), ("sharpColors", on())]);
effect_case("ec.stylize.findedges", &[]);
effect_case("ec.stylize.findedges", &[("invert", on()), ("blend", n(30.0))]);
effect_case("ec.stylize.emboss", &[]);
effect_case("ec.stylize.emboss", &[("direction", n(160.0)), ("relief", n(4.0)), ("contrast", n(250.0)), ("blend", n(20.0))]);
}
#[test]
fn displacement_map_self() {
effect_case("ec.distort.displacementmap", &[("maxHorizontal", n(8.0)), ("maxVertical", n(-5.0))]);
effect_case("ec.distort.displacementmap", &[("useForHorizontal", e(4)), ("useForVertical", e(3)), ("wrapPixelsAround", on())]);
effect_case(
"ec.distort.displacementmap",
&[("useForHorizontal", e(10)), ("useForVertical", e(6)), ("maxVertical", n(12.0)), ("expandOutput", Value::Bool(false))],
);
}
/// Displacement Map reading another layer (Center / Stretch / Tile), composited.
#[test]
fn displacement_map_layer() {
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
for (behavior, wrap) in [(0, false), (1, false), (2, true), (2, false)] {
let mut s = Scene::new(depth);
let bg = s.solid([0.15, 0.1, 0.2], 97, 61);
s.push(bg);
let mut map = s.footage(50, 36);
map.switches.video = false;
let mid = s.push(map);
let mut l = s.footage(70, 44);
s.effect(
&mut l,
"ec.distort.displacementmap",
&[
("displacementMapLayer", Value::Layer(Some(mid.0))),
("useForHorizontal", e(0)),
("useForVertical", e(4)),
("maxHorizontal", n(9.0)),
("maxVertical", n(6.0)),
("displacementMapBehavior", e(behavior)),
("wrapPixelsAround", Value::Bool(wrap)),
],
);
set(&mut l, "transform/rotation", Value::Scalar(8.0));
s.push(l);
let label = format!("displacement map {behavior} {wrap} {depth:?}");
check(&label, compare_at(&s, opts(), Tick::ZERO), 0.0);
check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0);
}
}
}

View File

@ -0,0 +1,200 @@
//! GPU vs CPU for the blur, transition and generate family (`fx_generate`).
use effectcraft_keyframe::Value;
use effectcraft_project::BitDepth;
use effectcraft_render::RenderOpts;
use effectcraft_time::Tick;
use crate::tests::{Scene, c, check, compare_at, effect_case, n, opts, set};
fn e(v: u32) -> Value {
Value::Enum(v)
}
fn b(v: bool) -> Value {
Value::Bool(v)
}
fn pt(x: f64, y: f64) -> Value {
Value::Vec2([x, y])
}
#[test]
fn radial_blur() {
effect_case("ec.blur.radial", &[("amount", n(12.0))]);
effect_case("ec.blur.radial", &[("amount", n(30.0)), ("type", e(1)), ("center", pt(20.3, 14.7))]);
effect_case("ec.blur.radial", &[("amount", n(-45.0)), ("center", pt(50.0, 30.0))]);
}
#[test]
fn cc_radial_fast_blur() {
effect_case("ec.blur.ccradialfast", &[("amount", n(40.0))]);
effect_case("ec.blur.ccradialfast", &[("amount", n(70.0)), ("zoom", e(1)), ("center", pt(22.4, 16.1))]);
effect_case("ec.blur.ccradialfast", &[("amount", n(25.0)), ("zoom", e(2))]);
}
#[test]
fn camera_lens_blur() {
effect_case("ec.blur.cameralens", &[("blurRadius", n(5.0))]);
effect_case(
"ec.blur.cameralens",
&[
("blurRadius", n(7.5)),
("irisProperties/irisShape", e(2)),
("irisProperties/irisRotation", n(20.0)),
("irisProperties/irisAspectRatio", n(1.6)),
("repeatEdge", b(true)),
],
);
effect_case(
"ec.blur.cameralens",
&[
("blurRadius", n(4.0)),
("irisProperties/irisShape", e(8)),
("highlight/specularBrightness", n(60.0)),
("highlight/specularThreshold", n(150.0)),
("highlight/highlightSaturation", n(40.0)),
("useLinear", b(true)),
],
);
// Large iris: per-row prefix sums.
effect_case("ec.blur.cameralens", &[("blurRadius", n(14.0)), ("irisProperties/irisRoundness", n(60.0))]);
effect_case("ec.blur.cameralens", &[("blurRadius", n(12.0)), ("repeatEdge", b(true)), ("useLinear", b(true))]);
}
#[test]
fn wipes() {
effect_case("ec.transition.linearwipe", &[("completion", n(40.0)), ("angle", n(30.0)), ("feather", n(12.0))]);
effect_case("ec.transition.linearwipe", &[("completion", n(55.0)), ("angle", n(-120.0))]);
effect_case("ec.transition.linearwipe", &[("completion", n(20.0)), ("angle", n(90.0)), ("feather", n(3.0))]);
effect_case("ec.transition.venetian", &[("completion", n(35.0)), ("direction", n(30.0)), ("width", n(11.0)), ("feather", n(2.0))]);
effect_case("ec.transition.venetian", &[("completion", n(60.0)), ("width", n(17.0))]);
effect_case("ec.transition.venetian", &[("completion", n(25.0)), ("direction", n(-70.0)), ("width", n(9.0)), ("feather", n(0.5))]);
effect_case("ec.transition.radialwipe", &[("completion", n(30.0)), ("startAngle", n(25.0)), ("feather", n(10.0))]);
effect_case("ec.transition.radialwipe", &[("completion", n(45.0)), ("wipe", e(1)), ("center", pt(30.2, 19.6))]);
effect_case("ec.transition.radialwipe", &[("completion", n(60.0)), ("wipe", e(2)), ("startAngle", n(-40.0)), ("feather", n(3.0))]);
effect_case("ec.transition.radialwipe", &[("completion", n(0.0)), ("feather", n(20.0))]);
effect_case("ec.transition.gradientwipe", &[("completion", n(40.0))]);
effect_case("ec.transition.gradientwipe", &[("completion", n(55.0)), ("softness", n(30.0)), ("invert", b(true))]);
effect_case("ec.transition.gradientwipe", &[("completion", n(100.0))]);
}
/// Gradient Wipe driven by another layer (fetched through the effect host, placed on the CPU).
#[test]
fn gradient_wipe_with_a_gradient_layer() {
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
for placement in 0..3 {
let mut s = Scene::new(depth);
let bg = s.solid([0.15, 0.1, 0.2], 97, 61);
s.push(bg);
let grad = s.footage(40, 30);
let gid = s.push(grad);
let mut l = s.footage(70, 44);
s.effect(
&mut l,
"ec.transition.gradientwipe",
&[("completion", n(45.0)), ("softness", n(20.0)), ("gradientLayer", Value::Layer(Some(gid.0))), ("gradientPlacement", e(placement))],
);
set(&mut l, "transform/rotation", Value::Scalar(8.0));
s.push(l);
let label = format!("gradient wipe layer {placement} {depth:?}");
check(&label, compare_at(&s, opts(), Tick::ZERO), 0.0);
check(&format!("{label} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, Tick::ZERO), 0.0);
}
}
}
#[test]
fn generators() {
effect_case("ec.generate.cellpattern", &[("size", n(14.0))]);
effect_case("ec.generate.cellpattern", &[("cellPattern", e(2)), ("size", n(11.0)), ("evolution", n(130.0)), ("overflow", e(1)), ("contrast", n(150.0))]);
effect_case(
"ec.generate.cellpattern",
&[
("cellPattern", e(11)),
("size", n(9.0)),
("tilingOptions/enableTiling", b(true)),
("tilingOptions/cellsHorizontal", n(4.0)),
("tilingOptions/cellsVertical", n(3.0)),
("overflow", e(2)),
("contrast", n(250.0)),
("invert", b(true)),
("evolutionOptions/randomSeed", n(17.0)),
],
);
effect_case("ec.generate.cellpattern", &[("cellPattern", e(5)), ("size", n(16.0)), ("disperse", n(0.6)), ("offset", pt(3.3, -7.1))]);
effect_case("ec.generate.checkerboard", &[("width", n(9.0))]);
effect_case(
"ec.generate.checkerboard",
&[
("sizeFrom", e(2)),
("width", n(13.0)),
("height", n(7.0)),
("feather/featherWidth", n(4.0)),
("anchor", pt(5.3, 2.2)),
("blendingMode", e(5)),
("opacity", n(70.0)),
],
);
effect_case("ec.generate.checkerboard", &[("sizeFrom", e(0)), ("corner", pt(16.0, 11.0)), ("color", c(0.9, 0.3, 0.1)), ("blendingMode", e(1))]);
effect_case("ec.generate.grid", &[("width", n(12.0)), ("border", n(3.0))]);
effect_case(
"ec.generate.grid",
&[
("sizeFrom", e(2)),
("width", n(15.0)),
("height", n(9.0)),
("border", n(2.5)),
("feather/featherWidth", n(3.0)),
("feather/featherHeight", n(2.0)),
("invertGrid", b(true)),
("blendingMode", e(4)),
],
);
effect_case(
"ec.generate.grid",
&[("sizeFrom", e(0)), ("corner", pt(11.0, 8.0)), ("anchor", pt(2.5, 3.5)), ("blendingMode", e(14)), ("color", c(0.2, 0.8, 0.4))],
);
effect_case("ec.generate.fourcolor", &[]);
effect_case("ec.generate.fourcolor", &[("blend", n(40.0)), ("jitter", n(50.0)), ("opacity", n(80.0)), ("blendingMode", e(3))]);
effect_case("ec.generate.fourcolor", &[("blend", n(600.0)), ("blendingMode", e(16)), ("positionsColors/point2", pt(30.0, 20.0))]);
}
#[test]
fn noise_and_grain() {
effect_case("ec.noise.noise", &[("amount", n(30.0))]);
effect_case("ec.noise.noise", &[("amount", n(50.0)), ("color", b(false))]);
effect_case("ec.noise.noise", &[("amount", n(40.0)), ("clip", b(false))]);
effect_case("ec.noise.addgrain", &[("tweaking/intensity", n(2.0))]);
effect_case(
"ec.noise.addgrain",
&[
("tweaking/intensity", n(3.0)),
("tweaking/size", n(2.5)),
("tweaking/softness", n(1.5)),
("tweaking/aspectRatio", n(1.4)),
("tweaking/channelSize/redSize", n(0.6)),
("color/saturation", n(0.5)),
("color/tintAmount", n(0.4)),
("color/tintColor", c(1.0, 0.6, 0.2)),
("application/blendingMode", e(4)),
],
);
effect_case(
"ec.noise.addgrain",
&[
("tweaking/intensity", n(1.5)),
("color/monochromatic", b(true)),
("application/blendingMode", e(3)),
("application/shadows", n(0.3)),
("application/highlights", n(1.8)),
("application/midpoint", n(0.35)),
("animation/randomSeed", n(42.0)),
("animation/animationSpeed", n(2.0)),
],
);
effect_case(
"ec.noise.addgrain",
&[("tweaking/intensity", n(2.0)), ("application/blendingMode", e(1)), ("tweaking/channelIntensities/blueIntensity", n(2.5))],
);
}

View File

@ -0,0 +1,155 @@
//! GPU particles vs the CPU simulation (the oracle): CC Particle World, CC Particle Systems II
//! and Particle Playground's cannon at fixed seeds must give the same live particles (same
//! ids) at positions within tolerance, from scratch and when advancing / seeking from GPU
//! checkpoints.
use effectcraft_effects::psim::{ParticleSim, SimParticle};
use effectcraft_effects::{EffectCtx, EffectEnv, EffectHost, LayerPixels, Params};
use effectcraft_keyframe::Value;
use crate::Gpu;
use crate::tests::{gpu, n};
struct Host(&'static Gpu);
impl EffectHost for Host {
fn layer(&self, _id: u64, _masks_and_effects: bool) -> Option<LayerPixels> {
None
}
fn audio(&self, _id: u64, _start: f64, _frames: usize, _rate: u32) -> Option<Vec<f32>> {
None
}
fn particles(&self) -> Option<&dyn ParticleSim> {
Some(self.0)
}
}
fn params(id: &str, vals: &[(&str, Value)]) -> Params {
let spec = effectcraft_effects::find(id).unwrap();
let size = [320.0, 240.0];
let mut p = Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), effectcraft_effects::default_value(ps, size))).collect() };
for (k, v) in vals {
assert!(p.values.contains_key(*k), "{id}: no {k}");
p.values.insert(k.to_string(), v.clone());
}
p
}
/// (CPU, GPU) particles of effect `id` at layer time `t`.
fn states(id: &str, p: &Params, t: f64, host: &Host) -> (Vec<SimParticle>, Vec<SimParticle>) {
let ctx = |env| EffectCtx { params: p, time: t, layer_size: [320.0, 240.0], seed: 5, adjustment: false, env };
let run =
|c: &EffectCtx| if id == "ec.sim.particleplayground" { effectcraft_effects::playground_state(c) } else { effectcraft_effects::particle_state(id, c) };
let cpu = run(&ctx(EffectEnv::default())).expect("cpu state");
let gpu = run(&ctx(EffectEnv { host: Some(host), ..Default::default() })).expect("gpu state");
(cpu, gpu)
}
/// Same ids alive (up to `id_slack` particles dying a step apart), positions within `tol`
/// (world units) for 99 % of them and on average within `tol / 10`.
fn agree(label: &str, cpu: &[SimParticle], gpu: &[SimParticle], tol: f32, id_slack: usize) {
assert!(cpu.len() > 20, "{label}: the scene has particles ({})", cpu.len());
let gi: std::collections::HashMap<u32, &SimParticle> = gpu.iter().map(|q| (q.id, q)).collect();
let mut missing = 0;
let mut errs = vec![];
for q in cpu {
match gi.get(&q.id) {
Some(g) => errs.push((0..3).map(|k| (q.p[k] - g.p[k]).abs()).fold(0.0f32, f32::max)),
None => missing += 1,
}
}
let extra = gpu.len() + missing - cpu.len();
assert!(missing + extra <= id_slack, "{label}: {missing} CPU particles missing on the GPU, {extra} extra (of {})", cpu.len());
errs.sort_by(f32::total_cmp);
let mean = errs.iter().sum::<f32>() / errs.len() as f32;
let p99 = errs[(errs.len() * 99 / 100).min(errs.len() - 1)];
eprintln!("{label}: {} particles, mean err {mean:.2e}, p99 {p99:.2e}, max {:.2e}", cpu.len(), errs.last().unwrap());
assert!(p99 <= tol && mean <= tol / 10.0, "{label}: positions differ (mean {mean}, p99 {p99})");
}
#[test]
fn particle_world_matches_the_cpu() {
let Some(g) = gpu() else { return };
let host = Host(g);
for anim in [0u32, 1, 2, 3, 4, 5, 6, 7, 8, 9] {
let p = params(
"ec.sim.ccparticleworld",
&[
("physics/animation", Value::Enum(anim)),
("birthRate", n(2.0)),
("longevity", n(1.2)),
("physics/extra", n(0.6)),
("physics/resistance", n(0.5)),
("extras/randomSeed", n(7.0)),
],
);
let (cpu, gpu) = states("ec.sim.ccparticleworld", &p, 1.5, &host);
// Fractal / fire animations sample value noise along the path: rounding differences can
// grow, so they get a looser bound.
let tol = if matches!(anim, 6 | 8 | 9) { 1e-3 } else { 1e-4 };
agree(&format!("particle world anim {anim}"), &cpu, &gpu, tol, cpu.len() / 100 + 1);
}
}
#[test]
fn particle_systems_ii_matches_the_cpu() {
let Some(g) = gpu() else { return };
let host = Host(g);
for anim in [0u32, 1, 2, 4, 5, 8] {
let p = params(
"ec.sim.ccparticlesystems2",
&[("physics/animation", Value::Enum(anim)), ("birthRate", n(3.0)), ("longevity", n(1.5)), ("physics/gravity", n(0.8)), ("randomSeed", n(3.0))],
);
let (cpu, gpu) = states("ec.sim.ccparticlesystems2", &p, 2.0, &host);
let tol = if anim == 8 { 1e-3 } else { 1e-4 };
agree(&format!("particle systems ii anim {anim}"), &cpu, &gpu, tol, cpu.len() / 100 + 1);
}
}
#[test]
fn playground_cannon_matches_the_cpu_and_checkpoints_reuse() {
let Some(g) = gpu() else { return };
let host = Host(g);
for (radius, spread) in [(4.0, 0.0), (-10.0, 50.0)] {
let p = params(
"ec.sim.particleplayground",
&[
("grid/particlesAcross", n(0.0)),
("cannon/barrelRadius", n(radius)),
("cannon/particlesPerSecond", n(90.0)),
("cannon/directionRandomSpread", n(30.0)),
("cannon/velocityRandomSpread", n(40.0)),
("gravity/gravityForceRandomSpread", n(spread)),
],
);
// Advance through checkpoints (0.5 → 1.0 → 2.0), seek back (1.5), compare each.
for t in [0.5, 1.0, 2.0, 1.5] {
let (cpu, gpu) = states("ec.sim.particleplayground", &p, t, &host);
// Positions are layer pixels here.
agree(&format!("cannon r {radius} spread {spread} t {t}"), &cpu, &gpu, 0.01, cpu.len() / 100 + 1);
}
}
}
/// Through the renderer: the GPU backend's effect host hands the simulation to the GPU, and
/// the composited frames agree with the CPU render.
#[test]
fn particle_effects_render_like_the_cpu() {
use effectcraft_project::BitDepth;
use effectcraft_time::Tick;
use crate::tests::{Scene, check, compare_at, opts};
for (id, vals) in [
("ec.sim.ccparticleworld", vec![("birthRate", n(1.0)), ("physics/animation", Value::Enum(4))]),
("ec.sim.ccparticlesystems2", vec![("birthRate", n(2.0))]),
("ec.sim.particleplayground", vec![("grid/particlesAcross", n(0.0)), ("cannon/particlesPerSecond", n(60.0))]),
] {
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
let mut s = Scene::new(depth);
let mut l = s.solid([0.1, 0.1, 0.15], 97, 61);
s.effect(&mut l, id, &vals);
s.push(l);
check(&format!("{id} {depth:?}"), compare_at(&s, opts(), Tick::from_seconds_f64(1.0)), 0.002);
}
}
}

View File

@ -1,8 +1,9 @@
//! The per-frame compositing walk on the GPU: the same steps as `Renderer::draw_comp` (layer
//! order, 3D runs, collapsed precomps, track mattes, Preserve Transparency, layer styles, motion
//! blur, bit-depth quantisation, colour conversions), with layer pixels taken from the CPU
//! renderer and its layer cache. Steps without a GPU implementation (3D runs, adjustment and
//! wireframe layers) read the canvas back, run on the CPU and upload the result.
//! renderer and its layer cache. Classic 3D runs and adjustment layers run on the GPU; steps
//! without a GPU implementation (Advanced 3D runs, wireframes, anything a kernel declines) read
//! the canvas back, run on the CPU and upload the result.
use std::sync::Arc;
@ -48,6 +49,14 @@ pub(crate) fn render<'g>(e: &mut Enc<'g>, r: &Renderer, comp_id: ItemId, t: Tick
canvas = ops::quantize(e, &canvas, l);
}
}
if r.depth() == 0
&& let Some((lin, mode, enc)) = pipe.output_hdr()
{
on_cpu(e, &mut canvas, |img| {
effectcraft_render::color::output_hdr(img, lin, mode, enc);
pipe.quantize(img);
})?;
}
Some(canvas)
}
@ -69,7 +78,7 @@ fn draw_comp<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, canvas: &mut
while j < visible.len() && visible[j].is_3d() {
j += 1;
}
on_cpu(e, canvas, |img| r.draw_3d_run(ctx, &visible[i..j], img))?;
draw_3d(e, r, ctx, &visible[i..j], canvas)?;
i = j;
} else {
match r.collapsed(ctx, visible[i]) {
@ -86,6 +95,25 @@ fn draw_comp<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, canvas: &mut
Some(())
}
/// A run of consecutive 3D layers: Classic 3D on the GPU (`classic3d`), with 3D adjustment
/// layers splitting the run as on the CPU; Advanced 3D, wireframes and runs the kernel cannot
/// hold draw on the CPU.
fn draw_3d<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, run: &[&'a Layer], canvas: &mut GpuImage) -> Option<()> {
if let Some(k) = run.iter().position(|l| l.switches.adjustment) {
draw_3d(e, r, ctx, &run[..k], canvas)?;
draw_layer(e, r, ctx, run[k], canvas)?;
return draw_3d(e, r, ctx, &run[k + 1..], canvas);
}
if run.is_empty() {
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))?,
}
Some(())
}
fn draw_collapsed<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &Layer, item: ItemId, canvas: &mut GpuImage) -> Option<()> {
let opacity = r.layer_opacity(ctx, layer);
if opacity <= 0.0 {
@ -105,9 +133,14 @@ fn draw_collapsed<'a>(e: &mut Enc, r: &Renderer<'a>, ctx: &EvalCtx<'a>, layer: &
}
fn draw_layer(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: &mut GpuImage) -> Option<()> {
if layer.switches.adjustment || layer.switches.quality == Quality::Wireframe {
// Adjustment layers run their effects on the comp below (CPU, with GPU effects where
// available); wireframes draw outlines.
if layer.switches.adjustment {
if draw_adjustment(e, r, ctx, layer, canvas).is_some() {
return Some(());
}
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));
}
let opacity = r.layer_opacity(ctx, layer);
@ -143,6 +176,33 @@ fn draw_layer(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: &
Some(())
}
/// Adjustment layer (`Renderer::draw_adjustment`): the effect stack runs on the comp below,
/// resident on the GPU (non-GPU effects read back, run on the CPU and upload), and the result
/// replaces the canvas inside the layer's footprint × opacity. `None` = draw it on the CPU.
fn draw_adjustment(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: &mut GpuImage) -> Option<()> {
let opacity = r.layer_opacity(ctx, layer);
let Some(foot) = r.adjustment_footprint(ctx, layer) else { return Some(()) };
let (w, h) = (canvas.width, canvas.height);
let matte = place(e, r, ctx, layer, &Arc::new(foot), &e.image(w, h), BlendMode::Normal, 1.0)?;
let pipe = r.pipe();
// Effects run in the working space, not the linear blending space.
let below = match pipe.from_blend() {
Some(c) => ops::convert(e, canvas, &c),
None => canvas.clone(),
};
let mut fx = crate::effects::GpuFx::new(e, below, [0.0; 2], r.opts.scale, pipe.levels);
r.run_effects_on(ctx, layer, true, &mut fx);
let (mut adjusted, _, _) = fx.finish()?;
if adjusted.width != w || adjusted.height != h {
return Some(());
}
if let Some(c) = pipe.to_blend() {
adjusted = ops::convert(e, &adjusted, &c);
}
*canvas = ops::adjust_mix(e, canvas, &adjusted, &matte, opacity);
Some(())
}
fn composite_layer(e: &mut Enc, r: &Renderer, ctx: &EvalCtx, layer: &Layer, buf: &Arc<Buf>, canvas: &mut GpuImage, opacity: f32) -> Option<()> {
let matte = r.track_matte(ctx, layer).is_some();
if !matte && !layer.preserve_transparency {

View File

@ -468,4 +468,46 @@ mod tests {
s.execute("prop.setExpression", json!({"layer": a, "path": "transform/opacity", "expression": "50"})).unwrap();
assert_eq!(value(&s, a, "transform/opacity").as_f64(), 50.0);
}
/// Create Stereo 3D Rig: the eye cameras' expressions follow the master camera and the
/// Stereo 3D Controls (and agree with the values the command wrote).
#[test]
fn stereo_rig_expressions_follow_the_master_camera() {
let mut s = super::session();
s.execute("comp.new", json!({"name": "Shot", "width": 640, "height": 360, "frameRate": 30, "duration": 2})).unwrap();
let src = s.active_comp_id().unwrap();
let cam = s.execute("layer.newCamera", json!({"name": "Main", "position": [320, 180, -800], "poi": [320, 180, 0], "zoom": 900})).unwrap()["layer"]
.as_u64()
.unwrap();
let r = s.execute("camera.stereoRig", json!({"sceneDepth": 4})).unwrap();
let ctl = r["controls"].as_u64().unwrap();
let left = effectcraft_engine::project::ItemId(r["leftComp"].as_u64().unwrap());
// The left eye camera's evaluated value (expressions on).
let v3 = |s: &effectcraft_engine::Session, path: &str| {
let comp = s.project.comp(left).unwrap();
let layer = &comp.layers[0];
let ctx = effectcraft_engine::render::EvalCtx {
project: &s.project,
comp_id: left,
comp,
time: Default::default(),
expr: s.expr.as_deref(),
footage: None,
};
ctx.value(layer, layer.props.prop(path).unwrap()).as_vec3()
};
let close = |a: [f64; 3], b: [f64; 3]| (0..3).all(|i| (a[i] - b[i]).abs() < 1e-6);
let d = 0.04 * 640.0;
assert!(close(v3(&s, "transform/position"), [320.0 - d / 2.0, 180.0, -800.0]), "{:?}", v3(&s, "transform/position"));
assert!(close(v3(&s, "transform/poi"), [320.0 - d / 2.0, 180.0, 0.0]), "{:?}", v3(&s, "transform/poi"));
// Moving the master moves the eyes.
s.execute("comp.open", json!({"comp": src.0})).unwrap();
s.execute("prop.set", json!({"layer": cam, "path": "transform/position", "value": [300, 180, -800]})).unwrap();
s.execute("prop.set", json!({"layer": cam, "path": "transform/poi", "value": [300, 180, 0]})).unwrap();
assert!(close(v3(&s, "transform/position"), [300.0 - d / 2.0, 180.0, -800.0]), "{:?}", v3(&s, "transform/position"));
// Controls: Center & Left puts the left eye a full separation left; convergence toes in.
s.execute("prop.set", json!({"layer": ctl, "path": "effects/#1/configuration", "value": 2})).unwrap();
s.execute("prop.set", json!({"layer": ctl, "path": "effects/#1/convergence", "value": true})).unwrap();
assert!(close(v3(&s, "transform/position"), [300.0 - d, 180.0, -800.0]), "{:?}", v3(&s, "transform/position"));
assert!(close(v3(&s, "transform/poi"), [300.0, 180.0, 0.0]), "{:?}", v3(&s, "transform/poi"));
}
}

View File

@ -7,8 +7,9 @@
//! (base colour, metallic-roughness, normal, occlusion and emissive textures; alpha modes;
//! double-sided; `KHR_materials_emissive_strength`, `KHR_materials_unlit`), embedded or
//! external PNG/JPEG images with sampler wrap modes, skins and animations (step, linear and
//! cubic-spline translation/rotation/scale). Sparse accessors, morph targets, cameras and
//! lights are ignored.
//! cubic-spline translation/rotation/scale), perspective and orthographic cameras, and
//! `KHR_lights_punctual` lights (directional, point, spot). Sparse accessors and morph targets
//! are ignored.
use std::sync::Arc;
@ -363,6 +364,8 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<
matrix,
mesh: idx(n, "mesh").filter(|&m| m < meshes.len()),
skin: idx(n, "skin"),
camera: idx(n, "camera"),
light: n.get("extensions").and_then(|e| e.get("KHR_lights_punctual")).and_then(|e| idx(e, "light")),
children: arr(n, "children").iter().filter_map(Value::as_u64).map(|c| c as usize).collect(),
});
}
@ -434,7 +437,54 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<
animations.push(anim);
}
Ok(Model { meshes, nodes, roots, materials, textures, skins, animations })
let cameras = arr(&j, "cameras").iter().enumerate().map(|(i, c)| camera(c, i)).collect();
let lights = j
.get("extensions")
.and_then(|e| e.get("KHR_lights_punctual"))
.map(|e| arr(e, "lights").iter().enumerate().map(|(i, l)| light(l, i)).collect())
.unwrap_or_default();
Ok(Model { meshes, nodes, roots, materials, textures, skins, animations, cameras, lights })
}
fn name_or(v: &Value, d: String) -> String {
v.get("name").and_then(Value::as_str).filter(|s| !s.is_empty()).map(str::to_string).unwrap_or(d)
}
/// A glTF camera (glTF 2.0 §5.12): `perspective` {yfov, aspectRatio?, znear} or
/// `orthographic` {xmag, ymag}.
fn camera(c: &Value, i: usize) -> crate::ModelCamera {
let projection = match (c.get("type").and_then(Value::as_str), c.get("orthographic")) {
(Some("orthographic"), Some(o)) => crate::CameraProjection::Orthographic { xmag: num(o, "xmag", 1.0), ymag: num(o, "ymag", 1.0) },
_ => {
let p = c.get("perspective").cloned().unwrap_or(Value::Null);
crate::CameraProjection::Perspective {
yfov: num(&p, "yfov", 0.8).clamp(1e-3, 3.1),
aspect: p.get("aspectRatio").and_then(Value::as_f64),
znear: num(&p, "znear", 0.01),
}
}
};
crate::ModelCamera { name: name_or(c, format!("Camera {}", i + 1)), projection }
}
/// A `KHR_lights_punctual` light: type, color, intensity, range, spot cone angles.
fn light(l: &Value, i: usize) -> crate::ModelLight {
let kind = match l.get("type").and_then(Value::as_str) {
Some("directional") => crate::ModelLightKind::Directional,
Some("spot") => {
let s = l.get("spot").cloned().unwrap_or(Value::Null);
let outer = num(&s, "outerConeAngle", std::f64::consts::FRAC_PI_4).clamp(1e-3, std::f64::consts::FRAC_PI_2);
crate::ModelLightKind::Spot { inner: num(&s, "innerConeAngle", 0.0).clamp(0.0, outer), outer }
}
_ => crate::ModelLightKind::Point,
};
crate::ModelLight {
name: name_or(l, format!("Light {}", i + 1)),
kind,
color: nums(l, "color", [1.0; 3]),
intensity: num(l, "intensity", 1.0).max(0.0),
range: l.get("range").and_then(Value::as_f64),
}
}
fn primitive(d: &Doc, p: &Value, materials: &[Material]) -> Result<Option<Primitive>> {

View File

@ -295,6 +295,10 @@ pub struct Node {
pub matrix: Option<Mat4>,
pub mesh: Option<usize>,
pub skin: Option<usize>,
/// A camera of [`Model::cameras`] at this node (looking down the node's −Z, +Y up).
pub camera: Option<usize>,
/// A light of [`Model::lights`] at this node (`KHR_lights_punctual`, shining down −Z).
pub light: Option<usize>,
pub children: Vec<usize>,
}
@ -308,6 +312,8 @@ impl Default for Node {
matrix: None,
mesh: None,
skin: None,
camera: None,
light: None,
children: vec![],
}
}
@ -495,6 +501,60 @@ pub struct Model {
pub textures: Vec<Arc<Texture>>,
pub skins: Vec<Skin>,
pub animations: Vec<Animation>,
/// Cameras embedded in the file (glTF `cameras`).
pub cameras: Vec<ModelCamera>,
/// Punctual lights embedded in the file (glTF `KHR_lights_punctual`).
pub lights: Vec<ModelLight>,
}
/// A camera embedded in a model file.
#[derive(Clone, Debug, PartialEq)]
pub struct ModelCamera {
pub name: String,
pub projection: CameraProjection,
}
/// glTF camera projections.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum CameraProjection {
/// Vertical field of view (radians), optional aspect ratio, near plane.
Perspective { yfov: f64, aspect: Option<f64>, znear: f64 },
/// Half the orthographic view's width and height (model units).
Orthographic { xmag: f64, ymag: f64 },
}
/// Kinds of `KHR_lights_punctual` lights.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ModelLightKind {
Directional,
Point,
/// Inner and outer cone angles (radians, from the axis).
Spot {
inner: f64,
outer: f64,
},
}
/// A punctual light embedded in a model file.
#[derive(Clone, Debug, PartialEq)]
pub struct ModelLight {
pub name: String,
pub kind: ModelLightKind,
/// Linear RGB.
pub color: [f64; 3],
/// Candela (point, spot) or lux (directional).
pub intensity: f64,
pub range: Option<f64>,
}
/// A camera or light placed in model space by [`Model::placed`].
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Placed {
/// Index into [`Model::cameras`] or [`Model::lights`].
pub index: usize,
pub node: usize,
/// Node → model space.
pub world: Mat4,
}
/// One mesh placed in model space by [`Model::instances`].
@ -579,6 +639,31 @@ impl Model {
out
}
/// The scene's cameras (`lights = false`) or lights (`true`) placed at animation `clip`
/// time `t`, in node order.
pub fn placed(&self, lights: bool, clip: Option<usize>, t: f64) -> Vec<Placed> {
let world = self.world_pose(&self.local_pose(clip, t));
let mut reach = vec![false; self.nodes.len()];
let mut stack: Vec<usize> = self.roots.clone();
while let Some(i) = stack.pop() {
if i >= self.nodes.len() || reach[i] {
continue;
}
reach[i] = true;
stack.extend(self.nodes[i].children.iter().copied());
}
let count = if lights { self.lights.len() } else { self.cameras.len() };
self.nodes
.iter()
.enumerate()
.filter(|(i, _)| reach[*i])
.filter_map(|(i, n)| {
let index = if lights { n.light } else { n.camera }?;
(index < count).then_some(Placed { index, node: i, world: world[i] })
})
.collect()
}
/// Model-space bounds of the rest pose (None for an empty model).
pub fn bounds(&self) -> Option<([f64; 3], [f64; 3])> {
let mut lo = [f64::INFINITY; 3];

View File

@ -316,3 +316,47 @@ fn contours_group_into_outlines_by_nesting() {
assert_eq!(g.len(), 3);
assert_eq!(g.iter().map(|o| o.holes.len()).sum::<usize>(), 2);
}
#[test]
fn gltf_cameras_and_punctual_lights() {
let doc = r#"{
"asset": {"version": "2.0"},
"scene": 0,
"scenes": [{"nodes": [0, 2]}],
"nodes": [
{"name": "Rig", "translation": [0, 0, 10], "children": [1]},
{"name": "Cam", "camera": 0, "translation": [1, 0, 0]},
{"name": "Sun", "rotation": [-0.7071068, 0, 0, 0.7071068], "extensions": {"KHR_lights_punctual": {"light": 1}}},
{"name": "Orphan", "camera": 1}
],
"cameras": [
{"type": "perspective", "name": "Shot", "perspective": {"yfov": 0.5, "znear": 0.1, "aspectRatio": 1.5}},
{"type": "orthographic", "orthographic": {"xmag": 2, "ymag": 1, "znear": 0, "zfar": 10}}
],
"extensions": {"KHR_lights_punctual": {"lights": [
{"type": "spot", "name": "Key", "color": [1, 0.5, 0.25], "intensity": 3, "spot": {"innerConeAngle": 0.2, "outerConeAngle": 0.4}},
{"type": "directional", "intensity": 2}
]}}
}"#;
let m = crate::gltf::parse(doc.as_bytes(), &|_| None).unwrap();
assert_eq!(m.cameras.len(), 2);
assert_eq!(m.cameras[0].name, "Shot");
assert_eq!(m.cameras[0].projection, CameraProjection::Perspective { yfov: 0.5, aspect: Some(1.5), znear: 0.1 });
assert_eq!(m.cameras[1].projection, CameraProjection::Orthographic { xmag: 2.0, ymag: 1.0 });
assert_eq!(m.lights.len(), 2);
assert_eq!(m.lights[0].kind, ModelLightKind::Spot { inner: 0.2, outer: 0.4 });
assert_eq!(m.lights[0].color, [1.0, 0.5, 0.25]);
assert_eq!(m.lights[1].kind, ModelLightKind::Directional);
assert_eq!(m.lights[1].name, "Light 2");
// Only nodes of the scene are placed; the camera node inherits its parent's translation.
let cams = m.placed(false, None, 0.0);
assert_eq!(cams.len(), 1);
assert_eq!((cams[0].index, cams[0].node), (0, 1));
let eye = cams[0].world.apply(vec3(0.0, 0.0, 0.0));
assert!((eye.x - 1.0).abs() < 1e-9 && (eye.z - 10.0).abs() < 1e-9, "{eye:?}");
let lights = m.placed(true, None, 0.0);
assert_eq!(lights.len(), 1);
// −90° about X: the light's −Z axis points down (−Y in glTF).
let d = lights[0].world.apply_vec(vec3(0.0, 0.0, -1.0));
assert!((d.y + 1.0).abs() < 1e-6, "{d:?}");
}

View File

@ -847,6 +847,7 @@ pub fn layer(project: &mut Project, comp: &Comp, name: &str, source: LayerSource
markers: vec![],
markers_locked: false,
environment: false,
environment_background: false,
auto_orient,
props: root,
}

View File

@ -95,6 +95,62 @@ pub enum TimeDisplayStyle {
#[default]
Timecode,
Frames,
/// Feet + Frames, 35 mm film (16 frames per foot).
Feet35,
/// Feet + Frames, 16 mm film (40 frames per foot).
Feet16,
}
impl TimeDisplayStyle {
/// Frames per foot of the Feet + Frames styles.
pub fn frames_per_foot(self) -> Option<i64> {
match self {
TimeDisplayStyle::Feet35 => Some(16),
TimeDisplayStyle::Feet16 => Some(40),
_ => None,
}
}
pub fn id(self) -> &'static str {
match self {
TimeDisplayStyle::Timecode => "timecode",
TimeDisplayStyle::Frames => "frames",
TimeDisplayStyle::Feet35 => "feet35",
TimeDisplayStyle::Feet16 => "feet16",
}
}
pub fn parse(s: &str) -> Option<TimeDisplayStyle> {
let k: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase();
match k.as_str() {
"timecode" => Some(TimeDisplayStyle::Timecode),
"frames" => Some(TimeDisplayStyle::Frames),
"feet35" | "feetframes" | "feetandframes" | "feet35mm" | "35mm" | "feetframes35mm" => Some(TimeDisplayStyle::Feet35),
"feet16" | "feet16mm" | "16mm" | "feetframes16mm" => Some(TimeDisplayStyle::Feet16),
_ => None,
}
}
}
/// Project Settings ▸ Color ▸ Color Engine.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum ColorEngine {
/// Built-in colour management (ICC-style working spaces).
#[default]
Adobe,
/// OCIO managed, with the built-in configuration (ACES working spaces).
Ocio,
}
/// Project Settings ▸ Color ▸ HDR: how over-range (HDR) values reach a standard-dynamic-range
/// display or output.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum HdrMode {
/// Values above 1.0 clip.
#[default]
Clip,
/// Highlights above 80% are companded (an exponential knee) into the remaining range.
Compand,
/// Extended Reinhard tone mapping of luminance (white at 4.0).
ToneMap,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@ -121,6 +177,16 @@ pub struct ProjectSettings {
/// The project's comment (Metadata panel).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub comment: String,
/// Color Engine (Adobe-style built-in or OCIO with ACES working spaces).
#[serde(default)]
pub color_engine: ColorEngine,
/// HDR handling for standard-dynamic-range output (display and renders).
#[serde(default)]
pub hdr: HdrMode,
/// The space the top-level comp is output in (renders and the viewer's source). `None` =
/// sRGB; Rec. 2100 PQ / HLG give HDR output.
#[serde(default)]
pub output_space: Option<ColorSpace>,
}
impl Default for ProjectSettings {
@ -135,6 +201,9 @@ impl Default for ProjectSettings {
audio_sample_rate: 48_000,
gpu_acceleration: true,
comment: String::new(),
color_engine: ColorEngine::Adobe,
hdr: HdrMode::Clip,
output_space: None,
}
}
}
@ -556,6 +625,10 @@ pub struct Layer {
/// reflections) of Advanced 3D comps instead of being drawn.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub environment: bool,
/// Layer ▸ Light ▸ Create Environment Light Background Layer: the layer's (equirectangular)
/// image is drawn as the 3D scene's backdrop, seen through the camera, instead of as a card.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub environment_background: bool,
/// The property tree (Masks, Effects, Transform, Text, Contents, Camera/Light options…).
pub props: PropGroup,
}

View File

@ -17,7 +17,7 @@
//! pixels to their parent). Without a working space the sRGB curve is used.
use effectcraft_color::{ColorSpace, Conversion};
use effectcraft_project::ProjectSettings;
use effectcraft_project::{HdrMode, ProjectSettings};
use effectcraft_raster::Image;
use rayon::prelude::*;
@ -32,12 +32,24 @@ pub struct Pipe {
pub linear: bool,
/// Blend in linear light (and the working space is not already linear).
pub linear_blend: bool,
/// The top-level output's space (sRGB unless Project Settings choose another).
pub out: ColorSpace,
/// HDR handling for standard-dynamic-range output.
pub hdr: HdrMode,
}
impl Pipe {
pub fn of(s: &ProjectSettings) -> Pipe {
let linear = s.linearize && s.working_space.is_some();
Pipe { levels: s.bit_depth.levels(), space: s.working_space, linear, linear_blend: s.blend_linear && !linear }
// ACES working spaces are linear light by definition.
let linear = s.working_space.is_some_and(|w| s.linearize || w.is_linear());
Pipe {
levels: s.bit_depth.levels(),
space: s.working_space,
linear,
linear_blend: s.blend_linear && !linear,
out: s.output_space.unwrap_or(ColorSpace::Srgb),
hdr: s.hdr,
}
}
/// The space whose curve encodes working-space pixels (sRGB when unmanaged).
@ -67,10 +79,22 @@ impl Pipe {
self.linear_blend.then(|| Conversion::delinearize(self.curve()))
}
/// Working space → sRGB display (top-level comp output).
/// Working space → the output space (top-level comp output; sRGB by default). `None` when
/// unmanaged or when [`Pipe::output_hdr`] applies.
pub fn output(&self) -> Option<Conversion> {
let ws = self.space?;
Conversion::new(ws, self.linear, ColorSpace::Srgb, false)
if self.output_hdr().is_some() {
return None;
}
Conversion::new(ws, self.linear, self.out, false)
}
/// Output with HDR compand / tone mapping (standard-dynamic-range outputs only): working
/// space → linear output primaries, the HDR curve, then the output's encoding.
pub fn output_hdr(&self) -> Option<(Option<Conversion>, HdrMode, Conversion)> {
let ws = self.space?;
(self.hdr != HdrMode::Clip && !self.out.is_hdr())
.then(|| (Conversion::new(ws, self.linear, self.out, true), self.hdr, Conversion::delinearize(self.out)))
}
/// One pixel clamped and quantised to the bit depth.
@ -122,6 +146,7 @@ impl Pipe {
let mut k = self.levels.map_or(0, |l| l as u64);
k = k.wrapping_mul(31).wrapping_add(self.space.map_or(7, |s| s as u64 + 11));
k = k.wrapping_mul(31).wrapping_add(self.linear as u64 * 2 + self.linear_blend as u64);
k = k.wrapping_mul(31).wrapping_add(self.out as u64 * 4 + self.hdr as u64);
k
}
}
@ -202,3 +227,42 @@ pub fn convert(img: &mut Image, c: &Conversion) {
*p = [o[0] * a, o[1] * a, o[2] * a, a];
});
}
/// Compand: an exponential knee from 80%, approaching 1.0 (linear light, per channel).
pub fn compand(v: f32) -> f32 {
const K: f32 = 0.8;
if v <= K { v } else { K + (1.0 - K) * (1.0 - (-(v - K) / (1.0 - K)).exp()) }
}
/// Extended Reinhard tone mapping of luminance with white at `W` (linear light).
pub fn tone_map(c: [f32; 3]) -> [f32; 3] {
const W: f32 = 4.0;
let l = effectcraft_color::luminance(c[0], c[1], c[2]);
if l <= 0.0 {
return c;
}
let lm = l * (1.0 + l / (W * W)) / (1.0 + l);
let k = lm / l;
c.map(|v| v * k)
}
/// The HDR step of [`Pipe::output_hdr`] on premultiplied pixels.
pub fn output_hdr(img: &mut Image, to_linear: Option<Conversion>, mode: HdrMode, encode: Conversion) {
img.data.par_iter_mut().for_each(|p| {
let a = p[3];
if a <= 0.0 {
return;
}
let mut c = [p[0] / a, p[1] / a, p[2] / a];
if let Some(t) = &to_linear {
c = t.apply(c);
}
c = match mode {
HdrMode::Clip => c,
HdrMode::Compand => c.map(compand),
HdrMode::ToneMap => tone_map(c),
};
let o = encode.apply(c);
*p = [o[0] * a, o[1] * a, o[2] * a, a];
});
}

View File

@ -82,6 +82,9 @@ struct FxHost<'r, 'a, 'c> {
const MAX_FX_DEPTH: usize = 8;
impl EffectHost for FxHost<'_, '_, '_> {
fn particles(&self) -> Option<&dyn effectcraft_effects::psim::ParticleSim> {
self.r.active_accel().and_then(|a| a.particles())
}
fn layer(&self, id: u64, masks_and_effects: bool) -> Option<LayerPixels> {
let other = self.ctx.layer(effectcraft_project::LayerId(id))?;
if other.id == self.layer.id || self.r.depth > MAX_FX_DEPTH {
@ -240,6 +243,46 @@ pub trait Accelerator: Send + Sync {
fn raster_3d(&self, _scene: &three_d::adv::Scene) -> Option<three_d::adv::Target> {
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> {
None
}
}
/// Where an effect stack runs (see [`Renderer::run_effects_on`]): a CPU buffer, or an image
/// resident on an accelerator.
pub trait FxTarget {
/// Run a chain of accelerator-supported effects (quantising after each to the bit depth).
/// `false` = not handled: the effects then run one by one through [`FxTarget::cpu`].
fn gpu(&mut self, steps: &[FxStep]) -> bool;
/// Run one CPU effect: `f` maps the buffer.
fn cpu(&mut self, f: &mut dyn FnMut(Buf) -> Buf);
}
/// The CPU effect target (GPU chains go through [`Accelerator::effects`]: upload, readback).
struct CpuFx<'a> {
accel: Option<&'a dyn Accelerator>,
levels: Option<f32>,
buf: Option<Buf>,
}
impl FxTarget for CpuFx<'_> {
fn gpu(&mut self, steps: &[FxStep]) -> bool {
let (Some(a), Some(b)) = (self.accel, &self.buf) else { return false };
match a.effects(steps, b, self.levels) {
Some(out) => {
self.buf = Some(out);
true
}
None => false,
}
}
fn cpu(&mut self, f: &mut dyn FnMut(Buf) -> Buf) {
if let Some(b) = self.buf.take() {
self.buf = Some(f(b));
}
}
}
#[derive(Clone, Copy, Debug)]
@ -494,11 +537,8 @@ impl<'a> Renderer<'a> {
/// Render a composition at comp time `t` (transparent background, comp size × scale).
/// Top-level frames go to the GPU when [`Self::active_accel`] is set and handles them.
pub fn comp_frame(&self, comp_id: ItemId, t: Tick) -> Image {
// Auto keeps comps with 3D layers on the CPU compositor: 3D runs are still composited on
// the CPU, and the GPU path's readback/upload around them makes such comps slower.
let auto_3d = self.opts.backend == Backend::Auto && self.project.comp(comp_id).is_some_and(|c| c.has_3d());
// Classic 3D runs composite on the GPU too (M12.7), so Auto sends 3D comps there as well.
if self.depth == 0
&& !auto_3d
&& let Some(a) = self.active_accel()
&& let Some(img) = a.comp_frame(self, comp_id, t)
{
@ -551,6 +591,12 @@ impl<'a> Renderer<'a> {
color::convert(&mut canvas, &c);
self.pipe.quantize(&mut canvas);
}
if self.depth == 0
&& let Some((lin, mode, enc)) = self.pipe.output_hdr()
{
color::output_hdr(&mut canvas, lin, mode, enc);
self.pipe.quantize(&mut canvas);
}
canvas
}
@ -595,19 +641,28 @@ impl<'a> Renderer<'a> {
}
/// Run the first `limit` effects of the layer's stack (by position; disabled ones count).
fn apply_effects_timed(
fn apply_effects_timed(&self, ctx: &EvalCtx, layer: &Layer, buf: Buf, adjustment: bool, limit: usize, timing: Option<&mut Vec<(String, f64)>>) -> Buf {
let mut t = CpuFx { accel: self.active_accel(), levels: self.pipe.levels, buf: Some(buf) };
self.run_effect_stack(ctx, layer, adjustment, limit, timing, &mut t);
t.buf.unwrap_or_else(|| Buf { img: Image::new(1, 1), offset: [0.0; 2], scale: self.opts.scale })
}
/// Run the layer's video effect stack on `target`: runs of GPU-capable effects go to
/// [`FxTarget::gpu`] (when an accelerator is active and supports them), the rest to
/// [`FxTarget::cpu`] one by one (quantised to the bit depth after each).
fn run_effect_stack(
&self,
ctx: &EvalCtx,
layer: &Layer,
mut buf: Buf,
adjustment: bool,
limit: usize,
mut timing: Option<&mut Vec<(String, f64)>>,
) -> Buf {
target: &mut dyn FxTarget,
) {
if !layer.switches.effects {
return buf;
return;
}
let Some(fx) = layer.effects() else { return buf };
let Some(fx) = layer.effects() else { return };
let lt = layer.layer_time(ctx.time);
let size = source_size(self.project, layer);
let layer_size = if size.0 == 0 { [ctx.comp.width as f64, ctx.comp.height as f64] } else { [size.0 as f64, size.1 as f64] };
@ -638,7 +693,7 @@ impl<'a> Renderer<'a> {
let mut k = 0;
while k < stack.len() {
let (i, g, spec) = stack[k];
// A run of GPU effects goes to the accelerator in one upload / readback.
// A run of GPU effects goes to the accelerator in one go.
if let Some(a) = accel
&& spec.gpu
&& a.supports_effect(spec.id)
@ -654,8 +709,7 @@ impl<'a> Renderer<'a> {
})
.collect();
let t0 = web_time::Instant::now();
if let Some(out) = a.effects(&steps, &buf, self.pipe.levels) {
buf = out;
if target.gpu(&steps) {
if let Some(v) = timing.as_deref_mut() {
let ms = t0.elapsed().as_secs_f64() * 1e3 / run.len() as f64;
v.extend(run.iter().map(|(_, _, s)| (format!("{} (gpu)", s.id), ms)));
@ -670,14 +724,16 @@ impl<'a> Renderer<'a> {
let env = EffectEnv { effect_index: i, ..env };
let ectx = EffectCtx { params: &params, time: lt.seconds(), layer_size, seed: g.uid as u32, adjustment, env };
let t0 = web_time::Instant::now();
buf = effectcraft_effects::apply(spec, &ectx, buf);
// 8/16 bpc effects write integer pixels.
self.pipe.quantize(&mut buf.img);
target.cpu(&mut |buf| {
let mut buf = effectcraft_effects::apply(spec, &ectx, buf);
// 8/16 bpc effects write integer pixels.
self.pipe.quantize(&mut buf.img);
buf
});
if let Some(v) = timing.as_deref_mut() {
v.push((spec.id.to_string(), t0.elapsed().as_secs_f64() * 1e3));
}
}
buf
}
/// Source pixels of a layer in layer space (before masks/effects).
@ -1016,7 +1072,7 @@ impl<'a> Renderer<'a> {
/// The region of interest of a top-level 2D frame: (x, y) offset of the output in output
/// pixels and its size. `None` renders the whole comp (no ROI, or a nested comp).
fn roi_offset(&self) -> Option<(f64, f64, u32, u32)> {
pub(crate) fn roi_offset(&self) -> Option<(f64, f64, u32, u32)> {
let r = self.opts.roi.filter(|_| self.depth == 0)?;
let s = self.opts.scale;
if r[2] <= 0.0 || r[3] <= 0.0 {
@ -1256,17 +1312,29 @@ impl<'a> Renderer<'a> {
}
/// Apply the track matte / preserve transparency to an isolated layer render, then blend.
fn composite_iso(&self, ctx: &EvalCtx, layer: &Layer, mut iso: Image, canvas: &mut Image, opacity: f32) {
fn composite_iso(&self, ctx: &EvalCtx, layer: &Layer, iso: Image, canvas: &mut Image, opacity: f32) {
self.composite_iso_with(ctx, layer, iso, canvas, opacity, None);
}
/// [`Self::composite_iso`] with the track matte's pixels already drawn (`matte`, e.g. a 3D
/// matte seen through the camera); otherwise the matte layer is placed in 2D.
pub(crate) fn composite_iso_with(&self, ctx: &EvalCtx, layer: &Layer, mut iso: Image, canvas: &mut Image, opacity: f32, matte_img: Option<Image>) {
let matte = layer.track_matte.and_then(|tm| ctx.comp.layer(tm.layer).filter(|m| m.id != layer.id).map(|m| (m, tm.kind)));
let preserve = layer.preserve_transparency;
if let Some((m, kind)) = matte {
let mut mimg = Image::new(canvas.width, canvas.height);
if m.is_active_at(ctx.time)
&& let Some(mb) = self.layer_buf(ctx, m)
{
let mo = ctx.opacity(m) as f32;
self.place(ctx, m, &mb, &mut mimg, BlendMode::Normal, mo);
}
let mimg = match matte_img {
Some(i) if i.width == canvas.width && i.height == canvas.height => i,
_ => {
let mut mimg = Image::new(canvas.width, canvas.height);
if m.is_active_at(ctx.time)
&& let Some(mb) = self.layer_buf(ctx, m)
{
let mo = ctx.opacity(m) as f32;
self.place(ctx, m, &mb, &mut mimg, BlendMode::Normal, mo);
}
mimg
}
};
iso.data.par_iter_mut().zip(mimg.data.par_iter()).for_each(|(p, q)| {
let k = match kind {
MatteKind::Alpha => q[3],
@ -1385,6 +1453,29 @@ impl<'a> Renderer<'a> {
three_d::compose::draw_run(self, ctx, run, canvas);
}
/// A run of consecutive 3D layers prepared for an accelerator (planes, lights, shadow
/// casters; see [`three_d::Run3d`]) at output size `out`. `None` = draw it with
/// [`Self::draw_3d_run`] (adjustment or wireframe layers in the run, Advanced 3D).
pub fn prepare_3d_run(&self, ctx: &EvalCtx<'a>, run: &[&Layer], out: (u32, u32)) -> Option<three_d::Run3d> {
three_d::compose::gpu_run(self, ctx, run, 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
/// layers).
pub fn run_effects_on(&self, ctx: &EvalCtx, layer: &Layer, adjustment: bool, target: &mut dyn FxTarget) {
self.run_effect_stack(ctx, layer, adjustment, usize::MAX, None, target);
}
/// An adjustment layer's footprint (its source with masks applied, layer space): where
/// its effects show. `None` = the layer changes nothing.
pub fn adjustment_footprint(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Buf> {
let mut foot = self.source(ctx, layer)?;
let _ = masks::apply(ctx, layer, &mut foot);
Some(foot)
}
/// Draw one 2D layer into `canvas` on the CPU (any kind: adjustment, wireframe, styled…).
pub fn draw_layer_cpu(&self, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image) {
self.draw_layer(ctx, layer, canvas, false);

View File

@ -390,7 +390,7 @@ impl crate::Accelerator for ProbeAccel {
}
#[test]
fn auto_backend_keeps_3d_comps_on_the_cpu() {
fn auto_backend_sends_2d_and_3d_comps_to_the_gpu() {
let (mut p, cid, comp) = setup();
p.settings.gpu_acceleration = true;
let l = solid(&mut p, &comp, [1.0, 0.0, 0.0], 50, 50);
@ -404,8 +404,11 @@ fn auto_backend_keeps_3d_comps_on_the_cpu() {
};
// 2D comp: Auto uses the GPU compositor.
assert!(asked(&p, crate::Backend::Auto));
// A 3D layer: Auto stays on the CPU; an explicit Gpu request still goes to the GPU.
// A 3D layer: Classic 3D runs on the GPU too (M12.7), so Auto still asks it.
p.comp_mut(cid).unwrap().layers[0].switches.three_d = true;
assert!(!asked(&p, crate::Backend::Auto));
assert!(asked(&p, crate::Backend::Auto));
assert!(asked(&p, crate::Backend::Gpu));
// Software Only: never.
p.settings.gpu_acceleration = false;
assert!(!asked(&p, crate::Backend::Auto));
}

View File

@ -140,25 +140,135 @@ pub fn scene_of(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u32)) ->
scene::build(r, ctx, run, out)
}
/// Draw a run of 3D layers with the Advanced 3D renderer into `canvas`.
pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut Image) {
let s = scene::build(r, ctx, run, (canvas.width, canvas.height));
if s.indices.is_empty() {
return;
/// Rasterise one scene (GPU when available, else CPU) and resolve it.
fn raster_resolved(r: &Renderer, s: &Scene) -> (Image, Vec<f32>) {
let target = r.active_accel().and_then(|a| a.raster_3d(s)).filter(|t| t.width == s.width && t.height == s.height).unwrap_or_else(|| raster::render(s));
resolve(&target, s.ssaa.max(1))
}
/// Number of motion-blur sub-samples for a set of layers (1 = none blurred).
fn mb_samples(r: &Renderer, ctx: &EvalCtx, layers: &[&Layer]) -> usize {
if !layers.iter().any(|l| scene::motion_blurred(r, ctx, l)) {
return 1;
}
let target = r.active_accel().and_then(|a| a.raster_3d(&s)).filter(|t| t.width == s.width && t.height == s.height).unwrap_or_else(|| raster::render(&s));
let (mut img, depth) = resolve(&target, s.ssaa.max(1));
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);
if s.indices.is_empty() {
return None;
}
let (img, depth) = raster_resolved(r, &s);
(img, depth, s.linear_io)
} 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;
if s.indices.is_empty() {
continue;
}
any = true;
let (img, d) = raster_resolved(r, &s);
acc.data.par_iter_mut().zip(img.data.par_iter()).for_each(|(a, p)| {
for c in 0..4 {
a[c] += p[c] * k;
}
});
depth.par_iter_mut().zip(d.par_iter()).for_each(|(a, b)| *a = a.min(*b));
}
if !any {
return None;
}
(acc, depth, linear)
};
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 !s.linear_io {
if !linear {
encode(&mut img);
}
canvas.data.par_iter_mut().zip(img.data.par_iter()).for_each(|(d, s)| {
let k = 1.0 - s[3];
*d = [s[0] + d[0] * k, s[1] + d[1] * k, s[2] + d[2] * k, s[3] + d[3] * k];
});
Some((img, depth))
}
/// 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
|| l.preserve_transparency
|| l.track_matte.is_some_and(|tm| tm.layer != l.id && ctx.comp.layer(tm.layer).is_some())
}
/// Draw a run of 3D layers with the Advanced 3D renderer into `canvas`.
///
/// Layers with a blend mode, a track matte or Preserve Transparency can't blend inside the
/// depth-buffered scene: they are rendered on their own (lit by the same lights, through the
/// same camera), hidden where the rest of the run is nearer, then composited through the 2D
/// path (matte, Preserve Transparency, blend mode) from the farthest to the nearest.
pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut Image) {
let out = (canvas.width, canvas.height);
let (special, plain): (Vec<&Layer>, Vec<&Layer>) = run.iter().copied().partition(|l| needs_2d_composite(ctx, l));
let main = render_layers(r, ctx, &plain, out);
if let Some((img, _)) = &main {
canvas.data.par_iter_mut().zip(img.data.par_iter()).for_each(|(d, s)| {
let k = 1.0 - s[3];
*d = [s[0] + d[0] * k, s[1] + d[1] * k, s[2] + d[2] * k, s[3] + d[3] * k];
});
}
if special.is_empty() {
return;
}
let cam = camera_for(r, ctx);
let mut order: Vec<(f64, &Layer)> = special
.into_iter()
.map(|l| {
let anchor = l.transform().map_or([0.0; 3], |tr| ctx.v3(l, tr, "anchor", [0.0; 3]));
(cam.depth(ctx.world_matrix(l).apply(effectcraft_geom::Vec3::from(anchor))), l)
})
.collect();
order.sort_by(|a, b| b.0.total_cmp(&a.0));
for (_, l) in order {
let Some((mut iso, depth)) = render_layers(r, ctx, &[l], out) else { continue };
if let Some((m, md)) = &main {
iso.data.par_iter_mut().zip(depth.par_iter()).zip(m.data.par_iter().zip(md.par_iter())).for_each(|((p, z), (q, mz))| {
if *mz < *z - 0.01 * z.abs().max(1.0) {
let k = 1.0 - q[3].clamp(0.0, 1.0);
for c in p.iter_mut() {
*c *= k;
}
}
});
}
// A 3D matte layer is drawn through the camera too.
let matte3d = l.track_matte.and_then(|tm| ctx.comp.layer(tm.layer).filter(|m| m.id != l.id && m.is_3d())).map(|m| {
let mut solo = m.clone();
solo.track_matte = None;
solo.blend_mode = effectcraft_color::BlendMode::Normal;
solo.preserve_transparency = false;
solo.switches.video = true;
if m.is_active_at(ctx.time) {
render_layers(r, ctx, &[&solo], out).map(|(i, _)| i).unwrap_or_else(|| Image::new(out.0, out.1))
} else {
Image::new(out.0, out.1)
}
});
r.composite_iso_with(ctx, l, iso, canvas, 1.0, matte3d);
}
}
#[cfg(test)]

View File

@ -806,6 +806,20 @@ fn ssaa_for(r: &Renderer, out: (u32, u32)) -> u32 {
/// Build the scene of a run of 3D layers.
pub(crate) fn build(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u32)) -> Scene {
build_at(r, ctx, None, run, out)
}
/// Whether a layer's motion is blurred (Motion Blur switch, comp setting and render option).
pub(crate) fn motion_blurred(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> bool {
r.opts.motion_blur && ctx.comp.enable_motion_blur && layer.switches.motion_blur
}
/// [`build`] at a motion-blur sub-sample: layers with Motion Blur on, the camera and the lights
/// are evaluated at `sub`'s time; the other layers stay at `ctx`'s.
pub(crate) fn build_at(r: &Renderer, ctx: &EvalCtx, sub: Option<&EvalCtx>, run: &[&Layer], out: (u32, u32)) -> Scene {
let base = ctx;
let at = sub.unwrap_or(ctx);
let ctx = at;
let cam = camera_for(r, ctx);
let lin = r.pipe.linear || r.pipe.linear_blend;
let ssaa = ssaa_for(r, out);
@ -816,6 +830,7 @@ pub(crate) fn build(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u32)
cam,
};
for layer in run {
let ctx = if sub.is_some() && motion_blurred(r, base, layer) { at } else { base };
if layer.environment || layer.switches.adjustment || ctx.opacity(layer) <= 0.0 || !layer.has_video() {
continue;
}

View File

@ -312,3 +312,116 @@ fn extruded_text_renders_as_a_mesh() {
let img = render(&p, cid);
assert!(img.data.iter().filter(|q| q[3] > 0.5).count() > 200);
}
// ---------------------------------------------------------------- motion blur, blend modes, mattes
fn solid(p: &mut Project, color: [f32; 3], w: u32, h: u32) -> ItemId {
p.add_item("S", Default::default(), None, ItemKind::Solid(effectcraft_project::Solid { color, width: w, height: h, pixel_aspect: 1.0 }))
}
/// Pixels of row `y` whose alpha is partial (motion-blur smear or anti-aliasing).
fn partial(img: &crate::Image, y: u32) -> usize {
(0..img.width).filter(|x| (0.05..0.95).contains(&img.data[(y * img.width + x) as usize][3])).count()
}
#[test]
fn motion_blur_smears_moving_meshes() {
let (mut p, cid) = project(160, 120);
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Cube }, |l| {
set(l, "geometryOptions/width", Value::Scalar(30.0));
set(l, "geometryOptions/height", Value::Scalar(30.0));
set(l, "geometryOptions/depth", Value::Scalar(30.0));
// 60 px per frame to the right.
let pr = l.props.prop_mut("transform/position").unwrap();
pr.keys = vec![
effectcraft_keyframe::Keyframe::new(Tick::ZERO, Value::Vec3([50.0, 60.0, 0.0])),
effectcraft_keyframe::Keyframe::new(Tick::from_seconds_f64(1.0), Value::Vec3([50.0 + 30.0 * 60.0, 60.0, 0.0])),
];
});
let sharp = render(&p, cid);
let hard = partial(&sharp, 60);
assert!(hard <= 4, "no blur without the switch: {hard}");
p.comp_mut(cid).unwrap().layers[0].switches.motion_blur = true;
let blurred = render(&p, cid);
let smear = partial(&blurred, 60);
// A 180° shutter smears over half a frame of motion (≈ 30 px).
assert!(smear > 20, "smeared: {smear}");
// Coverage is conserved: the summed alpha along the row stays about the cube's width.
let sum = |im: &crate::Image| (0..160).map(|x| im.data[60 * 160 + x][3]).sum::<f32>();
assert!((sum(&blurred) - sum(&sharp)).abs() < 4.0, "{} vs {}", sum(&blurred), sum(&sharp));
// The comp's motion blur switch and the render option turn it off.
p.comp_mut(cid).unwrap().enable_motion_blur = false;
assert!(partial(&render(&p, cid), 60) <= 4);
p.comp_mut(cid).unwrap().enable_motion_blur = true;
let r = Renderer::new(&p, &NoFootage, RenderOpts { motion_blur: false, ..Default::default() });
assert!(partial(&r.comp_frame(cid, Tick::ZERO), 60) <= 4);
// Shutter angle 0 has no exposure time: no smear.
p.comp_mut(cid).unwrap().shutter_angle = 0.0;
assert!(partial(&render(&p, cid), 60) <= 4);
}
#[test]
fn blend_modes_and_occlusion_in_advanced_3d() {
let (mut p, cid) = project(120, 90);
let red = solid(&mut p, [1.0, 0.0, 0.0], 120, 90);
let gray = solid(&mut p, [0.5, 0.5, 0.5], 60, 40);
let blue = solid(&mut p, [0.0, 0.0, 1.0], 20, 20);
// Back: a red card; middle: a gray Multiply card; front: a small blue card on the left.
add(&mut p, cid, LayerSource::Solid { item: red }, |l| {
l.switches.three_d = true;
});
add(&mut p, cid, LayerSource::Solid { item: gray }, |l| {
l.switches.three_d = true;
l.blend_mode = effectcraft_color::BlendMode::Multiply;
set(l, "transform/position", Value::Vec3([60.0, 45.0, -20.0]));
set(l, "transform/anchor", Value::Vec3([30.0, 20.0, 0.0]));
});
add(&mut p, cid, LayerSource::Solid { item: blue }, |l| {
l.switches.three_d = true;
set(l, "transform/position", Value::Vec3([45.0, 45.0, -40.0]));
set(l, "transform/anchor", Value::Vec3([10.0, 10.0, 0.0]));
});
let img = render(&p, cid);
let at = |x: usize, y: usize| img.data[y * 120 + x];
// Multiply over red: half red, no gray.
let c = at(70, 45);
assert!((c[0] - 0.5).abs() < 0.02 && c[1] < 0.02 && c[2] < 0.02 && c[3] > 0.99, "multiplied {c:?}");
// Outside the gray card: plain red.
let r = at(115, 5);
assert!(r[0] > 0.98 && r[1] < 0.02, "{r:?}");
// The nearer blue card hides the Multiply card behind it.
let b = at(40, 45);
assert!(b[2] > 0.98 && b[0] < 0.02, "occluded {b:?}");
// Normal mode for comparison: gray covers the red.
p.comp_mut(cid).unwrap().layers[1].blend_mode = effectcraft_color::BlendMode::Normal;
let n = render(&p, cid).data[45 * 120 + 70];
assert!((n[0] - 0.5).abs() < 0.02 && (n[1] - 0.5).abs() < 0.02, "{n:?}");
}
#[test]
fn track_mattes_in_advanced_3d() {
let (mut p, cid) = project(120, 90);
let white = solid(&mut p, [1.0, 1.0, 1.0], 120, 90);
let small = solid(&mut p, [1.0, 1.0, 1.0], 40, 30);
// Layer order (top first): the matte, then the matted layer.
add(&mut p, cid, LayerSource::Solid { item: white }, |l| l.switches.three_d = true);
add(&mut p, cid, LayerSource::Solid { item: small }, |l| {
l.switches.three_d = true;
l.switches.video = false;
// A 3D matte is seen through the camera.
set(l, "transform/position", Value::Vec3([30.0, 30.0, 0.0]));
set(l, "transform/anchor", Value::Vec3([20.0, 15.0, 0.0]));
});
let matte = p.comp(cid).unwrap().layers[0].id;
p.comp_mut(cid).unwrap().layers[1].track_matte = Some(effectcraft_project::TrackMatte { layer: matte, kind: effectcraft_project::MatteKind::Alpha });
let img = render(&p, cid);
let inside = img.data[30 * 120 + 30];
let outside = img.data[70 * 120 + 100];
assert!(inside[3] > 0.99 && inside[0] > 0.99, "{inside:?}");
assert!(outside[3] < 0.01, "{outside:?}");
// An inverted alpha matte keeps the outside instead.
p.comp_mut(cid).unwrap().layers[1].track_matte =
Some(effectcraft_project::TrackMatte { layer: matte, kind: effectcraft_project::MatteKind::AlphaInverted });
let img = render(&p, cid);
assert!(img.data[30 * 120 + 30][3] < 0.01 && img.data[70 * 120 + 100][3] > 0.99);
}

View File

@ -340,11 +340,15 @@ pub enum View3D {
Custom1,
Custom2,
Custom3,
/// The comp's default camera (50 mm, looking at the comp centre), ignoring camera layers;
/// orbitable like the custom views.
Default,
}
impl View3D {
pub const ALL: [View3D; 10] = [
pub const ALL: [View3D; 11] = [
View3D::ActiveCamera,
View3D::Default,
View3D::Front,
View3D::Left,
View3D::Top,
@ -367,6 +371,7 @@ impl View3D {
View3D::Custom1 => "Custom View 1",
View3D::Custom2 => "Custom View 2",
View3D::Custom3 => "Custom View 3",
View3D::Default => "Default",
}
}
/// Stable id used by commands (`activeCamera`, `front`, `custom1`…).
@ -382,6 +387,7 @@ impl View3D {
View3D::Custom1 => "custom1",
View3D::Custom2 => "custom2",
View3D::Custom3 => "custom3",
View3D::Default => "default",
}
}
pub fn from_id(s: &str) -> Option<View3D> {
@ -433,6 +439,7 @@ pub fn default_view_cam(v: View3D, w: f64, h: f64) -> ViewCam {
View3D::Custom1 => custom(30.0, -20.0),
View3D::Custom2 => custom(0.0, -35.0),
View3D::Custom3 => custom(-30.0, -20.0),
View3D::Default => ViewCam { eye: arr(vec3(w / 2.0, h / 2.0, -zoom)), poi: arr(c), down: [0.0, 1.0, 0.0], zoom, ortho: false },
}
}

View File

@ -29,16 +29,18 @@ use crate::{EvalCtx, Renderer};
/// One position of a plane (several with motion blur).
#[derive(Clone, Copy, Debug)]
pub(crate) struct Geo {
pub struct Geo {
/// Output pixel → homogeneous layer coordinates.
hinv: Mat3,
pub hinv: Mat3,
/// Camera-space depth as a function of layer (u, v): `d0·u + d1·v + d2`.
depth: [f64; 3],
world: Mat4,
normal: Vec3,
pub depth: [f64; 3],
/// Layer → world.
pub world: Mat4,
/// Unit world normal.
pub normal: Vec3,
/// Output pixel bounds [x0, y0, x1, y1) covered by the plane.
bbox: [i64; 4],
cam: CameraState,
pub bbox: [i64; 4],
pub cam: CameraState,
}
/// A prepared 3D layer.
@ -72,7 +74,7 @@ impl Item<'_> {
}
/// Build the plane geometry of a layer for a camera.
fn geo(cam: &CameraState, world: Mat4, buf: &Buf, comp: (f64, f64), scale: f64, out: (u32, u32)) -> Option<Geo> {
fn geo(cam: &CameraState, world: Mat4, b: [f64; 4], comp: (f64, f64), scale: f64, out: (u32, u32)) -> Option<Geo> {
let proj = Mat4::scale(vec3(scale, scale, 1.0)) * cam.projection(comp.0, comp.1);
let full = proj * world;
let h = full.plane_to_mat3();
@ -89,7 +91,6 @@ fn geo(cam: &CameraState, world: Mat4, buf: &Buf, comp: (f64, f64), scale: f64,
return None;
}
let normal = normal.normalize();
let b = buf_bounds(buf);
let corners = [(b[0], b[1]), (b[2], b[1]), (b[2], b[3]), (b[0], b[3])];
let (mut x0, mut y0, mut x1, mut y1) = (f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
let mut behind = 0;
@ -211,9 +212,10 @@ fn prepare_with<'a>(
if let Some(dof) = cam.dof.filter(|_| !ctx.comp.draft_3d && !r.opts.draft) {
buf = dof_blur(&cam, &dof, world, buf);
}
let bounds = buf_bounds(&buf);
let n = if in_run { mb_samples(r, ctx, layer) } else { 1 };
let geos: Vec<Geo> = if n <= 1 {
geo(&cam, world, &buf, comp, s, out).into_iter().collect()
geo(&cam, world, bounds, comp, s, out).into_iter().collect()
} else {
let fd = ctx.comp.frame_duration().seconds();
let angle = ctx.comp.shutter_angle / 360.0;
@ -223,7 +225,7 @@ fn prepare_with<'a>(
let f = phase + angle * i as f64 / (n - 1) as f64;
let sub = ctx.at(ctx.time + Tick::from_seconds_f64(f * fd));
let c = camera_for(r, &sub);
geo(&c, outer * sub.world_matrix(layer) * local, &buf, comp, s, out)
geo(&c, outer * sub.world_matrix(layer) * local, bounds, comp, s, out)
})
.collect()
};
@ -235,7 +237,6 @@ fn prepare_with<'a>(
bbox = [bbox[0].min(g.bbox[0]), bbox[1].min(g.bbox[1]), bbox[2].max(g.bbox[2]), bbox[3].max(g.bbox[3])];
}
let mat = Material::of(ctx, layer);
let bounds = buf_bounds(&buf);
Some(Item {
layer,
order,
@ -338,6 +339,15 @@ fn scene_lights(ctx: &EvalCtx) -> Vec<LightState> {
/// Draw a run of consecutive 3D layers (bottom-to-top order) into `canvas`.
pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut Image) {
// Environment background layers: an infinitely distant sky behind the rest of the run.
if run.iter().any(|l| l.environment_background) {
for l in run.iter().filter(|l| l.environment_background) {
draw_sky(r, ctx, l, canvas);
}
let rest: Vec<&Layer> = run.iter().copied().filter(|l| !l.environment_background).collect();
draw_run(r, ctx, &rest, canvas);
return;
}
// 3D adjustment layers act on everything below them in the stack (like 2D ones): split.
if let Some(k) = run.iter().position(|l| l.switches.adjustment) {
draw_run(r, ctx, &run[..k], canvas);
@ -355,6 +365,23 @@ pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut
return;
}
let out = (canvas.width, canvas.height);
let g = gather(r, ctx, run, out);
let casters: Vec<&Item> = g.items.iter().chain(g.extra.iter()).filter(|i| i.mat.casts_shadows != 0 && !i.geos.is_empty()).collect();
composite(canvas, &g.items, &g.lights, &casters, run[0].id.0 as u32);
for l in run.iter().filter(|l| l.switches.quality == effectcraft_project::Quality::Wireframe) {
r.draw_layer(ctx, l, canvas, false);
}
}
/// The prepared planes of a run: drawn items (stack order), shadow casters outside the run, and
/// the lights.
struct Gathered<'a> {
items: Vec<Item<'a>>,
extra: Vec<Item<'a>>,
lights: Vec<LightState>,
}
fn gather<'a>(r: &Renderer<'a>, ctx: &EvalCtx<'a>, run: &[&'a Layer], out: (u32, u32)) -> Gathered<'a> {
// A collapsed precomp's 3D layers are lit by the outer comp's lights.
let lights = scene_lights(&r.collapse3d.map_or(*ctx, |c| c.parent));
let mut items: Vec<Item> = run
@ -391,11 +418,7 @@ pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut
.collect();
}
items.sort_by_key(|i| i.order);
let casters: Vec<&Item> = items.iter().chain(extra.iter()).filter(|i| i.mat.casts_shadows != 0 && !i.geos.is_empty()).collect();
composite(canvas, &items, &lights, &casters, run[0].id.0 as u32);
for l in run.iter().filter(|l| l.switches.quality == effectcraft_project::Quality::Wireframe) {
r.draw_layer(ctx, l, canvas, false);
}
Gathered { items, extra, lights }
}
/// A collapsed 3D precomp layer inside a 3D run: its nested layers become planes in this comp's
@ -615,6 +638,83 @@ fn occlusion(c: &Item, p: Vec3, l: &LightState) -> [f32; 3] {
out
}
// ------------------------------------------------------------------ accelerator export
/// A Classic 3D run prepared for an accelerator ([`Renderer::prepare_3d_run`]): every plane
/// with its geometry, material and buffer, ready for the per-pixel depth sort, lighting and
/// ray-cast shadows of [`draw_run`] on another device.
#[derive(Clone, Debug)]
pub struct Run3d {
/// Drawn planes in stack order, then shadow casters from outside the run (`draw` false).
pub planes: Vec<Plane3d>,
/// Indices (into `planes`) of the shadow casters, in the CPU's order.
pub casters: Vec<usize>,
pub lights: Vec<LightState>,
/// Dissolve seed of the run.
pub seed: u32,
}
/// One plane of a [`Run3d`].
#[derive(Clone, Debug)]
pub struct Plane3d {
/// The layer buffer (blending space; depth of field already applied).
pub buf: Arc<Buf>,
pub bicubic: bool,
/// One per motion-blur sub-sample (equal weights).
pub geos: Vec<Geo>,
pub mat: Material,
pub opacity: f32,
pub draw: bool,
/// Screen-space track matte factor (output size, row-major).
pub matte: Option<Arc<Vec<f32>>>,
pub preserve: bool,
/// World → layer, and the (blurred) buffer's layer-space bounds.
pub winv: Mat4,
pub bounds: [f64; 4],
/// Output pixel bounds of all geometries.
pub bbox: [i64; 4],
/// Stack order (coplanar fragments draw lower orders first).
pub order: usize,
pub layer_id: u32,
pub mode: BlendMode,
}
/// Prepare a run of 3D layers for an accelerator. `None` when the run must be drawn on the CPU
/// (adjustment, wireframe or environment background layers, Advanced 3D).
pub(crate) fn gpu_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u32)) -> Option<Run3d> {
if run.is_empty() || run.iter().any(|l| l.switches.adjustment || l.environment_background || l.switches.quality == effectcraft_project::Quality::Wireframe)
{
return None;
}
if ctx.comp.renderer == effectcraft_project::Renderer::Advanced3D && r.collapse3d.is_none() {
return None;
}
let g = gather(r, ctx, run, out);
let planes: Vec<Plane3d> = g
.items
.into_iter()
.chain(g.extra)
.map(|it| Plane3d {
layer_id: it.layer.id.0 as u32,
mode: it.layer.blend_mode,
buf: it.buf,
bicubic: it.bicubic,
geos: it.geos,
mat: it.mat,
opacity: it.opacity,
draw: it.draw,
matte: it.matte,
preserve: it.preserve,
winv: it.winv,
bounds: it.bounds,
bbox: it.bbox,
order: it.order,
})
.collect();
let casters = planes.iter().enumerate().filter(|(_, p)| p.mat.casts_shadows != 0 && !p.geos.is_empty()).map(|(i, _)| i).collect();
Some(Run3d { planes, casters, lights: g.lights, seed: run[0].id.0 as u32 })
}
// ------------------------------------------------------------------ auxiliary render pass
/// One surface seen through an output pixel (aux pass).
@ -793,3 +893,49 @@ pub(crate) fn aux_pass(r: &Renderer, ctx: &EvalCtx) -> effectcraft_raster::AuxCh
aux.manifests = vec![("CryptoObject".into(), manifest_obj), ("CryptoMaterial".into(), manifest_mat)];
aux
}
/// Rotation (radians) of the comp's first Environment light, which also turns its backdrop.
pub(crate) fn environment_rotation(ctx: &EvalCtx) -> f32 {
ctx.comp
.layers
.iter()
.find(|l| matches!(l.source, effectcraft_project::LayerSource::Light { kind: LightKind::Environment }) && l.is_active_at(ctx.time))
.and_then(|l| l.props.sub("lightOptions").map(|g| ctx.f(l, g, "rotation", 0.0)))
.unwrap_or(0.0)
.to_radians() as f32
}
/// Draw an Environment Light Background layer: its (equirectangular) image looked up by each
/// pixel's view direction through the camera, as an infinitely distant backdrop.
pub(crate) fn draw_sky(r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image) {
let Some(buf) = r.blend_layer_buf(ctx, layer) else { return };
let img = &buf.img;
if img.width == 0 || img.height == 0 {
return;
}
let op = (ctx.opacity(layer) as f32 * r.opacity_mul()).clamp(0.0, 1.0);
if op <= 0.0 {
return;
}
let cam = camera_for(r, ctx);
let (fwd, right, down) = (cam.forward(), cam.right(), cam.down());
let s = r.opts.scale.max(1e-9);
let (cw, ch) = (ctx.comp.width as f64, ctx.comp.height as f64);
let (ox, oy) = r.roi_offset().map_or((0.0, 0.0), |(x, y, _, _)| (x, y));
let rot = environment_rotation(ctx);
let (iw, ih) = (img.width as f64, img.height as f64);
canvas.rows_mut().for_each(|(y, row)| {
let cy = (y as f64 + 0.5 + oy) / s;
for (x, d) in row.iter_mut().enumerate() {
let cx = (x as f64 + 0.5 + ox) / s;
let dir = if cam.ortho { fwd } else { (fwd * cam.zoom + right * (cx - cw / 2.0) + down * (cy - ch / 2.0)).normalize() };
let (u, v) = super::adv::shade::equirect_uv([dir.x as f32, dir.y as f32, dir.z as f32], rot);
// Wrap horizontally across the seam.
let sx = (u as f64 * iw).clamp(0.5, iw - 0.5);
let sy = (v as f64 * ih).clamp(0.5, ih - 0.5);
let p = img.sample_bilinear(sx, sy);
let k = 1.0 - p[3] * op;
*d = [p[0] * op + d[0] * k, p[1] * op + d[1] * k, p[2] * op + d[2] * k, p[3] * op + d[3] * k];
}
});
}

View File

@ -17,6 +17,7 @@ mod tests;
mod tests_orient;
pub use camera::{CameraState, Dof, Rig, View3D, ViewCam, Views3D, active_camera, default_camera, default_view_cam};
pub use compose::{Geo as PlaneGeo, Plane3d, Run3d};
pub use light::{LightState, Material, lights_at};
use effectcraft_geom::Mat3;

View File

@ -435,6 +435,55 @@ pub fn parse_timecode(input: &str, rate: FrameRate, drop_frame: bool, current: i
})
}
/// Format a frame count as Feet + Frames (film footage counting) with `per_foot` frames per
/// foot (35 mm film: 16, 16 mm: 40): `0012+07`. Negative counts get a leading `-`.
pub fn format_feet_frames(frame: i64, per_foot: i64) -> String {
let pf = per_foot.max(1);
let sign = if frame < 0 { "-" } else { "" };
let a = frame.abs();
let w = if pf > 100 { 3 } else { 2 };
format!("{sign}{:04}+{:0w$}", a / pf, a % pf)
}
/// Parse a Feet + Frames field into a frame count: `12+07` (feet + frames; frames may overflow
/// a foot), `-1+04`, a bare frame count (`250`), or `+n` / `-n` frames relative to `current`.
pub fn parse_feet_frames(input: &str, per_foot: i64, current: i64) -> Result<i64, ParseError> {
let s = input.trim();
let bad = || ParseError(format!("not feet+frames: `{input}`"));
let num = |t: &str| -> Result<i64, ParseError> {
let t = t.trim();
if t.is_empty() {
return Ok(0);
}
if !t.bytes().all(|b| b.is_ascii_digit()) {
return Err(bad());
}
t.parse::<i64>().map_err(|_| ParseError("number too large".into()))
};
if s.is_empty() {
return Err(ParseError("empty feet+frames".into()));
}
let (sign, body) = match s.as_bytes()[0] {
b'-' => (-1, &s[1..]),
b'+' => (1, &s[1..]),
_ => (0, s),
};
match body.rfind('+') {
Some(i) => {
let v = num(&body[..i])? * per_foot.max(1) + num(&body[i + 1..])?;
// A sign before feet+frames is the sign of the count, not a relative move.
Ok(if sign < 0 { -v } else { v })
}
None => {
let v = num(body)?;
Ok(match sign {
0 => v,
k => current + k * v,
})
}
}
}
/// Common audio sample rates offered in sequence settings.
pub const SAMPLE_RATES: [i64; 6] = [32_000, 44_100, 48_000, 88_200, 96_000, 192_000];
@ -525,4 +574,29 @@ mod tests {
prop_assert_eq!(parse_timecode(&txt, r, false, 0).unwrap(), f);
}
}
#[test]
fn feet_and_frames() {
// 35 mm: 16 frames per foot; 16 mm: 40.
assert_eq!(format_feet_frames(0, 16), "0000+00");
assert_eq!(format_feet_frames(16 * 12 + 7, 16), "0012+07");
assert_eq!(format_feet_frames(95, 40), "0002+15");
assert_eq!(format_feet_frames(-20, 16), "-0001+04");
assert_eq!(parse_feet_frames("12+07", 16, 0), Ok(199));
assert_eq!(parse_feet_frames("0012+07", 16, 0), Ok(199));
assert_eq!(parse_feet_frames("2+15", 40, 0), Ok(95));
assert_eq!(parse_feet_frames("-1+04", 16, 0), Ok(-20));
assert_eq!(parse_feet_frames("1+20", 16, 0), Ok(36), "frames may overflow a foot");
assert_eq!(parse_feet_frames("250", 16, 0), Ok(250));
assert_eq!(parse_feet_frames("+10", 16, 100), Ok(110));
assert_eq!(parse_feet_frames("-10", 16, 100), Ok(90));
assert_eq!(parse_feet_frames("+", 16, 5), Ok(5));
assert!(parse_feet_frames("1:2", 16, 0).is_err());
assert!(parse_feet_frames("", 16, 0).is_err());
for f in [0, 1, 15, 16, 17, 1234, 99_999] {
for pf in [16, 40] {
assert_eq!(parse_feet_frames(&format_feet_frames(f, pf), pf, 0), Ok(f));
}
}
}
}

View File

@ -286,6 +286,10 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
("HDTV (Rec. 709)", json!("rec709")),
("Rec. 2020", json!("rec2020")),
("Display P3", json!("p3")),
("ACEScg", json!("acescg")),
("ACES2065-1", json!("aces2065")),
("Rec. 2100 PQ", json!("rec2100pq")),
("Rec. 2100 HLG", json!("rec2100hlg")),
],
f.color_profile.map_or(0, |c| 1 + effectcraft_engine::project::ColorSpace::ALL.iter().position(|x| *x == c).unwrap_or(0)),
),
@ -316,8 +320,27 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
Field::choice(
"timeDisplay",
"Time display style",
&[("Timecode", json!("timecode")), ("Frames", json!("frames"))],
usize::from(matches!(st.time_display, effectcraft_engine::project::TimeDisplayStyle::Frames)),
&[
("Timecode", json!("timecode")),
("Frames", json!("frames")),
("Feet + Frames (35mm)", json!("feet35")),
("Feet + Frames (16mm)", json!("feet16")),
],
{
use effectcraft_engine::project::TimeDisplayStyle as T;
match st.time_display {
T::Timecode => 0,
T::Frames => 1,
T::Feet35 => 2,
T::Feet16 => 3,
}
},
),
Field::choice(
"colorEngine",
"Color engine",
&[("Adobe-style built-in", json!("adobe")), ("OCIO (built-in ACES config)", json!("ocio"))],
usize::from(st.color_engine == effectcraft_engine::project::ColorEngine::Ocio),
),
Field::choice(
"workingSpace",
@ -328,11 +351,42 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
("HDTV (Rec. 709)", json!("rec709")),
("Rec. 2020", json!("rec2020")),
("Display P3", json!("p3")),
("ACEScg", json!("acescg")),
("ACES2065-1", json!("aces2065")),
],
st.working_space.map_or(0, |c| 1 + effectcraft_engine::project::ColorSpace::ALL.iter().position(|x| *x == c).unwrap_or(0)),
st.working_space.map_or(0, |c| 1 + effectcraft_engine::project::ColorSpace::WORKING.iter().position(|x| *x == c).unwrap_or(0)),
),
Field::bool("linearize", "Linearize working space", st.linearize),
Field::bool("blendLinear", "Blend colors using 1.0 gamma", st.blend_linear),
Field::choice(
"hdr",
"HDR on SDR displays and outputs",
&[("Clip", json!("clip")), ("Compand", json!("compand")), ("Tone map", json!("toneMap"))],
st.hdr as usize,
),
Field::choice(
"outputSpace",
"Output color space",
&[
("sRGB IEC61966-2.1 (default)", json!("default")),
("HDTV (Rec. 709)", json!("rec709")),
("Rec. 2020", json!("rec2020")),
("Display P3", json!("p3")),
("Rec. 2100 PQ (HDR)", json!("rec2100pq")),
("Rec. 2100 HLG (HDR)", json!("rec2100hlg")),
],
{
use effectcraft_engine::project::ColorSpace as C;
match st.output_space {
Some(C::Rec709) => 1,
Some(C::Rec2020) => 2,
Some(C::DisplayP3) => 3,
Some(C::Rec2100Pq) => 4,
Some(C::Rec2100Hlg) => 5,
_ => 0,
}
},
),
// Video Rendering and Effects ▸ Use.
Field::choice(
"renderer",

View File

@ -46,7 +46,7 @@ pub mod viewer_tools;
pub mod waveform;
use effectcraft_engine::Session;
use effectcraft_engine::project::{Comp, TimeDisplayStyle};
use effectcraft_engine::project::Comp;
use effectcraft_engine::time::Tick;
use egui::Rect;
@ -66,14 +66,10 @@ pub enum DragPayload {
Files(Vec<String>),
}
/// The current time formatted per project settings (timecode with `;` for drop-frame, or frames).
/// The current time formatted per project settings (timecode with `;` for drop-frame, frames
/// or Feet + Frames).
pub fn timecode(session: &Session, comp: &Comp, t: Tick) -> String {
let fr = comp.frame_rate;
match session.project.settings.time_display {
TimeDisplayStyle::Frames => format!("{:05}", fr.frame_at(t) + session.project.settings.frame_start),
// AE's display: `0:00:02:15` (non-drop-frame counting, as AE shows new comps).
TimeDisplayStyle::Timecode => effectcraft_engine::time::format_timecode_ae(fr.frame_at(t + comp.display_start), fr, false),
}
effectcraft_engine::commands::time::display_time(session, comp, t)
}
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rect) {

View File

@ -312,6 +312,70 @@ fn aux_views(
main
}
/// View ▸ Split with New Locked Viewer: the locked viewer on the left of the Composition panel
/// (its comp and 3D view stay put whatever comp is active). Returns the main viewer's rectangle.
fn locked_pane(app: &mut EffectcraftApp, ui: &mut egui::Ui, full: Rect, bg: Color32) -> Rect {
let Some(lv) = app.session.state.locked_viewer else { return full };
let Some(comp) = app.session.project.comp(lv.comp).cloned() else { return full };
let name = app.session.project.item(lv.comp).map(|i| i.name.clone()).unwrap_or_default();
let gap = 2.0;
let mid = full.center().x;
let r = Rect::from_min_max(full.min, pos2(mid - gap / 2.0, full.max.y));
let main = Rect::from_min_max(pos2(mid + gap / 2.0, full.min.y), full.max);
let ctx = ui.ctx().clone();
let p = ui.painter().clone();
p.rect_filled(Rect::from_min_max(pos2(r.max.x, full.min.y), pos2(main.min.x, full.max.y)), 0.0, Color32::from_black_alpha(200));
p.rect_filled(r, 0.0, bg);
let (cw, ch) = (comp.width as f32, comp.height as f32);
let fit = ((r.width() - 20.0) / cw).min((r.height() - 44.0) / ch).max(0.01);
let cr = Rect::from_center_size(r.center(), vec2(cw * fit, ch * fit));
let scale = (fit * ctx.pixels_per_point()).min(1.0) as f64;
let t = app.session.time_of(lv.comp);
let cam = (lv.view != View3D::ActiveCamera && comp.has_3d())
.then(|| app.session.state.views3d.get(&lv.comp).cloned().unwrap_or_default().cam(lv.view, comp.width as f64, comp.height as f64).state());
let dc = effectcraft_engine::viewer::DisplayColor::of(&app.session);
let key = {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
(app.session.revision, lv.comp.0, t.0, scale.to_bits(), format!("{cam:?}{}", dc.is_some())).hash(&mut h);
h.finish()
};
let id = egui::Id::new("viewer-locked");
let cached: Option<(u64, egui::TextureHandle)> = ctx.data(|d| d.get_temp(id));
let tex = match cached {
Some((k, tex)) if k == key => tex,
_ => {
let opts = effectcraft_engine::render::RenderOpts { scale, view: cam, ..Default::default() };
let img = app.session.render(lv.comp, t, opts);
let mut ci = crate::frames::to_color_image(&img);
if let Some(dc) = dc {
let mut px: Vec<[u8; 4]> = ci.pixels.iter().map(|c| c.to_array()).collect();
dc.apply(&mut px);
ci = egui::ColorImage::new(ci.size, px.into_iter().map(|a| Color32::from_rgba_premultiplied(a[0], a[1], a[2], a[3])).collect());
}
let tex = ctx.load_texture("viewer-locked", ci, egui::TextureOptions::LINEAR);
ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone())));
tex
}
};
let b = comp.background;
p.rect_filled(cr, 0.0, Color32::from_rgb((b[0] * 255.0) as u8, (b[1] * 255.0) as u8, (b[2] * 255.0) as u8));
p.image(tex.id(), cr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
p.rect_stroke(cr, 0.0, Stroke::new(1.0, Color32::from_black_alpha(160)), StrokeKind::Outside);
let label = format!("Locked: {name} \u{2014} {}", if comp.has_3d() { lv.view.label() } else { "Active Camera" });
p.text(r.left_top() + vec2(8.0, 8.0), Align2::LEFT_TOP, &label, Tokens::ui(11.0), Color32::from_white_alpha(210));
app.auto.add("viewer.locked", r, &label);
// Close the locked viewer.
let close = Rect::from_min_size(pos2(r.max.x - 26.0, r.min.y + 4.0), vec2(20.0, 20.0));
let resp = ui.interact(close, egui::Id::new("viewer-locked-close"), Sense::click());
p.text(close.center(), Align2::CENTER_CENTER, "\u{00d7}", Tokens::ui(14.0), if resp.hovered() { Color32::WHITE } else { Color32::from_white_alpha(170) });
app.auto.add("viewer.locked.close", close, "Close Locked Viewer");
if resp.clicked() {
let _ = app.session.execute("view.closeLockedViewer", serde_json::json!({}));
}
main
}
pub(crate) fn checker(p: &egui::Painter, r: Rect) {
let s = 10.0;
p.rect_filled(r, 0.0, Color32::from_gray(0xcc));
@ -380,6 +444,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let pasteboard = app.ui.viewer.pasteboard.map(|[r, g, b]| Color32::from_rgb(r, g, b)).unwrap_or(t.pasteboard);
// View ▸ Switch View Layout: extra views (Top / Front / Right) beside the main view, which
// keeps the overlays and the interaction.
let full = locked_pane(app, ui, full, pasteboard);
let area = aux_views(app, ui, &comp, cid, full, pasteboard);
p.rect_filled(area, 0.0, pasteboard);
// View ▸ Show Rulers takes a strip on the top and left.

View File

@ -307,8 +307,13 @@ pub(crate) fn showing_snapshot(app: &EffectcraftApp, ctx: &egui::Context) -> boo
app.session.snapshot.is_some() && (app.session.state.viewer.show_snapshot || f5)
}
fn transformed(img: &egui::ColorImage, ch: Channel, colorized: bool, stops: f32) -> egui::ColorImage {
/// Show Channel and exposure, then the display colour conversion (View ▸ Use Display Color
/// Management / Simulate Output).
fn transformed(img: &egui::ColorImage, ch: Channel, colorized: bool, stops: f32, dc: Option<vw::DisplayColor>) -> egui::ColorImage {
let mut px: Vec<[u8; 4]> = img.pixels.iter().map(|c| c.to_array()).collect();
if let Some(dc) = dc.filter(|_| matches!(ch, Channel::Rgb | Channel::RgbStraight)) {
dc.apply(&mut px);
}
vw::display_transform(&mut px, ch, colorized, stops);
egui::ColorImage::new(img.size, px.into_iter().map(|a| Color32::from_rgba_premultiplied(a[0], a[1], a[2], a[3])).collect())
}
@ -336,14 +341,18 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter:
}
return;
}
let plain = opts.channel == Channel::Rgb && opts.exposure == 0.0;
let dc = vw::DisplayColor::of(&app.session);
// The display conversion's inputs, for the texture caches.
let dc_key = format!("{:?}{:?}{}", dc.is_some(), opts.simulation, app.session.prefs.previews.display_profile)
+ &format!("{:?}{:?}{}", app.session.project.settings.working_space, app.session.project.settings.output_space, opts.display_color_management);
let plain = opts.channel == Channel::Rgb && opts.exposure == 0.0 && dc.is_none();
let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
if snap {
let Some(s) = app.session.snapshot.clone() else { return };
let key = {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
(std::sync::Arc::as_ptr(&s.image) as usize, opts.channel.id(), opts.colorized, opts.exposure.to_bits()).hash(&mut h);
(std::sync::Arc::as_ptr(&s.image) as usize, opts.channel.id(), opts.colorized, opts.exposure.to_bits(), &dc_key).hash(&mut h);
h.finish()
};
let id = egui::Id::new("viewer-snapshot-tex");
@ -351,7 +360,7 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter:
Some((k, tex)) if k == key => tex,
_ => {
let img = crate::frames::to_color_image(&s.image);
let img = if plain { img } else { transformed(&img, opts.channel, opts.colorized, opts.exposure) };
let img = if plain { img } else { transformed(&img, opts.channel, opts.colorized, opts.exposure, dc) };
let tex = ctx.load_texture("viewer-snapshot", img, egui::TextureOptions::LINEAR);
ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone())));
tex
@ -394,14 +403,14 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter:
let key = {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
(std::sync::Arc::as_ptr(&src) as usize, opts.channel.id(), opts.colorized, opts.exposure.to_bits()).hash(&mut h);
(std::sync::Arc::as_ptr(&src) as usize, opts.channel.id(), opts.colorized, opts.exposure.to_bits(), &dc_key).hash(&mut h);
h.finish()
};
let id = egui::Id::new("viewer-display-tex");
let tex = match ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(id)) {
Some((k, tex)) if k == key => tex,
_ => {
let tex = ctx.load_texture("viewer-display", transformed(&src, opts.channel, opts.colorized, opts.exposure), egui::TextureOptions::LINEAR);
let tex = ctx.load_texture("viewer-display", transformed(&src, opts.channel, opts.colorized, opts.exposure, dc), egui::TextureOptions::LINEAR);
ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone())));
tex
}

View File

@ -101,9 +101,15 @@ transparent ones are sorted back to front and blended. The software rasteriser
`Accelerator::raster_3d` with a wgpu render pipeline (`advanced3d.wgsl`) that repeats the
shading step for step, and tests compare the two. Resolve, depth of field (a gather blur by
each pixel's circle of confusion from the camera's Depth of Field settings) and the conversion
back to the working encoding run on the CPU for both. Not yet: motion blur, track mattes and
blend modes inside an Advanced 3D run, collapsed 3D precomps (drawn flattened), morph targets,
extruded strokes.
back to the working encoding run on the CPU for both. **Motion blur** renders the scene at
sub-samples spread over the comp's shutter (angle, phase, samples per frame) — layers with the
Motion Blur switch, the camera and the lights at the sub-sample time, other layers at the frame
time — and averages them. Layers with a **blend mode, a track matte or Preserve Transparency**
are rendered on their own through the same camera and lights, hidden where the rest of the run
is nearer, and composited from the farthest to the nearest through the 2D path (a 3D matte is
itself drawn through the camera). **Environment Light Background** layers (Layer ▸ Light) draw
their equirectangular image behind the run, looked up by each pixel's view direction. Not yet:
collapsed 3D precomps (drawn flattened), morph targets, extruded strokes.
A **layer cache** keeps each layer's finished pixels (source, masks and effects) keyed by a hash of
its evaluated inputs, excluding the transform. Static and transform-only layers render once;
@ -157,17 +163,31 @@ accelerator is attached and the project's renderer is the GPU). A GPU frame walk
sampling (nearest, bilinear, Catmull-Rom bicubic, the same minification pre-filter), motion-blur
sub-samples are accumulated, and track mattes, Preserve Transparency, layer style passes,
knockout, all 38 blend modes (a WGSL port of `color::blend`), the 8/16 bpc clamp-and-quantise steps
and colour-space conversions run on the GPU. 3D runs, adjustment layers and wireframes run on the
CPU between GPU steps (read back, draw, upload). GPU effects (`effects::GPU_EFFECTS`: Gaussian,
Fast Box and Directional Blur, Glow, Levels, Curves, Hue/Saturation, Tint, Fill, Gradient Ramp,
Fractal Noise, Drop Shadow, Brightness & Contrast, Exposure, Invert, Transform) repeat the CPU
effect's steps (padding, box radii, parameters) as kernels; consecutive GPU effects run as one
and colour-space conversions run on the GPU. Classic 3D runs composite on the GPU too
(`gpu::classic3d`): the render crate prepares the planes (`Renderer::prepare_3d_run`: layer buffers
with their bokeh depth of field, homographies per motion-blur sub-sample, materials, lights, track
mattes), the GPU packs the buffers into one atlas texture and one kernel does the per-pixel
fragment sort (intersecting planes, coplanar stack order), Blinn-Phong lighting, ray-cast shadows
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
effects (`effects::GPU_EFFECTS`, 58 of them: blurs, colour, keying incl. Key Light, distortion,
transitions, generators, noise and grain; see [effects.md](effects.md)) repeat the CPU effect's
steps (padding, box radii, parameters, hashes) as kernels; consecutive GPU effects run as one
chain with one upload and one readback. Tests render scenes on both paths and compare them
(≤ 1/255 at 8 bpc, ≤ 1e-3 at 32 bpc); they skip without an adapter. The desktop viewer builds the
`Gpu` on egui-wgpu's device and shows frames from GPU textures without reading them back
(`ui-egui::frames`); headless renders, the CLI (unless `--gpu`) and CI use the CPU. On the web
(WebGPU) the GPU composites viewer frames; steps that need a readback fall back to the CPU (the
Info panel's pixel readout reads GPU frames back asynchronously).
Info panel's pixel readout reads GPU frames back asynchronously). **GPU particles**
(`effects::psim`, `gpu::particles`): CC Particle World, CC Particle Systems II and Particle
Playground cannons without interacting forces evolve every particle independently, so the GPU
backend (reached through `EffectHost::particles` when the GPU compositor is active) simulates one
particle per invocation from the CPU's birth schedule and keeps per-key state checkpoints on the
GPU, like `SimCache`; the CPU simulation stays the oracle (tests compare ids and positions).
`effectcraft-cli bench --gpu` reports CPU vs GPU ms/frame, speed-up and pixel agreement for every
comp plus an adjustment-layer comp.
**Colour and bit depth** (`crates/render/src/color.rs`, `crates/color/src/space.rs`). Pixels are
`f32`, but 8 and 16 bpc projects clamp and quantise each layer after its source and masks and

View File

@ -3,11 +3,11 @@
<!-- Generated from the effect registry (crates/effects/src/catalog.rs); do not edit by hand.
Regenerate: UPDATE_DOCS=1 cargo test -p effectcraft-effects --lib effects_doc_is_current -->
EffectCraft ships 303 effects with 2434 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over.
EffectCraft ships 304 effects with 2446 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over.
- **GPU**: 24 effects also run on the GPU compositor with identical results.
- **32**: 303 effects process 32-bit float (HDR, overbright) pixels without clamping.
- **Status**: 261 implemented in full, 42 partial (what is missing is listed).
- **GPU**: 59 effects also run on the GPU compositor with identical results.
- **32**: 304 effects process 32-bit float (HDR, overbright) pixels without clamping.
- **Status**: 262 implemented in full, 42 partial (what is missing is listed).
Effects are addressed by id (`ec.<category>.<name>`) or display name in commands, scripts and the control channel, and every parameter by its id (`effects/#1/blurriness`).
@ -49,16 +49,16 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| Bilateral Blur | `ec.blur.bilateral` | 3 | | 32 | Implemented |
| CC Cross Blur | `ec.blur.cccross` | 3 | | 32 | Implemented |
| CC Radial Blur | `ec.blur.ccradial` | 4 | | 32 | Implemented |
| CC Radial Fast Blur | `ec.blur.ccradialfast` | 3 | | 32 | Implemented |
| CC Radial Fast Blur | `ec.blur.ccradialfast` | 3 | GPU | 32 | Implemented |
| CC Vector Blur | `ec.blur.ccvector` | 4 | | 32 | Implemented |
| Camera Lens Blur | `ec.blur.cameralens` | 10 | | 32 | Partial: no Diffraction Fringe or Blur Map layer |
| Camera Lens Blur | `ec.blur.cameralens` | 10 | GPU | 32 | Partial: no Diffraction Fringe or Blur Map layer |
| Camera-Shake Deblur | `ec.blur.camerashakedeblur` | 8 | | 32 | Implemented |
| Channel Blur | `ec.blur.channel` | 6 | | 32 | Implemented |
| Compound Blur | `ec.blur.compound` | 4 | | 32 | Implemented |
| Directional Blur | `ec.blur.directional` | 2 | GPU | 32 | Implemented |
| Fast Box Blur | `ec.blur.fastbox` | 4 | GPU | 32 | Implemented |
| Gaussian Blur | `ec.blur.gaussian` | 3 | GPU | 32 | Implemented |
| Radial Blur | `ec.blur.radial` | 4 | | 32 | Implemented |
| Radial Blur | `ec.blur.radial` | 4 | GPU | 32 | Implemented |
| Sharpen | `ec.blur.sharpen` | 1 | | 32 | Implemented |
| Smart Blur | `ec.blur.smart` | 3 | | 32 | Implemented |
| Unsharp Mask | `ec.blur.unsharp` | 3 | | 32 | Implemented |
@ -88,7 +88,7 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| Auto Color | `ec.color.autocolor` | 6 | | 32 | Implemented |
| Auto Contrast | `ec.color.autocontrast` | 5 | | 32 | Implemented |
| Auto Levels | `ec.color.autolevels` | 5 | | 32 | Implemented |
| Black & White | `ec.color.blackwhite` | 8 | | 32 | Implemented |
| Black & White | `ec.color.blackwhite` | 8 | GPU | 32 | Implemented |
| Brightness & Contrast | `ec.color.brightnesscontrast` | 3 | GPU | 32 | Implemented |
| Broadcast Colors | `ec.color.broadcast` | 3 | | 32 | Implemented |
| CC Color Neutralizer | `ec.color.cccolorneutralizer` | 11 | | 32 | Implemented |
@ -97,12 +97,12 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| CC Toner | `ec.color.cctoner` | 7 | | 32 | Implemented |
| Change Color | `ec.color.changecolor` | 9 | | 32 | Implemented |
| Change to Color | `ec.color.changetocolor` | 9 | | 32 | Implemented |
| Channel Mixer | `ec.color.channelmixer` | 13 | | 32 | Implemented |
| Color Balance | `ec.color.colorbalance` | 10 | | 32 | Implemented |
| Channel Mixer | `ec.color.channelmixer` | 13 | GPU | 32 | Implemented |
| Color Balance | `ec.color.colorbalance` | 10 | GPU | 32 | Implemented |
| Color Balance (HLS) | `ec.color.colorbalancehls` | 3 | | 32 | Implemented |
| Color Link | `ec.color.colorlink` | 6 | | 32 | Implemented |
| Color Stabilizer | `ec.color.colorstabilizer` | 6 | | 32 | Implemented |
| Colorama | `ec.color.colorama` | 5 | | 32 | Partial: no Add Phase, editable Output Cycle, Modify, Pixel Selection or Masking |
| Colorama | `ec.color.colorama` | 5 | GPU | 32 | Partial: no Add Phase, editable Output Cycle, Modify, Pixel Selection or Masking |
| Curves | `ec.color.curves` | 0 | GPU | 32 | Partial: no Channel popup |
| Equalize | `ec.color.equalize` | 2 | | 32 | Implemented |
| Exposure | `ec.color.exposure` | 14 | GPU | 32 | Implemented |
@ -111,19 +111,19 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| Leave Color | `ec.color.leavecolor` | 5 | | 32 | Implemented |
| Levels | `ec.color.levels` | 7 | GPU | 32 | Partial: master channel only (no Channel popup) |
| Levels (Individual Controls) | `ec.color.levelsic` | 27 | | 32 | Implemented |
| Lumetri Color | `ec.color.lumetri` | 38 | | 32 | Partial: no HSL Secondary, hue/saturation curves, HDR mode or look files |
| Lumetri Color | `ec.color.lumetri` | 38 | GPU | 32 | Partial: no HSL Secondary, hue/saturation curves, HDR mode or look files |
| OCIO CDL Transform | `ec.color.ociocdl` | 12 | | 32 | Implemented |
| OCIO Color Space Transform | `ec.color.ociocolorspace` | 4 | | 32 | Implemented |
| OCIO Display Transform | `ec.color.ociodisplay` | 5 | | 32 | Implemented |
| OCIO Color Space Transform | `ec.color.ociocolorspace` | 7 | | 32 | Implemented |
| OCIO Display Transform | `ec.color.ociodisplay` | 9 | | 32 | Implemented |
| OCIO File Transform | `ec.color.ociofile` | 4 | | 32 | Implemented |
| OCIO Look Transform | `ec.color.ociolook` | 6 | | 32 | Implemented |
| PS Arbitrary Map | `ec.color.psarbitrarymap` | 2 | | 32 | Implemented |
| Photo Filter | `ec.color.photofilter` | 4 | | 32 | Implemented |
| Selective Color | `ec.color.selectivecolor` | 37 | | 32 | Partial: simplified Colors / Details layout |
| Selective Color | `ec.color.selectivecolor` | 37 | GPU | 32 | Partial: simplified Colors / Details layout |
| Shadow/Highlight | `ec.color.shadowhighlight` | 14 | | 32 | Implemented |
| Tint | `ec.color.tint` | 3 | GPU | 32 | Implemented |
| Tritone | `ec.color.tritone` | 4 | | 32 | Implemented |
| Vibrance | `ec.color.vibrance` | 2 | | 32 | Implemented |
| Tritone | `ec.color.tritone` | 4 | GPU | 32 | Implemented |
| Vibrance | `ec.color.vibrance` | 2 | GPU | 32 | Implemented |
| Video Limiter | `ec.color.videolimiter` | 5 | | 32 | Implemented |
## Distort
@ -131,13 +131,13 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| Effect | Id | Params | GPU | 32 | Status |
|---|---|---:|:-:|:-:|---|
| Bezier Warp | `ec.distort.bezierwarp` | 13 | | 32 | Implemented |
| Bulge | `ec.distort.bulge` | 6 | | 32 | Implemented |
| Bulge | `ec.distort.bulge` | 6 | GPU | 32 | Implemented |
| CC Bend It | `ec.distort.ccbendit` | 4 | | 32 | Implemented |
| CC Bender | `ec.distort.ccbender` | 5 | | 32 | Implemented |
| CC Blobbylize | `ec.distort.ccblobbylize` | 15 | | 32 | Implemented |
| CC Flo Motion | `ec.distort.ccflomotion` | 7 | | 32 | Implemented |
| CC Griddler | `ec.distort.ccgriddler` | 5 | | 32 | Implemented |
| CC Lens | `ec.distort.cclens` | 3 | | 32 | Implemented |
| CC Lens | `ec.distort.cclens` | 3 | GPU | 32 | Implemented |
| CC Page Turn | `ec.distort.ccpageturn` | 7 | | 32 | Implemented |
| CC Power Pin | `ec.distort.ccpowerpin` | 10 | | 32 | Implemented |
| CC Ripple Pulse | `ec.distort.ccripplepulse` | 5 | | 32 | Implemented |
@ -148,27 +148,27 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| CC Tiler | `ec.distort.cctiler` | 3 | | 32 | Implemented |
| Corner Pin | `ec.distort.cornerpin` | 4 | | 32 | Implemented |
| Detail-preserving Upscale | `ec.distort.upscale` | 4 | | 32 | Implemented |
| Displacement Map | `ec.distort.displacementmap` | 8 | | 32 | Implemented |
| Displacement Map | `ec.distort.displacementmap` | 8 | GPU | 32 | Implemented |
| Liquify | `ec.distort.liquify` | 12 | | 32 | Implemented |
| Magnify | `ec.distort.magnify` | 10 | | 32 | Implemented |
| Mesh Warp | `ec.distort.meshwarp` | 3 | | 32 | Implemented |
| Mesh Warp | `ec.distort.meshwarp` | 3 | GPU | 32 | Implemented |
| Mirror | `ec.distort.mirror` | 2 | | 32 | Implemented |
| Offset | `ec.distort.offset` | 2 | | 32 | Implemented |
| Optics Compensation | `ec.distort.opticscompensation` | 5 | | 32 | Partial: no Optimal Pixels |
| Polar Coordinates | `ec.distort.polar` | 2 | | 32 | Implemented |
| Puppet | `ec.distort.puppet` | 1 | | 32 | Implemented |
| Reshape | `ec.distort.reshape` | 6 | | 32 | Partial: no correspondence points |
| Ripple | `ec.distort.ripple` | 7 | | 32 | Implemented |
| Ripple | `ec.distort.ripple` | 7 | GPU | 32 | Implemented |
| Rolling Shutter Repair | `ec.distort.rollingshutterrepair` | 5 | | 32 | Partial: Pixel Motion Detail has no effect |
| Smear | `ec.distort.smear` | 8 | | 32 | Implemented |
| Spherize | `ec.distort.spherize` | 2 | | 32 | Implemented |
| Transform | `ec.distort.transform` | 10 | GPU | 32 | Partial: no shutter-angle motion blur |
| Turbulent Displace | `ec.distort.turbulentdisplace` | 11 | | 32 | Partial: no locked pinning variants |
| Twirl | `ec.distort.twirl` | 3 | | 32 | Implemented |
| Turbulent Displace | `ec.distort.turbulentdisplace` | 11 | GPU | 32 | Partial: no locked pinning variants |
| Twirl | `ec.distort.twirl` | 3 | GPU | 32 | Implemented |
| Twirl (Legacy) | `ec.distort.twirllegacy` | 3 | | 32 | Implemented |
| Warp | `ec.distort.warp` | 5 | | 32 | Implemented |
| Warp Stabilizer | `ec.distort.warpstabilizer` | 20 | | 32 | Implemented |
| Wave Warp | `ec.distort.wavewarp` | 7 | | 32 | Implemented |
| Wave Warp | `ec.distort.wavewarp` | 7 | GPU | 32 | Implemented |
## Expression Controls
@ -182,12 +182,13 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| Layer Control | `ec.control.layer` | 1 | GPU | 32 | Implemented |
| Point Control | `ec.control.point` | 1 | GPU | 32 | Implemented |
| Slider Control | `ec.control.slider` | 1 | GPU | 32 | Implemented |
| Stereo 3D Controls | `ec.control.stereo3d` | 5 | GPU | 32 | Implemented |
## Generate
| Effect | Id | Params | GPU | 32 | Status |
|---|---|---:|:-:|:-:|---|
| 4-Color Gradient | `ec.generate.fourcolor` | 12 | | 32 | Implemented |
| 4-Color Gradient | `ec.generate.fourcolor` | 12 | GPU | 32 | Implemented |
| Advanced Lightning | `ec.generate.advancedlightning` | 16 | | 32 | Partial: no Alpha Obstacle; most Expert Settings missing |
| Audio Spectrum | `ec.generate.audiospectrum` | 23 | | 32 | Implemented |
| Audio Waveform | `ec.generate.audiowaveform` | 17 | | 32 | Implemented |
@ -197,15 +198,15 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| CC Light Rays | `ec.generate.cclightrays` | 8 | | 32 | Implemented |
| CC Light Sweep | `ec.generate.cclightsweep` | 9 | | 32 | Implemented |
| CC Threads | `ec.generate.ccthreads` | 8 | | 32 | Implemented |
| Cell Pattern | `ec.generate.cellpattern` | 14 | | 32 | Partial: HQ variants render like the standard ones |
| Checkerboard | `ec.generate.checkerboard` | 10 | | 32 | Implemented |
| Cell Pattern | `ec.generate.cellpattern` | 14 | GPU | 32 | Partial: HQ variants render like the standard ones |
| Checkerboard | `ec.generate.checkerboard` | 10 | GPU | 32 | Implemented |
| Circle | `ec.generate.circle` | 11 | | 32 | Implemented |
| Ellipse | `ec.generate.ellipse` | 8 | | 32 | Implemented |
| Eyedropper Fill | `ec.generate.eyedropperfill` | 5 | | 32 | Implemented |
| Fill | `ec.generate.fill` | 3 | GPU | 32 | Partial: no Fill Mask / All Masks / feather |
| Fractal | `ec.generate.fractal` | 21 | | 32 | Implemented |
| Gradient Ramp | `ec.generate.gradientramp` | 7 | GPU | 32 | Implemented |
| Grid | `ec.generate.grid` | 12 | | 32 | Implemented |
| Grid | `ec.generate.grid` | 12 | GPU | 32 | Implemented |
| Lens Flare | `ec.generate.lensflare` | 4 | | 32 | Implemented |
| Paint Bucket | `ec.generate.paintbucket` | 11 | | 32 | Implemented |
| Radio Waves | `ec.generate.radiowaves` | 17 | | 32 | Partial: no Image Contours or Mask wave types, Parameters Are Set At, reflection or stroke profile |
@ -243,8 +244,8 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| Extract | `ec.key.extract` | 6 | | 32 | Implemented |
| Inner/Outer Key | `ec.key.innerouter` | 10 | | 32 | Partial: one additional mask per side; no cleanup strokes |
| Key Cleaner | `ec.key.keycleaner` | 4 | | 32 | Implemented |
| Key Light | `ec.keying.keylight` | 42 | | 32 | Partial: no Source Crops X/Y Method or Edge Colour; no colour suppression / balancing |
| Linear Color Key | `ec.key.linearcolor` | 6 | | 32 | Implemented |
| Key Light | `ec.keying.keylight` | 42 | GPU | 32 | Partial: no Source Crops X/Y Method or Edge Colour; no colour suppression / balancing |
| Linear Color Key | `ec.key.linearcolor` | 6 | GPU | 32 | Implemented |
| Screen Key | `ec.key.screen` | 11 | | 32 | Implemented |
| Unmult | `ec.key.unmult` | 6 | | 32 | Implemented |
@ -263,14 +264,14 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| Effect | Id | Params | GPU | 32 | Status |
|---|---|---:|:-:|:-:|---|
| Add Grain | `ec.noise.addgrain` | 22 | | 32 | Partial: no preview region, presets, channel balance or temporal controls |
| Add Grain | `ec.noise.addgrain` | 22 | GPU | 32 | Partial: no preview region, presets, channel balance or temporal controls |
| Curl Noise | `ec.noise.curlnoise` | 10 | | 32 | Implemented |
| Dust & Scratches | `ec.noise.dustscratches` | 3 | | 32 | Implemented |
| Fractal Noise | `ec.noise.fractal` | 26 | GPU | 32 | Implemented |
| Match Grain | `ec.noise.matchgrain` | 25 | | 32 | Partial: no preview region, presets, sampling or temporal controls |
| Median | `ec.noise.median` | 2 | | 32 | Implemented |
| Median (Legacy) | `ec.noise.medianlegacy` | 2 | | 32 | Implemented |
| Noise | `ec.noise.noise` | 3 | | 32 | Implemented |
| Noise | `ec.noise.noise` | 3 | GPU | 32 | Implemented |
| Noise Alpha | `ec.noise.noisealpha` | 8 | | 32 | Implemented |
| Noise HLS | `ec.noise.noisehls` | 6 | | 32 | Implemented |
| Noise HLS Auto | `ec.noise.noisehlsauto` | 6 | | 32 | Implemented |
@ -353,10 +354,10 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| CC Vignette | `ec.stylize.ccvignette` | 4 | | 32 | Implemented |
| Cartoon | `ec.stylize.cartoon` | 11 | | 32 | Partial: no Edge Enhancement |
| Color Emboss | `ec.stylize.coloremboss` | 4 | | 32 | Implemented |
| Emboss | `ec.stylize.emboss` | 4 | | 32 | Implemented |
| Find Edges | `ec.stylize.findedges` | 2 | | 32 | Implemented |
| Emboss | `ec.stylize.emboss` | 4 | GPU | 32 | Implemented |
| Find Edges | `ec.stylize.findedges` | 2 | GPU | 32 | Implemented |
| Glow | `ec.stylize.glow` | 13 | GPU | 32 | Partial: no Glow Operation or arbitrary colour map |
| Mosaic | `ec.stylize.mosaic` | 3 | | 32 | Implemented |
| Mosaic | `ec.stylize.mosaic` | 3 | GPU | 32 | Implemented |
| Motion Tile | `ec.stylize.motiontile` | 8 | | 32 | Implemented |
| Posterize | `ec.stylize.posterize` | 1 | | 32 | Implemented |
| Roughen Edges | `ec.stylize.roughenedges` | 13 | | 32 | Implemented |
@ -401,11 +402,11 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
| CC Twister | `ec.transition.cctwister` | 5 | | 32 | Implemented |
| CC WarpoMatic | `ec.transition.ccwarpomatic` | 8 | | 32 | Implemented |
| Card Wipe | `ec.transition.cardwipe` | 12 | | 32 | Partial: 2D flip only; no back layer picker, camera, lighting or jitter |
| Gradient Wipe | `ec.transition.gradientwipe` | 5 | | 32 | Implemented |
| Gradient Wipe | `ec.transition.gradientwipe` | 5 | GPU | 32 | Implemented |
| Iris Wipe | `ec.transition.iriswipe` | 7 | | 32 | Implemented |
| Linear Wipe | `ec.transition.linearwipe` | 3 | | 32 | Implemented |
| Radial Wipe | `ec.transition.radialwipe` | 5 | | 32 | Implemented |
| Venetian Blinds | `ec.transition.venetian` | 4 | | 32 | Implemented |
| Linear Wipe | `ec.transition.linearwipe` | 3 | GPU | 32 | Implemented |
| Radial Wipe | `ec.transition.radialwipe` | 5 | GPU | 32 | Implemented |
| Venetian Blinds | `ec.transition.venetian` | 4 | GPU | 32 | Implemented |
## Utility

View File

@ -20,12 +20,9 @@ Animation 94%, Masks 94%, Preview 94%, Interface 93%, Shapes 93%, 3D 91%, Audio
Tracking 88%, Effects 85%, Web 65%.
What is left, in priority order: the web app's depth (threads, storage, audio, non-blocking
renders); stroke taper/wave and multi-segment dashes; variable mask feather points; camera iris/bokeh and focus-link commands; GPU
rendering of 3D runs and adjustment layers; Render Queue field render, crop/resize and templates;
approximated effects (Subspace Warp, Key Cleaner) and Liquify's viewer brush; Advanced 3D motion blur
and blend modes; a few viewer and colour-management menu items; OpenType features and variable
font axes; AI/EPS/PDF import; more codecs; 59 preferences not yet wired; ScriptUI; face
tracking; and the "better than After Effects" items (a plug-in API, branching history, GPU particles).
renders); stroke taper/wave and multi-segment dashes; variable mask feather points; camera iris/bokeh and focus-link commands; Render Queue field render, crop/resize and templates;
approximated effects (Subspace Warp, Key Cleaner) and Liquify's viewer brush; OpenType features and variable
font axes; AI/EPS/PDF import; more codecs; 59 preferences not yet wired; ScriptUI; face tracking; and the "better than After Effects" items (a plug-in API, branching history; GPU particles landed in M12.7).
The sections below are the original audit (morning of 2 October, ≈ 64%) and its updates, kept for
history.
@ -119,12 +116,12 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.)
| Animation | 67% | 10.0 | puppet, 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 | ≈ 91% | 0.8 | no OpenType feature panel, no extruded strokes, variable-axis animation changes outlines but not advances (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 | 85% | 1.0 | viewer frames and Roto Brush propagation still on the page's thread; GPU effects in the browser (browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2) |
| 3D | 78% | 9.3 | multi-view layouts, Advanced 3D motion blur and blend modes (and iris shapes in Advanced 3D's depth of field), cameras/lights from models; 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 (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6 |
| Effects | ≈ 80% | 4.5 | GPU versions of more effects 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) |
| 3D | 85% | 6.5 | multi-view layouts, iris shapes in Advanced 3D's depth of field, collapsed 3D precomps in Advanced 3D, the Extended Viewer; 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 (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6 |
| Effects | ≈ 80% | 4.0 | GPU versions of the remaining effects (58 run on the GPU since M12.7) 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 | a richer Learn area (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 | ≈ 60% | 6.5 | 8/16/32-bit pipeline and colour management, auto-save, folder moves (proxies and Interpret Footage fields / pixel aspect / alpha guess landed: PRJ-8, PRJ-3) |
| 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 | 62% | 5.0 | GPU 3D and adjustment layers (persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) |
| Preview | 66% | 4.0 | GPU bokeh depth of field, wireframes and Advanced 3D compositing (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 | ≈ 85% | 2.5 | face tracking, Subspace Warp's mesh warp, lens distortion in the camera solve (Rolling Shutter Repair landed in M9.11; point tracker, mask tracking, Warp Stabilizer and the 3D Camera Tracker landed in M6.x / M12.5 / M12.6) |
| Paint | 0% | 4.0 | Brush, Clone Stamp, Eraser |
@ -142,7 +139,7 @@ Mocha and Cineware are third-party and not counted.
|---|---|---|
| 3D Channel | 3D Channel Extract, Cryptomatte, Depth Matte, Depth of Field, EXtractoR, Fog 3D, ID Matte, IDentifier | Read auxiliary channels: multi-layer OpenEXR (any `layer.channel`, Cryptomatte manifests from the header) or the compositor's own depth / layer-ID / normal / UV / Cryptomatte pass for precomps of 3D comps. |
| Immersive Video | VR Blur, VR Chromatic Aberrations, VR Color Gradients, VR Converter, VR De-Noise, VR Digital Glitch, VR Fractal Noise, VR Glow, VR Plane to Sphere, VR Rotate Sphere, VR Sharpen, VR Sphere to Plane | Seam-aware equirectangular processing (wrap-around, pole continuation, latitude-scaled filters), mono / over-under / side-by-side layouts; VR Converter between equirectangular, cube maps (4:3, 3:2, 6:1), sphere map and 2D. |
| Color Correction | OCIO CDL / Color Space / Display / File / Look Transform, Color Stabilizer | Built-in minimal OCIO-style config (ACES2065-1, ACEScg, ACEScct, sRGB, Rec.709, Rec.2020, Display P3, linear variants, XYZ) from published matrices and transfer functions; `.cube` / `.3dl` / `.csp` LUTs and ASC `.cc` / `.ccc` / `.cdl`. Custom `.ocio` config files are not read yet. |
| Color Correction | OCIO CDL / Color Space / Display / File / Look Transform, Color Stabilizer | Built-in minimal OCIO-style config (ACES2065-1, ACEScg, ACEScct, sRGB, Rec.709, Rec.2020, Display P3, linear variants, XYZ) from published matrices and transfer functions; `.cube` / `.3dl` / `.csp` LUTs and ASC `.cc` / `.ccc` / `.cdl`. Custom `.ocio` config files are read (Configuration ▸ Custom: colorspaces, roles, displays/views; Matrix, File, Exponent, Log, LogAffine, CDL, Range and Group transforms). |
| Distort | CC Flo Motion, Liquify, Rolling Shutter Repair | Liquify strokes are effect data replayed into a displacement mesh (`liquify.stroke` / `liquify.clear` commands; no interactive viewer brush yet). Rolling Shutter Repair uses the KLT tracker. |
| Blur & Sharpen | Camera-Shake Deblur, CC Radial Blur | Deblur substitutes aligned patches from sharper neighbouring frames. |
| Audio | Compressor, Distortion, Gate | Applied in the mixdown like the other audio effects. |
@ -190,8 +187,8 @@ Browser in the web app.
1. ~~On-canvas text editing and per-character styles~~ (landed: M9.9–M9.10).
2. Effect Controls widgets: angle dial, point crosshair, eyedropper, curves and levels editors.
3. ~~Viewer basics: snapping, rulers, channel view, snapshots, exposure, a drawable region of interest.~~ (M0.13)
4. ~~A GPU (wgpu) compositor, then GPU effects~~ (M12.2: 2D compositing and 16 GPU effects; 3D runs
and adjustment layers still composite on the CPU).
4. ~~A GPU (wgpu) compositor, then GPU effects~~ (M12.2: 2D compositing and 16 GPU effects; M12.7:
Classic 3D runs, adjustment layers, 58 GPU effects and GPU particles).
5. ~~Pen tool for shape paths, shape vertex editing, free transform~~ (M6.5); the Layer viewer.
6. ~~Motion-path handles in the viewer; graph editor transform box and snapping.~~ (M5.8)
7. Point tracking and stabilization (in progress).

View File

@ -89,6 +89,7 @@ These rows are in the dialog for After Effects parity and are stored, but nothin
- `composition.hardwareAcceleratePanels`: Hardware Accelerate Composition, Layer and Footage Panels
- `previews.showInternalWireframes`: Show Internal Wireframes
- `previews.zoomQuality`: Viewer Zoom Quality
- `previews.displayProfile`: Display Color Space
- `appearance.useLabelColorForTabs`: Use Label Color for Related Tabs
- `appearance.cycleMaskColors`: Cycle Mask Colors
- `appearance.useGradients`: Use Gradients