Merge main into M13.6 panels and content tools
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
a5be68499e
|
|
@ -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(())
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
]
|
||||
}
|
||||
|
|
@ -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"], "{}"),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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`"))?)
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: "{}" },
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
|
@ -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}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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])
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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))]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))],
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>> {
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: ¶ms, 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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue