Effects: CC Particle World follows the comp camera (#397)
Cause: CC Particle World projected its particles through a fixed camera (distance 2 layer widths, looking at the layer centre) and never asked the host for the comp camera, so moving or orbiting a camera layer left the particles flat on the layer. It also had no Extras > Effect Camera. Separately, the layer cache key ignored the comp camera and lights, so the effects that read them (Card Dance / Shatter / Card Wipe Comp Camera, First Comp Light, Caustics) kept a stale frame when the camera, a light or the layer moved. Fix: as the Cycore manual describes, Particle World sees its particles through the comp's active camera layer when there is one (EffectHost::comp_scene now says whether its camera is a camera layer's, CompScene::camera_layer), through card3d's comp-camera projection (factored out of Card Dance as comp_proj, with Proj::view for depth, size scale and screen velocity). Otherwise it uses the new Extras > Effect Camera: Distance (2), Rotation X / Y / Z (0) and FOV (50) orbit the world origin; FOV moves the camera so the origin's plane keeps the scale Distance gives it, so the defaults reproduce the old fixed camera bit for bit (golden hashes unchanged) and old projects render as before. The Effect Camera is left out of the simulation cache key, so animating it doesn't restart the simulation. The plan is shared, so the GPU compositor follows too. The layer cache folds the comp scene into the key of layers running an effect in effects::READS_COMP_SCENE (render::comp_scene is shared with the host). Tests: sim particle_world_follows_the_comp_camera (panning a camera layer moves the particles, zooming spreads them, the Effect Camera is ignored then, the default view keeps the Effect Camera, cameras facing away / zero / NaN draw nothing) and particle_world_effect_camera (missing params = defaults, rotation, distance and FOV change the view, extremes stay finite, the sim key ignores the camera); render particle_world_follows_the_comp_camera (a real camera layer panned 40 px moves the particles left, and the cached render after the move equals an uncached one: it failed with the old cache key); gpu particle_world_through_the_comp_camera (CPU vs GPU at 8 / 32 bpc, full and half resolution). particle_golden / sim_golden unchanged. effects.md regenerated; parity.md and gaps.md describe the fix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
e9032c5ce2
commit
923d978119
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue