mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-24 10:09:32 -06:00
Merge pull request #454 from storytold/fix/397-particle-world-camera
Effects: CC Particle World follows the comp camera (#397)
This commit is contained in:
@@ -4,7 +4,8 @@
|
||||
//!
|
||||
//! Pieces live in "buffer world" space: x, y in buffer pixels, z in buffer pixels away from the
|
||||
//! viewer (the layer plane is z = 0). A [`Proj`] maps buffer-world points to buffer pixels
|
||||
//! (homogeneous), so every camera system reduces to one 3×4 matrix.
|
||||
//! (homogeneous), so every camera system reduces to one 3×4 matrix. CC Particle World sees its
|
||||
//! particles through the comp camera with the same [`comp_proj`].
|
||||
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::ParamUi;
|
||||
@@ -170,6 +171,29 @@ impl Proj {
|
||||
(w > self.near).then(|| [r(0) / w, r(1) / w])
|
||||
}
|
||||
|
||||
/// How a buffer-world point `p` moving by `v` is seen: its buffer pixel, its depth W, the
|
||||
/// buffer pixels per buffer-world pixel of a camera-facing size there, and its velocity in
|
||||
/// buffer pixels. `None` behind or too close to the camera, or for a degenerate camera.
|
||||
pub fn view(&self, p: [f64; 3], v: [f64; 3]) -> Option<Seen> {
|
||||
let row = |i: usize| [self.m[i][0], self.m[i][1], self.m[i][2]];
|
||||
let (m0, m1, m2) = (row(0), row(1), row(2));
|
||||
let w = dot(m2, p) + self.m[2][3];
|
||||
if !(w > self.near && w.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let at = [(dot(m0, p) + self.m[0][3]) / w, (dot(m1, p) + self.m[1][3]) / w];
|
||||
// The focal length (pixels per unit at unit depth): |m_i × m2| / |m2|² for K·[R|t]
|
||||
// scaled by |m2|; the geometric mean of the two image axes.
|
||||
let l2 = dot(m2, m2);
|
||||
let scale = (len(cross(m0, m2)) * len(cross(m1, m2))).sqrt() / (l2.sqrt() * w);
|
||||
if !(scale > 0.0 && scale.is_finite() && at.iter().all(|a| a.is_finite())) {
|
||||
return None;
|
||||
}
|
||||
// d(X / W) = (m_i − at_i · m2) · dp / W.
|
||||
let vel = [(dot(m0, v) - at[0] * dot(m2, v)) / w, (dot(m1, v) - at[1] * dot(m2, v)) / w];
|
||||
Some(Seen { at, depth: w, scale, vel })
|
||||
}
|
||||
|
||||
/// The camera's centre in buffer-world space (where the projection collapses), or a point
|
||||
/// far in front of the layer when it has none (orthographic).
|
||||
pub fn eye(&self) -> [f64; 3] {
|
||||
@@ -184,6 +208,15 @@ impl Proj {
|
||||
}
|
||||
}
|
||||
|
||||
/// A point seen through a [`Proj`] (see [`Proj::view`]).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) struct Seen {
|
||||
pub at: [f64; 2],
|
||||
pub depth: f64,
|
||||
pub scale: f64,
|
||||
pub vel: [f64; 2],
|
||||
}
|
||||
|
||||
pub(crate) fn inv3(m: &M3) -> Option<M3> {
|
||||
let det = m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1]) - m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
|
||||
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]);
|
||||
@@ -235,24 +268,10 @@ pub(crate) fn projection(ctx: &EffectCtx, b: &Buf) -> Proj {
|
||||
None => base,
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
// Comp Camera: the comp's camera looking at this layer (layer px → layer px).
|
||||
let base = Proj::simple(c, size * 2.0);
|
||||
let Some(cam) = ctx.env.host.and_then(|h| h.comp_scene()).and_then(|sc| sc.camera) else { return base };
|
||||
// buffer world → layer: (p − offset) / s; layer → buffer: · s + offset.
|
||||
let mut m = [[0.0; 4]; 3];
|
||||
for i in 0..3 {
|
||||
for j in 0..3 {
|
||||
m[i][j] = cam[i][j] / s;
|
||||
}
|
||||
m[i][3] = cam[i][3] - (cam[i][0] * b.offset[0] + cam[i][1] * b.offset[1]) / s;
|
||||
}
|
||||
let back: M3 = [[s, 0.0, b.offset[0]], [0.0, s, b.offset[1]], [0.0, 0.0, 1.0]];
|
||||
let p = Proj { m, near: 1e-6 }.then(&back);
|
||||
// W of the layer centre sets the scale of `near`.
|
||||
let wc = p.m[2][0] * c[0] + p.m[2][1] * c[1] + p.m[2][3];
|
||||
Proj { near: (wc.abs() * 0.02).max(1e-9), ..p }
|
||||
}
|
||||
2 => match ctx.env.host.and_then(|h| h.comp_scene()).and_then(|sc| sc.camera) {
|
||||
Some(cam) => comp_proj(&cam, c, b),
|
||||
None => Proj::simple(c, size * 2.0),
|
||||
},
|
||||
_ => {
|
||||
let zpos = f("cameraPosition/cameraZ", 2.0).max(0.05);
|
||||
let focal = f("cameraPosition/focalLength", 70.0).max(1.0);
|
||||
@@ -282,6 +301,25 @@ pub(crate) fn projection(ctx: &EffectCtx, b: &Buf) -> Proj {
|
||||
}
|
||||
}
|
||||
|
||||
/// The comp camera `cam` ([`CompScene::camera`], layer px → layer px) looking at buffer `b`,
|
||||
/// whose layer centre is buffer pixel `c`.
|
||||
pub(crate) fn comp_proj(cam: &[[f64; 4]; 3], c: [f64; 2], b: &Buf) -> Proj {
|
||||
let s = b.scale;
|
||||
// buffer world → layer: (p − offset) / s; layer → buffer: · s + offset.
|
||||
let mut m = [[0.0; 4]; 3];
|
||||
for i in 0..3 {
|
||||
for j in 0..3 {
|
||||
m[i][j] = cam[i][j] / s;
|
||||
}
|
||||
m[i][3] = cam[i][3] - (cam[i][0] * b.offset[0] + cam[i][1] * b.offset[1]) / s;
|
||||
}
|
||||
let back: M3 = [[s, 0.0, b.offset[0]], [0.0, s, b.offset[1]], [0.0, 0.0, 1.0]];
|
||||
let p = Proj { m, near: 1e-6 }.then(&back);
|
||||
// W of the layer centre sets the scale of `near`.
|
||||
let wc = p.m[2][0] * c[0] + p.m[2][1] * c[1] + p.m[2][3];
|
||||
Proj { near: (wc.abs() * 0.02).max(1e-9), ..p }
|
||||
}
|
||||
|
||||
/// The comp's first light as the effect sees it (layer pixels), from [`crate::EffectHost::comp_scene`].
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct CompLight {
|
||||
@@ -302,6 +340,9 @@ pub struct CompScene {
|
||||
/// camera's view of the layer brought back into the layer's own pixel grid (identity on the
|
||||
/// layer plane for the default camera).
|
||||
pub camera: Option<[[f64; 4]; 3]>,
|
||||
/// `camera` is an active camera layer's, not the comp's default view (CC Particle World
|
||||
/// follows only a camera layer).
|
||||
pub camera_layer: bool,
|
||||
pub light: Option<CompLight>,
|
||||
}
|
||||
|
||||
@@ -334,6 +375,12 @@ fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
|
||||
fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
|
||||
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
||||
}
|
||||
fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
|
||||
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
|
||||
}
|
||||
fn len(a: [f64; 3]) -> f64 {
|
||||
dot(a, a).sqrt()
|
||||
}
|
||||
fn norm(a: [f64; 3]) -> [f64; 3] {
|
||||
let l = dot(a, a).sqrt();
|
||||
if l < 1e-12 { [0.0, 0.0, -1.0] } else { [a[0] / l, a[1] / l, a[2] / l] }
|
||||
|
||||
@@ -681,6 +681,7 @@ pub const PARAM_GROUPS: &[(&str, &str)] = &[
|
||||
("sourceCrops", "Source Crops"),
|
||||
// Simulation.
|
||||
("extras", "Extras"),
|
||||
("effectCamera", "Effect Camera"),
|
||||
("wiggle", "Wiggle"),
|
||||
("light", "Light"),
|
||||
("shading", "Shading"),
|
||||
@@ -1108,6 +1109,11 @@ pub const GPU_EFFECTS: &[&str] = &[
|
||||
"ec.obsolete.pathtext",
|
||||
];
|
||||
|
||||
/// Effects that can read [`EffectHost::comp_scene`] (the comp camera or the first comp light):
|
||||
/// their output changes when the camera, the lights or the layer's own transform move, so the
|
||||
/// renderer's layer cache folds the comp scene into the key for these.
|
||||
pub const READS_COMP_SCENE: &[&str] = &["ec.sim.carddance", "ec.sim.shatter", "ec.transition.cardwipe", "ec.sim.caustics", "ec.sim.ccparticleworld"];
|
||||
|
||||
/// Effects whose output depends on [`EffectCtx::time`] directly (not only through animated
|
||||
/// parameters). The renderer's layer cache folds the layer time into the key for these.
|
||||
/// The `time_dependence_is_declared` test checks the list against every registered effect.
|
||||
|
||||
+227
-20
@@ -15,7 +15,7 @@ use effectcraft_raster::{Image, Px};
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::generate::value_noise;
|
||||
use crate::util::{SimCache, hash1, params_key, unpremul};
|
||||
use crate::util::{SimCache, hash1, params_key, params_key_except, unpremul};
|
||||
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
|
||||
|
||||
pub(crate) fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
|
||||
@@ -989,11 +989,18 @@ pub fn particle_state(id: &str, ctx: &EffectCtx) -> Option<Vec<crate::psim::SimP
|
||||
"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 key = sim_key(ctx, 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())
|
||||
}
|
||||
|
||||
/// The simulation cache key of a stepped particle effect: every parameter but CC Particle World's
|
||||
/// Effect Camera, which only changes how the particles are seen (animating it doesn't restart
|
||||
/// the simulation).
|
||||
fn sim_key(ctx: &EffectCtx, salt: u64) -> u64 {
|
||||
params_key_except(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, salt, &[PW_EFFECT_CAMERA])
|
||||
}
|
||||
|
||||
/// 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))
|
||||
@@ -1068,6 +1075,90 @@ fn particle_world(ctx: &EffectCtx, b: Buf) -> Buf {
|
||||
}
|
||||
}
|
||||
|
||||
/// CC Particle World's Extras ▸ Effect Camera parameters (id prefix).
|
||||
const PW_EFFECT_CAMERA: &str = "extras/effectCamera/";
|
||||
/// Effect Camera ▸ FOV's default (degrees).
|
||||
const PW_FOV: f64 = 50.0;
|
||||
|
||||
/// What CC Particle World sees its particles through.
|
||||
enum PwCamera {
|
||||
/// Extras ▸ Effect Camera, in world units (layer widths): `dist` from the world origin (the
|
||||
/// layer centre) along −z, the world turned by `rot` (`None` = not turned) about the origin,
|
||||
/// `focal` the scale at unit depth; particles nearer than `near` aren't drawn.
|
||||
Effect { rot: Option<[[f32; 3]; 3]>, dist: f32, focal: f32, near: f32 },
|
||||
/// The comp's active camera layer, on buffer-world points (After Effects then ignores the
|
||||
/// Effect Camera).
|
||||
Comp(crate::card3d::Proj),
|
||||
}
|
||||
|
||||
/// A particle as the camera sees it: depth (larger is farther), buffer pixel, size scale `k`
|
||||
/// (buffer pixels per world unit = `k · layer width · buffer scale`) and velocity in world units
|
||||
/// on screen (× the same factor for buffer pixels).
|
||||
struct PwSeen {
|
||||
depth: f32,
|
||||
x: f32,
|
||||
y: f32,
|
||||
k: f32,
|
||||
v: [f32; 2],
|
||||
}
|
||||
|
||||
impl PwCamera {
|
||||
fn new(ctx: &EffectCtx, b: &Buf) -> PwCamera {
|
||||
let [lw, lh] = ctx.layer_size;
|
||||
if let Some(cam) = ctx.env.host.and_then(|h| h.comp_scene()).filter(|sc| sc.camera_layer).and_then(|sc| sc.camera) {
|
||||
let c = [b.offset[0] + lw * 0.5 * b.scale, b.offset[1] + lh * 0.5 * b.scale];
|
||||
return PwCamera::Comp(crate::card3d::comp_proj(&cam, c, b));
|
||||
}
|
||||
let f = |id: &str, d: f64| ctx.params.get(&format!("{PW_EFFECT_CAMERA}{id}")).map(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(d);
|
||||
// The camera keeps the origin's plane at the scale Distance gives it as FOV changes
|
||||
// (moving in for a wider view), so FOV sets how strong the perspective is. Distance 2
|
||||
// shows the origin's plane at the layer's own scale.
|
||||
let half = |fov: f64| (fov.clamp(1.0, 179.0).to_radians() * 0.5).tan();
|
||||
let zoom = half(PW_FOV) / half(f("fov", PW_FOV));
|
||||
let dist = f("distance", 2.0).max(0.01) * zoom;
|
||||
let a = [f("rotationX", 0.0), f("rotationY", 0.0), f("rotationZ", 0.0)];
|
||||
let rot = (a != [0.0; 3]).then(|| {
|
||||
let r = crate::card3d::rot_ordered(a[0].to_radians(), a[1].to_radians(), a[2].to_radians(), 0);
|
||||
r.map(|row| row.map(|v| v as f32))
|
||||
});
|
||||
PwCamera::Effect { rot, dist: dist as f32, focal: (2.0 * zoom) as f32, near: (0.025 * dist) as f32 }
|
||||
}
|
||||
|
||||
/// How the camera sees world point `p` moving at `v`; `None` behind or too close to it.
|
||||
fn see(&self, p: [f32; 3], v: [f32; 3], lw: f32, lh: f32, b: &Buf) -> Option<PwSeen> {
|
||||
match self {
|
||||
PwCamera::Effect { rot, dist, focal, near } => {
|
||||
let turn = |r: &[[f32; 3]; 3], q: [f32; 3]| r.map(|row| row[0] * q[0] + row[1] * q[1] + row[2] * q[2]);
|
||||
let (p, v) = match rot {
|
||||
Some(r) => (turn(r, p), turn(r, v)),
|
||||
None => (p, v),
|
||||
};
|
||||
let zc = dist + p[2];
|
||||
if zc.is_nan() || zc <= *near {
|
||||
return None;
|
||||
}
|
||||
let k = focal / zc;
|
||||
let (x, y) = b.to_px([(lw * 0.5 + p[0] * lw * k) as f64, (lh * 0.5 + p[1] * lw * k) as f64]);
|
||||
Some(PwSeen { depth: zc, x: x as f32, y: y as f32, k, v: [v[0], v[1]] })
|
||||
}
|
||||
PwCamera::Comp(proj) => {
|
||||
let (s, lw, lh) = (b.scale, lw as f64, lh as f64);
|
||||
let u = lw * s;
|
||||
let at = [b.offset[0] + lw * 0.5 * s + p[0] as f64 * u, b.offset[1] + lh * 0.5 * s + p[1] as f64 * u, p[2] as f64 * u];
|
||||
let seen = proj.view(at, [v[0] as f64, v[1] as f64, v[2] as f64])?;
|
||||
let k = seen.scale;
|
||||
Some(PwSeen {
|
||||
depth: seen.depth as f32,
|
||||
x: seen.at[0] as f32,
|
||||
y: seen.at[1] as f32,
|
||||
k: k as f32,
|
||||
v: [(seen.vel[0] / k) as f32, (seen.vel[1] / k) as f32],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// CC Particle World's sprites (far to near), `None` before anything is born.
|
||||
fn particle_world_plan(ctx: &EffectCtx, b: &Buf) -> Option<SpritePlan> {
|
||||
let pr = ctx.params;
|
||||
@@ -1076,7 +1167,7 @@ fn particle_world_plan(ctx: &EffectCtx, b: &Buf) -> Option<SpritePlan> {
|
||||
if phys.rate <= 0.0 && ctx.time <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
let key = params_key(ctx, &Buf { img: Image::new(0, 0), offset: [0.0; 2], scale: 1.0 }, 1);
|
||||
let key = sim_key(ctx, 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");
|
||||
@@ -1085,24 +1176,16 @@ fn particle_world_plan(ctx: &EffectCtx, b: &Buf) -> Option<SpritePlan> {
|
||||
let svar = pr.f("particle/sizeVariation") as f32 / 100.0;
|
||||
let max_op = pr.f("particle/maxOpacity") as f32 / 100.0;
|
||||
let s = b.scale as f32;
|
||||
// Camera at distance 2 (world units = layer width) looking at the origin (layer centre).
|
||||
let cam = 2.0f32;
|
||||
let cam = PwCamera::new(ctx, b);
|
||||
let mut list: Vec<(f32, Sprite)> = st
|
||||
.parts
|
||||
.par_iter()
|
||||
.filter_map(|q| {
|
||||
let zc = cam + q.p[2];
|
||||
if zc <= 0.05 {
|
||||
return None;
|
||||
}
|
||||
let k = cam / zc;
|
||||
let x = lw * 0.5 + q.p[0] * lw * k;
|
||||
let y = lh * 0.5 + q.p[1] * lw * k;
|
||||
let (bx, by) = b.to_px([x as f64, y as f64]);
|
||||
let PwSeen { depth, x, y, k, v } = cam.see(q.p, q.v, lw, lh, b)?;
|
||||
let (c, size) = particle_look(q, birth, death, bs, ds, svar, max_op);
|
||||
let r = size * 0.5 * lw * k * s * 0.25;
|
||||
let sp = type_sprite(kind, bx as f32, by as f32, r, c, q.v[0] * lw * k * s, q.v[1] * lw * k * s, (q.id % 628) as f32 * 0.01 + q.age * 2.0);
|
||||
Some((zc, sp))
|
||||
let sp = type_sprite(kind, x, y, r, c, v[0] * lw * k * s, v[1] * lw * k * s, (q.id % 628) as f32 * 0.01 + q.age * 2.0);
|
||||
Some((depth, sp))
|
||||
})
|
||||
.collect();
|
||||
list.sort_by(|a, b| b.0.total_cmp(&a.0));
|
||||
@@ -1167,7 +1250,7 @@ fn particle_systems2_plan(ctx: &EffectCtx, b: &Buf) -> SpritePlan {
|
||||
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 key = sim_key(ctx, 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");
|
||||
@@ -1440,6 +1523,11 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
p("particle/birthColor", "Birth Color", col(1.0, 1.0, 0.0), ParamUi::Color),
|
||||
p("particle/deathColor", "Death Color", col(0.6, 0.0, 0.0), ParamUi::Color),
|
||||
p("particle/transferMode", "Transfer Mode", Value::Enum(0), popup(&["Composite", "Screen", "Add", "Black Matte"])),
|
||||
p("extras/effectCamera/distance", "Distance", num(2.0), slider(0.0, 100.0, 0.0, 10.0, 2)),
|
||||
p("extras/effectCamera/rotationX", "Rotation X", num(0.0), ParamUi::Angle),
|
||||
p("extras/effectCamera/rotationY", "Rotation Y", num(0.0), ParamUi::Angle),
|
||||
p("extras/effectCamera/rotationZ", "Rotation Z", num(0.0), ParamUi::Angle),
|
||||
p("extras/effectCamera/fov", "FOV", num(PW_FOV), slider(1.0, 179.0, 10.0, 120.0, 1)),
|
||||
p("extras/randomSeed", "Random Seed", num(0.0), slider(0.0, 10_000.0, 0.0, 1000.0, 0)),
|
||||
],
|
||||
particle_world,
|
||||
@@ -1532,18 +1620,24 @@ mod tests {
|
||||
a.data.iter().zip(&b.data).map(|(p, q)| (0..4).map(|k| (p[k] - q[k]).abs()).sum::<f32>()).sum()
|
||||
}
|
||||
|
||||
/// Centre of mass (luminance-weighted difference from `base`) in y.
|
||||
fn mass_y(img: &Image, base: &Image) -> f32 {
|
||||
let (mut m, mut sy) = (0.0, 0.0);
|
||||
/// Centre of mass (luminance-weighted difference from `base`), `[-1, -1]` when equal.
|
||||
fn mass(img: &Image, base: &Image) -> [f32; 2] {
|
||||
let (mut m, mut sx, mut sy) = (0.0, 0.0, 0.0);
|
||||
for y in 0..img.height {
|
||||
for x in 0..img.width {
|
||||
let i = img.idx(x, y);
|
||||
let d: f32 = (0..3).map(|k| (img.data[i][k] - base.data[i][k]).abs()).sum();
|
||||
m += d;
|
||||
sx += d * x as f32;
|
||||
sy += d * y as f32;
|
||||
}
|
||||
}
|
||||
if m > 0.0 { sy / m } else { -1.0 }
|
||||
if m > 0.0 { [sx / m, sy / m] } else { [-1.0; 2] }
|
||||
}
|
||||
|
||||
/// [`mass`] in y.
|
||||
fn mass_y(img: &Image, base: &Image) -> f32 {
|
||||
mass(img, base)[1]
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1695,6 +1789,119 @@ mod tests {
|
||||
assert!(down > up + 2.0, "{up} {down}");
|
||||
}
|
||||
|
||||
/// A comp whose camera is `camera` ([`crate::CompScene::camera`]); `layer`: a camera layer
|
||||
/// (else the comp's default view).
|
||||
struct Comp {
|
||||
camera: [[f64; 4]; 3],
|
||||
layer: bool,
|
||||
}
|
||||
impl crate::EffectHost for Comp {
|
||||
fn layer(&self, _: u64, _: bool) -> Option<crate::LayerPixels> {
|
||||
None
|
||||
}
|
||||
fn audio(&self, _: u64, _: f64, _: usize, _: u32) -> Option<Vec<f32>> {
|
||||
None
|
||||
}
|
||||
fn comp_scene(&self) -> Option<crate::CompScene> {
|
||||
Some(crate::CompScene { camera: Some(self.camera), camera_layer: self.layer, light: None })
|
||||
}
|
||||
}
|
||||
|
||||
/// A camera `d` px in front of layer point `c` looking at it, moved right by `dx` px and
|
||||
/// zoomed by `zoom` (1 = identity on the layer plane): `c + zoom·d·(p − c − dx) / (z + d)`.
|
||||
fn cam(c: [f64; 2], d: f64, dx: f64, zoom: f64) -> [[f64; 4]; 3] {
|
||||
let k = zoom * d;
|
||||
[[k, 0.0, c[0], d * c[0] - k * (c[0] + dx)], [0.0, k, c[1], d * c[1] - k * c[1]], [0.0, 0.0, 1.0, d]]
|
||||
}
|
||||
|
||||
fn pw_in(comp: &Comp, vals: &[(&str, Value)]) -> Image {
|
||||
run_fx("ec.sim.ccparticleworld", vals, Image::new(48, 48), 1.0, EffectEnv { host: Some(comp), ..Default::default() }).img
|
||||
}
|
||||
|
||||
fn finite(img: &Image) -> bool {
|
||||
img.data.iter().all(|p| p.iter().all(|c| c.is_finite()))
|
||||
}
|
||||
|
||||
/// #397: with a camera layer in the comp, the particles are seen through it.
|
||||
#[test]
|
||||
fn particle_world_follows_the_comp_camera() {
|
||||
let v = pw_vals(3.0);
|
||||
let blank = Image::new(48, 48);
|
||||
let here = Comp { camera: cam([24.0, 24.0], 96.0, 0.0, 1.0), layer: true };
|
||||
let base = pw_in(&here, &v);
|
||||
assert!(base.data.iter().any(|p| p[3] > 0.0), "particles alive");
|
||||
// Moving the camera right moves the particles left.
|
||||
let moved = pw_in(&Comp { camera: cam([24.0, 24.0], 96.0, 8.0, 1.0), ..here }, &v);
|
||||
let (a, b) = (mass(&base, &blank), mass(&moved, &blank));
|
||||
assert!(b[0] < a[0] - 4.0 && (b[1] - a[1]).abs() < 2.0, "{a:?} -> {b:?}");
|
||||
// Zooming in spreads them out.
|
||||
let zoomed = pw_in(&Comp { camera: cam([24.0, 24.0], 96.0, 0.0, 2.0), ..here }, &v);
|
||||
let cover = |i: &Image| i.data.iter().filter(|p| p[3] > 0.01).count();
|
||||
assert!(cover(&zoomed) > cover(&base), "{} {}", cover(&zoomed), cover(&base));
|
||||
// The Effect Camera is ignored then.
|
||||
let mut turned = v.clone();
|
||||
turned.push(("extras/effectCamera/rotationY", num(60.0)));
|
||||
assert_eq!(pw_in(&here, &turned), base);
|
||||
// The comp's default view (no camera layer) leaves it to the Effect Camera.
|
||||
let default_view = Comp { layer: false, ..here };
|
||||
assert_eq!(pw_in(&default_view, &v), run("ec.sim.ccparticleworld", &v, blank.clone(), 1.0));
|
||||
// A camera facing away, or a degenerate one, draws nothing and never NaN.
|
||||
for camera in [[[96.0, 0.0, 24.0, 0.0], [0.0, 96.0, 24.0, 0.0], [0.0, 0.0, -1.0, -96.0]], [[0.0; 4]; 3], [[f64::NAN; 4]; 3]] {
|
||||
assert_eq!(pw_in(&Comp { camera, layer: true }, &v), blank);
|
||||
}
|
||||
// A camera among the particles draws the ones in front of it.
|
||||
let inside = pw_in(&Comp { camera: cam([24.0, 24.0], 1.0, 0.0, 1.0), ..here }, &v);
|
||||
assert!(finite(&inside));
|
||||
}
|
||||
|
||||
/// Extras ▸ Effect Camera: the defaults are the fixed camera Particle World always had.
|
||||
#[test]
|
||||
fn particle_world_effect_camera() {
|
||||
let v = pw_vals(5.0);
|
||||
let base = run("ec.sim.ccparticleworld", &v, Image::new(48, 48), 1.0);
|
||||
let with = |extra: &[(&'static str, Value)]| {
|
||||
let mut w = v.clone();
|
||||
w.extend_from_slice(extra);
|
||||
run("ec.sim.ccparticleworld", &w, Image::new(48, 48), 1.0)
|
||||
};
|
||||
// Missing parameters (projects saved before) read as the defaults.
|
||||
let spec = crate::find("ec.sim.ccparticleworld").unwrap();
|
||||
let mut params = crate::Params { values: spec.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() };
|
||||
params.values.retain(|k, _| !k.starts_with(PW_EFFECT_CAMERA));
|
||||
for (k, val) in &v {
|
||||
params.values.insert(k.to_string(), val.clone());
|
||||
}
|
||||
let ctx = EffectCtx { params: ¶ms, time: 1.0, layer_size: [48.0, 48.0], seed: 1, adjustment: false, env: EffectEnv::default() };
|
||||
assert_eq!(crate::apply(spec, &ctx, Buf { img: Image::new(48, 48), offset: [0.0; 2], scale: 1.0 }).img, base);
|
||||
assert_eq!(with(&[("extras/effectCamera/fov", num(f64::NAN))]), base);
|
||||
// Turning or pulling back the camera changes the view.
|
||||
let turned = with(&[("extras/effectCamera/rotationY", num(60.0))]);
|
||||
assert!(diff(&turned, &base) > 1.0);
|
||||
let cover = |i: &Image| i.data.iter().filter(|p| p[3] > 0.01).count();
|
||||
let far = with(&[("extras/effectCamera/distance", num(4.0))]);
|
||||
assert!(cover(&far) < cover(&base), "{} {}", cover(&far), cover(&base));
|
||||
assert!(diff(&with(&[("extras/effectCamera/fov", num(120.0))]), &base) > 0.1);
|
||||
// Extremes never panic or produce NaN.
|
||||
for extra in [
|
||||
("extras/effectCamera/distance", num(0.0)),
|
||||
("extras/effectCamera/distance", num(-5.0)),
|
||||
("extras/effectCamera/fov", num(0.0)),
|
||||
("extras/effectCamera/fov", num(180.0)),
|
||||
("extras/effectCamera/rotationX", num(1e12)),
|
||||
] {
|
||||
assert!(finite(&with(std::slice::from_ref(&extra))), "{extra:?}");
|
||||
}
|
||||
// Moving the camera doesn't restart the simulation.
|
||||
let key = |pr: &crate::Params| {
|
||||
sim_key(&EffectCtx { params: pr, time: 1.0, layer_size: [48.0, 48.0], seed: 1, adjustment: false, env: EffectEnv::default() }, 1)
|
||||
};
|
||||
let mut moved = params.clone();
|
||||
moved.values.insert("extras/effectCamera/rotationZ".into(), num(30.0));
|
||||
assert_eq!(key(¶ms), key(&moved));
|
||||
moved.values.insert("extras/randomSeed".into(), num(9.0));
|
||||
assert_ne!(key(¶ms), key(&moved));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn particle_systems2_seek_and_direction() {
|
||||
let v = vec![
|
||||
|
||||
@@ -2468,7 +2468,7 @@ mod tests {
|
||||
None
|
||||
}
|
||||
fn comp_scene(&self) -> Option<crate::CompScene> {
|
||||
Some(crate::CompScene { camera: self.camera, light: self.light })
|
||||
Some(crate::CompScene { camera: self.camera, camera_layer: true, light: self.light })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -462,9 +462,15 @@ pub fn layer_or_self(ctx: &crate::EffectCtx, b: &crate::Buf, id: &str, masks_and
|
||||
/// A stable hash of an effect instance's evaluated parameters, seed, layer size and buffer
|
||||
/// scale (cache key for simulations).
|
||||
pub fn params_key(ctx: &crate::EffectCtx, b: &crate::Buf, salt: u64) -> u64 {
|
||||
params_key_except(ctx, b, salt, &[])
|
||||
}
|
||||
|
||||
/// [`params_key`] without the parameters whose ids start with one of `skip` (ones that only
|
||||
/// change how the simulation is viewed, so animating them doesn't restart it).
|
||||
pub fn params_key_except(ctx: &crate::EffectCtx, b: &crate::Buf, salt: u64, skip: &[&str]) -> u64 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
let mut keys: Vec<&String> = ctx.params.values.keys().collect();
|
||||
let mut keys: Vec<&String> = ctx.params.values.keys().filter(|k| !skip.iter().any(|s| k.starts_with(s))).collect();
|
||||
keys.sort();
|
||||
for k in keys {
|
||||
k.hash(&mut h);
|
||||
|
||||
@@ -3,8 +3,11 @@
|
||||
//! and composited at 8 and 32 bpc (`tests_fx_sim`'s harness).
|
||||
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::{BitDepth, LayerSource, build};
|
||||
use effectcraft_render::RenderOpts;
|
||||
use effectcraft_time::Tick;
|
||||
|
||||
use crate::tests::n;
|
||||
use crate::tests::{Scene, check, compare_at, n, opts, set};
|
||||
use crate::tests_fx_sim::{case, composited, e, off, on};
|
||||
|
||||
fn v2(x: f64, y: f64) -> Value {
|
||||
@@ -28,6 +31,24 @@ fn particle_world_types_and_modes() {
|
||||
case("ec.sim.ccparticleworld", &[("birthRate", n(0.0))], 0.0, 0.0);
|
||||
}
|
||||
|
||||
/// #397: CC Particle World seen through a camera layer (the plan is shared, so the GPU follows
|
||||
/// the camera with the CPU).
|
||||
#[test]
|
||||
fn particle_world_through_the_comp_camera() {
|
||||
for depth in [BitDepth::Bpc8, BitDepth::Bpc32] {
|
||||
let mut s = Scene::new(depth);
|
||||
let mut l = s.footage(70, 44);
|
||||
s.effect(&mut l, "ec.sim.ccparticleworld", &[("birthRate", n(1.5)), ("particle/particleType", e(1)), ("particle/birthSize", n(0.3))]);
|
||||
s.push(l);
|
||||
let mut cam = build::layer(&mut s.p, &s.comp, "Camera", LayerSource::Camera, (97, 61), None);
|
||||
set(&mut cam, "transform/position", Value::Vec3([75.0, 15.0, -120.0]));
|
||||
s.push(cam);
|
||||
let at = Tick::from_seconds_f64(1.0);
|
||||
check(&format!("comp camera {depth:?}"), compare_at(&s, opts(), at), 0.0);
|
||||
check(&format!("comp camera {depth:?} half"), compare_at(&s, RenderOpts { scale: 0.5, ..opts() }, at), 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn particle_systems_ii_types_and_modes() {
|
||||
for kind in 0..9 {
|
||||
|
||||
@@ -620,9 +620,25 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo
|
||||
layer.start_time.0.hash(&mut h);
|
||||
layer.stretch.to_bits().hash(&mut h);
|
||||
}
|
||||
// Effects that see the comp camera or lights (#397) change when they or the layer move.
|
||||
if reads_comp_scene(layer) {
|
||||
hash_debug(&mut h, &crate::comp_scene(ctx, layer));
|
||||
}
|
||||
Some(h.finish())
|
||||
}
|
||||
|
||||
/// Does the layer run an effect that reads the comp camera or lights (Card Dance, CC Particle
|
||||
/// World…)?
|
||||
fn reads_comp_scene(layer: &Layer) -> bool {
|
||||
layer.switches.effects
|
||||
&& layer.effects().is_some_and(|fx| {
|
||||
fx.groups().any(|g| {
|
||||
g.enabled
|
||||
&& matches!(&g.kind, effectcraft_project::GroupKind::Effect { effect } if effectcraft_effects::READS_COMP_SCENE.contains(&effect.as_str()))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Cache key for the styled layer (content key + the Layer Styles group: structure, eye
|
||||
/// switches, values, keyframes, expressions and their values at the context time — Global Light
|
||||
/// included, as every layer mirrors it in its Blending Options).
|
||||
|
||||
+29
-22
@@ -106,6 +106,34 @@ impl FxHost<'_, '_, '_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The comp's camera and first light relative to `layer`, in its layer pixels (see
|
||||
/// [`EffectHost::comp_scene`]): the comp camera's view of the layer (as if 3D at its transform),
|
||||
/// brought back into the layer's own pixel grid through the inverse of how the layer itself
|
||||
/// composites.
|
||||
pub(crate) fn comp_scene(ctx: &EvalCtx, layer: &Layer) -> Option<effectcraft_effects::CompScene> {
|
||||
let world = ctx.world_matrix(layer);
|
||||
let (cam, _, _) = ctx.camera();
|
||||
let p = (cam * world).0;
|
||||
let back = ctx.layer_to_comp(layer).0.inverse()?.0;
|
||||
let rows = [p[0], p[1], p[3]];
|
||||
let camera: [[f64; 4]; 3] = std::array::from_fn(|i| std::array::from_fn(|j| (0..3).map(|k| back[i][k] * rows[k][j]).sum()));
|
||||
let inv = world.inverse();
|
||||
let light = three_d::light::lights_at(ctx).first().and_then(|l| {
|
||||
let inv = inv.as_ref()?;
|
||||
let pos = inv.apply(l.pos);
|
||||
let dir = inv.apply_vec(l.dir);
|
||||
let len = (dir.x * dir.x + dir.y * dir.y + dir.z * dir.z).sqrt().max(1e-12);
|
||||
let kind = match l.kind {
|
||||
effectcraft_project::LightKind::Parallel => 0,
|
||||
effectcraft_project::LightKind::Ambient => 2,
|
||||
_ => 1,
|
||||
};
|
||||
Some(effectcraft_effects::CompLight { pos: [pos.x, pos.y, pos.z], dir: [dir.x / len, dir.y / len, dir.z / len], color: l.color, kind })
|
||||
});
|
||||
let camera_layer = ctx.comp.active_camera(ctx.time).is_some();
|
||||
Some(effectcraft_effects::CompScene { camera: Some(camera), camera_layer, light })
|
||||
}
|
||||
|
||||
/// Nesting limit for effects that render layers (other layers, other times).
|
||||
const MAX_FX_DEPTH: usize = 8;
|
||||
|
||||
@@ -186,28 +214,7 @@ impl EffectHost for FxHost<'_, '_, '_> {
|
||||
}
|
||||
|
||||
fn comp_scene(&self) -> Option<effectcraft_effects::CompScene> {
|
||||
// The comp camera's view of the layer (as if 3D at its transform), brought back into the
|
||||
// layer's own pixel grid through the inverse of how the layer itself composites.
|
||||
let world = self.ctx.world_matrix(self.layer);
|
||||
let (cam, _, _) = self.ctx.camera();
|
||||
let p = (cam * world).0;
|
||||
let back = self.ctx.layer_to_comp(self.layer).0.inverse()?.0;
|
||||
let rows = [p[0], p[1], p[3]];
|
||||
let camera: [[f64; 4]; 3] = std::array::from_fn(|i| std::array::from_fn(|j| (0..3).map(|k| back[i][k] * rows[k][j]).sum()));
|
||||
let inv = world.inverse();
|
||||
let light = three_d::light::lights_at(self.ctx).first().and_then(|l| {
|
||||
let inv = inv.as_ref()?;
|
||||
let pos = inv.apply(l.pos);
|
||||
let dir = inv.apply_vec(l.dir);
|
||||
let len = (dir.x * dir.x + dir.y * dir.y + dir.z * dir.z).sqrt().max(1e-12);
|
||||
let kind = match l.kind {
|
||||
effectcraft_project::LightKind::Parallel => 0,
|
||||
effectcraft_project::LightKind::Ambient => 2,
|
||||
_ => 1,
|
||||
};
|
||||
Some(effectcraft_effects::CompLight { pos: [pos.x, pos.y, pos.z], dir: [dir.x / len, dir.y / len, dir.z / len], color: l.color, kind })
|
||||
});
|
||||
Some(effectcraft_effects::CompScene { camera: Some(camera), light }.rebased(self.origin))
|
||||
Some(comp_scene(self.ctx, self.layer)?.rebased(self.origin))
|
||||
}
|
||||
|
||||
fn layer_at(&self, id: u64, comp_time: f64, masks_and_effects: bool) -> Option<LayerPixels> {
|
||||
|
||||
@@ -176,6 +176,51 @@ fn card_dance_comp_camera_matches_the_default_view() {
|
||||
assert!(img.get(10, 50)[3] < 0.01);
|
||||
}
|
||||
|
||||
/// A black 2D solid with CC Particle World, under a camera layer panned right by `pan` px.
|
||||
fn particle_world_scene(pan: f64) -> (Project, ItemId) {
|
||||
let (mut p, cid, comp) = setup();
|
||||
let mut l = solid(&mut p, &comp, [0.0, 0.0, 0.0], 200, 100);
|
||||
let vals = [("birthRate", Value::Scalar(2.0)), ("particle/particleType", Value::Enum(2)), ("particle/birthSize", Value::Scalar(0.1))];
|
||||
add_effect(&mut p, &mut l, "ec.sim.ccparticleworld", [200.0, 100.0], &vals);
|
||||
let mut cam = build::layer(&mut p, &comp, "Camera", LayerSource::Camera, (200, 100), None);
|
||||
for path in ["transform/position", "transform/poi"] {
|
||||
let pr = cam.props.prop_mut(path).unwrap();
|
||||
let Value::Vec3(v) = pr.value else { panic!("{path}: {:?}", pr.value) };
|
||||
pr.value = Value::Vec3([v[0] + pan, v[1], v[2]]);
|
||||
}
|
||||
p.comp_mut(cid).unwrap().layers = vec![cam, l];
|
||||
(p, cid)
|
||||
}
|
||||
|
||||
/// The red-weighted centre of the particles (x).
|
||||
fn particles_x(img: &crate::Image) -> f32 {
|
||||
let (mut m, mut sx) = (0.0, 0.0);
|
||||
for y in 0..img.height {
|
||||
for x in 0..img.width {
|
||||
let r = img.get(x as i64, y as i64)[0];
|
||||
m += r;
|
||||
sx += r * x as f32;
|
||||
}
|
||||
}
|
||||
assert!(m > 1.0, "particles drawn");
|
||||
sx / m
|
||||
}
|
||||
|
||||
/// #397: CC Particle World on a 2D layer is seen through the comp's camera layer, and the layer
|
||||
/// cache notices the camera moving.
|
||||
#[test]
|
||||
fn particle_world_follows_the_comp_camera() {
|
||||
let t = Tick::from_seconds_f64(1.0);
|
||||
let (p, cid) = particle_world_scene(0.0);
|
||||
let (q, _) = particle_world_scene(40.0);
|
||||
let cache = crate::LayerCache::default();
|
||||
let here = render_cached(&p, cid, t, Some(&cache));
|
||||
let panned = render_cached(&q, cid, t, Some(&cache));
|
||||
assert_same(&panned, &render_cached(&q, cid, t, None), "cached render after the camera moved");
|
||||
let (a, b) = (particles_x(&here), particles_x(&panned));
|
||||
assert!(b < a - 20.0, "panning the camera right moves the particles left: {a} -> {b}");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------ layer cache
|
||||
|
||||
fn render_cached(p: &Project, cid: ItemId, t: Tick, cache: Option<&crate::LayerCache>) -> crate::Image {
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@
|
||||
<!-- 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 306 effects with 3009 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 306 effects with 3014 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**: 280 effects also run on the GPU compositor with identical results.
|
||||
- **32**: 306 effects process 32-bit float (HDR, overbright) pixels without clamping.
|
||||
@@ -323,7 +323,7 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
|
||||
| CC Hair | `ec.sim.cchair` | 20 | GPU | 32 | Implemented |
|
||||
| CC Mr. Mercury | `ec.sim.ccmrmercury` | 23 | GPU | 32 | Implemented |
|
||||
| CC Particle Systems II | `ec.sim.ccparticlesystems2` | 21 | GPU | 32 | Implemented |
|
||||
| CC Particle World | `ec.sim.ccparticleworld` | 30 | GPU | 32 | Implemented |
|
||||
| CC Particle World | `ec.sim.ccparticleworld` | 35 | GPU | 32 | Implemented |
|
||||
| CC Pixel Polly | `ec.sim.ccpixelpolly` | 10 | GPU | 32 | Implemented |
|
||||
| CC Rainfall | `ec.sim.ccrainfall` | 14 | GPU | 32 | Implemented |
|
||||
| CC Scatterize | `ec.sim.ccscatterize` | 4 | GPU | 32 | Implemented |
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+4
-1
@@ -814,7 +814,10 @@ are pinned bit for bit by golden hashes (`crates/effects/tests/particle_golden.r
|
||||
packed into one atlas), CC Ball Action, CC Pixel Polly and CC Scatterize (their plans read the
|
||||
frame back once: the particles take the layer's colours and, for Ball Action's twist, its
|
||||
luminance). Plans too large for one item table draw in several passes, each over the previous
|
||||
one, with the same per-pixel arithmetic.
|
||||
one, with the same per-pixel arithmetic. Since #397 CC Particle World sees its particles
|
||||
through the comp's active camera layer, as After Effects does, and through Extras ▸ Effect
|
||||
Camera (Distance, Rotation X / Y / Z, FOV) when there is none; both compositors draw the same
|
||||
plan, and the layer cache keys the effects that read the comp camera or lights by them.
|
||||
- **Curl Noise**: the fBm potential, its curl and the streamline trace as three kernels.
|
||||
- **Former fallbacks**: Shatter's wireframe views (the default view) draw their lines as
|
||||
sprites; Foam's User Defined texture, Environment Map and flow-map preview draw over the
|
||||
|
||||
Reference in New Issue
Block a user